@fieldnotes/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,12 +7,15 @@ A lightweight, framework-agnostic infinite canvas SDK for the web — with first
7
7
  - **Infinite canvas** — pan, zoom, pinch-to-zoom
8
8
  - **Freehand drawing** — pencil tool with stroke smoothing and pressure-sensitive width
9
9
  - **Sticky notes** — editable text notes with customizable colors
10
- - **Arrows** — curved bezier arrows with draggable control points
11
- - **Images** — drag & drop or programmatic placement
10
+ - **Arrows** — curved bezier arrows with element binding
11
+ - **Shapes** — rectangles, ellipses with fill and stroke
12
+ - **Text** — standalone text elements with font size and alignment
13
+ - **Images** — drag & drop or programmatic placement (canvas-rendered for proper layer ordering)
12
14
  - **HTML embedding** — add any DOM element as a fully interactive canvas citizen
13
- - **Select & multi-select** — click, drag box, move, resize
15
+ - **Layers** — named layers with visibility, locking, and absolute ordering
16
+ - **Select & multi-select** — click, drag box, move, resize (layer-aware)
14
17
  - **Undo / redo** — full history stack with configurable depth
15
- - **State serialization** — export/import JSON snapshots
18
+ - **State serialization** — export/import JSON snapshots with automatic migration
16
19
  - **Touch & tablet** — Pointer Events API, pinch-to-zoom, two-finger pan, stylus pressure
17
20
  - **Zero dependencies** — vanilla TypeScript, no framework required
18
21
  - **Tree-shakeable** — ESM + CJS output
@@ -91,10 +94,13 @@ viewport.stopInteracting();
91
94
  ```typescript
92
95
  // Programmatic
93
96
  viewport.addImage('https://example.com/photo.jpg', { x: 0, y: 0 });
97
+ viewport.addImage('/assets/map.png', { x: 0, y: 0 }, { w: 800, h: 600 });
94
98
 
95
99
  // Drag & drop is handled automatically — drop images onto the canvas
96
100
  ```
97
101
 
102
+ > **Important: Use URLs, not base64 data URLs.** Images are stored inline in the serialized state. A single base64-encoded photo can be 2-5MB, which will blow past the `localStorage` ~5MB quota and make JSON exports impractical. Upload images to your server or CDN and use the URL. For offline/local-first apps, store blobs in IndexedDB and reference them by URL.
103
+
98
104
  ## Camera Control
99
105
 
100
106
  ```typescript
@@ -147,6 +153,45 @@ viewport.history.onChange(() => {
147
153
  });
148
154
  ```
149
155
 
156
+ ## Layers
157
+
158
+ Organize elements into named layers with visibility, lock, and ordering controls. All elements on a higher layer render above all elements on a lower layer, regardless of individual z-index.
159
+
160
+ ```typescript
161
+ const { layerManager } = viewport;
162
+
163
+ // Create layers
164
+ const background = layerManager.activeLayer; // "Layer 1" exists by default
165
+ layerManager.renameLayer(background.id, 'Map');
166
+ const tokens = layerManager.createLayer('Tokens');
167
+ const notes = layerManager.createLayer('Notes');
168
+
169
+ // Set active layer — new elements are created on the active layer
170
+ layerManager.setActiveLayer(tokens.id);
171
+
172
+ // Visibility and locking
173
+ layerManager.setLayerVisible(background.id, false); // hide
174
+ layerManager.setLayerLocked(background.id, true); // prevent selection/editing
175
+
176
+ // Move elements between layers
177
+ layerManager.moveElementToLayer(elementId, notes.id);
178
+
179
+ // Reorder layers
180
+ layerManager.reorderLayer(tokens.id, 5); // higher order = renders on top
181
+
182
+ // Query
183
+ layerManager.getLayers(); // sorted by order
184
+ layerManager.isLayerVisible(id);
185
+ layerManager.isLayerLocked(id);
186
+
187
+ // Listen for changes
188
+ layerManager.on('change', () => {
189
+ /* update UI */
190
+ });
191
+ ```
192
+
193
+ Locked layers prevent selection, erasing, and arrow binding on their elements. Hidden layers are invisible and non-interactive. The active layer cannot be hidden or locked — if you try, it automatically switches to the next available layer.
194
+
150
195
  ## State Serialization
151
196
 
152
197
  ```typescript
@@ -158,6 +203,8 @@ localStorage.setItem('canvas', json);
158
203
  viewport.loadJSON(localStorage.getItem('canvas'));
159
204
  ```
160
205
 
206
+ > **Note:** Serialized state includes all layers and element `layerId` assignments. States saved before layers were introduced are automatically migrated — elements are placed on a default "Layer 1".
207
+
161
208
  ## Tool Switching
162
209
 
163
210
  ```typescript
@@ -296,16 +343,19 @@ interface BaseElement {
296
343
  position: { x: number; y: number };
297
344
  zIndex: number;
298
345
  locked: boolean;
346
+ layerId: string;
299
347
  }
300
348
  ```
301
349
 
302
- | Type | Key Fields |
303
- | -------- | ---------------------------------------------------- |
304
- | `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
305
- | `note` | `size`, `text`, `backgroundColor` |
306
- | `arrow` | `from`, `to`, `bend`, `color`, `width` |
307
- | `image` | `size`, `src` |
308
- | `html` | `size` |
350
+ | Type | Key Fields |
351
+ | -------- | ---------------------------------------------------------------------- |
352
+ | `stroke` | `points: StrokePoint[]`, `color`, `width`, `opacity` |
353
+ | `note` | `size`, `text`, `backgroundColor`, `textColor` |
354
+ | `arrow` | `from`, `to`, `bend`, `color`, `width`, `fromBinding`, `toBinding` |
355
+ | `image` | `size`, `src` |
356
+ | `shape` | `size`, `shape` (`rectangle` \| `ellipse`), `strokeColor`, `fillColor` |
357
+ | `text` | `size`, `text`, `fontSize`, `color`, `textAlign` |
358
+ | `html` | `size` |
309
359
 
310
360
  ## Built-in Interactions
311
361
 
package/dist/index.cjs CHANGED
@@ -52,6 +52,7 @@ __export(index_exports, {
52
52
  Viewport: () => Viewport,
53
53
  clearStaleBindings: () => clearStaleBindings,
54
54
  createArrow: () => createArrow,
55
+ createGrid: () => createGrid,
55
56
  createHtmlElement: () => createHtmlElement,
56
57
  createId: () => createId,
57
58
  createImage: () => createImage,
@@ -165,7 +166,7 @@ function validateState(data) {
165
166
  ];
166
167
  }
167
168
  }
168
- var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text", "shape"]);
169
+ var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html", "text", "shape", "grid"]);
169
170
  function validateElement(el) {
170
171
  if (!el || typeof el !== "object") {
171
172
  throw new Error("Invalid element: expected an object");
@@ -286,7 +287,11 @@ var AutoSave = class {
286
287
  if (typeof localStorage === "undefined") return;
287
288
  const layers = this.layerManager?.snapshot() ?? [];
288
289
  const state = exportState(this.store.snapshot(), this.camera, layers);
289
- localStorage.setItem(this.key, JSON.stringify(state));
290
+ try {
291
+ localStorage.setItem(this.key, JSON.stringify(state));
292
+ } catch {
293
+ console.warn("Auto-save failed: storage quota exceeded. State too large for localStorage.");
294
+ }
290
295
  }
291
296
  };
292
297
 
@@ -1022,6 +1027,118 @@ function smoothToSegments(points) {
1022
1027
  return segments;
1023
1028
  }
1024
1029
 
1030
+ // src/elements/grid-renderer.ts
1031
+ function getSquareGridLines(bounds, cellSize) {
1032
+ if (cellSize <= 0) return { verticals: [], horizontals: [] };
1033
+ const verticals = [];
1034
+ const startX = Math.floor(bounds.minX / cellSize) * cellSize;
1035
+ const endX = Math.ceil(bounds.maxX / cellSize) * cellSize;
1036
+ for (let x = startX; x <= endX; x += cellSize) {
1037
+ verticals.push(x);
1038
+ }
1039
+ const horizontals = [];
1040
+ const startY = Math.floor(bounds.minY / cellSize) * cellSize;
1041
+ const endY = Math.ceil(bounds.maxY / cellSize) * cellSize;
1042
+ for (let y = startY; y <= endY; y += cellSize) {
1043
+ horizontals.push(y);
1044
+ }
1045
+ return { verticals, horizontals };
1046
+ }
1047
+ function getHexVertices(cx, cy, circumradius, orientation) {
1048
+ const vertices = [];
1049
+ const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1050
+ for (let i = 0; i < 6; i++) {
1051
+ const angle = Math.PI / 3 * i + angleOffset;
1052
+ vertices.push({
1053
+ x: cx + circumradius * Math.cos(angle),
1054
+ y: cy + circumradius * Math.sin(angle)
1055
+ });
1056
+ }
1057
+ return vertices;
1058
+ }
1059
+ function getHexCenters(bounds, circumradius, orientation) {
1060
+ if (circumradius <= 0) return [];
1061
+ const centers = [];
1062
+ if (orientation === "pointy") {
1063
+ const hexW = Math.sqrt(3) * circumradius;
1064
+ const hexH = 2 * circumradius;
1065
+ const rowH = hexH * 0.75;
1066
+ const startRow = Math.floor((bounds.minY - circumradius) / rowH);
1067
+ const endRow = Math.ceil((bounds.maxY + circumradius) / rowH);
1068
+ const startCol = Math.floor((bounds.minX - hexW) / hexW);
1069
+ const endCol = Math.ceil((bounds.maxX + hexW) / hexW);
1070
+ for (let row = startRow; row <= endRow; row++) {
1071
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1072
+ for (let col = startCol; col <= endCol; col++) {
1073
+ centers.push({
1074
+ x: col * hexW + offsetX,
1075
+ y: row * rowH
1076
+ });
1077
+ }
1078
+ }
1079
+ } else {
1080
+ const hexW = 2 * circumradius;
1081
+ const hexH = Math.sqrt(3) * circumradius;
1082
+ const colW = hexW * 0.75;
1083
+ const startCol = Math.floor((bounds.minX - circumradius) / colW);
1084
+ const endCol = Math.ceil((bounds.maxX + circumradius) / colW);
1085
+ const startRow = Math.floor((bounds.minY - hexH) / hexH);
1086
+ const endRow = Math.ceil((bounds.maxY + hexH) / hexH);
1087
+ for (let col = startCol; col <= endCol; col++) {
1088
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1089
+ for (let row = startRow; row <= endRow; row++) {
1090
+ centers.push({
1091
+ x: col * colW,
1092
+ y: row * hexH + offsetY
1093
+ });
1094
+ }
1095
+ }
1096
+ }
1097
+ return centers;
1098
+ }
1099
+ function renderSquareGrid(ctx, bounds, cellSize, strokeColor, strokeWidth, opacity) {
1100
+ if (cellSize <= 0) return;
1101
+ const { verticals, horizontals } = getSquareGridLines(bounds, cellSize);
1102
+ ctx.save();
1103
+ ctx.strokeStyle = strokeColor;
1104
+ ctx.lineWidth = strokeWidth;
1105
+ ctx.globalAlpha = opacity;
1106
+ ctx.beginPath();
1107
+ for (const x of verticals) {
1108
+ ctx.moveTo(x, bounds.minY);
1109
+ ctx.lineTo(x, bounds.maxY);
1110
+ }
1111
+ for (const y of horizontals) {
1112
+ ctx.moveTo(bounds.minX, y);
1113
+ ctx.lineTo(bounds.maxX, y);
1114
+ }
1115
+ ctx.stroke();
1116
+ ctx.restore();
1117
+ }
1118
+ function renderHexGrid(ctx, bounds, cellSize, orientation, strokeColor, strokeWidth, opacity) {
1119
+ if (cellSize <= 0) return;
1120
+ const centers = getHexCenters(bounds, cellSize, orientation);
1121
+ ctx.save();
1122
+ ctx.strokeStyle = strokeColor;
1123
+ ctx.lineWidth = strokeWidth;
1124
+ ctx.globalAlpha = opacity;
1125
+ ctx.beginPath();
1126
+ for (const center of centers) {
1127
+ const verts = getHexVertices(center.x, center.y, cellSize, orientation);
1128
+ const first = verts[0];
1129
+ if (!first) continue;
1130
+ ctx.moveTo(first.x, first.y);
1131
+ for (let i = 1; i < verts.length; i++) {
1132
+ const v = verts[i];
1133
+ if (!v) continue;
1134
+ ctx.lineTo(v.x, v.y);
1135
+ }
1136
+ ctx.closePath();
1137
+ }
1138
+ ctx.stroke();
1139
+ ctx.restore();
1140
+ }
1141
+
1025
1142
  // src/elements/element-renderer.ts
1026
1143
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
1027
1144
  var ARROWHEAD_LENGTH = 12;
@@ -1030,12 +1147,20 @@ var ElementRenderer = class {
1030
1147
  store = null;
1031
1148
  imageCache = /* @__PURE__ */ new Map();
1032
1149
  onImageLoad = null;
1150
+ camera = null;
1151
+ canvasSize = null;
1033
1152
  setStore(store) {
1034
1153
  this.store = store;
1035
1154
  }
1036
1155
  setOnImageLoad(callback) {
1037
1156
  this.onImageLoad = callback;
1038
1157
  }
1158
+ setCamera(camera) {
1159
+ this.camera = camera;
1160
+ }
1161
+ setCanvasSize(w, h) {
1162
+ this.canvasSize = { w, h };
1163
+ }
1039
1164
  isDomElement(element) {
1040
1165
  return DOM_ELEMENT_TYPES.has(element.type);
1041
1166
  }
@@ -1053,6 +1178,9 @@ var ElementRenderer = class {
1053
1178
  case "image":
1054
1179
  this.renderImage(ctx, element);
1055
1180
  break;
1181
+ case "grid":
1182
+ this.renderGrid(ctx, element);
1183
+ break;
1056
1184
  }
1057
1185
  }
1058
1186
  renderStroke(ctx, stroke) {
@@ -1188,6 +1316,42 @@ var ElementRenderer = class {
1188
1316
  }
1189
1317
  }
1190
1318
  }
1319
+ renderGrid(ctx, grid) {
1320
+ if (!this.canvasSize) return;
1321
+ const cam = this.camera;
1322
+ if (!cam) return;
1323
+ const topLeft = cam.screenToWorld({ x: 0, y: 0 });
1324
+ const bottomRight = cam.screenToWorld({
1325
+ x: this.canvasSize.w,
1326
+ y: this.canvasSize.h
1327
+ });
1328
+ const bounds = {
1329
+ minX: topLeft.x,
1330
+ minY: topLeft.y,
1331
+ maxX: bottomRight.x,
1332
+ maxY: bottomRight.y
1333
+ };
1334
+ if (grid.gridType === "hex") {
1335
+ renderHexGrid(
1336
+ ctx,
1337
+ bounds,
1338
+ grid.cellSize,
1339
+ grid.hexOrientation,
1340
+ grid.strokeColor,
1341
+ grid.strokeWidth,
1342
+ grid.opacity
1343
+ );
1344
+ } else {
1345
+ renderSquareGrid(
1346
+ ctx,
1347
+ bounds,
1348
+ grid.cellSize,
1349
+ grid.strokeColor,
1350
+ grid.strokeWidth,
1351
+ grid.opacity
1352
+ );
1353
+ }
1354
+ }
1191
1355
  renderImage(ctx, image) {
1192
1356
  const img = this.getImage(image.src);
1193
1357
  if (!img) return;
@@ -1630,6 +1794,22 @@ function createShape(input) {
1630
1794
  fillColor: input.fillColor ?? "none"
1631
1795
  };
1632
1796
  }
1797
+ function createGrid(input) {
1798
+ return {
1799
+ id: createId("grid"),
1800
+ type: "grid",
1801
+ position: input.position ?? { x: 0, y: 0 },
1802
+ zIndex: input.zIndex ?? 0,
1803
+ locked: input.locked ?? false,
1804
+ layerId: input.layerId ?? "",
1805
+ gridType: input.gridType ?? "square",
1806
+ hexOrientation: input.hexOrientation ?? "pointy",
1807
+ cellSize: input.cellSize ?? 40,
1808
+ strokeColor: input.strokeColor ?? "#000000",
1809
+ strokeWidth: input.strokeWidth ?? 1,
1810
+ opacity: input.opacity ?? 1
1811
+ };
1812
+ }
1633
1813
  function createText(input) {
1634
1814
  return {
1635
1815
  id: createId("text"),
@@ -1807,6 +1987,7 @@ var Viewport = class {
1807
1987
  this.toolManager = new ToolManager();
1808
1988
  this.renderer = new ElementRenderer();
1809
1989
  this.renderer.setStore(this.store);
1990
+ this.renderer.setCamera(this.camera);
1810
1991
  this.renderer.setOnImageLoad(() => this.requestRender());
1811
1992
  this.noteEditor = new NoteEditor();
1812
1993
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
@@ -1953,6 +2134,34 @@ var Viewport = class {
1953
2134
  this.requestRender();
1954
2135
  return el.id;
1955
2136
  }
2137
+ addGrid(input) {
2138
+ const existing = this.store.getElementsByType("grid")[0];
2139
+ this.historyRecorder.begin();
2140
+ if (existing) {
2141
+ this.store.remove(existing.id);
2142
+ }
2143
+ const grid = createGrid({ ...input, layerId: this.layerManager.activeLayerId });
2144
+ this.store.add(grid);
2145
+ this.historyRecorder.commit();
2146
+ this.requestRender();
2147
+ return grid.id;
2148
+ }
2149
+ updateGrid(updates) {
2150
+ const grid = this.store.getElementsByType("grid")[0];
2151
+ if (!grid) return;
2152
+ this.historyRecorder.begin();
2153
+ this.store.update(grid.id, updates);
2154
+ this.historyRecorder.commit();
2155
+ this.requestRender();
2156
+ }
2157
+ removeGrid() {
2158
+ const grid = this.store.getElementsByType("grid")[0];
2159
+ if (!grid) return;
2160
+ this.historyRecorder.begin();
2161
+ this.store.remove(grid.id);
2162
+ this.historyRecorder.commit();
2163
+ this.requestRender();
2164
+ }
1956
2165
  destroy() {
1957
2166
  cancelAnimationFrame(this.animFrameId);
1958
2167
  this.stopInteracting();
@@ -1984,6 +2193,7 @@ var Viewport = class {
1984
2193
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1985
2194
  ctx.save();
1986
2195
  ctx.scale(dpr, dpr);
2196
+ this.renderer.setCanvasSize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
1987
2197
  this.background.render(ctx, this.camera);
1988
2198
  ctx.save();
1989
2199
  ctx.translate(this.camera.position.x, this.camera.position.y);
@@ -2970,6 +3180,7 @@ var SelectTool = class {
2970
3180
  for (const el of ctx.store.getAll()) {
2971
3181
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
2972
3182
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3183
+ if (el.type === "grid") continue;
2973
3184
  const bounds = this.getElementBounds(el);
2974
3185
  if (bounds && this.rectsOverlap(marquee, bounds)) {
2975
3186
  ids.push(el.id);
@@ -3006,11 +3217,13 @@ var SelectTool = class {
3006
3217
  for (const el of elements) {
3007
3218
  if (ctx.isLayerVisible && !ctx.isLayerVisible(el.layerId)) continue;
3008
3219
  if (ctx.isLayerLocked && ctx.isLayerLocked(el.layerId)) continue;
3220
+ if (el.type === "grid") continue;
3009
3221
  if (this.isInsideBounds(world, el)) return el;
3010
3222
  }
3011
3223
  return null;
3012
3224
  }
3013
3225
  isInsideBounds(point, el) {
3226
+ if (el.type === "grid") return false;
3014
3227
  if ("size" in el) {
3015
3228
  const s = el.size;
3016
3229
  return point.x >= el.position.x && point.x <= el.position.x + s.w && point.y >= el.position.y && point.y <= el.position.y + s.h;
@@ -3436,7 +3649,7 @@ var UpdateLayerCommand = class {
3436
3649
  };
3437
3650
 
3438
3651
  // src/index.ts
3439
- var VERSION = "0.6.0";
3652
+ var VERSION = "0.6.1";
3440
3653
  // Annotate the CommonJS export names for ESM import in node:
3441
3654
  0 && (module.exports = {
3442
3655
  AddElementCommand,
@@ -3471,6 +3684,7 @@ var VERSION = "0.6.0";
3471
3684
  Viewport,
3472
3685
  clearStaleBindings,
3473
3686
  createArrow,
3687
+ createGrid,
3474
3688
  createHtmlElement,
3475
3689
  createId,
3476
3690
  createImage,