@fieldnotes/core 0.58.0 → 0.60.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/dist/index.cjs CHANGED
@@ -5272,6 +5272,7 @@ var ToolManager = class {
5272
5272
  tools = /* @__PURE__ */ new Map();
5273
5273
  current = null;
5274
5274
  changeListeners = /* @__PURE__ */ new Set();
5275
+ registerListeners = /* @__PURE__ */ new Set();
5275
5276
  get activeTool() {
5276
5277
  return this.current;
5277
5278
  }
@@ -5280,6 +5281,7 @@ var ToolManager = class {
5280
5281
  }
5281
5282
  register(tool) {
5282
5283
  this.tools.set(tool.name, tool);
5284
+ this.registerListeners.forEach((fn) => fn(tool));
5283
5285
  }
5284
5286
  getTool(name) {
5285
5287
  return this.tools.get(name);
@@ -5305,6 +5307,10 @@ var ToolManager = class {
5305
5307
  this.changeListeners.add(listener);
5306
5308
  return () => this.changeListeners.delete(listener);
5307
5309
  }
5310
+ onRegister(listener) {
5311
+ this.registerListeners.add(listener);
5312
+ return () => this.registerListeners.delete(listener);
5313
+ }
5308
5314
  };
5309
5315
 
5310
5316
  // src/history/history-stack.ts
@@ -5496,6 +5502,7 @@ var HistoryRecorder = class {
5496
5502
  generation = 0;
5497
5503
  updateSnapshots = /* @__PURE__ */ new Map();
5498
5504
  unsubscribers;
5505
+ transactionEndListeners = /* @__PURE__ */ new Set();
5499
5506
  pause() {
5500
5507
  this.recording = false;
5501
5508
  }
@@ -5513,19 +5520,35 @@ var HistoryRecorder = class {
5513
5520
  get currentTransactionId() {
5514
5521
  return this.transaction !== null ? this.generation : null;
5515
5522
  }
5523
+ onTransactionEnd(listener) {
5524
+ this.transactionEndListeners.add(listener);
5525
+ return () => this.transactionEndListeners.delete(listener);
5526
+ }
5527
+ notifyTransactionEnd() {
5528
+ for (const listener of this.transactionEndListeners) {
5529
+ try {
5530
+ listener();
5531
+ } catch {
5532
+ }
5533
+ }
5534
+ }
5516
5535
  commit() {
5517
5536
  if (!this.transaction) return;
5518
5537
  const finalCommands = this.flushUpdateSnapshots();
5519
5538
  const all = [...this.transaction, ...finalCommands];
5520
5539
  this.transaction = null;
5521
5540
  this.updateSnapshots.clear();
5522
- if (all.length === 0) return;
5523
- const first = all[0];
5524
- this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
5541
+ if (all.length > 0) {
5542
+ const first = all[0];
5543
+ this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
5544
+ }
5545
+ this.notifyTransactionEnd();
5525
5546
  }
5526
5547
  rollback() {
5548
+ const wasOpen = this.transaction !== null;
5527
5549
  this.transaction = null;
5528
5550
  this.updateSnapshots.clear();
5551
+ if (wasOpen) this.notifyTransactionEnd();
5529
5552
  }
5530
5553
  destroy() {
5531
5554
  this.unsubscribers.forEach((fn) => fn());
@@ -5855,6 +5878,19 @@ function computeBounds(elements, padding) {
5855
5878
  h: maxY - minY + padding * 2
5856
5879
  };
5857
5880
  }
5881
+ function resolveExportBounds(region, elements, padding) {
5882
+ if (!region) return computeBounds(elements, padding);
5883
+ const finite = Number.isFinite(region.x) && Number.isFinite(region.y) && Number.isFinite(region.w) && Number.isFinite(region.h);
5884
+ if (!finite || region.w <= 0 || region.h <= 0) {
5885
+ throw new RangeError("region must have finite coordinates and positive w/h");
5886
+ }
5887
+ return {
5888
+ x: region.x - padding,
5889
+ y: region.y - padding,
5890
+ w: region.w + padding * 2,
5891
+ h: region.h + padding * 2
5892
+ };
5893
+ }
5858
5894
  function renderGridForBounds(ctx, grid, bounds) {
5859
5895
  const visibleBounds = {
5860
5896
  minX: bounds.x,
@@ -5897,6 +5933,29 @@ function nonNegativeOption(value, fallback, name) {
5897
5933
  }
5898
5934
  return resolved;
5899
5935
  }
5936
+ function fitExportScale(bounds, requestedScale, options) {
5937
+ const maxDimension = positiveOption(options.maxDimension, DEFAULT_MAX_DIMENSION, "maxDimension");
5938
+ const maxPixels = positiveOption(options.maxPixels, DEFAULT_MAX_PIXELS, "maxPixels");
5939
+ const fits = (scale) => {
5940
+ const w = Math.ceil(bounds.w * scale);
5941
+ const h = Math.ceil(bounds.h * scale);
5942
+ return w <= maxDimension && h <= maxDimension && w * h <= maxPixels;
5943
+ };
5944
+ if (fits(requestedScale)) return requestedScale;
5945
+ const analytical = Math.min(
5946
+ maxDimension / Math.max(bounds.w, bounds.h),
5947
+ Math.sqrt(maxPixels) / Math.sqrt(bounds.w) / Math.sqrt(bounds.h)
5948
+ );
5949
+ let hi = Math.min(requestedScale, analytical);
5950
+ if (fits(hi)) return hi;
5951
+ let lo = 0;
5952
+ for (let i = 0; i < 40; i++) {
5953
+ const mid = (lo + hi) / 2;
5954
+ if (fits(mid)) lo = mid;
5955
+ else hi = mid;
5956
+ }
5957
+ return lo;
5958
+ }
5900
5959
  function assertExportSize(width, height, options) {
5901
5960
  const maxDimension = positiveOption(options.maxDimension, DEFAULT_MAX_DIMENSION, "maxDimension");
5902
5961
  const maxPixels = positiveOption(options.maxPixels, DEFAULT_MAX_PIXELS, "maxPixels");
@@ -5971,10 +6030,23 @@ function loadImages(elements, options = {}) {
5971
6030
  });
5972
6031
  }
5973
6032
  async function exportImage(store, options = {}, layerManager) {
5974
- const scale = positiveOption(options.scale, 2, "scale");
6033
+ const requestedScale = positiveOption(options.scale, 2, "scale");
6034
+ const scaleMode = options.scaleMode ?? "exact";
6035
+ if (scaleMode !== "exact" && scaleMode !== "fit") {
6036
+ throw new RangeError(`scaleMode must be 'exact' or 'fit'`);
6037
+ }
5975
6038
  const padding = nonNegativeOption(options.padding, 0, "padding");
5976
6039
  validateExportResourceOptions(options);
5977
6040
  validateHtmlExportOptions(options);
6041
+ const format = options.format ?? "png";
6042
+ if (format !== "png" && format !== "jpeg") {
6043
+ throw new RangeError(`format must be 'png' or 'jpeg'`);
6044
+ }
6045
+ if (options.quality !== void 0) {
6046
+ if (!Number.isFinite(options.quality) || options.quality <= 0 || options.quality > 1) {
6047
+ throw new RangeError("quality must be a finite number in (0, 1]");
6048
+ }
6049
+ }
5978
6050
  const background = options.background ?? "#ffffff";
5979
6051
  const filter = options.filter;
5980
6052
  const allElements = store.getAll();
@@ -5982,8 +6054,9 @@ async function exportImage(store, options = {}, layerManager) {
5982
6054
  if (filter) {
5983
6055
  visibleElements = visibleElements.filter(filter);
5984
6056
  }
5985
- const bounds = computeBounds(visibleElements, padding);
6057
+ const bounds = resolveExportBounds(options.region, visibleElements, padding);
5986
6058
  if (!bounds) return null;
6059
+ const scale = scaleMode === "fit" ? fitExportScale(bounds, requestedScale, options) : requestedScale;
5987
6060
  const width = Math.ceil(bounds.w * scale);
5988
6061
  const height = Math.ceil(bounds.h * scale);
5989
6062
  assertExportSize(width, height, options);
@@ -6085,8 +6158,9 @@ async function exportImage(store, options = {}, layerManager) {
6085
6158
  renderGridForBounds(ctx, grid, bounds);
6086
6159
  ctx.restore();
6087
6160
  }
6161
+ const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
6088
6162
  return new Promise((resolve) => {
6089
- canvas.toBlob((blob) => resolve(blob), "image/png");
6163
+ canvas.toBlob((blob) => resolve(blob), mimeType, options.quality);
6090
6164
  });
6091
6165
  }
6092
6166
 
@@ -7766,6 +7840,14 @@ function rotateElementPatch(el, bounds, pivot, delta) {
7766
7840
  }
7767
7841
 
7768
7842
  // src/canvas/selection-ops.ts
7843
+ var STYLE_FIELDS = [
7844
+ "color",
7845
+ "fillColor",
7846
+ "strokeWidth",
7847
+ "opacity",
7848
+ "fontSize",
7849
+ "strokeStyle"
7850
+ ];
7769
7851
  function sharedValue(values) {
7770
7852
  const present = values.filter((v) => v !== void 0);
7771
7853
  if (present.length === 0) return void 0;
@@ -7800,6 +7882,42 @@ var SelectionOps = class {
7800
7882
  if (strokeStyle !== void 0) result.strokeStyle = strokeStyle;
7801
7883
  return result;
7802
7884
  }
7885
+ /**
7886
+ * Unlike `getStyle()` — which returns `{}` for a selection whose elements
7887
+ * have no applicable style fields (e.g. images) — this returns `null` when
7888
+ * no field is applicable, so callers can distinguish "nothing to show" from
7889
+ * "everything shared but empty".
7890
+ */
7891
+ getStyleDetails() {
7892
+ const ids = this.deps.getSelectedIds();
7893
+ if (ids.length === 0) return null;
7894
+ const styles = [];
7895
+ for (const id of ids) {
7896
+ const el = this.deps.store.getById(id);
7897
+ if (el) styles.push(getElementStyle(el));
7898
+ }
7899
+ if (styles.length === 0) return null;
7900
+ const common = {};
7901
+ const applicable = [];
7902
+ const mixed = [];
7903
+ for (const field of STYLE_FIELDS) {
7904
+ const allValues = styles.map((s) => s[field]);
7905
+ const values = [];
7906
+ for (const v of allValues) {
7907
+ if (v !== void 0) values.push(v);
7908
+ }
7909
+ if (values.length === 0) continue;
7910
+ applicable.push(field);
7911
+ const distinct = new Set(values);
7912
+ if (distinct.size > 1) {
7913
+ mixed.push(field);
7914
+ } else {
7915
+ common[field] = values[0];
7916
+ }
7917
+ }
7918
+ if (applicable.length === 0) return null;
7919
+ return { common, applicable, mixed };
7920
+ }
7803
7921
  applyStyle(style) {
7804
7922
  const ids = this.deps.getSelectedIds();
7805
7923
  if (ids.length === 0) return;
@@ -8174,7 +8292,7 @@ var ViewportInteractions = class {
8174
8292
  var EMPTY_IDS = [];
8175
8293
  function noop() {
8176
8294
  }
8177
- var Viewport = class {
8295
+ var Viewport = class _Viewport {
8178
8296
  constructor(container, options = {}) {
8179
8297
  this.container = container;
8180
8298
  this.camera = new Camera(options.camera);
@@ -8183,6 +8301,13 @@ var Viewport = class {
8183
8301
  this.store = new ElementStore();
8184
8302
  this.layerManager = new LayerManager(this.store);
8185
8303
  this.toolManager = new ToolManager();
8304
+ this.unsubToolRegister = this.toolManager.onRegister((tool) => {
8305
+ if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
8306
+ });
8307
+ const existingSelect = this.getSelectTool();
8308
+ if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
8309
+ this.attachSelectionSource(existingSelect);
8310
+ }
8186
8311
  this.renderer = new ElementRenderer();
8187
8312
  this.renderer.setStore(this.store);
8188
8313
  this.renderer.setCamera(this.camera);
@@ -8217,6 +8342,11 @@ var Viewport = class {
8217
8342
  this.dropHandler = options.onDrop;
8218
8343
  this.history = new HistoryStack();
8219
8344
  this.historyRecorder = new HistoryRecorder(this.store, this.history, this.layerManager);
8345
+ this.unsubRecorderEnd = this.historyRecorder.onTransactionEnd(() => {
8346
+ if (!this.pendingSelectionPrune) return;
8347
+ this.pendingSelectionPrune = false;
8348
+ this.pruneSelection();
8349
+ });
8220
8350
  this.selectionOps = new SelectionOps({
8221
8351
  store: this.store,
8222
8352
  recorder: this.historyRecorder,
@@ -8336,6 +8466,7 @@ var Viewport = class {
8336
8466
  this.domNodeManager.removeDomNode(el.id);
8337
8467
  this.renderLoop.markLayerDirty(el.layerId);
8338
8468
  this.requestRender();
8469
+ this.handleRemovedElement(el.id);
8339
8470
  }),
8340
8471
  this.store.on("update", ({ previous, current }) => {
8341
8472
  if (current.type === "grid") this.gridController.syncContext();
@@ -8350,6 +8481,7 @@ var Viewport = class {
8350
8481
  this.renderLoop.markAllLayersDirty();
8351
8482
  this.gridController.syncContext();
8352
8483
  this.requestRender();
8484
+ this.pruneSelection();
8353
8485
  })
8354
8486
  ];
8355
8487
  this.layerManager.on("change", () => {
@@ -8419,6 +8551,13 @@ var Viewport = class {
8419
8551
  minimap = null;
8420
8552
  htmlRenderers = /* @__PURE__ */ new Map();
8421
8553
  resizeListeners = /* @__PURE__ */ new Set();
8554
+ selectionListeners = /* @__PURE__ */ new Set();
8555
+ detachSelectionSource = null;
8556
+ unsubToolRegister = () => {
8557
+ };
8558
+ pendingSelectionPrune = false;
8559
+ unsubRecorderEnd = () => {
8560
+ };
8422
8561
  get ctx() {
8423
8562
  return this.canvasEl.getContext("2d");
8424
8563
  }
@@ -8677,6 +8816,42 @@ var Viewport = class {
8677
8816
  getSelectTool() {
8678
8817
  return this.toolManager.getTool("select");
8679
8818
  }
8819
+ pruneSelection() {
8820
+ const tool = this.getSelectTool();
8821
+ if (!tool) return;
8822
+ const ids = tool.selectedIds;
8823
+ const filtered = ids.filter((id) => this.store.getById(id) !== void 0);
8824
+ if (filtered.length !== ids.length) tool.setSelection(filtered);
8825
+ }
8826
+ handleRemovedElement(id) {
8827
+ if (!this.getSelectedIds().includes(id)) return;
8828
+ if (this.historyRecorder.currentTransactionId !== null) {
8829
+ this.pendingSelectionPrune = true;
8830
+ return;
8831
+ }
8832
+ this.pruneSelection();
8833
+ }
8834
+ static isSelectionSource(tool) {
8835
+ const candidate = tool;
8836
+ return tool.name === "select" && typeof candidate.onSelectionChange === "function" && typeof candidate.setSelection === "function";
8837
+ }
8838
+ emitSelectionChange() {
8839
+ for (const listener of this.selectionListeners) {
8840
+ try {
8841
+ listener();
8842
+ } catch {
8843
+ }
8844
+ }
8845
+ }
8846
+ attachSelectionSource(tool) {
8847
+ this.detachSelectionSource?.();
8848
+ this.detachSelectionSource = tool.onSelectionChange(() => this.emitSelectionChange());
8849
+ }
8850
+ /**
8851
+ * getSelectedIds() and the onSelectionChange emitter never surface stale ids:
8852
+ * once the enclosing history transaction completes, both reflect
8853
+ * the current selection.
8854
+ */
8680
8855
  getSelectedIds() {
8681
8856
  return this.getSelectTool()?.selectedIds ?? EMPTY_IDS;
8682
8857
  }
@@ -8710,13 +8885,29 @@ var Viewport = class {
8710
8885
  if (items.length === 0) return;
8711
8886
  this.contextMenu.open(items, screenPos);
8712
8887
  }
8888
+ /**
8889
+ * Persistent, viewport-owned selection-change emitter. Subscribing works
8890
+ * regardless of whether a select tool is registered yet; it forwards
8891
+ * events from whichever select tool is currently attached via
8892
+ * `toolManager.onRegister`. Never delivers stale ids once the enclosing
8893
+ * history transaction completes.
8894
+ */
8713
8895
  onSelectionChange(listener) {
8714
- const tool = this.getSelectTool();
8715
- return tool ? tool.onSelectionChange(listener) : noop;
8896
+ this.selectionListeners.add(listener);
8897
+ return () => {
8898
+ this.selectionListeners.delete(listener);
8899
+ };
8716
8900
  }
8717
8901
  getSelectionStyle() {
8718
8902
  return this.selectionOps.getStyle();
8719
8903
  }
8904
+ /**
8905
+ * Unlike `getSelectionStyle()` — which returns `{}` for a style-less
8906
+ * selection — this returns `null` when no style field is applicable.
8907
+ */
8908
+ getSelectionStyleDetails() {
8909
+ return this.selectionOps.getStyleDetails();
8910
+ }
8720
8911
  applyStyleToSelection(style) {
8721
8912
  this.selectionOps.applyStyle(style);
8722
8913
  }
@@ -8765,6 +8956,11 @@ var Viewport = class {
8765
8956
  this.inputHandler.destroy();
8766
8957
  this.unsubCamera();
8767
8958
  this.unsubToolChange();
8959
+ this.unsubToolRegister();
8960
+ this.unsubRecorderEnd();
8961
+ this.detachSelectionSource?.();
8962
+ this.detachSelectionSource = null;
8963
+ this.selectionListeners.clear();
8768
8964
  this.unsubStore.forEach((fn) => fn());
8769
8965
  this.resizeObserver?.disconnect();
8770
8966
  this.resizeObserver = null;
@@ -12366,7 +12562,7 @@ var PingTool = class {
12366
12562
  };
12367
12563
 
12368
12564
  // src/index.ts
12369
- var VERSION = "0.58.0";
12565
+ var VERSION = "0.60.0";
12370
12566
  // Annotate the CommonJS export names for ESM import in node:
12371
12567
  0 && (module.exports = {
12372
12568
  ArrowTool,