@lunchfirm/pentool
v0.1.5
Published
A zero-dependency, embeddable SVG pen tool for the web. Mount on any element, get standard SVG path data out.
Maintainers
Readme
PenTool.js
A zero-dependency, embeddable SVG pen tool for the web.
npm install @lunchfirm/pentoolMount it on any element. Users draw paths with Illustrator-style pen-tool interaction. The result is structured data that you can do whatever you want with.
What it is
The pen tool is an input device. It doesn't render anything permanent.
It gives you anchor positions, Bézier handles, and a valid SVG d string;
the consuming app builds whatever interface it wants around that.
Use it for:
- Image annotation, hotspot editors, ROI selectors
- Polygon-based labelling tools
- Region-of-interest pickers, mask editors
- Any UI where the user needs to trace a closed or open path on an image
Quick start
ESM (bundlers, modern browsers)
import { PenTool } from "@lunchfirm/pentool";
const pen = new PenTool(document.getElementById("canvas"), {
viewBox: [800, 600],
});
pen.on("path", (path) => {
console.log(path.d); // "M 100 200 C 150 100 ... Z"
console.log(path.points); // [{x, y, handleIn, handleOut}, ...]
console.log(path.closed); // true | false
});Script tag
<script src="https://unpkg.com/@lunchfirm/pentool"></script>
<script>
const pen = new PenTool(document.getElementById("canvas"), {
viewBox: [800, 600],
});
pen.on("path", (path) => console.log(path.d));
</script>The target element must have position: relative or absolute, the pen
tool mounts a transparent SVG overlay positioned to fill it.
Interactions
Identical to Illustrator / Figma / Inkscape.
Drawing
| Input | Action |
| ------------------------- | --------------------------------------------------- |
| Click | Place a corner anchor (straight segments) |
| Click + drag | Place a smooth anchor with symmetric Bézier handles |
| Click first anchor | Close the path (straight closing segment) |
| Click + drag first anchor | Close with a smooth loop point |
| Alt + drag last handle | Convert most-recent anchor to a cusp |
| Enter | Finish as an open path (no Z) |
| Escape | Cancel the in-progress path |
| Backspace / Delete | Remove the last placed anchor |
Editing (after the path is finished)
| Input | Action |
| -------------------------- | ------------------------------------------------------------------- |
| Drag anchor | Move it (its handles follow) |
| Drag handle | Adjust the curve (mirrors on smooth points) |
| Alt + drag handle | Break the mirror, move freely |
| Alt + click anchor | Toggle smooth/corner |
| + then click segment | Insert a new anchor on the curve (shape preserved via de Casteljau) |
| − then click anchor | Remove that anchor |
| Escape (in +/− mode) | Exit back to default editing |
API
new PenTool(element: HTMLElement, options?: { viewBox?: [number, number] })| Parameter | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| element | The container to mount on. Must be positioned. |
| options.viewBox | Authored coordinate space [w, h]. Optional — if omitted, uses pixel dimensions and updates on resize. |
Events
pen.on("path", (path) => {}); // user committed (close, edit, +/-, toggle)
pen.on("update", (path) => {}); // every change during a drag — for live previews
pen.on("cancel", () => {}); // user pressed Escape during drawingpath is { d: string, closed: boolean, points: PathPoint[] }.
PathPoint is { x, y, handleIn, handleOut }. Handles are {x, y} in
absolute coordinates (matching SVG C semantics), or null for straight
segments.
Methods
| Method | Description |
| ----------------- | ----------------------------------------------------------------------------------------------- |
| pen.snapshot() | Read the current path state. |
| pen.load(input) | Load existing path data for editing. Accepts a points array or a { points, closed } snapshot. |
| pen.clear() | Reset to drawing mode. |
| pen.destroy() | Remove the overlay, detach all listeners. |
Coordinate system
The pen tool is always pinned to the coordinate space of the element it's
mounted on. Pointer events map to viewBox coordinates via
getBoundingClientRect(), the same transform SVG performs natively. Resize
the element and the overlay scales with it; emitted points never change.
Pass viewBox: [imageWidth, imageHeight] when drawing over an image so the
coordinates you get back are in the image's native space, regardless of how
the image is sized on screen.
Styling
The overlay reads its colors from CSS custom properties. Override on
.pentool-overlay or any parent:
.pentool-overlay {
--pt-path-stroke: #1a1410;
--pt-anchor-fill: #1a1410;
--pt-anchor-stroke: transparent;
--pt-handle-fill: #ffffff;
--pt-handle-stroke: #6b5945;
--pt-handle-line: #a08c6e;
--pt-close-hint: #8c2818;
--pt-mode-bg: #ffffff;
--pt-mode-fg: #1a1410;
}All sizes (anchor radius, handle radius, stroke widths) are kept constant in screen space regardless of viewBox scale.
Demo
The repo ships with two examples:
examples/basic.html— minimal stage + livedstring outputexamples/hotspot.html— a hotspot editor that lets you load an image, draw multiple named regions with optional href and arbitrary key/value metadata, and export a single self-contained interactive HTML file
Run them locally:
git clone https://github.com/declareupdate/pentool
cd pentool
python3 -m http.server 8080
# open http://localhost:8080/examples/hotspot.htmlDesign
Full design rationale, interaction priority, data format conventions, and
build plan are in pen-tool-spec.html, open it in a
browser for the rendered spec.
License
MIT — see LICENSE.
