@lancercomet/zoom-pan
v0.4.0
Published
Yet another web 2D rendering lib.
Readme
ZoomPan
ZoomPan is a 2D rendering viewport / render-pipeline library.
It is not a scene-graph engine. If you are looking for a full display tree, rich shape primitives, filters, batching, and a large ecosystem, you probably want PixiJS / Fabric.js / Konva.
It provides a consistent world/screen coordinate model, pan/zoom interactions, a pluggable render pipeline (RenderPipeline/Pass), a layer system, and optional rendering backends (Canvas2D/WebGL).
Use it as a simple “image viewer with pan & zoom”, or as the rendering core of a “drawing app / annotation tool / editor”.
ZoomPan vs Pixi / Fabric / Konva
| Topic | ZoomPan | Pixi / Fabric / Konva |
| --- | --- | --- |
| Primary goal | Viewport + render orchestration for editor-style apps | General-purpose 2D rendering engine / scene graph |
| Best for | Zoom/pan canvas viewers, drawing apps, editors, annotation tools | Complex object trees, shape primitives, filters, rich rendering features |
| Extension model | Explicit render pipeline passes + plugins | Scene graph + framework lifecycle + ecosystem plugins |
| World/screen split | Built-in phases: world and screen | Often requires custom conventions / layers / containers |
| What it does not try to be | A full engine with display tree, batching, filters | A small viewport kernel |
Features
- Selectable renderer backend:
renderer: 'canvas2d' | 'webgl' | 'auto' | IRenderer - Pluggable render pipeline: 4 phases
beforeWorld / world / afterWorld / screen+ stable ordering viaorder - Viewport transform: pan/zoom + world ↔ screen coordinate conversion
- Interaction plugin: mouse/touch/pen input + inertia +
cancel()for focus-loss cleanup - Layer system: split world/screen rendering (you can insert passes between them)
- Bounds (document bounds): background/shadow/clip/border, pan clamping,
zoomToFit({ maxScale }) - History (Undo/Redo): provided via plugin/examples (see
examples/)
Install
npm install @lancercomet/zoom-panQuick Start
Image Viewer
import { ViewManager, ContentLayerManager, createInteractionPlugin } from '@lancercomet/zoom-pan'
const content = new ContentLayerManager()
const view = new ViewManager(canvas, {
renderer: 'auto' // 'canvas2d' | 'webgl' | 'auto'
})
view.registerLayerManager(content)
view.use(createInteractionPlugin())
await content.createImageLayer({
src: 'image.png',
x: 0,
y: 0
})Drawing App (simplified)
import {
ViewManager,
ContentLayerManager,
CanvasLayer,
HistoryManager,
createInteractionPlugin,
createBoundsPlugin,
createSnapshotCommand
} from '@lancercomet/zoom-pan'
const content = new ContentLayerManager()
const history = new HistoryManager({ maxHistorySize: 50 })
const view = new ViewManager(canvas, { renderer: 'auto' })
view.registerLayerManager(content)
// Interaction plugin controls pan/zoom.
// - Touch gestures (pan/pinch) always work
// - Mouse/pen pan is controlled by setPanEnabled()
const interaction = view.use(createInteractionPlugin())
// Bounds defines document rect/margins and clamps pan.
const bounds = view.use(createBoundsPlugin({
rect: { x: 0, y: 0, width: 1200, height: 800 },
margins: { left: 50, right: 50, top: 50, bottom: 50 }
}))
// Prevent upscaling small documents
bounds.zoomToFit({ maxScale: 1 })
const drawLayer = new CanvasLayer({ width: 1200, height: 800 })
content.addLayer(drawLayer)
// Drawing mode: disable mouse/pen pan (touch still works)
interaction.setPanEnabled(false)
// On focus loss, cancel any ongoing drag/pinch/inertia
window.addEventListener('blur', () => interaction.cancel())
let snapshotBefore: ImageData | null = null
canvas.onpointerdown = (e) => {
if (e.pointerType === 'touch') return
const { wx, wy } = view.toWorld(e.offsetX, e.offsetY)
snapshotBefore = drawLayer.captureSnapshot()
drawLayer.beginStroke(wx, wy)
}
canvas.onpointermove = (e) => {
if (e.pointerType === 'touch') return
if (e.buttons !== 1) return
const { wx, wy } = view.toWorld(e.offsetX, e.offsetY)
drawLayer.stroke(wx, wy, '#000', 10, e.pressure, 'brush')
view.requestRender()
}
canvas.onpointerup = () => {
drawLayer.endStroke()
const snapshotAfter = drawLayer.captureSnapshot()
const cmd = createSnapshotCommand(drawLayer, snapshotBefore, snapshotAfter)
if (cmd) history.addCommand(cmd)
}Core Concepts
ViewManager
The main controller: render loop, coordinate transforms, renderer backend, render pipeline, and plugins.
const view = new ViewManager(canvas, {
minZoom: 0.2,
maxZoom: 10,
background: '#fff',
renderer: 'auto'
})
const { wx, wy } = view.toWorld(screenX, screenY)
const { x, y } = view.toScreen(worldX, worldY)
view.zoomToAtScreen(anchorX, anchorY, 2.0)
view.zoomByFactorAtScreen(anchorX, anchorY, 1.5)Renderer Selection
import { ViewManager } from '@lancercomet/zoom-pan'
new ViewManager(canvas, { renderer: 'canvas2d' })
new ViewManager(canvas, { renderer: 'webgl' })
new ViewManager(canvas, { renderer: 'auto' }) // tries WebGL first, falls back to Canvas2DRenderPipeline / Pass
You can insert your own passes (e.g. draw a debug grid before world rendering):
view.addRenderPass({
name: 'debug.grid',
phase: 'beforeWorld',
order: -50,
render: ({ renderer }) => {
const ctx = renderer.getContentContext()
ctx.save()
ctx.strokeStyle = 'rgba(0,0,0,0.08)'
ctx.lineWidth = 1
// draw grid / guides here
ctx.restore()
}
})InteractionPlugin
import { createInteractionPlugin } from '@lancercomet/zoom-pan'
const interaction = view.use(createInteractionPlugin())
interaction.setPanEnabled(true) // mouse/pen can pan
interaction.setPanEnabled(false) // mouse/pen cannot pan (drawing mode)
interaction.setZoomEnabled(true) // wheel zoom + pinch zoom
interaction.cancel() // cancel drag/pinch/inertia (blur/visibilitychange)BoundsPlugin
BoundsPlugin draws background/shadow, clips the world, and draws border via pipeline passes. It also supports per-instance pass ordering (useful when you need to insert your own passes around the bounds clip).
import { createBoundsPlugin } from '@lancercomet/zoom-pan'
const bounds = view.use(createBoundsPlugin({
rect: { x: 0, y: 0, width: 1200, height: 800 },
margins: { left: 50, right: 50, top: 50, bottom: 50 },
drawBorder: true,
background: '#f0f0f0',
shadow: { blur: 20, color: 'rgba(0,0,0,0.3)', offsetX: 0, offsetY: 5 },
passOrder: {
beforeWorld: -100,
afterWorld: 100
}
}))
bounds.zoomToFit({ maxScale: 1 })
bounds.setPanClampMode('minVisible') // 'margin' | 'minVisible'Layers
import { ViewManager, ContentLayerManager, TopScreenLayerManager, CanvasLayer } from '@lancercomet/zoom-pan'
const view = new ViewManager(canvas, { renderer: 'auto' })
// World-space content
const content = new ContentLayerManager()
view.registerLayerManager(content)
// Screen-space overlay (UI / cursor / HUD)
const overlay = new TopScreenLayerManager()
view.registerLayerManager(overlay)
content.addLayer(new CanvasLayer({ width: 1200, height: 800 }))Default rendering is split by pipeline phase:
world: calls each LayerManager'srenderWorldLayersIn(view)screen: calls each LayerManager'srenderScreenLayersIn(view)
This means you can insert passes between world and screen (e.g. selection overlay after world, UI in screen).
Plugin Lifecycle
ViewManager.use(plugin) is transactional: if install() throws, it performs a best-effort rollback to avoid leaving a half-installed plugin behind.
view.hasPlugin('bounds')
view.listPlugins()
view.clearPlugins()
view.unuse('bounds')Examples
See examples/:
examples/viewer/: image viewerexamples/painter/: drawing app (brush/eraser/layers/undo-redo)examples/bounds/: bounds (background/shadow/zoomToFit)examples/history/: history (undo/redo)
