@overtone-art/canvas-editor-core 0.7.0 → 0.8.1

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
@@ -29,7 +29,7 @@ import {
29
29
  } from "./chunk-XNGPX7FG.mjs";
30
30
 
31
31
  // src/editor.ts
32
- import { Canvas, FabricImage as FabricImage3, Group as Group7, Textbox as Textbox2, filters } from "fabric";
32
+ import { Canvas, FabricImage as FabricImage3, Group as Group7, Rect as Rect4, Textbox as Textbox2, filters } from "fabric";
33
33
 
34
34
  // src/events.ts
35
35
  var EventEmitter = class {
@@ -73,6 +73,53 @@ import { ActiveSelection } from "fabric";
73
73
  import { nanoid } from "nanoid";
74
74
  var generateId = () => nanoid(12);
75
75
 
76
+ // src/blend.ts
77
+ var BLEND_MODES = [
78
+ "normal",
79
+ "darken",
80
+ "multiply",
81
+ "color-burn",
82
+ "lighten",
83
+ "screen",
84
+ "color-dodge",
85
+ "linear-dodge",
86
+ "overlay",
87
+ "soft-light",
88
+ "hard-light",
89
+ "difference",
90
+ "exclusion",
91
+ "hue",
92
+ "saturation",
93
+ "color",
94
+ "luminosity"
95
+ ];
96
+ var OPERATION_EXCEPTIONS = {
97
+ normal: "source-over",
98
+ "linear-dodge": "lighter"
99
+ };
100
+ function blendOperation(mode) {
101
+ return OPERATION_EXCEPTIONS[mode] ?? mode;
102
+ }
103
+ function blendModeOf(operation) {
104
+ if (!operation) return "normal";
105
+ for (const mode of BLEND_MODES) {
106
+ if (blendOperation(mode) === operation) return mode;
107
+ }
108
+ return "normal";
109
+ }
110
+ var BLEND_GROUPS = [
111
+ { label: "Normal", modes: ["normal"] },
112
+ { label: "Darken", modes: ["darken", "multiply", "color-burn"] },
113
+ { label: "Lighten", modes: ["lighten", "screen", "color-dodge", "linear-dodge"] },
114
+ { label: "Contrast", modes: ["overlay", "soft-light", "hard-light"] },
115
+ { label: "Comparative", modes: ["difference", "exclusion"] },
116
+ { label: "Composite", modes: ["hue", "saturation", "color", "luminosity"] }
117
+ ];
118
+ function blendModeLabel(mode) {
119
+ if (mode === "linear-dodge") return "Linear Dodge (Add)";
120
+ return mode.split("-").map((word) => (word[0] ?? "").toUpperCase() + word.slice(1)).join(" ");
121
+ }
122
+
76
123
  // src/layer.ts
77
124
  var Layer = class {
78
125
  id;
@@ -116,6 +163,7 @@ var Layer = class {
116
163
  return Object.keys(this.meta).length > 0;
117
164
  }
118
165
  toData() {
166
+ const blend = blendModeOf(this.fabricObject.globalCompositeOperation);
119
167
  return {
120
168
  id: this.id,
121
169
  type: this.type,
@@ -123,6 +171,7 @@ var Layer = class {
123
171
  visible: this.visible,
124
172
  locked: this.locked,
125
173
  opacity: this.opacity,
174
+ ...blend !== "normal" ? { blend } : {},
126
175
  ...this.hasMeta() ? { meta: this.meta } : {},
127
176
  ...this.children.length > 0 ? { children: this.children.map((c) => c.toData()) } : {}
128
177
  };
@@ -284,7 +333,11 @@ var LayerManager = class {
284
333
  visible: layer.visible,
285
334
  opacity: layer.opacity,
286
335
  selectable: !layer.locked,
287
- evented: !layer.locked
336
+ evented: !layer.locked,
337
+ // Compositing lives only on the fabric object (see `blendModeOf`); carry
338
+ // it from the object being replaced or a blend set before this call
339
+ // silently reverts to normal.
340
+ globalCompositeOperation: previous.globalCompositeOperation
288
341
  });
289
342
  this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
290
343
  if (layer.renderProxy) {
@@ -314,7 +367,12 @@ var LayerManager = class {
314
367
  visible: layer.visible,
315
368
  opacity: layer.opacity,
316
369
  selectable: !layer.locked,
317
- evented: !layer.locked
370
+ evented: !layer.locked,
371
+ // The proxy is what actually draws once a layer has one (see
372
+ // `LayerManager`'s class doc); a blend set before the proxy existed
373
+ // must not silently stop applying. Read off the layer's own object —
374
+ // that stays the single source of truth (see `blendModeOf`).
375
+ globalCompositeOperation: layer.fabricObject.globalCompositeOperation
318
376
  });
319
377
  this.canvas.add(proxy);
320
378
  this.syncZOrder();
@@ -4015,6 +4073,110 @@ var LayerMaskManager = class extends MaskStackStore {
4015
4073
  }
4016
4074
  };
4017
4075
 
4076
+ // src/gradient.ts
4077
+ import { Color, Gradient } from "fabric";
4078
+ var DEFAULT_GRADIENT_CONFIG = {
4079
+ kind: "linear",
4080
+ angle: 90,
4081
+ center: { x: 0.5, y: 0.5 },
4082
+ radius: 0.5,
4083
+ stops: [
4084
+ { color: "#ffffff", opacity: 1, position: 0 },
4085
+ { color: "#000000", opacity: 1, position: 1 }
4086
+ ]
4087
+ };
4088
+ var MIN_RADIUS = 1e-3;
4089
+ var MAX_GRADIENT_STOPS = 256;
4090
+ var FALLBACK_STOP_COLOR = DEFAULT_GRADIENT_CONFIG.stops[0].color;
4091
+ function linearCoords(angle, box) {
4092
+ const radians = angle * Math.PI / 180;
4093
+ const sin = Math.sin(radians);
4094
+ const cos = Math.cos(radians);
4095
+ const vx = box.width * sin;
4096
+ const vy = -box.height * cos;
4097
+ const length = Math.abs(box.width * sin) + Math.abs(box.height * cos);
4098
+ const scale = length / (vx * vx + vy * vy || 1);
4099
+ return {
4100
+ x1: 0.5 - scale * vx / 2,
4101
+ y1: 0.5 - scale * vy / 2,
4102
+ x2: 0.5 + scale * vx / 2,
4103
+ y2: 0.5 + scale * vy / 2
4104
+ };
4105
+ }
4106
+ function angleFromCoords(coords, box) {
4107
+ const dx = (coords.x2 - coords.x1) / (box.width || 1);
4108
+ const dy = (coords.y2 - coords.y1) / (box.height || 1);
4109
+ const degrees = Math.atan2(dx, -dy) * 180 / Math.PI;
4110
+ return (degrees + 360) % 360;
4111
+ }
4112
+ function stopColor(stop) {
4113
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4114
+ const color = new Color(source);
4115
+ const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
4116
+ color.setAlpha(clamp(opacity, 0, 1));
4117
+ return color.toRgba();
4118
+ }
4119
+ function toFabricGradient(config, box) {
4120
+ const colorStops = [...config.stops].sort((a, b) => a.position - b.position).slice(0, MAX_GRADIENT_STOPS).map((stop) => {
4121
+ const position = Number.isFinite(stop.position) ? stop.position : 0;
4122
+ return { offset: clamp(position, 0, 1), color: stopColor(stop) };
4123
+ });
4124
+ if (config.kind === "radial") {
4125
+ const radius = Math.max(MIN_RADIUS, config.radius);
4126
+ return new Gradient({
4127
+ type: "radial",
4128
+ gradientUnits: "percentage",
4129
+ coords: {
4130
+ x1: config.center.x,
4131
+ y1: config.center.y,
4132
+ r1: 0,
4133
+ x2: config.center.x,
4134
+ y2: config.center.y,
4135
+ r2: radius
4136
+ },
4137
+ colorStops
4138
+ });
4139
+ }
4140
+ return new Gradient({
4141
+ type: "linear",
4142
+ gradientUnits: "percentage",
4143
+ coords: linearCoords(config.angle, box),
4144
+ colorStops
4145
+ });
4146
+ }
4147
+ function readGradientConfig(object) {
4148
+ const fill = object.fill;
4149
+ if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
4150
+ const stops = fill.colorStops.map((stop) => {
4151
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4152
+ const color = new Color(source);
4153
+ const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
4154
+ return {
4155
+ color: `#${color.toHex().toLowerCase()}`,
4156
+ opacity: color.getAlpha(),
4157
+ position: clamp(offset, 0, 1)
4158
+ };
4159
+ });
4160
+ if (fill.type === "radial") {
4161
+ const { x1, y1, r2 } = fill.coords;
4162
+ return {
4163
+ kind: "radial",
4164
+ angle: DEFAULT_GRADIENT_CONFIG.angle,
4165
+ center: { x: x1, y: y1 },
4166
+ radius: r2,
4167
+ stops
4168
+ };
4169
+ }
4170
+ const box = { width: object.width || 1, height: object.height || 1 };
4171
+ return {
4172
+ kind: "linear",
4173
+ angle: angleFromCoords(fill.coords, box),
4174
+ center: { ...DEFAULT_GRADIENT_CONFIG.center },
4175
+ radius: DEFAULT_GRADIENT_CONFIG.radius,
4176
+ stops
4177
+ };
4178
+ }
4179
+
4018
4180
  // src/editor.ts
4019
4181
  var MIN_ZOOM = 0.1;
4020
4182
  var MAX_ZOOM = 8;
@@ -4171,6 +4333,65 @@ var CanvasEditor = class {
4171
4333
  this.history.save();
4172
4334
  return layer;
4173
4335
  }
4336
+ /**
4337
+ * Insert a gradient layer. It is an ordinary `Rect` with a gradient fill —
4338
+ * which is exactly why the Node print compositor needs no code for it.
4339
+ */
4340
+ addGradient(config = DEFAULT_GRADIENT_CONFIG, box) {
4341
+ const width = box?.width ?? this.canvas.getWidth();
4342
+ const height = box?.height ?? this.canvas.getHeight();
4343
+ const object = new Rect4({
4344
+ left: box?.left ?? 0,
4345
+ top: box?.top ?? 0,
4346
+ originX: "left",
4347
+ originY: "top",
4348
+ width,
4349
+ height,
4350
+ // A gradient fill IS the artwork; an outline is not part of this
4351
+ // layer's design, only its own gradient (unlike a plain shape, where a
4352
+ // stroke is a legitimate look).
4353
+ strokeWidth: 0,
4354
+ fill: toFabricGradient(config, { width, height })
4355
+ });
4356
+ const layer = this.layers.add("gradient", object, "Gradient");
4357
+ this.layers.select(layer.id);
4358
+ this.history.save();
4359
+ return layer;
4360
+ }
4361
+ /**
4362
+ * Repaint a layer's gradient. `save: false` while a slider is being dragged —
4363
+ * the settled value is the one worth an undo step.
4364
+ */
4365
+ setGradient(layerId, config, save = true) {
4366
+ const layer = this.layers.get(layerId);
4367
+ if (!layer) return;
4368
+ const object = layer.fabricObject;
4369
+ object.set({
4370
+ fill: toFabricGradient(config, {
4371
+ width: object.width || 1,
4372
+ height: object.height || 1
4373
+ })
4374
+ });
4375
+ object.dirty = true;
4376
+ this.canvas.requestRenderAll();
4377
+ this.events.emit("layer:modified", { layerId });
4378
+ if (save) this.history.save();
4379
+ }
4380
+ /** Set a layer's blend mode. Applied to the render proxy too, like opacity. */
4381
+ setBlend(layerId, mode) {
4382
+ const layer = this.layers.get(layerId);
4383
+ if (!layer) return;
4384
+ const operation = blendOperation(mode);
4385
+ layer.fabricObject.set({ globalCompositeOperation: operation });
4386
+ layer.fabricObject.dirty = true;
4387
+ if (layer.renderProxy) {
4388
+ layer.renderProxy.set({ globalCompositeOperation: operation });
4389
+ layer.renderProxy.dirty = true;
4390
+ }
4391
+ this.canvas.requestRenderAll();
4392
+ this.events.emit("layer:modified", { layerId });
4393
+ this.history.save();
4394
+ }
4174
4395
  async addTemplate(template, params) {
4175
4396
  const values = {};
4176
4397
  for (const parameter of template.parameters) {
@@ -5078,10 +5299,13 @@ var AnnotationOverlay = class {
5078
5299
  };
5079
5300
  export {
5080
5301
  AnnotationOverlay,
5302
+ BLEND_GROUPS,
5303
+ BLEND_MODES,
5081
5304
  CANVAS_MASK_TARGET,
5082
5305
  CANVAS_SIZE_PRESETS,
5083
5306
  CanvasEditor,
5084
5307
  CropController,
5308
+ DEFAULT_GRADIENT_CONFIG,
5085
5309
  DEFAULT_LAYER_SHADOW,
5086
5310
  DEFAULT_PATTERN_CONFIG,
5087
5311
  DEFAULT_SELECTION_STYLE,
@@ -5107,11 +5331,15 @@ export {
5107
5331
  TextWrapManager,
5108
5332
  TiledPatternObject,
5109
5333
  UnitConverter,
5334
+ angleFromCoords,
5110
5335
  applyAspectLock,
5111
5336
  applyLayerShadow,
5112
5337
  applyObjectSelectionStyle,
5113
5338
  applySelectionStyle,
5114
5339
  applyTextWrapToObject,
5340
+ blendModeLabel,
5341
+ blendModeOf,
5342
+ blendOperation,
5115
5343
  buildCurveLinePaths,
5116
5344
  buildCurvePathData,
5117
5345
  clamp,
@@ -5137,9 +5365,11 @@ export {
5137
5365
  isMaskPresetId,
5138
5366
  isShapeMaskId,
5139
5367
  isTextureMaskId,
5368
+ linearCoords,
5140
5369
  needsAbsoluteSpace,
5141
5370
  normalizeTextCurve,
5142
5371
  preWrapWordSplit,
5372
+ readGradientConfig,
5143
5373
  readLayerShadow,
5144
5374
  readTextWrap,
5145
5375
  renderTextureMask,
@@ -5152,6 +5382,7 @@ export {
5152
5382
  textPathCurve,
5153
5383
  textPathSpec,
5154
5384
  toCanvasSpace,
5385
+ toFabricGradient,
5155
5386
  toHostSpace,
5156
5387
  unwrapGroup
5157
5388
  };