A clean, kernel-first TypeScript scaffold for a two-canvas CAD/diagram widget. DraftX separates world-space (content/grid) from device-space (rulers/overlays), centralizes viewport math, and enforces canvas sizing invariants before every frame. It’s small, strict, and designed to grow without drifting into spaghetti.
view:changed → schedule → postframe:view)any), ESLint flat config, Vite dev server# Install
npm i
# Run Dev UI at /dev/
npm run dev
# Type check & lint
npm run typecheck
npm run lint
Open http://localhost:5173/dev/ — click New to reset the view and verify rulers/grid.
Note: Aliases (
@app,@core,@kernel,@render,@util) are configured in tsconfig and vite.config.ts.
import { createWidget } from 'draftx'; // in dev we import from src/index
const widget = createWidget({ gutterCSS: 32 });
widget.mount(hostElement);
widget.newDocument(); // resets zoom=1 and positions world(0,0) at (gutter,gutter) in CSS space
// More coming soon:
// widget.fit();
// widget.importSvg(svgText);
// widget.setGridVisible(true/false);
// widget.on('view:changed', payload => ...);
Options
gutterCSS (number): device-space gutter reserved for rulers (default: 32)src/
app/
runtime.ts # composition root & public widget surface
core/
bus.ts # typed event bus
state.ts # App/View/UI state types & initial values
commands/
view.ts # thin view commands that call kernel + applyView
kernel/
viewport.ts # pure viewport math & world transform helper
commit.ts # applyView (atomic state change + events)
scheduler.ts # frame loop with canvas size invariants
render/
pipeline.ts # frame orchestration: base→grid→content, then overlay
layers/
grid.ts # world-space grid
rulers.ts # device-space rulers
io/
// svg.ts (planned)
util/
// dom & helpers (planned)
dev/
index.html # simple side panel + host
app.ts # mounts the widget
test/
smoke/
smoke_runner.js # placeholder (will add rulers/grid/fit checks)
flowchart LR
A[Dev UI] -->|calls| B[Widget API];
B --> C[Runtime];
C --> D[FrameScheduler];
D --> E[Render Pipeline];
E --> F[Base Canvas - world];
E --> G[Overlay Canvas - device];
C --> H[Event Bus];
C --> I[Commands];
I --> J[Kernel applyView];
J --> H;
sequenceDiagram
participant CMD as Command
participant K as applyView
participant BUS as EventBus
participant S as Scheduler
participant R as Renderer
CMD->>K: nextCenter, nextZoom
K->>BUS: view:changed
K->>S: requestFrame
K->>BUS: postframe:view
S->>R: renderFrame(base → overlay)
flowchart LR
RS[Frame Start] --> SZ[Ensure canvas sizes];
SZ -->|resize| B0[Draw base only and requeue];
SZ -->|ok| B1[Clear base];
B1 --> WT[Apply world transform];
WT --> G[Draw grid];
G --> C[Draw content - future];
C --> O0[Clear overlay];
O0 --> OR[Draw rulers];
OR --> OS[Draw selection - future];
src/kernelapplyView (assign → events → schedule)any, explicit interfaces, type-aware ESLintviewport.ts
computeOriginTL(size, gutter, zoom) -> centerfitBoundsWithGutter(bounds, size, gutter) -> { center, zoom }cssToWorld(pCss, view, size), worldToCss(pWorld, view, size)applyWorldTransform(ctx, view, size)commit.ts
applyView(state, bus, schedule, center, zoom)scheduler.ts
round(css*dpr); on resize, renders base only and requeuesview.ts
newDocument(...) sets zoom=1 and origin to inner TL via kernel + applyViewmoveOriginTL, fit, zoomBy, panByCss (scaffolded)grid.ts (world): hairline lines, 1/zoom stroke, inner-rect basedrulers.ts (device): top + left bars, ticks, simple labels; sizes derived from backing store (canvas.width / dpr) for early-frame safetytest/smoke):
(0,0) aligns to inner TL at zoom=1computeOriginTL, fitBoundsWithGutter, transforms)applyView{
"dev": "vite",
"build": "tsc -p tsconfig.json",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:smoke": "node test/smoke/smoke_runner.js"
}
@app/runtime)vite.config.ts includes:
resolve: { alias: { '@app': new URL('./src/app', import.meta.url) /* … */ } }
{ nodes, bounds }) + auto fit(bounds, size, gutter)MIT (add your preferred license file)