@overtone-art/canvas-editor-core 0.8.4 → 0.8.6

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
@@ -3387,6 +3387,123 @@ import { Group as Group6 } from "fabric";
3387
3387
 
3388
3388
  // src/masks/compose.ts
3389
3389
  import { Group as Group2, Rect as Rect2 } from "fabric";
3390
+
3391
+ // src/gradient.ts
3392
+ import { Color, Gradient } from "fabric";
3393
+ var DEFAULT_GRADIENT_CONFIG = {
3394
+ kind: "linear",
3395
+ angle: 90,
3396
+ center: { x: 0.5, y: 0.5 },
3397
+ radius: 0.5,
3398
+ stops: [
3399
+ { color: "#ffffff", opacity: 1, position: 0 },
3400
+ { color: "#000000", opacity: 1, position: 1 }
3401
+ ]
3402
+ };
3403
+ var MIN_RADIUS = 1e-3;
3404
+ var MAX_GRADIENT_STOPS = 256;
3405
+ var FALLBACK_STOP_COLOR = DEFAULT_GRADIENT_CONFIG.stops[0].color;
3406
+ function linearCoords(angle, box) {
3407
+ const radians = angle * Math.PI / 180;
3408
+ const sin = Math.sin(radians);
3409
+ const cos = Math.cos(radians);
3410
+ const vx = box.width * sin;
3411
+ const vy = -box.height * cos;
3412
+ const length = Math.abs(box.width * sin) + Math.abs(box.height * cos);
3413
+ const scale = length / (vx * vx + vy * vy || 1);
3414
+ return {
3415
+ x1: 0.5 - scale * vx / 2,
3416
+ y1: 0.5 - scale * vy / 2,
3417
+ x2: 0.5 + scale * vx / 2,
3418
+ y2: 0.5 + scale * vy / 2
3419
+ };
3420
+ }
3421
+ function angleFromCoords(coords, box) {
3422
+ const dx = (coords.x2 - coords.x1) / (box.width || 1);
3423
+ const dy = (coords.y2 - coords.y1) / (box.height || 1);
3424
+ const degrees = Math.atan2(dx, -dy) * 180 / Math.PI;
3425
+ return (degrees + 360) % 360;
3426
+ }
3427
+ function stopColor(stop) {
3428
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
3429
+ const color = new Color(source);
3430
+ const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
3431
+ color.setAlpha(clamp(opacity, 0, 1));
3432
+ return color.toRgba();
3433
+ }
3434
+ function toFabricGradient(config, box) {
3435
+ const colorStops = [...config.stops].sort((a, b) => a.position - b.position).slice(0, MAX_GRADIENT_STOPS).map((stop) => {
3436
+ const position = Number.isFinite(stop.position) ? stop.position : 0;
3437
+ return { offset: clamp(position, 0, 1), color: stopColor(stop) };
3438
+ });
3439
+ if (config.kind === "radial") {
3440
+ const radius = Math.max(MIN_RADIUS, config.radius);
3441
+ return new Gradient({
3442
+ type: "radial",
3443
+ gradientUnits: "percentage",
3444
+ coords: {
3445
+ x1: config.center.x,
3446
+ y1: config.center.y,
3447
+ r1: 0,
3448
+ x2: config.center.x,
3449
+ y2: config.center.y,
3450
+ r2: radius
3451
+ },
3452
+ colorStops
3453
+ });
3454
+ }
3455
+ return new Gradient({
3456
+ type: "linear",
3457
+ gradientUnits: "percentage",
3458
+ coords: linearCoords(config.angle, box),
3459
+ colorStops
3460
+ });
3461
+ }
3462
+ function toFabricMaskGradient(config, box) {
3463
+ const gradient = toFabricGradient(config, box);
3464
+ gradient.colorStops = gradient.colorStops.map((stop) => {
3465
+ const color = new Color(stop.color);
3466
+ const [red, green, blue] = color.getSource();
3467
+ const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
3468
+ color.setSource([255, 255, 255, clamp(luminance * color.getAlpha(), 0, 1)]);
3469
+ return { offset: stop.offset, color: color.toRgba() };
3470
+ });
3471
+ return gradient;
3472
+ }
3473
+ function readGradientConfig(object) {
3474
+ const fill = object.fill;
3475
+ if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
3476
+ const stops = fill.colorStops.map((stop) => {
3477
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
3478
+ const color = new Color(source);
3479
+ const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
3480
+ return {
3481
+ color: `#${color.toHex().toLowerCase()}`,
3482
+ opacity: color.getAlpha(),
3483
+ position: clamp(offset, 0, 1)
3484
+ };
3485
+ });
3486
+ if (fill.type === "radial") {
3487
+ const { x1, y1, r2 } = fill.coords;
3488
+ return {
3489
+ kind: "radial",
3490
+ angle: DEFAULT_GRADIENT_CONFIG.angle,
3491
+ center: { x: x1, y: y1 },
3492
+ radius: r2,
3493
+ stops
3494
+ };
3495
+ }
3496
+ const box = { width: object.width || 1, height: object.height || 1 };
3497
+ return {
3498
+ kind: "linear",
3499
+ angle: angleFromCoords(fill.coords, box),
3500
+ center: { ...DEFAULT_GRADIENT_CONFIG.center },
3501
+ radius: DEFAULT_GRADIENT_CONFIG.radius,
3502
+ stops
3503
+ };
3504
+ }
3505
+
3506
+ // src/masks/compose.ts
3390
3507
  var MODE_OPERATION = {
3391
3508
  add: "source-over",
3392
3509
  subtract: "destination-out",
@@ -3416,13 +3533,21 @@ function composeMaskGroup(children, entries, options) {
3416
3533
  neutralize(child);
3417
3534
  return;
3418
3535
  }
3536
+ if (entry.gradient) {
3537
+ child.set({
3538
+ fill: toFabricMaskGradient(entry.gradient, {
3539
+ width: Math.max(1, child.width ?? 1),
3540
+ height: Math.max(1, child.height ?? 1)
3541
+ })
3542
+ });
3543
+ }
3419
3544
  child.set({
3420
3545
  opacity: entry.opacity,
3421
3546
  globalCompositeOperation: MODE_OPERATION[entry.mode] ?? "source-over"
3422
3547
  });
3423
3548
  });
3424
3549
  const first = entries.find((entry) => entry.visible);
3425
- const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
3550
+ const withBase = !first || first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
3426
3551
  return new Group2(withBase, {
3427
3552
  absolutePositioned: options.absolute,
3428
3553
  // Cached, so the children's compositing operations resolve against each
@@ -3438,10 +3563,104 @@ function needsAbsoluteSpace(entries) {
3438
3563
 
3439
3564
  // src/masks/edit.ts
3440
3565
  import { util as util3 } from "fabric";
3566
+
3567
+ // src/masks/edit-viewport.ts
3568
+ var MIN_PADDING = 48;
3569
+ var MAX_PADDING = 512;
3570
+ var PADDING_FRACTION = 0.35;
3571
+ var OUTSIDE_SHADE = "rgba(15, 15, 15, 0.55)";
3572
+ var MaskEditViewport = class {
3573
+ constructor(canvas) {
3574
+ this.canvas = canvas;
3575
+ }
3576
+ canvas;
3577
+ state = null;
3578
+ open(handle) {
3579
+ if (this.state) return;
3580
+ const width = this.canvas.getWidth();
3581
+ const height = this.canvas.getHeight();
3582
+ const base = Math.min(
3583
+ MAX_PADDING,
3584
+ Math.max(MIN_PADDING, Math.max(width, height) * PADDING_FRACTION)
3585
+ );
3586
+ handle.setCoords();
3587
+ const bounds = handle.getBoundingRect();
3588
+ const left = Math.min(MAX_PADDING, Math.max(base, -bounds.left + MIN_PADDING));
3589
+ const top = Math.min(MAX_PADDING, Math.max(base, -bounds.top + MIN_PADDING));
3590
+ const right = Math.min(
3591
+ MAX_PADDING,
3592
+ Math.max(base, bounds.left + bounds.width - width + MIN_PADDING)
3593
+ );
3594
+ const bottom = Math.min(
3595
+ MAX_PADDING,
3596
+ Math.max(base, bounds.top + bounds.height - height + MIN_PADDING)
3597
+ );
3598
+ const viewport = [...this.canvas.viewportTransform];
3599
+ const wrapper = this.canvas.wrapperEl;
3600
+ this.state = {
3601
+ width,
3602
+ height,
3603
+ left,
3604
+ top,
3605
+ viewport,
3606
+ wrapperCss: wrapper.style.cssText
3607
+ };
3608
+ this.canvas.setDimensions({ width: width + left + right, height: height + top + bottom });
3609
+ const paddedViewport = [...viewport];
3610
+ paddedViewport[4] += left;
3611
+ paddedViewport[5] += top;
3612
+ this.canvas.setViewportTransform(paddedViewport);
3613
+ wrapper.style.position = "absolute";
3614
+ wrapper.style.left = `${-left}px`;
3615
+ wrapper.style.top = `${-top}px`;
3616
+ wrapper.style.zIndex = "1";
3617
+ this.canvas.on("after:render", this.onAfterRender);
3618
+ this.canvas.calcOffset();
3619
+ this.canvas.requestRenderAll();
3620
+ }
3621
+ close() {
3622
+ const state = this.state;
3623
+ if (!state) return;
3624
+ this.canvas.off("after:render", this.onAfterRender);
3625
+ this.canvas.setViewportTransform(state.viewport);
3626
+ this.canvas.setDimensions({ width: state.width, height: state.height });
3627
+ this.canvas.wrapperEl.style.cssText = state.wrapperCss;
3628
+ this.canvas.calcOffset();
3629
+ this.state = null;
3630
+ }
3631
+ /** Save with real document dimensions, then reopen the same editing view. */
3632
+ checkpoint(handle, save) {
3633
+ this.close();
3634
+ try {
3635
+ save();
3636
+ } finally {
3637
+ this.open(handle);
3638
+ }
3639
+ }
3640
+ onAfterRender = ({ ctx }) => {
3641
+ const state = this.state;
3642
+ if (!state || ctx !== this.canvas.getContext()) return;
3643
+ const fullWidth = this.canvas.getWidth();
3644
+ const fullHeight = this.canvas.getHeight();
3645
+ const right = state.left + state.width;
3646
+ const bottom = state.top + state.height;
3647
+ ctx.save();
3648
+ ctx.fillStyle = OUTSIDE_SHADE;
3649
+ ctx.fillRect(0, 0, fullWidth, state.top);
3650
+ ctx.fillRect(0, bottom, fullWidth, fullHeight - bottom);
3651
+ ctx.fillRect(0, state.top, state.left, state.height);
3652
+ ctx.fillRect(right, state.top, fullWidth - right, state.height);
3653
+ ctx.restore();
3654
+ this.canvas.drawControls(ctx);
3655
+ };
3656
+ };
3657
+
3658
+ // src/masks/edit.ts
3441
3659
  var MaskEditController = class {
3442
3660
  constructor(canvas, onCommit) {
3443
3661
  this.canvas = canvas;
3444
3662
  this.onCommit = onCommit;
3663
+ this.viewport = new MaskEditViewport(canvas);
3445
3664
  }
3446
3665
  canvas;
3447
3666
  onCommit;
@@ -3449,6 +3668,7 @@ var MaskEditController = class {
3449
3668
  editing = null;
3450
3669
  child = null;
3451
3670
  group = null;
3671
+ viewport;
3452
3672
  active() {
3453
3673
  return this.editing;
3454
3674
  }
@@ -3479,6 +3699,7 @@ var MaskEditController = class {
3479
3699
  this.group = group;
3480
3700
  this.canvas.add(handle);
3481
3701
  this.canvas.setActiveObject(handle);
3702
+ this.viewport.open(handle);
3482
3703
  this.canvas.on("object:moving", this.onTransform);
3483
3704
  this.canvas.on("object:scaling", this.onTransform);
3484
3705
  this.canvas.on("object:rotating", this.onTransform);
@@ -3493,6 +3714,7 @@ var MaskEditController = class {
3493
3714
  this.canvas.off("object:scaling", this.onTransform);
3494
3715
  this.canvas.off("object:rotating", this.onTransform);
3495
3716
  this.canvas.off("object:modified", this.onModified);
3717
+ this.viewport.close();
3496
3718
  if (this.canvas.getActiveObject() === this.handle) this.canvas.discardActiveObject();
3497
3719
  this.canvas.remove(this.handle);
3498
3720
  this.handle = null;
@@ -3511,7 +3733,7 @@ var MaskEditController = class {
3511
3733
  onModified = (event) => {
3512
3734
  if (!this.handle || event.target !== this.handle) return;
3513
3735
  this.write();
3514
- this.onCommit();
3736
+ this.viewport.checkpoint(this.handle, this.onCommit);
3515
3737
  };
3516
3738
  /** Handle transform (canvas space) → clip child transform (group-relative). */
3517
3739
  write() {
@@ -3530,7 +3752,7 @@ var MaskEditController = class {
3530
3752
  };
3531
3753
 
3532
3754
  // src/masks/store.ts
3533
- import { Group as Group5 } from "fabric";
3755
+ import { Gradient as Gradient2, Group as Group5 } from "fabric";
3534
3756
 
3535
3757
  // src/masks/host.ts
3536
3758
  import { Rect as Rect3 } from "fabric";
@@ -3642,6 +3864,29 @@ var MaskStackStore = class {
3642
3864
  get(target, maskId) {
3643
3865
  return this.list(target).find((entry) => entry.id === maskId);
3644
3866
  }
3867
+ /** Repaint a gradient mask without replacing or re-fitting its geometry. */
3868
+ setGradient(target, maskId, gradient, options = {}) {
3869
+ const host = this.host(target);
3870
+ if (!host) return false;
3871
+ const entries = [...this.list(target)];
3872
+ const index = entries.findIndex((entry) => entry.id === maskId);
3873
+ if (index === -1) return false;
3874
+ const sources = this.unwrap(target, host);
3875
+ const source = sources[index];
3876
+ if (!source) return false;
3877
+ source.set({
3878
+ fill: toFabricGradient(gradient, {
3879
+ width: Math.max(1, source.width ?? 1),
3880
+ height: Math.max(1, source.height ?? 1)
3881
+ })
3882
+ });
3883
+ entries[index] = {
3884
+ ...entries[index],
3885
+ ...options.meta ? { meta: options.meta } : {}
3886
+ };
3887
+ this.commit(target, host, entries, sources, options.save ?? true);
3888
+ return true;
3889
+ }
3645
3890
  /** Every target that currently carries at least one mask. */
3646
3891
  targets() {
3647
3892
  return this.layers.getAll().filter((layer) => (layer.meta.maskStack ?? []).length > 0).map((layer) => layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
@@ -3705,11 +3950,11 @@ var MaskStackStore = class {
3705
3950
  if (entries.length === 0 || !(clip instanceof Group5)) {
3706
3951
  const source = asObject(clip);
3707
3952
  if (meta?.overflow === "hidden") source.clipPath = void 0;
3708
- return [source];
3953
+ return restoreGradientPaint(entries, [source]);
3709
3954
  }
3710
3955
  const children = unwrapGroup(clip);
3711
3956
  const extra = children.length - entries.length;
3712
- return extra > 0 ? children.slice(extra) : children;
3957
+ return restoreGradientPaint(entries, extra > 0 ? children.slice(extra) : children);
3713
3958
  }
3714
3959
  /**
3715
3960
  * Entries for a stack, adopting a pre-stack clip as the first one. Without
@@ -3742,7 +3987,8 @@ var MaskStackStore = class {
3742
3987
  if (!layer) return;
3743
3988
  const absolute = forceAbsolute || target === CANVAS_MASK_TARGET || needsAbsoluteSpace(entries);
3744
3989
  convertSpace(host, sources, absolute);
3745
- const next = withRelativeTransforms(entries, sources, host, absolute);
3990
+ const painted = captureGradientPaint(entries, sources);
3991
+ const next = withRelativeTransforms(painted, sources, host, absolute);
3746
3992
  installClip(host, next, sources, this.hostBox(target, absolute), absolute);
3747
3993
  if (next.length === 0) {
3748
3994
  delete layer.meta.maskStack;
@@ -3756,12 +4002,41 @@ var MaskStackStore = class {
3756
4002
  if (save) this.history.save();
3757
4003
  }
3758
4004
  };
4005
+ function restoreGradientPaint(entries, sources) {
4006
+ entries.forEach((entry, index) => {
4007
+ const source = sources[index];
4008
+ if (!source || !entry.gradient) return;
4009
+ const box = {
4010
+ width: Math.max(1, source.width ?? 1),
4011
+ height: Math.max(1, source.height ?? 1)
4012
+ };
4013
+ const fill = source.fill;
4014
+ const rendered = toFabricMaskGradient(entry.gradient, box);
4015
+ if (fill instanceof Gradient2 && JSON.stringify(fill.toObject()) !== JSON.stringify(rendered.toObject())) {
4016
+ return;
4017
+ }
4018
+ source.set({
4019
+ fill: toFabricGradient(entry.gradient, box)
4020
+ });
4021
+ });
4022
+ return sources;
4023
+ }
4024
+ function captureGradientPaint(entries, sources) {
4025
+ return entries.map((entry, index) => {
4026
+ const gradient = sources[index] ? readGradientConfig(sources[index]) : null;
4027
+ if (gradient) return { ...entry, gradient };
4028
+ if (!entry.gradient) return entry;
4029
+ const next = { ...entry };
4030
+ delete next.gradient;
4031
+ return next;
4032
+ });
4033
+ }
3759
4034
 
3760
4035
  // src/masks/manager.ts
3761
4036
  var LayerMaskManager = class extends MaskStackStore {
3762
4037
  // Committing mid-drag would recompose the group the handle writes into and
3763
4038
  // leave it pointing at a discarded object; the geometry is settled on endEdit.
3764
- edits = new MaskEditController(this.canvas, () => this.history.save());
4039
+ edits = new MaskEditController(this.canvas, () => this.history.saveImmediate());
3765
4040
  onLayersChanged = () => this.pin();
3766
4041
  // Selecting a layer means the user has moved on from the mask they had open;
3767
4042
  // leaving both selected would show mask controls for an unrelated layer.
@@ -4148,110 +4423,6 @@ function layerTypeOf(object) {
4148
4423
  return "shape";
4149
4424
  }
4150
4425
 
4151
- // src/gradient.ts
4152
- import { Color, Gradient } from "fabric";
4153
- var DEFAULT_GRADIENT_CONFIG = {
4154
- kind: "linear",
4155
- angle: 90,
4156
- center: { x: 0.5, y: 0.5 },
4157
- radius: 0.5,
4158
- stops: [
4159
- { color: "#ffffff", opacity: 1, position: 0 },
4160
- { color: "#000000", opacity: 1, position: 1 }
4161
- ]
4162
- };
4163
- var MIN_RADIUS = 1e-3;
4164
- var MAX_GRADIENT_STOPS = 256;
4165
- var FALLBACK_STOP_COLOR = DEFAULT_GRADIENT_CONFIG.stops[0].color;
4166
- function linearCoords(angle, box) {
4167
- const radians = angle * Math.PI / 180;
4168
- const sin = Math.sin(radians);
4169
- const cos = Math.cos(radians);
4170
- const vx = box.width * sin;
4171
- const vy = -box.height * cos;
4172
- const length = Math.abs(box.width * sin) + Math.abs(box.height * cos);
4173
- const scale = length / (vx * vx + vy * vy || 1);
4174
- return {
4175
- x1: 0.5 - scale * vx / 2,
4176
- y1: 0.5 - scale * vy / 2,
4177
- x2: 0.5 + scale * vx / 2,
4178
- y2: 0.5 + scale * vy / 2
4179
- };
4180
- }
4181
- function angleFromCoords(coords, box) {
4182
- const dx = (coords.x2 - coords.x1) / (box.width || 1);
4183
- const dy = (coords.y2 - coords.y1) / (box.height || 1);
4184
- const degrees = Math.atan2(dx, -dy) * 180 / Math.PI;
4185
- return (degrees + 360) % 360;
4186
- }
4187
- function stopColor(stop) {
4188
- const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4189
- const color = new Color(source);
4190
- const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
4191
- color.setAlpha(clamp(opacity, 0, 1));
4192
- return color.toRgba();
4193
- }
4194
- function toFabricGradient(config, box) {
4195
- const colorStops = [...config.stops].sort((a, b) => a.position - b.position).slice(0, MAX_GRADIENT_STOPS).map((stop) => {
4196
- const position = Number.isFinite(stop.position) ? stop.position : 0;
4197
- return { offset: clamp(position, 0, 1), color: stopColor(stop) };
4198
- });
4199
- if (config.kind === "radial") {
4200
- const radius = Math.max(MIN_RADIUS, config.radius);
4201
- return new Gradient({
4202
- type: "radial",
4203
- gradientUnits: "percentage",
4204
- coords: {
4205
- x1: config.center.x,
4206
- y1: config.center.y,
4207
- r1: 0,
4208
- x2: config.center.x,
4209
- y2: config.center.y,
4210
- r2: radius
4211
- },
4212
- colorStops
4213
- });
4214
- }
4215
- return new Gradient({
4216
- type: "linear",
4217
- gradientUnits: "percentage",
4218
- coords: linearCoords(config.angle, box),
4219
- colorStops
4220
- });
4221
- }
4222
- function readGradientConfig(object) {
4223
- const fill = object.fill;
4224
- if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
4225
- const stops = fill.colorStops.map((stop) => {
4226
- const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4227
- const color = new Color(source);
4228
- const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
4229
- return {
4230
- color: `#${color.toHex().toLowerCase()}`,
4231
- opacity: color.getAlpha(),
4232
- position: clamp(offset, 0, 1)
4233
- };
4234
- });
4235
- if (fill.type === "radial") {
4236
- const { x1, y1, r2 } = fill.coords;
4237
- return {
4238
- kind: "radial",
4239
- angle: DEFAULT_GRADIENT_CONFIG.angle,
4240
- center: { x: x1, y: y1 },
4241
- radius: r2,
4242
- stops
4243
- };
4244
- }
4245
- const box = { width: object.width || 1, height: object.height || 1 };
4246
- return {
4247
- kind: "linear",
4248
- angle: angleFromCoords(fill.coords, box),
4249
- center: { ...DEFAULT_GRADIENT_CONFIG.center },
4250
- radius: DEFAULT_GRADIENT_CONFIG.radius,
4251
- stops
4252
- };
4253
- }
4254
-
4255
4426
  // src/editor.ts
4256
4427
  var MIN_ZOOM = 0.01;
4257
4428
  var MAX_ZOOM = 8;