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

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;
@@ -1872,18 +1965,55 @@ function buildCurvePathData(config, width, fontSize) {
1872
1965
  }
1873
1966
 
1874
1967
  // src/text-curve.ts
1875
- var MEASURE_WIDTH = 1e5;
1968
+ var MEASURE_WIDTH2 = 1e5;
1876
1969
  var PATH_SLACK = 0.06;
1877
1970
  var FALLBACK_LINE_HEIGHT = 1.16;
1878
1971
  var patches = /* @__PURE__ */ new WeakMap();
1972
+ var dimensionPatches = /* @__PURE__ */ new WeakMap();
1879
1973
  var authoredCaching = /* @__PURE__ */ new WeakMap();
1974
+ var GLYPH_HALF_HEIGHT = 0.7;
1975
+ function curvedInkSize(text) {
1976
+ const lineCount = text._textLines?.length ?? 0;
1977
+ for (let index = 0; index < lineCount; index += 1) text.getLineWidth?.(index);
1978
+ const lines = text.__charBounds;
1979
+ if (!lines?.length) return null;
1980
+ let halfWidth = 0;
1981
+ let halfHeight = 0;
1982
+ for (const line of lines) {
1983
+ for (const box of line ?? []) {
1984
+ const { renderLeft, renderTop } = box;
1985
+ if (typeof renderLeft !== "number" || typeof renderTop !== "number") continue;
1986
+ const halfGlyphWidth = (box.kernedWidth ?? box.width ?? 0) / 2;
1987
+ const halfGlyphHeight = (box.height ?? text.fontSize) * GLYPH_HALF_HEIGHT;
1988
+ const angle = box.angle ?? 0;
1989
+ const cos = Math.abs(Math.cos(angle));
1990
+ const sin = Math.abs(Math.sin(angle));
1991
+ halfWidth = Math.max(
1992
+ halfWidth,
1993
+ Math.abs(renderLeft) + halfGlyphWidth * cos + halfGlyphHeight * sin
1994
+ );
1995
+ halfHeight = Math.max(
1996
+ halfHeight,
1997
+ Math.abs(renderTop) + halfGlyphWidth * sin + halfGlyphHeight * cos
1998
+ );
1999
+ }
2000
+ }
2001
+ if (halfWidth === 0 && halfHeight === 0) return null;
2002
+ return { width: halfWidth * 2, height: halfHeight * 2 };
2003
+ }
2004
+ function growBoxToInk(text) {
2005
+ const ink = curvedInkSize(text);
2006
+ if (!ink) return;
2007
+ if (ink.width > (text.width ?? 0)) text.width = ink.width;
2008
+ if (ink.height > (text.height ?? 0)) text.height = ink.height;
2009
+ }
1880
2010
  function isCurvable(object) {
1881
2011
  return !!object && typeof object.text === "string";
1882
2012
  }
1883
2013
  function measureText(text) {
1884
2014
  const authored = text.width;
1885
2015
  try {
1886
- text.set({ width: MEASURE_WIDTH });
2016
+ text.set({ width: MEASURE_WIDTH2 });
1887
2017
  text.initDimensions?.();
1888
2018
  return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1889
2019
  } finally {
@@ -2028,6 +2158,35 @@ var TextCurveManager = class {
2028
2158
  pathStartOffset: 0
2029
2159
  });
2030
2160
  this.patchLineMeasure(text, paths, union);
2161
+ this.patchDimensions(text);
2162
+ }
2163
+ /**
2164
+ * Re-apply {@link growBoxToInk} after every one of fabric's own dimension
2165
+ * passes. Typing, a font change, a style edit and a state restore all call
2166
+ * `initDimensions`, and each one puts the box back to the path's bounding box
2167
+ * — so growing it once, here, would hold only until the next keystroke.
2168
+ */
2169
+ patchDimensions(text) {
2170
+ if (dimensionPatches.has(text)) return;
2171
+ const original = text.initDimensions;
2172
+ if (typeof original !== "function") return;
2173
+ const installed = function() {
2174
+ original.call(this);
2175
+ growBoxToInk(this);
2176
+ };
2177
+ const ownedOriginal = Object.prototype.hasOwnProperty.call(text, "initDimensions");
2178
+ text.initDimensions = installed;
2179
+ dimensionPatches.set(text, { original, installed, ownedOriginal });
2180
+ }
2181
+ /** Restore fabric's own dimension pass, if this manager replaced it. */
2182
+ unpatchDimensions(text) {
2183
+ const patch = dimensionPatches.get(text);
2184
+ if (!patch) return;
2185
+ if (text.initDimensions === patch.installed) {
2186
+ if (patch.ownedOriginal) text.initDimensions = patch.original;
2187
+ else delete text.initDimensions;
2188
+ }
2189
+ dimensionPatches.delete(text);
2031
2190
  }
2032
2191
  /**
2033
2192
  * Fabric lays every line of a text object along `this.path`, from one
@@ -2079,6 +2238,7 @@ var TextCurveManager = class {
2079
2238
  }
2080
2239
  detach(text, authoredWidth) {
2081
2240
  this.unpatchLineMeasure(text);
2241
+ this.unpatchDimensions(text);
2082
2242
  const caching = authoredCaching.get(text);
2083
2243
  if (caching !== void 0) {
2084
2244
  text.set({ objectCaching: caching });
@@ -3620,8 +3780,8 @@ var LayerMaskManager = class extends MaskStackStore {
3620
3780
  onLayersChanged = () => this.pin();
3621
3781
  // Selecting a layer means the user has moved on from the mask they had open;
3622
3782
  // leaving both selected would show mask controls for an unrelated layer.
3623
- onLayerSelected = () => {
3624
- if (this.selected) this.select(null, null);
3783
+ onLayerSelected = ({ layerId }) => {
3784
+ if (layerId && this.selected) this.select(null, null);
3625
3785
  };
3626
3786
  onObjectModified = (event) => {
3627
3787
  const object = event.target;
@@ -3681,6 +3841,7 @@ var LayerMaskManager = class extends MaskStackStore {
3681
3841
  }
3682
3842
  select(target, maskId) {
3683
3843
  this.selected = target && maskId ? { target, maskId } : null;
3844
+ if (this.selected) this.layers.select(null);
3684
3845
  this.events.emit("mask:selected", { target, maskId: this.selected ? maskId : null });
3685
3846
  }
3686
3847
  // ─── Mutations ───────────────────────────────────────
@@ -4902,11 +5063,15 @@ var DEFAULT_PATTERN_CONFIG = {
4902
5063
  horizontalSpacing: 0,
4903
5064
  verticalSpacing: 0,
4904
5065
  angle: 0,
4905
- horizontalOffset: 0,
5066
+ // Half a tile: the offset only bites in the brick modes, and a brick course
5067
+ // that is not half-shifted is just a grid with extra steps.
5068
+ horizontalOffset: 50,
4906
5069
  offsetX: 0,
4907
5070
  offsetY: 0,
4908
5071
  rotationStepH: 0,
4909
- rotationStepV: 0
5072
+ rotationStepV: 0,
5073
+ horizontalLimit: 0,
5074
+ verticalLimit: 0
4910
5075
  };
4911
5076
 
4912
5077
  // src/presets.ts
@@ -5011,6 +5176,7 @@ export {
5011
5176
  exportPNG,
5012
5177
  exportPrintArea,
5013
5178
  exportSVG,
5179
+ fitTextWidth,
5014
5180
  fitToBox,
5015
5181
  generateId,
5016
5182
  isCssColor,