@overtone-art/canvas-editor-core 0.6.2 → 0.6.4

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.mjs CHANGED
@@ -1143,7 +1143,60 @@ var STROKE2 = "#22c55e";
1143
1143
  import { util } from "fabric";
1144
1144
 
1145
1145
  // src/pattern/tiled-pattern-object.ts
1146
- import { FabricObject, Point } from "fabric";
1146
+ import { FabricObject, Point as Point2 } from "fabric";
1147
+
1148
+ // src/text-fit.ts
1149
+ import { Point } from "fabric";
1150
+ var TEXT_FIT_SLACK = 0.5;
1151
+ var MEASURE_WIDTH = 1e5;
1152
+ function unwrappedWidth(text) {
1153
+ const authored = text.width;
1154
+ try {
1155
+ text.set({ width: MEASURE_WIDTH });
1156
+ text.initDimensions?.();
1157
+ const measured = text.calcTextWidth?.() ?? authored;
1158
+ return Number.isFinite(measured) && measured > 0 ? measured : authored;
1159
+ } finally {
1160
+ text.set({ width: authored });
1161
+ text.initDimensions?.();
1162
+ }
1163
+ }
1164
+ function asText(object) {
1165
+ if (!object) return null;
1166
+ const text = object;
1167
+ return typeof text.text === "string" && typeof text.calcTextWidth === "function" ? text : null;
1168
+ }
1169
+ function textInk(text) {
1170
+ const boxWidth = Math.max(0, text.width ?? 0);
1171
+ const measured = text.calcTextWidth?.() ?? boxWidth;
1172
+ const width = Math.max(1, Math.min(boxWidth, Number.isFinite(measured) ? measured : boxWidth));
1173
+ const slack = (boxWidth - width) / 2;
1174
+ const align = text.textAlign ?? "left";
1175
+ if (align.includes("center")) return { width, dx: 0 };
1176
+ const flip = text.flipX ? -1 : 1;
1177
+ return { width, dx: flip * (align.includes("right") ? slack : -slack) };
1178
+ }
1179
+ function fitTextWidth(object) {
1180
+ const text = asText(object);
1181
+ if (!text || text.path) return false;
1182
+ const before = textInk(text);
1183
+ const fitted = unwrappedWidth(text) + TEXT_FIT_SLACK;
1184
+ if (Math.abs(fitted - text.width) < TEXT_FIT_SLACK) return false;
1185
+ const centre = text.getCenterPoint();
1186
+ text.set({ width: fitted });
1187
+ text.initDimensions?.();
1188
+ const after = textInk(text);
1189
+ const shift = (before.dx - after.dx) * (text.scaleX ?? 1);
1190
+ const radians = (text.angle ?? 0) * Math.PI / 180;
1191
+ const moved = new Point(
1192
+ centre.x + shift * Math.cos(radians),
1193
+ centre.y + shift * Math.sin(radians)
1194
+ );
1195
+ text.setPositionByOrigin(moved, "center", "center");
1196
+ text.setCoords();
1197
+ text.dirty = true;
1198
+ return true;
1199
+ }
1147
1200
 
1148
1201
  // src/pattern/tile-geometry.ts
1149
1202
  var MAX_TILES_PER_AXIS = 200;
@@ -1156,13 +1209,13 @@ function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
1156
1209
  const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
1157
1210
  const cols = Math.ceil(span / tileW) + 2;
1158
1211
  const rows = Math.ceil(span / tileH) + 2;
1159
- const halfCols = Math.ceil(cols / 2);
1160
- const halfRows = Math.ceil(rows / 2);
1212
+ const [colFrom, colTo] = axisRange(config.horizontalLimit, Math.ceil(cols / 2));
1213
+ const [rowFrom, rowTo] = axisRange(config.verticalLimit, Math.ceil(rows / 2));
1161
1214
  const shiftX = tileW * (clampOffset(config.offsetX) / 100);
1162
1215
  const shiftY = tileH * (clampOffset(config.offsetY) / 100);
1163
1216
  const placements = [];
1164
- for (let j = -halfRows; j <= halfRows; j++) {
1165
- for (let i = -halfCols; i <= halfCols; i++) {
1217
+ for (let j = rowFrom; j <= rowTo; j++) {
1218
+ for (let i = colFrom; i <= colTo; i++) {
1166
1219
  let x = i * tileW + shiftX;
1167
1220
  let y = j * tileH + shiftY;
1168
1221
  if (config.mode === "brick-horizontal" && mod2(j) === 1) {
@@ -1192,6 +1245,12 @@ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin, dra
1192
1245
  }
1193
1246
  ctx.restore();
1194
1247
  }
1248
+ function axisRange(limit, half) {
1249
+ const count = Number.isFinite(limit) ? Math.floor(limit) : 0;
1250
+ if (!count || count <= 0) return [-half, half];
1251
+ const from = -Math.floor((count - 1) / 2);
1252
+ return [from, from + count - 1];
1253
+ }
1195
1254
  function cornerRadius(origin, w, h) {
1196
1255
  const dx = Math.max(Math.abs(origin.x), Math.abs(w - origin.x));
1197
1256
  const dy = Math.max(Math.abs(origin.y), Math.abs(h - origin.y));
@@ -1263,7 +1322,7 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1263
1322
  */
1264
1323
  syncBox() {
1265
1324
  const { tileW, tileH } = this.baseTile();
1266
- const origin = this.source.getCenterPoint();
1325
+ const origin = this.source.getCenterPoint().add(this.inkOffset());
1267
1326
  const anchor = this.anchorFromOrigin(origin, tileW, tileH, this.config.angle);
1268
1327
  this.set({
1269
1328
  left: anchor.x,
@@ -1293,7 +1352,8 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1293
1352
  const { tileW, tileH } = this.liveTile();
1294
1353
  const centre = this.getCenterPoint();
1295
1354
  const shift = this.shiftVector(tileW, tileH, this.liveAngle());
1296
- return new Point(centre.x - shift.x, centre.y - shift.y);
1355
+ const ink = this.inkOffset();
1356
+ return new Point2(centre.x - shift.x - ink.x, centre.y - shift.y - ink.y);
1297
1357
  }
1298
1358
  _render(ctx) {
1299
1359
  const { width, height } = this.area;
@@ -1308,8 +1368,7 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1308
1368
  ctx.scale(1 / (this.scaleX || 1), 1 / (this.scaleY || 1));
1309
1369
  ctx.rotate(-angle * Math.PI / 180);
1310
1370
  ctx.translate(-centre.x, -centre.y);
1311
- const shift = this.shiftVector(tileW, tileH, angle);
1312
- const origin = { x: centre.x - shift.x, y: centre.y - shift.y };
1371
+ const origin = { x: centre.x, y: centre.y };
1313
1372
  const config = { ...this.config, angle, offsetX: 0, offsetY: 0 };
1314
1373
  drawTiles(
1315
1374
  ctx,
@@ -1338,7 +1397,6 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1338
1397
  if (!snapshot) return [];
1339
1398
  const centre = this.getCenterPoint();
1340
1399
  const angle = this.liveAngle();
1341
- const shift = this.shiftVector(tileW, tileH, angle);
1342
1400
  drawTiles(
1343
1401
  ctx,
1344
1402
  snapshot,
@@ -1347,7 +1405,7 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1347
1405
  height,
1348
1406
  tileW,
1349
1407
  tileH,
1350
- { x: centre.x - shift.x, y: centre.y - shift.y },
1408
+ { x: centre.x, y: centre.y },
1351
1409
  // The fallback raster is built at 1:1, so a device pixel is a canvas unit.
1352
1410
  this.drawSize(tileW, tileH, TILE_BLEED_DEVICE_PX)
1353
1411
  );
@@ -1390,7 +1448,7 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1390
1448
  return Promise.resolve(copy);
1391
1449
  }
1392
1450
  /**
1393
- * The source's tile footprint: its bounding box, minus a stroke it reserves
1451
+ * The source's tile footprint: the art it paints, minus a stroke it reserves
1394
1452
  * room for but never paints.
1395
1453
  *
1396
1454
  * fabric keeps `strokeWidth` inside an object's box whether or not a `stroke`
@@ -1398,11 +1456,18 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1398
1456
  * stroke. Stepping the grid by that box puts a transparent seam between every
1399
1457
  * pair of neighbours at zero spacing: the artwork is a pixel narrower than the
1400
1458
  * box it is stepped by.
1459
+ *
1460
+ * Text is the same problem an order of magnitude larger — see {@link textInk}.
1401
1461
  */
1402
1462
  sourceBox() {
1463
+ const text = asText(this.source);
1464
+ const paints = paintsStroke(this.source);
1403
1465
  const rect = this.source.getBoundingRect();
1404
- if (paintsStroke(this.source)) return { width: rect.width, height: rect.height };
1405
- const dims = this.source._getTransformedDimensions({ strokeWidth: 0 });
1466
+ if (paints && !text) return { width: rect.width, height: rect.height };
1467
+ const options = {};
1468
+ if (!paints) options.strokeWidth = 0;
1469
+ if (text) options.width = textInk(text).width;
1470
+ const dims = this.source._getTransformedDimensions(options);
1406
1471
  const radians = (this.source.angle ?? 0) * Math.PI / 180;
1407
1472
  const cos = Math.abs(Math.cos(radians));
1408
1473
  const sin = Math.abs(Math.sin(radians));
@@ -1411,6 +1476,22 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1411
1476
  height: dims.x * sin + dims.y * cos
1412
1477
  };
1413
1478
  }
1479
+ /**
1480
+ * Vector from the source's centre to the centre of the art it paints.
1481
+ *
1482
+ * Zero for everything except text that does not fill its box: the tile has to
1483
+ * sit on the glyphs, or the frame the user drags frames empty space and the
1484
+ * grid registers off the run by half the padding.
1485
+ */
1486
+ inkOffset() {
1487
+ const text = asText(this.source);
1488
+ if (!text) return new Point2(0, 0);
1489
+ const { dx } = textInk(text);
1490
+ if (!dx) return new Point2(0, 0);
1491
+ const scaled = dx * (this.source.scaleX ?? 1);
1492
+ const radians = (this.source.angle ?? 0) * Math.PI / 180;
1493
+ return new Point2(scaled * Math.cos(radians), scaled * Math.sin(radians));
1494
+ }
1414
1495
  /** The source's on-canvas width, before the tile scale. */
1415
1496
  baseW() {
1416
1497
  return Math.max(1, this.sourceBox().width);
@@ -1470,11 +1551,11 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1470
1551
  const radians = angle * Math.PI / 180;
1471
1552
  const cos = Math.cos(radians);
1472
1553
  const sin = Math.sin(radians);
1473
- return new Point(dx * cos - dy * sin, dx * sin + dy * cos);
1554
+ return new Point2(dx * cos - dy * sin, dx * sin + dy * cos);
1474
1555
  }
1475
1556
  anchorFromOrigin(origin, tileW, tileH, angle) {
1476
1557
  const shift = this.shiftVector(tileW, tileH, angle);
1477
- return new Point(origin.x + shift.x, origin.y + shift.y);
1558
+ return new Point2(origin.x + shift.x, origin.y + shift.y);
1478
1559
  }
1479
1560
  /**
1480
1561
  * Snapshot the source at (at least) `scale`, reusing the cached one while it
@@ -1487,7 +1568,15 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1487
1568
  const source = this.source;
1488
1569
  const opacity = source.opacity;
1489
1570
  source.opacity = 1;
1571
+ const text = asText(source);
1572
+ const authoredWidth = text?.width;
1573
+ const fitted = text ? textInk(text).width + TEXT_FIT_SLACK : 0;
1574
+ const hug = text !== null && authoredWidth !== void 0 && fitted < authoredWidth;
1490
1575
  try {
1576
+ if (hug && text) {
1577
+ text.set({ width: fitted });
1578
+ text.initDimensions?.();
1579
+ }
1491
1580
  const el = source.toCanvasElement({ multiplier: wanted, enableRetinaScaling: false });
1492
1581
  if (!el.width || !el.height) return null;
1493
1582
  this.snapshotEl = el;
@@ -1496,6 +1585,10 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1496
1585
  return this.snapshotEl;
1497
1586
  } finally {
1498
1587
  source.opacity = opacity;
1588
+ if (hug && text && authoredWidth !== void 0) {
1589
+ text.set({ width: authoredWidth });
1590
+ text.initDimensions?.();
1591
+ }
1499
1592
  source.dirty = false;
1500
1593
  }
1501
1594
  return this.snapshotEl;
@@ -1782,7 +1875,11 @@ function normalizeTextCurve(config) {
1782
1875
  shape: config.shape ?? inferred,
1783
1876
  arc,
1784
1877
  wave,
1785
- waveLength: clamp(config.waveLength ?? DEFAULT_TEXT_CURVE.waveLength, MIN_WAVE_LENGTH, MAX_WAVE_LENGTH),
1878
+ waveLength: clamp(
1879
+ config.waveLength ?? DEFAULT_TEXT_CURVE.waveLength,
1880
+ MIN_WAVE_LENGTH,
1881
+ MAX_WAVE_LENGTH
1882
+ ),
1786
1883
  offset: clamp(config.offset ?? 0, -100, 100),
1787
1884
  centerOffset: clamp(config.centerOffset ?? 0, -100, 100)
1788
1885
  };
@@ -1872,18 +1969,55 @@ function buildCurvePathData(config, width, fontSize) {
1872
1969
  }
1873
1970
 
1874
1971
  // src/text-curve.ts
1875
- var MEASURE_WIDTH = 1e5;
1972
+ var MEASURE_WIDTH2 = 1e5;
1876
1973
  var PATH_SLACK = 0.06;
1877
1974
  var FALLBACK_LINE_HEIGHT = 1.16;
1878
1975
  var patches = /* @__PURE__ */ new WeakMap();
1976
+ var dimensionPatches = /* @__PURE__ */ new WeakMap();
1879
1977
  var authoredCaching = /* @__PURE__ */ new WeakMap();
1978
+ var GLYPH_HALF_HEIGHT = 0.7;
1979
+ function curvedInkSize(text) {
1980
+ const lineCount = text._textLines?.length ?? 0;
1981
+ for (let index = 0; index < lineCount; index += 1) text.getLineWidth?.(index);
1982
+ const lines = text.__charBounds;
1983
+ if (!lines?.length) return null;
1984
+ let halfWidth = 0;
1985
+ let halfHeight = 0;
1986
+ for (const line of lines) {
1987
+ for (const box of line ?? []) {
1988
+ const { renderLeft, renderTop } = box;
1989
+ if (typeof renderLeft !== "number" || typeof renderTop !== "number") continue;
1990
+ const halfGlyphWidth = (box.kernedWidth ?? box.width ?? 0) / 2;
1991
+ const halfGlyphHeight = (box.height ?? text.fontSize) * GLYPH_HALF_HEIGHT;
1992
+ const angle = box.angle ?? 0;
1993
+ const cos = Math.abs(Math.cos(angle));
1994
+ const sin = Math.abs(Math.sin(angle));
1995
+ halfWidth = Math.max(
1996
+ halfWidth,
1997
+ Math.abs(renderLeft) + halfGlyphWidth * cos + halfGlyphHeight * sin
1998
+ );
1999
+ halfHeight = Math.max(
2000
+ halfHeight,
2001
+ Math.abs(renderTop) + halfGlyphWidth * sin + halfGlyphHeight * cos
2002
+ );
2003
+ }
2004
+ }
2005
+ if (halfWidth === 0 && halfHeight === 0) return null;
2006
+ return { width: halfWidth * 2, height: halfHeight * 2 };
2007
+ }
2008
+ function growBoxToInk(text) {
2009
+ const ink = curvedInkSize(text);
2010
+ if (!ink) return;
2011
+ if (ink.width > (text.width ?? 0)) text.width = ink.width;
2012
+ if (ink.height > (text.height ?? 0)) text.height = ink.height;
2013
+ }
1880
2014
  function isCurvable(object) {
1881
2015
  return !!object && typeof object.text === "string";
1882
2016
  }
1883
2017
  function measureText(text) {
1884
2018
  const authored = text.width;
1885
2019
  try {
1886
- text.set({ width: MEASURE_WIDTH });
2020
+ text.set({ width: MEASURE_WIDTH2 });
1887
2021
  text.initDimensions?.();
1888
2022
  return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1889
2023
  } finally {
@@ -2028,6 +2162,35 @@ var TextCurveManager = class {
2028
2162
  pathStartOffset: 0
2029
2163
  });
2030
2164
  this.patchLineMeasure(text, paths, union);
2165
+ this.patchDimensions(text);
2166
+ }
2167
+ /**
2168
+ * Re-apply {@link growBoxToInk} after every one of fabric's own dimension
2169
+ * passes. Typing, a font change, a style edit and a state restore all call
2170
+ * `initDimensions`, and each one puts the box back to the path's bounding box
2171
+ * — so growing it once, here, would hold only until the next keystroke.
2172
+ */
2173
+ patchDimensions(text) {
2174
+ if (dimensionPatches.has(text)) return;
2175
+ const original = text.initDimensions;
2176
+ if (typeof original !== "function") return;
2177
+ const installed = function() {
2178
+ original.call(this);
2179
+ growBoxToInk(this);
2180
+ };
2181
+ const ownedOriginal = Object.prototype.hasOwnProperty.call(text, "initDimensions");
2182
+ text.initDimensions = installed;
2183
+ dimensionPatches.set(text, { original, installed, ownedOriginal });
2184
+ }
2185
+ /** Restore fabric's own dimension pass, if this manager replaced it. */
2186
+ unpatchDimensions(text) {
2187
+ const patch = dimensionPatches.get(text);
2188
+ if (!patch) return;
2189
+ if (text.initDimensions === patch.installed) {
2190
+ if (patch.ownedOriginal) text.initDimensions = patch.original;
2191
+ else delete text.initDimensions;
2192
+ }
2193
+ dimensionPatches.delete(text);
2031
2194
  }
2032
2195
  /**
2033
2196
  * Fabric lays every line of a text object along `this.path`, from one
@@ -2079,6 +2242,7 @@ var TextCurveManager = class {
2079
2242
  }
2080
2243
  detach(text, authoredWidth) {
2081
2244
  this.unpatchLineMeasure(text);
2245
+ this.unpatchDimensions(text);
2082
2246
  const caching = authoredCaching.get(text);
2083
2247
  if (caching !== void 0) {
2084
2248
  text.set({ objectCaching: caching });
@@ -3620,8 +3784,8 @@ var LayerMaskManager = class extends MaskStackStore {
3620
3784
  onLayersChanged = () => this.pin();
3621
3785
  // Selecting a layer means the user has moved on from the mask they had open;
3622
3786
  // leaving both selected would show mask controls for an unrelated layer.
3623
- onLayerSelected = () => {
3624
- if (this.selected) this.select(null, null);
3787
+ onLayerSelected = ({ layerId }) => {
3788
+ if (layerId && this.selected) this.select(null, null);
3625
3789
  };
3626
3790
  onObjectModified = (event) => {
3627
3791
  const object = event.target;
@@ -3681,6 +3845,7 @@ var LayerMaskManager = class extends MaskStackStore {
3681
3845
  }
3682
3846
  select(target, maskId) {
3683
3847
  this.selected = target && maskId ? { target, maskId } : null;
3848
+ if (this.selected) this.layers.select(null);
3684
3849
  this.events.emit("mask:selected", { target, maskId: this.selected ? maskId : null });
3685
3850
  }
3686
3851
  // ─── Mutations ───────────────────────────────────────
@@ -3841,6 +4006,9 @@ var LayerMaskManager = class extends MaskStackStore {
3841
4006
  if (resolved === layerId) return null;
3842
4007
  const object = layer.fabricObject;
3843
4008
  if (!object.fill && object.type !== "image") object.set({ fill: "#000000" });
4009
+ const stackHost = this.host(resolved);
4010
+ const absolute = resolved === CANVAS_MASK_TARGET || needsAbsoluteSpace(this.list(resolved));
4011
+ if (!absolute && stackHost) toHostSpace(object, stackHost);
3844
4012
  this.history.beginTransaction();
3845
4013
  try {
3846
4014
  if (this.canvas.getActiveObject() === object) this.canvas.discardActiveObject();
@@ -4902,11 +5070,15 @@ var DEFAULT_PATTERN_CONFIG = {
4902
5070
  horizontalSpacing: 0,
4903
5071
  verticalSpacing: 0,
4904
5072
  angle: 0,
4905
- horizontalOffset: 0,
5073
+ // Half a tile: the offset only bites in the brick modes, and a brick course
5074
+ // that is not half-shifted is just a grid with extra steps.
5075
+ horizontalOffset: 50,
4906
5076
  offsetX: 0,
4907
5077
  offsetY: 0,
4908
5078
  rotationStepH: 0,
4909
- rotationStepV: 0
5079
+ rotationStepV: 0,
5080
+ horizontalLimit: 0,
5081
+ verticalLimit: 0
4910
5082
  };
4911
5083
 
4912
5084
  // src/presets.ts
@@ -5011,6 +5183,7 @@ export {
5011
5183
  exportPNG,
5012
5184
  exportPrintArea,
5013
5185
  exportSVG,
5186
+ fitTextWidth,
5014
5187
  fitToBox,
5015
5188
  generateId,
5016
5189
  isCssColor,