@overtone-art/canvas-editor-core 0.7.0 → 0.8.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.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,109 @@ 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 FALLBACK_STOP_COLOR = DEFAULT_GRADIENT_CONFIG.stops[0].color;
4090
+ function linearCoords(angle, box) {
4091
+ const radians = angle * Math.PI / 180;
4092
+ const sin = Math.sin(radians);
4093
+ const cos = Math.cos(radians);
4094
+ const vx = box.width * sin;
4095
+ const vy = -box.height * cos;
4096
+ const length = Math.abs(box.width * sin) + Math.abs(box.height * cos);
4097
+ const scale = length / (vx * vx + vy * vy || 1);
4098
+ return {
4099
+ x1: 0.5 - scale * vx / 2,
4100
+ y1: 0.5 - scale * vy / 2,
4101
+ x2: 0.5 + scale * vx / 2,
4102
+ y2: 0.5 + scale * vy / 2
4103
+ };
4104
+ }
4105
+ function angleFromCoords(coords, box) {
4106
+ const dx = (coords.x2 - coords.x1) / (box.width || 1);
4107
+ const dy = (coords.y2 - coords.y1) / (box.height || 1);
4108
+ const degrees = Math.atan2(dx, -dy) * 180 / Math.PI;
4109
+ return (degrees + 360) % 360;
4110
+ }
4111
+ function stopColor(stop) {
4112
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4113
+ const color = new Color(source);
4114
+ const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
4115
+ color.setAlpha(clamp(opacity, 0, 1));
4116
+ return color.toRgba();
4117
+ }
4118
+ function toFabricGradient(config, box) {
4119
+ const colorStops = [...config.stops].sort((a, b) => a.position - b.position).map((stop) => {
4120
+ const position = Number.isFinite(stop.position) ? stop.position : 0;
4121
+ return { offset: clamp(position, 0, 1), color: stopColor(stop) };
4122
+ });
4123
+ if (config.kind === "radial") {
4124
+ const radius = Math.max(MIN_RADIUS, config.radius);
4125
+ return new Gradient({
4126
+ type: "radial",
4127
+ gradientUnits: "percentage",
4128
+ coords: {
4129
+ x1: config.center.x,
4130
+ y1: config.center.y,
4131
+ r1: 0,
4132
+ x2: config.center.x,
4133
+ y2: config.center.y,
4134
+ r2: radius
4135
+ },
4136
+ colorStops
4137
+ });
4138
+ }
4139
+ return new Gradient({
4140
+ type: "linear",
4141
+ gradientUnits: "percentage",
4142
+ coords: linearCoords(config.angle, box),
4143
+ colorStops
4144
+ });
4145
+ }
4146
+ function readGradientConfig(object) {
4147
+ const fill = object.fill;
4148
+ if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
4149
+ const stops = fill.colorStops.map((stop) => {
4150
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4151
+ const color = new Color(source);
4152
+ const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
4153
+ return {
4154
+ color: `#${color.toHex().toLowerCase()}`,
4155
+ opacity: color.getAlpha(),
4156
+ position: clamp(offset, 0, 1)
4157
+ };
4158
+ });
4159
+ if (fill.type === "radial") {
4160
+ const { x1, y1, r2 } = fill.coords;
4161
+ return {
4162
+ kind: "radial",
4163
+ angle: DEFAULT_GRADIENT_CONFIG.angle,
4164
+ center: { x: x1, y: y1 },
4165
+ radius: r2,
4166
+ stops
4167
+ };
4168
+ }
4169
+ const box = { width: object.width || 1, height: object.height || 1 };
4170
+ return {
4171
+ kind: "linear",
4172
+ angle: angleFromCoords(fill.coords, box),
4173
+ center: { ...DEFAULT_GRADIENT_CONFIG.center },
4174
+ radius: DEFAULT_GRADIENT_CONFIG.radius,
4175
+ stops
4176
+ };
4177
+ }
4178
+
4018
4179
  // src/editor.ts
4019
4180
  var MIN_ZOOM = 0.1;
4020
4181
  var MAX_ZOOM = 8;
@@ -4171,6 +4332,65 @@ var CanvasEditor = class {
4171
4332
  this.history.save();
4172
4333
  return layer;
4173
4334
  }
4335
+ /**
4336
+ * Insert a gradient layer. It is an ordinary `Rect` with a gradient fill —
4337
+ * which is exactly why the Node print compositor needs no code for it.
4338
+ */
4339
+ addGradient(config = DEFAULT_GRADIENT_CONFIG, box) {
4340
+ const width = box?.width ?? this.canvas.getWidth();
4341
+ const height = box?.height ?? this.canvas.getHeight();
4342
+ const object = new Rect4({
4343
+ left: box?.left ?? 0,
4344
+ top: box?.top ?? 0,
4345
+ originX: "left",
4346
+ originY: "top",
4347
+ width,
4348
+ height,
4349
+ // A gradient fill IS the artwork; an outline is not part of this
4350
+ // layer's design, only its own gradient (unlike a plain shape, where a
4351
+ // stroke is a legitimate look).
4352
+ strokeWidth: 0,
4353
+ fill: toFabricGradient(config, { width, height })
4354
+ });
4355
+ const layer = this.layers.add("gradient", object, "Gradient");
4356
+ this.layers.select(layer.id);
4357
+ this.history.save();
4358
+ return layer;
4359
+ }
4360
+ /**
4361
+ * Repaint a layer's gradient. `save: false` while a slider is being dragged —
4362
+ * the settled value is the one worth an undo step.
4363
+ */
4364
+ setGradient(layerId, config, save = true) {
4365
+ const layer = this.layers.get(layerId);
4366
+ if (!layer) return;
4367
+ const object = layer.fabricObject;
4368
+ object.set({
4369
+ fill: toFabricGradient(config, {
4370
+ width: object.width || 1,
4371
+ height: object.height || 1
4372
+ })
4373
+ });
4374
+ object.dirty = true;
4375
+ this.canvas.requestRenderAll();
4376
+ this.events.emit("layer:modified", { layerId });
4377
+ if (save) this.history.save();
4378
+ }
4379
+ /** Set a layer's blend mode. Applied to the render proxy too, like opacity. */
4380
+ setBlend(layerId, mode) {
4381
+ const layer = this.layers.get(layerId);
4382
+ if (!layer) return;
4383
+ const operation = blendOperation(mode);
4384
+ layer.fabricObject.set({ globalCompositeOperation: operation });
4385
+ layer.fabricObject.dirty = true;
4386
+ if (layer.renderProxy) {
4387
+ layer.renderProxy.set({ globalCompositeOperation: operation });
4388
+ layer.renderProxy.dirty = true;
4389
+ }
4390
+ this.canvas.requestRenderAll();
4391
+ this.events.emit("layer:modified", { layerId });
4392
+ this.history.save();
4393
+ }
4174
4394
  async addTemplate(template, params) {
4175
4395
  const values = {};
4176
4396
  for (const parameter of template.parameters) {
@@ -5078,10 +5298,13 @@ var AnnotationOverlay = class {
5078
5298
  };
5079
5299
  export {
5080
5300
  AnnotationOverlay,
5301
+ BLEND_GROUPS,
5302
+ BLEND_MODES,
5081
5303
  CANVAS_MASK_TARGET,
5082
5304
  CANVAS_SIZE_PRESETS,
5083
5305
  CanvasEditor,
5084
5306
  CropController,
5307
+ DEFAULT_GRADIENT_CONFIG,
5085
5308
  DEFAULT_LAYER_SHADOW,
5086
5309
  DEFAULT_PATTERN_CONFIG,
5087
5310
  DEFAULT_SELECTION_STYLE,
@@ -5107,11 +5330,15 @@ export {
5107
5330
  TextWrapManager,
5108
5331
  TiledPatternObject,
5109
5332
  UnitConverter,
5333
+ angleFromCoords,
5110
5334
  applyAspectLock,
5111
5335
  applyLayerShadow,
5112
5336
  applyObjectSelectionStyle,
5113
5337
  applySelectionStyle,
5114
5338
  applyTextWrapToObject,
5339
+ blendModeLabel,
5340
+ blendModeOf,
5341
+ blendOperation,
5115
5342
  buildCurveLinePaths,
5116
5343
  buildCurvePathData,
5117
5344
  clamp,
@@ -5137,9 +5364,11 @@ export {
5137
5364
  isMaskPresetId,
5138
5365
  isShapeMaskId,
5139
5366
  isTextureMaskId,
5367
+ linearCoords,
5140
5368
  needsAbsoluteSpace,
5141
5369
  normalizeTextCurve,
5142
5370
  preWrapWordSplit,
5371
+ readGradientConfig,
5143
5372
  readLayerShadow,
5144
5373
  readTextWrap,
5145
5374
  renderTextureMask,
@@ -5152,6 +5381,7 @@ export {
5152
5381
  textPathCurve,
5153
5382
  textPathSpec,
5154
5383
  toCanvasSpace,
5384
+ toFabricGradient,
5155
5385
  toHostSpace,
5156
5386
  unwrapGroup
5157
5387
  };