@pixerate/editor 0.98.0 → 0.99.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.
@@ -3193,6 +3193,44 @@ var ImageEditorRenderer = class {
3193
3193
  }
3194
3194
  ctx.restore();
3195
3195
  }
3196
+ if (options.freeTransform) {
3197
+ const t = options.freeTransform;
3198
+ ctx.save();
3199
+ try {
3200
+ const scr = (p) => projectImagePoint(p, projection);
3201
+ const outline = t.outline.map(scr);
3202
+ ctx.beginPath();
3203
+ outline.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
3204
+ ctx.closePath();
3205
+ ctx.lineWidth = 3;
3206
+ ctx.strokeStyle = "rgba(0,0,0,0.5)";
3207
+ ctx.stroke();
3208
+ ctx.lineWidth = 1;
3209
+ ctx.strokeStyle = "#ffffff";
3210
+ ctx.stroke();
3211
+ for (const h of t.handles.map(scr)) {
3212
+ ctx.fillStyle = "#ffffff";
3213
+ ctx.strokeStyle = "#1e293b";
3214
+ ctx.fillRect(h.x - 4, h.y - 4, 8, 8);
3215
+ ctx.strokeRect(h.x - 4, h.y - 4, 8, 8);
3216
+ }
3217
+ const r = scr(t.reference);
3218
+ ctx.beginPath();
3219
+ ctx.arc(r.x, r.y, 5, 0, Math.PI * 2);
3220
+ ctx.moveTo(r.x - 8, r.y);
3221
+ ctx.lineTo(r.x + 8, r.y);
3222
+ ctx.moveTo(r.x, r.y - 8);
3223
+ ctx.lineTo(r.x, r.y + 8);
3224
+ ctx.lineWidth = 3;
3225
+ ctx.strokeStyle = "rgba(0,0,0,0.5)";
3226
+ ctx.stroke();
3227
+ ctx.lineWidth = 1;
3228
+ ctx.strokeStyle = "#ffffff";
3229
+ ctx.stroke();
3230
+ } catch {
3231
+ }
3232
+ ctx.restore();
3233
+ }
3196
3234
  if (isActivelyCropping) {
3197
3235
  const activeCrop = options.draftCrop !== void 0 && options.draftCrop !== null ? options.draftCrop : crop ?? { x: 0, y: 0, width: naturalWidth, height: naturalHeight };
3198
3236
  this.drawCropOverlay(ctx, getViewportMetrics(geoState, vpWidth, vpHeight, true, activeCrop));
@@ -4001,7 +4039,7 @@ function removeAnnotationLayers(layers) {
4001
4039
  function polygonCoverage(points, width, height, antiAlias = true) {
4002
4040
  const m = new Float32Array(width * height);
4003
4041
  if (points.length < 3 || width <= 0 || height <= 0) return m;
4004
- const sub = antiAlias ? 4 : 1;
4042
+ const sub2 = antiAlias ? 4 : 1;
4005
4043
  const edges = [];
4006
4044
  for (let i = 0; i < points.length; i++) {
4007
4045
  const a = points[i];
@@ -4016,8 +4054,8 @@ function polygonCoverage(points, width, height, antiAlias = true) {
4016
4054
  const xs = [];
4017
4055
  for (let py = 0; py < height; py++) {
4018
4056
  const row = py * width;
4019
- for (let s = 0; s < sub; s++) {
4020
- const y = py + (s + 0.5) / sub;
4057
+ for (let s = 0; s < sub2; s++) {
4058
+ const y = py + (s + 0.5) / sub2;
4021
4059
  while (next < edges.length && edges[next].top <= y) active.push(edges[next++]);
4022
4060
  if (active.length) active = active.filter((e) => e.bottom > y);
4023
4061
  if (!active.length) continue;
@@ -4039,7 +4077,7 @@ function polygonCoverage(points, width, height, antiAlias = true) {
4039
4077
  const c = cx + 0.5;
4040
4078
  cov = c >= x0 && c < x1 ? 1 : 0;
4041
4079
  }
4042
- if (cov > 0) m[row + cx] += cov / sub;
4080
+ if (cov > 0) m[row + cx] += cov / sub2;
4043
4081
  }
4044
4082
  }
4045
4083
  }
@@ -11180,7 +11218,7 @@ var ImageEditorController = class {
11180
11218
  const canvas = { x: 0, y: 0, width, height };
11181
11219
  const warnings = [];
11182
11220
  const note = /* @__PURE__ */ new Map();
11183
- const add = (msg, name) => note.set(msg, [...note.get(msg) ?? [], name]);
11221
+ const add2 = (msg, name) => note.set(msg, [...note.get(msg) ?? [], name]);
11184
11222
  const canRasterize = this.renderer.canRasterize();
11185
11223
  await this.renderer.prepareLayerAssets(this.state.layers, width, height);
11186
11224
  const sources = this.renderer.compositorSources(width, height);
@@ -11188,7 +11226,7 @@ var ImageEditorController = class {
11188
11226
  const bakeMask = (layer) => {
11189
11227
  const mask = layer.mask;
11190
11228
  if (!mask?.source) return mask;
11191
- add("Parametric (depth) masks were baked into pixel masks", layer.name);
11229
+ add2("Parametric (depth) masks were baked into pixel masks", layer.name);
11192
11230
  const plain3 = { ...layer, mask: { ...mask, density: 1, feather: 0, enabled: true } };
11193
11231
  const ox = layer.type === "pixel" ? layer.offset.x : 0;
11194
11232
  const oy = layer.type === "pixel" ? layer.offset.y : 0;
@@ -11204,7 +11242,7 @@ var ImageEditorController = class {
11204
11242
  };
11205
11243
  const prepare = async (layer) => {
11206
11244
  const p = await prepareLayer(layer);
11207
- if (p?.blend === "pigment") add("The Pigment blend mode has no PSD equivalent and was written as Normal", layer.name);
11245
+ if (p?.blend === "pigment") add2("The Pigment blend mode has no PSD equivalent and was written as Normal", layer.name);
11208
11246
  return p;
11209
11247
  };
11210
11248
  const prepareLayer = async (layer) => {
@@ -11219,10 +11257,10 @@ var ImageEditorController = class {
11219
11257
  if (layer.type === "adjustment") return { ...layer, mask: bakeMask(layer) };
11220
11258
  if (layer.type === "annotation") {
11221
11259
  if (!canRasterize) {
11222
- add("Annotation layers need a canvas (a browser) to rasterize and were left out", layer.name);
11260
+ add2("Annotation layers need a canvas (a browser) to rasterize and were left out", layer.name);
11223
11261
  return null;
11224
11262
  }
11225
- add("Annotation layers were written as pixel layers", layer.name);
11263
+ add2("Annotation layers were written as pixel layers", layer.name);
11226
11264
  const blend = layer.blend === "normal" && layer.annotation.blend ? layer.annotation.blend : layer.blend;
11227
11265
  const content = { ...layer, visible: true, opacity: 1, blend: "normal", annotation: { ...layer.annotation, blend: void 0 } };
11228
11266
  delete content.mask;
@@ -11253,7 +11291,7 @@ var ImageEditorController = class {
11253
11291
  }
11254
11292
  const active = layer.filters?.filter(isActiveFilter) ?? [];
11255
11293
  if (active.length) {
11256
- add("Layer filters (develop, blur) were baked into the pixels", layer.name);
11294
+ add2("Layer filters (develop, blur) were baked into the pixels", layer.name);
11257
11295
  const content = { ...out, visible: true, opacity: 1, blend: "normal" };
11258
11296
  delete content.mask;
11259
11297
  delete content.clipped;
@@ -11262,7 +11300,7 @@ var ImageEditorController = class {
11262
11300
  const data = compositor.composite([content], canvas, sources);
11263
11301
  out = { ...out, surface: surfaceFromPixels({ width, height, data }, 4, -layer.offset.x, -layer.offset.y) };
11264
11302
  }
11265
- if (layer.filters?.some((f) => !f.enabled)) add("Disabled layer filters were left out", layer.name);
11303
+ if (layer.filters?.some((f) => !f.enabled)) add2("Disabled layer filters were left out", layer.name);
11266
11304
  delete out.filters;
11267
11305
  const mask = bakeMask(layer);
11268
11306
  if (mask) out.mask = mask;
@@ -13416,7 +13454,7 @@ function createImageEffectTools(controller) {
13416
13454
  annotations: { title: "List layer effects", ...READ3 },
13417
13455
  handler: ({ layerId }) => result(layerArg(layerId), { globalLight: controller.getGlobalLight(), patterns: patternList(controller), kinds: [...LAYER_EFFECT_KINDS] })
13418
13456
  };
13419
- const add = {
13457
+ const add2 = {
13420
13458
  name: "add_layer_effect",
13421
13459
  title: "Add a layer effect",
13422
13460
  description: "Adds a live effect to a pixel, annotation or group layer with Photoshop's defaults, changed by settings: drop-shadow, inner-shadow, outer-glow, inner-glow, bevel-emboss, satin, color-overlay, gradient-overlay, pattern-overlay or stroke. Several of one kind are allowed (the first listed is on top). One undo step. Returns the new effect's id and the layer's effects.",
@@ -13514,7 +13552,7 @@ function createImageEffectTools(controller) {
13514
13552
  return { globalLight: controller.setGlobalLight(next) };
13515
13553
  }
13516
13554
  };
13517
- return [list2, add, update, remove, setEnabled, light];
13555
+ return [list2, add2, update, remove, setEnabled, light];
13518
13556
  }
13519
13557
 
13520
13558
  // src/image-editor/layers/paint-ui.ts
@@ -14779,6 +14817,7 @@ function layerMenu(controller) {
14779
14817
  { action: "apply-mask", label: "Apply Layer Mask", enabled: isPixel && Boolean(active?.mask) },
14780
14818
  { action: "delete-mask", label: "Delete Layer Mask", enabled: Boolean(active?.mask) },
14781
14819
  { action: "toggle-mask", label: active?.mask?.enabled === false ? "Enable Layer Mask" : "Disable Layer Mask", enabled: Boolean(active?.mask) },
14820
+ { action: "toggle-mask-link", label: active?.mask?.linked === false ? "Link Layer Mask" : "Unlink Layer Mask", enabled: Boolean(active?.mask && !active.mask.source) },
14782
14821
  { action: "rasterize", label: "Rasterize Layer", enabled: active?.type === "annotation" || isPixel && Boolean(active?.type === "pixel" && active.filters?.length) },
14783
14822
  { action: "toggle-clip", label: active?.clipped ? "Release Clipping Mask" : "Create Clipping Mask", enabled: Boolean(loc && loc.index > 0) }
14784
14823
  ];
@@ -14834,6 +14873,9 @@ async function runLayerMenuAction(controller, action) {
14834
14873
  case "toggle-mask":
14835
14874
  if (id && active?.mask) controller.setLayerMaskProps(id, { enabled: active.mask.enabled === false });
14836
14875
  break;
14876
+ case "toggle-mask-link":
14877
+ if (id && active?.mask) controller.setLayerMaskProps(id, { linked: active.mask.linked === false });
14878
+ break;
14837
14879
  case "rasterize":
14838
14880
  if (id) await controller.rasterizeLayer(id);
14839
14881
  break;
@@ -15052,6 +15094,445 @@ async function downloadPsd(controller, name = "image") {
15052
15094
  }
15053
15095
  return { fileName, warnings: res.warnings };
15054
15096
  }
15097
+
15098
+ // src/image-editor/layers/transform-ui.ts
15099
+ var CORNERS = ["nw", "ne", "se", "sw"];
15100
+ var SIDES = ["n", "e", "s", "w"];
15101
+ var CORNER_INDEX = { nw: 0, ne: 1, se: 2, sw: 3 };
15102
+ var SIDE_CORNERS = { n: [0, 1], e: [1, 2], s: [2, 3], w: [3, 0] };
15103
+ var HANDLE_FRACTION = { nw: [0, 0], n: [0.5, 0], ne: [1, 0], e: [1, 0.5], se: [1, 1], s: [0.5, 1], sw: [0, 1], w: [0, 0.5] };
15104
+ var ANCHOR_FRACTION2 = {
15105
+ "top-left": [0, 0],
15106
+ top: [0.5, 0],
15107
+ "top-right": [1, 0],
15108
+ left: [0, 0.5],
15109
+ center: [0.5, 0.5],
15110
+ right: [1, 0.5],
15111
+ "bottom-left": [0, 1],
15112
+ bottom: [0.5, 1],
15113
+ "bottom-right": [1, 1]
15114
+ };
15115
+ var FREE_TRANSFORM_MODE_OPTIONS = [
15116
+ { id: "scale", label: "Free", title: "Free Transform: corners scale, outside rotates, Ctrl/Cmd distorts" },
15117
+ { id: "rotate", label: "Rotate", title: "Every handle rotates about the reference point" },
15118
+ { id: "skew", label: "Skew", title: "Sides slide along themselves" },
15119
+ { id: "distort", label: "Distort", title: "Each corner moves freely" },
15120
+ { id: "perspective", label: "Perspective", title: "A corner and its partner move symmetrically" }
15121
+ ];
15122
+ var FREE_TRANSFORM_FIELDS = [
15123
+ { id: "x", label: "X", unit: "px", title: "Reference point X (document pixels)" },
15124
+ { id: "y", label: "Y", unit: "px", title: "Reference point Y (document pixels)" },
15125
+ { id: "scaleX", label: "W", unit: "%", title: "Width in percent of the original" },
15126
+ { id: "scaleY", label: "H", unit: "%", title: "Height in percent of the original" },
15127
+ { id: "rotate", label: "\u2220", unit: "\xB0", title: "Rotation in degrees, clockwise" },
15128
+ { id: "skewX", label: "H skew", unit: "\xB0", title: "Horizontal skew in degrees" },
15129
+ { id: "skewY", label: "V skew", unit: "\xB0", title: "Vertical skew in degrees" }
15130
+ ];
15131
+ var CURSOR = {
15132
+ nw: "nwse-resize",
15133
+ se: "nwse-resize",
15134
+ ne: "nesw-resize",
15135
+ sw: "nesw-resize",
15136
+ n: "ns-resize",
15137
+ s: "ns-resize",
15138
+ e: "ew-resize",
15139
+ w: "ew-resize",
15140
+ reference: "move",
15141
+ inside: "move",
15142
+ outside: "alias"
15143
+ };
15144
+ var mod = (m) => Boolean(m.ctrlKey || m.metaKey);
15145
+ var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
15146
+ var sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
15147
+ function rotateAbout(p, c, rad) {
15148
+ const cos = Math.cos(rad);
15149
+ const sin = Math.sin(rad);
15150
+ const d = sub(p, c);
15151
+ return { x: c.x + d.x * cos - d.y * sin, y: c.y + d.x * sin + d.y * cos };
15152
+ }
15153
+ function insideQuad(q2, p) {
15154
+ let sign = 0;
15155
+ for (let i = 0; i < 4; i++) {
15156
+ const a = q2[i];
15157
+ const b = q2[(i + 1) % 4];
15158
+ const c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
15159
+ if (Math.abs(c) < 1e-12) continue;
15160
+ const s = Math.sign(c);
15161
+ if (sign && s !== sign) return false;
15162
+ sign = s;
15163
+ }
15164
+ return true;
15165
+ }
15166
+ var FreeTransformTool = class {
15167
+ constructor(controller, options = {}) {
15168
+ this.controller = controller;
15169
+ this.options = options;
15170
+ }
15171
+ controller;
15172
+ options;
15173
+ sessionRef = null;
15174
+ modeValue = "scale";
15175
+ /** The reference point in the untransformed frame's space (follows the transform). */
15176
+ refBox = { x: 0, y: 0 };
15177
+ corners = null;
15178
+ drag = null;
15179
+ pending = false;
15180
+ viewScale = 1;
15181
+ viewClip;
15182
+ /** Values typed into the options bar since the last drag (V skew is not recovered from the shape). */
15183
+ typed = {};
15184
+ /** Width and height scale together when typed (the chain icon). */
15185
+ proportional = true;
15186
+ error = null;
15187
+ lastResult = null;
15188
+ get active() {
15189
+ return Boolean(this.sessionRef?.isOpen);
15190
+ }
15191
+ get session() {
15192
+ return this.active ? this.sessionRef : null;
15193
+ }
15194
+ get mode() {
15195
+ return this.modeValue;
15196
+ }
15197
+ get interpolation() {
15198
+ return this.sessionRef?.interpolation ?? "bicubic";
15199
+ }
15200
+ listeners = /* @__PURE__ */ new Set();
15201
+ /** Calls `listener` whenever the overlay, readout, mode or error changes. Returns the unsubscribe. */
15202
+ subscribe(listener) {
15203
+ this.listeners.add(listener);
15204
+ return () => {
15205
+ this.listeners.delete(listener);
15206
+ };
15207
+ }
15208
+ changed() {
15209
+ this.options.onChange?.();
15210
+ for (const l of this.listeners) l();
15211
+ }
15212
+ /** Opens a Free Transform (default: the selected pixels of the active layer, or the whole layer). Returns an error message or `null`. */
15213
+ begin(init = {}) {
15214
+ if (this.active) return null;
15215
+ try {
15216
+ if (!init.layerId && init.content !== "selection-outline" && !this.controller.getState().activeLayerId) {
15217
+ const top = [...flattenLayers(this.controller.getState().layers)].reverse().find((l) => l.type === "pixel" && l.visible);
15218
+ if (top) this.controller.setActiveLayer(top.id);
15219
+ }
15220
+ const s = this.controller.beginFreeTransform(init);
15221
+ this.sessionRef = s;
15222
+ this.corners = s.geometry.corners;
15223
+ this.refBox = { x: s.box.x + s.box.width / 2, y: s.box.y + s.box.height / 2 };
15224
+ this.modeValue = "scale";
15225
+ this.typed = {};
15226
+ this.error = null;
15227
+ } catch (e) {
15228
+ this.error = e instanceof Error ? e.message : String(e);
15229
+ }
15230
+ this.changed();
15231
+ return this.error;
15232
+ }
15233
+ setMode(mode) {
15234
+ this.modeValue = mode;
15235
+ this.changed();
15236
+ }
15237
+ /** Sets the reference point to an anchor of the frame (the options bar's locator). */
15238
+ setReference(anchor) {
15239
+ const s = this.session;
15240
+ if (!s) return;
15241
+ const [fx, fy] = ANCHOR_FRACTION2[anchor];
15242
+ this.refBox = { x: s.box.x + fx * s.box.width, y: s.box.y + fy * s.box.height };
15243
+ this.changed();
15244
+ }
15245
+ setInterpolation(interpolation) {
15246
+ const s = this.session;
15247
+ if (!s) return;
15248
+ this.run(() => s.update({ interpolation }, false));
15249
+ }
15250
+ /** Screen pixels per document pixel and the visible document area, for previews. */
15251
+ setView(scale, clip) {
15252
+ this.viewScale = scale > 0 ? scale : 1;
15253
+ this.viewClip = clip;
15254
+ }
15255
+ homography() {
15256
+ const s = this.session;
15257
+ return s && this.corners ? rectToQuad(s.box, this.corners) : null;
15258
+ }
15259
+ /** The reference point in document pixels. */
15260
+ referencePoint() {
15261
+ const h = this.homography();
15262
+ return h ? applyHomography(h, this.refBox) : null;
15263
+ }
15264
+ /** The outline, handles and reference point to draw, or `null` when no transform is open. */
15265
+ overlay() {
15266
+ const s = this.session;
15267
+ const h = this.homography();
15268
+ if (!s || !h || !this.corners) return null;
15269
+ const handles = [...CORNERS, ...SIDES].map((id) => {
15270
+ const [fx, fy] = HANDLE_FRACTION[id];
15271
+ const p = applyHomography(h, { x: s.box.x + fx * s.box.width, y: s.box.y + fy * s.box.height }) ?? { x: 0, y: 0 };
15272
+ return { id, x: p.x, y: p.y };
15273
+ });
15274
+ return { outline: this.corners.map((p) => ({ x: p.x, y: p.y })), handles, reference: this.referencePoint() ?? { x: 0, y: 0 } };
15275
+ }
15276
+ /** What a document point is over (`tolerance` in document pixels: a few screen pixels / scale). */
15277
+ hit(p, tolerance) {
15278
+ const o = this.overlay();
15279
+ if (!o) return null;
15280
+ let best = null;
15281
+ let bestD = tolerance;
15282
+ for (const h of o.handles) {
15283
+ const d = Math.hypot(h.x - p.x, h.y - p.y);
15284
+ if (d <= bestD) {
15285
+ best = h.id;
15286
+ bestD = d;
15287
+ }
15288
+ }
15289
+ if (Math.hypot(o.reference.x - p.x, o.reference.y - p.y) < bestD) best = "reference";
15290
+ if (best) return best;
15291
+ return insideQuad(o.outline, p) ? "inside" : "outside";
15292
+ }
15293
+ /** The CSS cursor for a handle. */
15294
+ cursor(handle) {
15295
+ if (!handle) return "default";
15296
+ if (this.modeValue === "rotate" && handle !== "inside" && handle !== "reference") return CURSOR.outside;
15297
+ return CURSOR[handle];
15298
+ }
15299
+ /** Starts a drag on what `p` is over. */
15300
+ down(p, tolerance) {
15301
+ if (!this.session || !this.corners) return null;
15302
+ const handle = this.hit(p, tolerance);
15303
+ if (!handle) return null;
15304
+ this.drag = { handle, start: { x: p.x, y: p.y }, corners: this.corners, refBox: { ...this.refBox }, ref: this.referencePoint() ?? p };
15305
+ return handle;
15306
+ }
15307
+ /** Continues the drag to `p` with the modifiers held now. */
15308
+ move(p, m = {}) {
15309
+ const d = this.drag;
15310
+ const s = this.session;
15311
+ if (!d || !s) return;
15312
+ if (d.handle === "reference") {
15313
+ const h = rectToQuad(s.box, d.corners);
15314
+ const inv = h && invertHomography(h);
15315
+ const q2 = inv && applyHomography(inv, p);
15316
+ if (q2) this.refBox = q2;
15317
+ this.changed();
15318
+ return;
15319
+ }
15320
+ const corners = this.dragCorners(d, p, m);
15321
+ if (corners) this.setCorners(corners);
15322
+ }
15323
+ /** Ends the drag (the transform stays open). */
15324
+ up() {
15325
+ this.drag = null;
15326
+ }
15327
+ dragCorners(d, p, m) {
15328
+ const s = this.session;
15329
+ const c0 = d.corners;
15330
+ let delta = sub(p, d.start);
15331
+ const map = (f) => c0.map(f);
15332
+ const handle = d.handle;
15333
+ const isCorner = handle in CORNER_INDEX;
15334
+ const isSide = handle in SIDE_CORNERS;
15335
+ const rotating = handle === "outside" || this.modeValue === "rotate" && (isCorner || isSide);
15336
+ if (handle === "inside") {
15337
+ if (m.shiftKey) delta = Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y };
15338
+ return map((q3) => add(q3, delta));
15339
+ }
15340
+ if (rotating) {
15341
+ const r = d.ref;
15342
+ let a = Math.atan2(p.y - r.y, p.x - r.x) - Math.atan2(d.start.y - r.y, d.start.x - r.x);
15343
+ if (m.shiftKey) a = Math.round(a / (Math.PI / 12)) * (Math.PI / 12);
15344
+ return map((q3) => rotateAbout(q3, r, a));
15345
+ }
15346
+ const perspective = this.modeValue === "perspective" || mod(m) && m.altKey && m.shiftKey;
15347
+ const distort = this.modeValue === "distort" || mod(m) && !perspective;
15348
+ const skew = this.modeValue === "skew" || mod(m) && isSide;
15349
+ if (isSide && (skew || perspective)) {
15350
+ const [i, j] = SIDE_CORNERS[handle];
15351
+ const dir = sub(c0[j], c0[i]);
15352
+ const len = Math.hypot(dir.x, dir.y) || 1;
15353
+ const t = (delta.x * dir.x + delta.y * dir.y) / len;
15354
+ const v = { x: dir.x / len * t, y: dir.y / len * t };
15355
+ const [oi, oj] = SIDE_CORNERS[{ n: "s", s: "n", e: "w", w: "e" }[handle]];
15356
+ return map((q3, k) => k === i || k === j ? add(q3, v) : m.altKey && (k === oi || k === oj) ? sub(q3, v) : q3);
15357
+ }
15358
+ if (isCorner && perspective) {
15359
+ const k = CORNER_INDEX[handle];
15360
+ if (Math.abs(delta.x) >= Math.abs(delta.y)) {
15361
+ const partner2 = [1, 0, 3, 2][k];
15362
+ return map((q3, i) => i === k ? { x: q3.x + delta.x, y: q3.y } : i === partner2 ? { x: q3.x - delta.x, y: q3.y } : q3);
15363
+ }
15364
+ const partner = [3, 2, 1, 0][k];
15365
+ return map((q3, i) => i === k ? { x: q3.x, y: q3.y + delta.y } : i === partner ? { x: q3.x, y: q3.y - delta.y } : q3);
15366
+ }
15367
+ if (distort || isCorner && this.modeValue === "skew") {
15368
+ if (isCorner) return map((q3, i2) => i2 === CORNER_INDEX[handle] ? add(q3, delta) : q3);
15369
+ const [i, j] = SIDE_CORNERS[handle];
15370
+ return map((q3, k) => k === i || k === j ? add(q3, delta) : q3);
15371
+ }
15372
+ const h = rectToQuad(s.box, c0);
15373
+ const inv = h && invertHomography(h);
15374
+ if (!h || !inv) return null;
15375
+ const q2 = applyHomography(inv, p);
15376
+ const q0 = applyHomography(inv, d.start);
15377
+ if (!q2 || !q0) return null;
15378
+ const b = s.box;
15379
+ const [fx, fy] = HANDLE_FRACTION[handle];
15380
+ const grab = { x: b.x + fx * b.width, y: b.y + fy * b.height };
15381
+ const anchor = m.altKey ? d.refBox : { x: b.x + (1 - fx) * b.width, y: b.y + (1 - fy) * b.height };
15382
+ const target = add(grab, sub(q2, q0));
15383
+ const ax = grab.x - anchor.x;
15384
+ const ay = grab.y - anchor.y;
15385
+ let sx = isCorner || handle === "e" || handle === "w" ? Math.abs(ax) > 1e-9 ? (target.x - anchor.x) / ax : 1 : 1;
15386
+ let sy = isCorner || handle === "n" || handle === "s" ? Math.abs(ay) > 1e-9 ? (target.y - anchor.y) / ay : 1 : 1;
15387
+ if (isCorner && !m.shiftKey) {
15388
+ const k = Math.abs(sx) >= Math.abs(sy) ? sx : sy;
15389
+ sx = k;
15390
+ sy = k;
15391
+ }
15392
+ const scaled = [
15393
+ { x: b.x, y: b.y },
15394
+ { x: b.x + b.width, y: b.y },
15395
+ { x: b.x + b.width, y: b.y + b.height },
15396
+ { x: b.x, y: b.y + b.height }
15397
+ ].map((c) => applyHomography(h, { x: anchor.x + (c.x - anchor.x) * sx, y: anchor.y + (c.y - anchor.y) * sy }));
15398
+ return scaled.every(Boolean) ? scaled : null;
15399
+ }
15400
+ /** Sets new corners and refreshes the preview (scheduled). Invalid shapes keep the last good one. */
15401
+ setCorners(corners) {
15402
+ const s = this.session;
15403
+ if (!s) return;
15404
+ const h = rectToQuad(s.box, corners);
15405
+ if (!h) return;
15406
+ this.corners = corners;
15407
+ this.typed = {};
15408
+ this.changed();
15409
+ this.preview();
15410
+ }
15411
+ preview() {
15412
+ if (this.pending) return;
15413
+ this.pending = true;
15414
+ const run = () => {
15415
+ this.pending = false;
15416
+ const s = this.session;
15417
+ if (!s || !this.corners) return;
15418
+ this.run(() => s.update({ corners: this.corners }, { scale: this.viewScale, clip: this.viewClip }));
15419
+ };
15420
+ if (this.options.schedule) this.options.schedule(run);
15421
+ else run();
15422
+ }
15423
+ run(fn) {
15424
+ try {
15425
+ fn();
15426
+ this.error = null;
15427
+ } catch (e) {
15428
+ this.error = e instanceof Error ? e.message : String(e);
15429
+ if (this.sessionRef && !this.sessionRef.isOpen) this.sessionRef = null;
15430
+ }
15431
+ this.changed();
15432
+ }
15433
+ /** The options bar's numbers for the current shape. */
15434
+ bar() {
15435
+ const s = this.session;
15436
+ if (!s || !this.corners) return null;
15437
+ const read = describeTransform({ box: s.box, corners: this.corners }, this.refBox);
15438
+ return {
15439
+ affine: read.affine,
15440
+ x: this.typed.x ?? read.x,
15441
+ y: this.typed.y ?? read.y,
15442
+ scaleX: this.typed.scaleX ?? read.scaleX,
15443
+ scaleY: this.typed.scaleY ?? read.scaleY,
15444
+ rotate: this.typed.rotate ?? read.rotate,
15445
+ skewX: this.typed.skewX ?? read.skewX,
15446
+ skewY: this.typed.skewY ?? 0
15447
+ };
15448
+ }
15449
+ /** Applies a typed options-bar value (W and H together while `proportional`). */
15450
+ setField(field, value) {
15451
+ const s = this.session;
15452
+ const cur = this.bar();
15453
+ if (!s || !cur || !Number.isFinite(value)) return;
15454
+ const next = { ...cur, [field]: value };
15455
+ if (this.proportional && field === "scaleX") next.scaleY = cur.scaleY === 0 ? value : value / cur.scaleX * cur.scaleY;
15456
+ if (this.proportional && field === "scaleY") next.scaleX = cur.scaleX === 0 ? value : value / cur.scaleY * cur.scaleX;
15457
+ if (!next.scaleX || !next.scaleY) {
15458
+ this.error = "Width and height must not be 0 %.";
15459
+ this.changed();
15460
+ return;
15461
+ }
15462
+ const m = matrixFromParams(s.box, {
15463
+ scaleX: next.scaleX,
15464
+ scaleY: next.scaleY,
15465
+ rotate: next.rotate,
15466
+ skewX: next.skewX,
15467
+ skewY: next.skewY,
15468
+ reference: this.refBox,
15469
+ dx: next.x - this.refBox.x,
15470
+ dy: next.y - this.refBox.y
15471
+ });
15472
+ this.setCorners(cornersFromMatrix(s.box, m));
15473
+ this.typed = { scaleX: next.scaleX, scaleY: next.scaleY, rotate: next.rotate, skewX: next.skewX, skewY: next.skewY, x: next.x, y: next.y };
15474
+ this.changed();
15475
+ }
15476
+ /** Finishes with full-quality resampling (one undo step). */
15477
+ commit() {
15478
+ const s = this.session;
15479
+ if (!s) return null;
15480
+ let res = null;
15481
+ this.pending = false;
15482
+ this.run(() => {
15483
+ if (this.corners) s.update({ corners: this.corners }, false);
15484
+ res = s.commit();
15485
+ });
15486
+ this.lastResult = res;
15487
+ this.sessionRef = null;
15488
+ this.corners = null;
15489
+ this.drag = null;
15490
+ this.changed();
15491
+ return res;
15492
+ }
15493
+ /** Abandons the transform and restores everything. */
15494
+ cancel() {
15495
+ this.sessionRef?.cancel();
15496
+ this.sessionRef = null;
15497
+ this.corners = null;
15498
+ this.drag = null;
15499
+ this.pending = false;
15500
+ this.error = null;
15501
+ this.changed();
15502
+ }
15503
+ /**
15504
+ * Keyboard: Enter commits, Escape cancels, arrows nudge by 1 px (Shift: 10). Ctrl/Cmd+T opens a
15505
+ * Free Transform when none is open. Returns true when the key was used (prevent its default).
15506
+ */
15507
+ key(e) {
15508
+ const t = e.target;
15509
+ const typing = Boolean(t && (t.isContentEditable || t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT"));
15510
+ if (!this.active) {
15511
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "t" && !typing) {
15512
+ this.begin();
15513
+ return true;
15514
+ }
15515
+ return false;
15516
+ }
15517
+ if (e.key === "Enter") {
15518
+ this.commit();
15519
+ return true;
15520
+ }
15521
+ if (e.key === "Escape") {
15522
+ this.cancel();
15523
+ return true;
15524
+ }
15525
+ if (typing) return false;
15526
+ const step = e.shiftKey ? 10 : 1;
15527
+ const nudge = { ArrowLeft: { x: -step, y: 0 }, ArrowRight: { x: step, y: 0 }, ArrowUp: { x: 0, y: -step }, ArrowDown: { x: 0, y: step } };
15528
+ const v = nudge[e.key];
15529
+ if (v && this.corners) {
15530
+ this.setCorners(this.corners.map((q2) => add(q2, v)));
15531
+ return true;
15532
+ }
15533
+ return false;
15534
+ }
15535
+ };
15055
15536
  export {
15056
15537
  ADJUSTMENT_KEYS,
15057
15538
  ADJUSTMENT_KINDS,
@@ -15081,8 +15562,11 @@ export {
15081
15562
  EMPTY_DISTANCE,
15082
15563
  EMPTY_MIXER_LOAD,
15083
15564
  FREE_TRANSFORM_CONTENTS,
15565
+ FREE_TRANSFORM_FIELDS,
15084
15566
  FREE_TRANSFORM_MODES,
15567
+ FREE_TRANSFORM_MODE_OPTIONS,
15085
15568
  FreeTransformSession,
15569
+ FreeTransformTool,
15086
15570
  GUIDED_FILTER_EPS,
15087
15571
  HISTOGRAM_BINS,
15088
15572
  HUE_RANGE_LABELS,