@fieldnotes/core 0.19.0 → 0.21.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
@@ -401,6 +401,15 @@ new Viewport(container, {
401
401
  spacing: 24, // grid spacing in px (default: 24)
402
402
  color: '#d0d0d0', // dot/line color (default: '#d0d0d0')
403
403
  },
404
+ // Called for every drop; replaces the built-in image-drop handling
405
+ onDrop: (event, worldPosition) => {
406
+ /* handle drop */
407
+ },
408
+ // Called when an image element fails to load; failed images render a gray
409
+ // placeholder. Falls back to console.warn when unset.
410
+ onImageError: ({ src, elementIds }) => {
411
+ /* handle broken image */
412
+ },
404
413
  });
405
414
  ```
406
415
 
package/dist/index.cjs CHANGED
@@ -92,6 +92,7 @@ __export(index_exports, {
92
92
  getHexDistance: () => getHexDistance,
93
93
  isBindable: () => isBindable,
94
94
  isNearBezier: () => isNearBezier,
95
+ isNoteContentEmpty: () => isNoteContentEmpty,
95
96
  parseState: () => parseState,
96
97
  sanitizeNoteHtml: () => sanitizeNoteHtml,
97
98
  setFontSize: () => setFontSize,
@@ -382,6 +383,12 @@ function sanitizeNode(node) {
382
383
  sanitizeNode(el);
383
384
  }
384
385
  }
386
+ function isNoteContentEmpty(html) {
387
+ if (!html) return true;
388
+ const doc = new DOMParser().parseFromString(html, "text/html");
389
+ const text = doc.body.textContent ?? "";
390
+ return text.replace(/\u00a0/g, " ").trim().length === 0;
391
+ }
385
392
  function sanitizeAttributes(el, tag) {
386
393
  const attrs = Array.from(el.attributes);
387
394
  for (const attr of attrs) {
@@ -2712,6 +2719,7 @@ var ElementRenderer = class {
2712
2719
  store = null;
2713
2720
  imageCache = /* @__PURE__ */ new Map();
2714
2721
  onImageLoad = null;
2722
+ onImageError = null;
2715
2723
  camera = null;
2716
2724
  canvasSize = null;
2717
2725
  hexTileCache = null;
@@ -2722,6 +2730,9 @@ var ElementRenderer = class {
2722
2730
  setOnImageLoad(callback) {
2723
2731
  this.onImageLoad = callback;
2724
2732
  }
2733
+ setOnImageError(callback) {
2734
+ this.onImageError = callback;
2735
+ }
2725
2736
  setCamera(camera) {
2726
2737
  this.camera = camera;
2727
2738
  }
@@ -3080,6 +3091,10 @@ var ElementRenderer = class {
3080
3091
  ctx.restore();
3081
3092
  }
3082
3093
  renderImage(ctx, image) {
3094
+ if (this.imageCache.get(image.src) === "failed") {
3095
+ this.renderImagePlaceholder(ctx, image);
3096
+ return;
3097
+ }
3083
3098
  const img = this.getImage(image.src);
3084
3099
  if (!img) return;
3085
3100
  ctx.drawImage(
@@ -3090,6 +3105,27 @@ var ElementRenderer = class {
3090
3105
  image.size.h
3091
3106
  );
3092
3107
  }
3108
+ renderImagePlaceholder(ctx, image) {
3109
+ const { x, y } = image.position;
3110
+ const { w, h } = image.size;
3111
+ ctx.save();
3112
+ ctx.fillStyle = "#eeeeee";
3113
+ ctx.fillRect(x, y, w, h);
3114
+ ctx.strokeStyle = "#bdbdbd";
3115
+ ctx.lineWidth = 1;
3116
+ ctx.strokeRect(x, y, w, h);
3117
+ const glyph = Math.min(24, w / 2, h / 2);
3118
+ const cx = x + w / 2;
3119
+ const cy = y + h / 2;
3120
+ ctx.strokeStyle = "#9e9e9e";
3121
+ ctx.lineWidth = 2;
3122
+ ctx.beginPath();
3123
+ ctx.arc(cx, cy, glyph / 2, 0, Math.PI * 2);
3124
+ ctx.moveTo(cx - glyph / 2, cy + glyph / 2);
3125
+ ctx.lineTo(cx + glyph / 2, cy - glyph / 2);
3126
+ ctx.stroke();
3127
+ ctx.restore();
3128
+ }
3093
3129
  getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
3094
3130
  const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
3095
3131
  if (this.hexTileCacheKey === key && this.hexTileCache) {
@@ -3105,6 +3141,7 @@ var ElementRenderer = class {
3105
3141
  getImage(src) {
3106
3142
  const cached = this.imageCache.get(src);
3107
3143
  if (cached) {
3144
+ if (cached === "failed") return null;
3108
3145
  if (cached instanceof HTMLImageElement) return cached.complete ? cached : null;
3109
3146
  return cached;
3110
3147
  }
@@ -3121,6 +3158,11 @@ var ElementRenderer = class {
3121
3158
  });
3122
3159
  }
3123
3160
  };
3161
+ img.onerror = () => {
3162
+ this.imageCache.set(src, "failed");
3163
+ this.onImageError?.(src);
3164
+ this.onImageLoad?.();
3165
+ };
3124
3166
  return null;
3125
3167
  }
3126
3168
  };
@@ -3473,17 +3515,36 @@ var FORMAT_SHORTCUTS = {
3473
3515
  i: toggleItalic,
3474
3516
  u: toggleUnderline
3475
3517
  };
3518
+ function ensureEditorStyles() {
3519
+ if (document.querySelector("style[data-fieldnotes-editor]")) return;
3520
+ const style = document.createElement("style");
3521
+ style.setAttribute("data-fieldnotes-editor", "");
3522
+ style.textContent = `[data-fn-placeholder][data-fn-empty='true']::before {
3523
+ content: attr(data-fn-placeholder);
3524
+ color: #9e9e9e;
3525
+ position: absolute;
3526
+ pointer-events: none;
3527
+ }`;
3528
+ document.head.appendChild(style);
3529
+ }
3530
+ function isNodeEmpty(node) {
3531
+ const text = node.textContent ?? "";
3532
+ return text.replace(/\u00a0/g, " ").trim().length === 0;
3533
+ }
3476
3534
  var NoteEditor = class {
3477
3535
  editingId = null;
3478
3536
  editingNode = null;
3479
3537
  blurHandler = null;
3480
3538
  keyHandler = null;
3481
3539
  pointerHandler = null;
3540
+ inputHandler = null;
3482
3541
  pendingEditId = null;
3483
3542
  onStopCallback = null;
3484
3543
  toolbar;
3544
+ placeholder;
3485
3545
  constructor(options) {
3486
3546
  this.toolbar = options?.toolbar === false ? null : new NoteToolbar(options?.fontSizePresets);
3547
+ this.placeholder = options?.placeholder ?? "Type\u2026";
3487
3548
  }
3488
3549
  get isEditing() {
3489
3550
  return this.editingId !== null;
@@ -3518,6 +3579,11 @@ var NoteEditor = class {
3518
3579
  if (this.pointerHandler) {
3519
3580
  this.editingNode.removeEventListener("pointerdown", this.pointerHandler);
3520
3581
  }
3582
+ if (this.inputHandler) {
3583
+ this.editingNode.removeEventListener("input", this.inputHandler);
3584
+ }
3585
+ this.editingNode.removeAttribute("data-fn-placeholder");
3586
+ this.editingNode.removeAttribute("data-fn-empty");
3521
3587
  const text = sanitizeNoteHtml(this.editingNode.innerHTML);
3522
3588
  store.update(this.editingId, { text });
3523
3589
  this.editingNode.contentEditable = "false";
@@ -3534,6 +3600,7 @@ var NoteEditor = class {
3534
3600
  this.blurHandler = null;
3535
3601
  this.keyHandler = null;
3536
3602
  this.pointerHandler = null;
3603
+ this.inputHandler = null;
3537
3604
  }
3538
3605
  destroy(store) {
3539
3606
  this.pendingEditId = null;
@@ -3564,6 +3631,13 @@ var NoteEditor = class {
3564
3631
  selection.removeAllRanges();
3565
3632
  selection.addRange(range);
3566
3633
  }
3634
+ ensureEditorStyles();
3635
+ node.setAttribute("data-fn-placeholder", this.placeholder);
3636
+ node.setAttribute("data-fn-empty", String(isNodeEmpty(node)));
3637
+ this.inputHandler = () => {
3638
+ node.setAttribute("data-fn-empty", String(isNodeEmpty(node)));
3639
+ };
3640
+ node.addEventListener("input", this.inputHandler);
3567
3641
  this.toolbar?.show(node);
3568
3642
  this.blurHandler = (e) => {
3569
3643
  const related = e.relatedTarget;
@@ -5000,9 +5074,21 @@ var Viewport = class {
5000
5074
  this.renderLoop.markAllLayersDirty();
5001
5075
  this.requestRender();
5002
5076
  });
5077
+ this.renderer.setOnImageError((src) => {
5078
+ const elementIds = [];
5079
+ for (const el of this.store.getAll()) {
5080
+ if (el.type === "image" && el.src === src) elementIds.push(el.id);
5081
+ }
5082
+ if (options.onImageError) {
5083
+ options.onImageError({ src, elementIds });
5084
+ } else {
5085
+ console.warn(`[fieldnotes] image failed to load: ${src}`);
5086
+ }
5087
+ });
5003
5088
  this.noteEditor = new NoteEditor({
5004
5089
  fontSizePresets: options.fontSizePresets,
5005
- toolbar: options.toolbar
5090
+ toolbar: options.toolbar,
5091
+ placeholder: options.placeholder
5006
5092
  });
5007
5093
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
5008
5094
  this.onHtmlElementMount = options.onHtmlElementMount;
@@ -5348,7 +5434,16 @@ var Viewport = class {
5348
5434
  }
5349
5435
  onTextEditStop(elementId) {
5350
5436
  const element = this.store.getById(elementId);
5351
- if (!element || element.type !== "text") return;
5437
+ if (!element) return;
5438
+ if (element.type === "note") {
5439
+ if (isNoteContentEmpty(element.text)) {
5440
+ this.historyRecorder.begin();
5441
+ this.store.remove(elementId);
5442
+ this.historyRecorder.commit();
5443
+ }
5444
+ return;
5445
+ }
5446
+ if (element.type !== "text") return;
5352
5447
  if (!element.text || element.text.trim() === "") {
5353
5448
  this.historyRecorder.begin();
5354
5449
  this.store.remove(elementId);
@@ -5694,6 +5789,43 @@ var PencilTool = class {
5694
5789
  }
5695
5790
  };
5696
5791
 
5792
+ // src/elements/stroke-hit.ts
5793
+ function distSqToSegment(p, a, b) {
5794
+ const abx = b.x - a.x;
5795
+ const aby = b.y - a.y;
5796
+ const apx = p.x - a.x;
5797
+ const apy = p.y - a.y;
5798
+ const lenSq = abx * abx + aby * aby;
5799
+ if (lenSq === 0) {
5800
+ return apx * apx + apy * apy;
5801
+ }
5802
+ const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq));
5803
+ const dx = p.x - (a.x + t * abx);
5804
+ const dy = p.y - (a.y + t * aby);
5805
+ return dx * dx + dy * dy;
5806
+ }
5807
+ function hitTestStroke(stroke, point, radius) {
5808
+ const bounds = getElementBounds(stroke);
5809
+ if (!bounds) return false;
5810
+ if (point.x < bounds.x - radius || point.x > bounds.x + bounds.w + radius || point.y < bounds.y - radius || point.y > bounds.y + bounds.h + radius) {
5811
+ return false;
5812
+ }
5813
+ const radiusSq = radius * radius;
5814
+ const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
5815
+ const { segments } = getStrokeRenderData(stroke);
5816
+ if (segments.length === 0) {
5817
+ const p = stroke.points[0];
5818
+ if (!p) return false;
5819
+ const dx = p.x - local.x;
5820
+ const dy = p.y - local.y;
5821
+ return dx * dx + dy * dy <= radiusSq;
5822
+ }
5823
+ for (const seg of segments) {
5824
+ if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
5825
+ }
5826
+ return false;
5827
+ }
5828
+
5697
5829
  // src/tools/eraser-tool.ts
5698
5830
  var DEFAULT_RADIUS = 20;
5699
5831
  function makeEraserCursor(radius) {
@@ -5752,12 +5884,7 @@ var EraserTool = class {
5752
5884
  if (erased) ctx.requestRender();
5753
5885
  }
5754
5886
  strokeIntersects(stroke, point) {
5755
- const radiusSq = this.radius * this.radius;
5756
- return stroke.points.some((p) => {
5757
- const dx = p.x + stroke.position.x - point.x;
5758
- const dy = p.y + stroke.position.y - point.y;
5759
- return dx * dx + dy * dy <= radiusSq;
5760
- });
5887
+ return hitTestStroke(stroke, point, this.radius);
5761
5888
  }
5762
5889
  };
5763
5890
 
@@ -5897,6 +6024,7 @@ var SelectTool = class {
5897
6024
  pendingSingleSelectId = null;
5898
6025
  hasDragged = false;
5899
6026
  resizeAspectRatio = 0;
6027
+ hoveredId = null;
5900
6028
  get selectedIds() {
5901
6029
  return [...this._selectedIds];
5902
6030
  }
@@ -5913,6 +6041,7 @@ var SelectTool = class {
5913
6041
  onDeactivate(ctx) {
5914
6042
  this._selectedIds = [];
5915
6043
  this.mode = { type: "idle" };
6044
+ this.hoveredId = null;
5916
6045
  ctx.setCursor?.("default");
5917
6046
  }
5918
6047
  snap(point, ctx) {
@@ -5920,6 +6049,7 @@ var SelectTool = class {
5920
6049
  }
5921
6050
  onPointerDown(state, ctx) {
5922
6051
  this.ctx = ctx;
6052
+ this.setHovered(null, ctx);
5923
6053
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5924
6054
  this.lastWorld = this.snap(world, ctx);
5925
6055
  this.currentWorld = world;
@@ -6062,7 +6192,8 @@ var SelectTool = class {
6062
6192
  }
6063
6193
  onHover(state, ctx) {
6064
6194
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
6065
- this.updateHoverCursor(world, ctx);
6195
+ const hoverId = this.updateHoverCursor(world, ctx);
6196
+ this.setHovered(hoverId, ctx);
6066
6197
  }
6067
6198
  renderOverlay(canvasCtx) {
6068
6199
  this.renderMarquee(canvasCtx);
@@ -6083,6 +6214,23 @@ var SelectTool = class {
6083
6214
  canvasCtx.restore();
6084
6215
  }
6085
6216
  }
6217
+ if (this.hoveredId && this.ctx && this.mode.type === "idle") {
6218
+ if (!this._selectedIds.includes(this.hoveredId)) {
6219
+ const el = this.ctx.store.getById(this.hoveredId);
6220
+ if (el) {
6221
+ const b = getElementBounds(el);
6222
+ if (b) {
6223
+ canvasCtx.save();
6224
+ canvasCtx.strokeStyle = "#2196F3";
6225
+ canvasCtx.globalAlpha = 0.35;
6226
+ canvasCtx.lineWidth = 1.5 / this.ctx.camera.zoom;
6227
+ canvasCtx.setLineDash([]);
6228
+ canvasCtx.strokeRect(b.x, b.y, b.w, b.h);
6229
+ canvasCtx.restore();
6230
+ }
6231
+ }
6232
+ }
6233
+ }
6086
6234
  }
6087
6235
  updateArrowsBoundTo(ids, ctx) {
6088
6236
  const movedNonArrowIds = /* @__PURE__ */ new Set();
@@ -6131,20 +6279,26 @@ var SelectTool = class {
6131
6279
  const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
6132
6280
  if (arrowHit) {
6133
6281
  ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
6134
- return;
6282
+ return null;
6135
6283
  }
6136
6284
  const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
6137
6285
  if (templateResizeHit) {
6138
6286
  ctx.setCursor?.("nwse-resize");
6139
- return;
6287
+ return null;
6140
6288
  }
6141
6289
  const resizeHit = this.hitTestResizeHandle(world, ctx);
6142
6290
  if (resizeHit) {
6143
6291
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
6144
- return;
6292
+ return null;
6145
6293
  }
6146
6294
  const hit = this.hitTest(world, ctx);
6147
6295
  ctx.setCursor?.(hit ? "move" : "default");
6296
+ return hit ? hit.id : null;
6297
+ }
6298
+ setHovered(id, ctx) {
6299
+ if (this.hoveredId === id) return;
6300
+ this.hoveredId = id;
6301
+ ctx.requestRender();
6148
6302
  }
6149
6303
  handleResize(world, ctx, shiftKey = false) {
6150
6304
  if (this.mode.type !== "resizing") return;
@@ -6410,12 +6564,7 @@ var SelectTool = class {
6410
6564
  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;
6411
6565
  }
6412
6566
  if (el.type === "stroke") {
6413
- const HIT_RADIUS = 10;
6414
- return el.points.some((p) => {
6415
- const dx = p.x + el.position.x - point.x;
6416
- const dy = p.y + el.position.y - point.y;
6417
- return dx * dx + dy * dy <= HIT_RADIUS * HIT_RADIUS;
6418
- });
6567
+ return hitTestStroke(el, point, 10);
6419
6568
  }
6420
6569
  if (el.type === "arrow") {
6421
6570
  return isNearBezier(point, el.from, el.to, el.bend, 10);
@@ -7251,7 +7400,7 @@ var TemplateTool = class {
7251
7400
  };
7252
7401
 
7253
7402
  // src/index.ts
7254
- var VERSION = "0.19.0";
7403
+ var VERSION = "0.21.0";
7255
7404
  // Annotate the CommonJS export names for ESM import in node:
7256
7405
  0 && (module.exports = {
7257
7406
  AddElementCommand,
@@ -7326,6 +7475,7 @@ var VERSION = "0.19.0";
7326
7475
  getHexDistance,
7327
7476
  isBindable,
7328
7477
  isNearBezier,
7478
+ isNoteContentEmpty,
7329
7479
  parseState,
7330
7480
  sanitizeNoteHtml,
7331
7481
  setFontSize,