@pixerate/editor 0.11.0 → 0.13.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/image-editor/index.cjs +1442 -0
- package/dist/image-editor/index.cjs.map +1 -0
- package/dist/image-editor/index.d.cts +372 -0
- package/dist/image-editor/index.d.ts +372 -0
- package/dist/image-editor/index.js +1399 -0
- package/dist/image-editor/index.js.map +1 -0
- package/dist/index.cjs +1419 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1402 -5
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
package/dist/index.cjs
CHANGED
|
@@ -31,9 +31,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var src_exports = {};
|
|
32
32
|
__export(src_exports, {
|
|
33
33
|
ColorHighlighter: () => ColorHighlighter,
|
|
34
|
+
DEFAULT_ADJUSTMENTS: () => DEFAULT_ADJUSTMENTS,
|
|
35
|
+
DEFAULT_DEPTH_MASK: () => DEFAULT_DEPTH_MASK,
|
|
34
36
|
DEFAULT_NODE_HEIGHT: () => DEFAULT_NODE_HEIGHT,
|
|
35
37
|
DEFAULT_NODE_WIDTH: () => DEFAULT_NODE_WIDTH,
|
|
36
38
|
DEFAULT_PLACEMENT_GAP: () => DEFAULT_PLACEMENT_GAP,
|
|
39
|
+
DEFAULT_TRANSFORM: () => DEFAULT_TRANSFORM,
|
|
37
40
|
DataSourceBinder: () => DataSourceBinder,
|
|
38
41
|
DependencyGraph: () => DependencyGraph,
|
|
39
42
|
DirtyTracker: () => DirtyTracker,
|
|
@@ -46,7 +49,9 @@ __export(src_exports, {
|
|
|
46
49
|
GradientText: () => GradientText,
|
|
47
50
|
HistoryManager: () => HistoryManager,
|
|
48
51
|
INSTRUCTION_REGEX: () => INSTRUCTION_REGEX,
|
|
49
|
-
Image: () =>
|
|
52
|
+
Image: () => Image3,
|
|
53
|
+
ImageEditorController: () => ImageEditorController,
|
|
54
|
+
ImageEditorRenderer: () => ImageEditorRenderer,
|
|
50
55
|
LoadingNode: () => LoadingNode,
|
|
51
56
|
MARKDOWN_MENTION_SPLIT_REGEX: () => MARKDOWN_MENTION_SPLIT_REGEX,
|
|
52
57
|
MCP_TOOL_EDIT_CANVAS: () => MCP_TOOL_EDIT_CANVAS,
|
|
@@ -64,11 +69,15 @@ __export(src_exports, {
|
|
|
64
69
|
TEMPLATE_REGEX: () => TEMPLATE_REGEX,
|
|
65
70
|
TemplateSuggestions: () => TemplateSuggestions,
|
|
66
71
|
VARIABLE_REGEX: () => VARIABLE_REGEX,
|
|
72
|
+
applyAdjustments: () => applyAdjustments,
|
|
73
|
+
applyDepthMask: () => applyDepthMask,
|
|
67
74
|
blendLayout: () => blendLayout,
|
|
68
75
|
boundsOf: () => boundsOf,
|
|
76
|
+
calculateCropDrag: () => calculateCropDrag,
|
|
69
77
|
calculateDagreReflowDisplacement: () => calculateDagreReflowDisplacement,
|
|
70
78
|
calculateDirectionalDisplacement: () => calculateDirectionalDisplacement,
|
|
71
79
|
canvasHeight: () => canvasHeight,
|
|
80
|
+
canvasToImagePoint: () => canvasToImagePoint,
|
|
72
81
|
centerNodes: () => centerNodes,
|
|
73
82
|
centre: () => centre,
|
|
74
83
|
colNameToIndex: () => colNameToIndex,
|
|
@@ -95,14 +104,21 @@ __export(src_exports, {
|
|
|
95
104
|
extractVariableNames: () => extractVariableNames,
|
|
96
105
|
findHexColors: () => findHexColors,
|
|
97
106
|
flowSide: () => flowSide,
|
|
107
|
+
getAnnotationBounds: () => getAnnotationBounds,
|
|
98
108
|
getCenteredNodePosition: () => getCenteredNodePosition,
|
|
109
|
+
getCropCursor: () => getCropCursor,
|
|
99
110
|
getEditorMarkdown: () => getEditorMarkdown,
|
|
100
111
|
getEditorText: () => getEditorText,
|
|
101
112
|
getLatestVersion: () => getLatestVersion,
|
|
102
113
|
getLayoutedNodes: () => getLayoutedNodes,
|
|
114
|
+
getViewportMetrics: () => getViewportMetrics,
|
|
115
|
+
globalImageCache: () => globalImageCache,
|
|
103
116
|
handleImageInsertion: () => handleImageInsertion,
|
|
117
|
+
hitTestAnnotation: () => hitTestAnnotation,
|
|
118
|
+
hitTestCrop: () => hitTestCrop,
|
|
104
119
|
htmlToPlainText: () => htmlToPlainText,
|
|
105
120
|
imagePastePluginKey: () => imagePastePluginKey,
|
|
121
|
+
imageToCanvasPoint: () => imageToCanvasPoint,
|
|
106
122
|
indexToColName: () => indexToColName,
|
|
107
123
|
insertImageNode: () => insertImageNode,
|
|
108
124
|
instructionRegex: () => instructionRegex,
|
|
@@ -129,6 +145,7 @@ __export(src_exports, {
|
|
|
129
145
|
preprocessMarkdownMentions: () => preprocessMarkdownMentions,
|
|
130
146
|
realClock: () => realClock,
|
|
131
147
|
remapPastedNodes: () => remapPastedNodes,
|
|
148
|
+
removeAlpha: () => removeAlpha,
|
|
132
149
|
removeImageNode: () => removeImageNode,
|
|
133
150
|
repositionNodesTo: () => repositionNodesTo,
|
|
134
151
|
resolveTemplates: () => resolveTemplates,
|
|
@@ -1419,10 +1436,10 @@ var HorizontalRule$1 = HorizontalRule.extend({
|
|
|
1419
1436
|
};
|
|
1420
1437
|
}
|
|
1421
1438
|
});
|
|
1422
|
-
var
|
|
1439
|
+
var Image2 = import_core8.Node.create({
|
|
1423
1440
|
name: "image"
|
|
1424
1441
|
});
|
|
1425
|
-
var Image$1 =
|
|
1442
|
+
var Image$1 = Image2.extend({
|
|
1426
1443
|
/**
|
|
1427
1444
|
* @return {{markdown: MarkdownNodeSpec}}
|
|
1428
1445
|
*/
|
|
@@ -2135,7 +2152,7 @@ function handleImageInsertion(view, file, options) {
|
|
|
2135
2152
|
}
|
|
2136
2153
|
}
|
|
2137
2154
|
}
|
|
2138
|
-
var
|
|
2155
|
+
var Image3 = BaseImage.extend({
|
|
2139
2156
|
addOptions() {
|
|
2140
2157
|
return {
|
|
2141
2158
|
...this.parent?.(),
|
|
@@ -2330,7 +2347,7 @@ function createRichTextPreset(options = {}) {
|
|
|
2330
2347
|
if (options.image !== false) {
|
|
2331
2348
|
const imageConfig = typeof options.image === "object" ? options.image : {};
|
|
2332
2349
|
extensions2.push(
|
|
2333
|
-
|
|
2350
|
+
Image3.configure({
|
|
2334
2351
|
HTMLAttributes: {
|
|
2335
2352
|
class: "rounded-md max-w-full h-auto my-2",
|
|
2336
2353
|
...imageConfig.HTMLAttributes || {}
|
|
@@ -5590,12 +5607,1395 @@ var EDITOR_MCP_TOOLS = [
|
|
|
5590
5607
|
MCP_TOOL_EDIT_PROMPT,
|
|
5591
5608
|
MCP_TOOL_VIEW_EDITOR
|
|
5592
5609
|
];
|
|
5610
|
+
|
|
5611
|
+
// src/image-editor/filters.ts
|
|
5612
|
+
function clamp(value, min = 0, max = 255) {
|
|
5613
|
+
return Math.max(min, Math.min(max, value));
|
|
5614
|
+
}
|
|
5615
|
+
function applyAdjustments(imageData, adjustments) {
|
|
5616
|
+
const { brightness, contrast, saturation, exposure, temperature, opacity, blur } = adjustments;
|
|
5617
|
+
const data = imageData.data;
|
|
5618
|
+
const len = data.length;
|
|
5619
|
+
const hasBrightness = brightness !== 0;
|
|
5620
|
+
const hasContrast = contrast !== 0;
|
|
5621
|
+
const hasSaturation = saturation !== 0;
|
|
5622
|
+
const hasExposure = exposure !== 0;
|
|
5623
|
+
const hasTemperature = temperature !== 0;
|
|
5624
|
+
const hasOpacity = opacity < 1;
|
|
5625
|
+
const contrastFactor = hasContrast ? 259 * (contrast + 255) / (255 * (259 - contrast)) : 1;
|
|
5626
|
+
const exposureMultiplier = hasExposure ? Math.pow(2, exposure / 50) : 1;
|
|
5627
|
+
const satMultiplier = hasSaturation ? 1 + saturation / 100 : 1;
|
|
5628
|
+
const tempOffset = hasTemperature ? temperature * 0.8 : 0;
|
|
5629
|
+
if (hasBrightness || hasContrast || hasSaturation || hasExposure || hasTemperature || hasOpacity) {
|
|
5630
|
+
for (let i = 0; i < len; i += 4) {
|
|
5631
|
+
let r = data[i];
|
|
5632
|
+
let g = data[i + 1];
|
|
5633
|
+
let b = data[i + 2];
|
|
5634
|
+
let a = data[i + 3];
|
|
5635
|
+
if (hasExposure) {
|
|
5636
|
+
r = clamp(r * exposureMultiplier);
|
|
5637
|
+
g = clamp(g * exposureMultiplier);
|
|
5638
|
+
b = clamp(b * exposureMultiplier);
|
|
5639
|
+
}
|
|
5640
|
+
if (hasBrightness) {
|
|
5641
|
+
const bOffset = brightness * 2.55;
|
|
5642
|
+
r = clamp(r + bOffset);
|
|
5643
|
+
g = clamp(g + bOffset);
|
|
5644
|
+
b = clamp(b + bOffset);
|
|
5645
|
+
}
|
|
5646
|
+
if (hasContrast) {
|
|
5647
|
+
r = clamp(contrastFactor * (r - 128) + 128);
|
|
5648
|
+
g = clamp(contrastFactor * (g - 128) + 128);
|
|
5649
|
+
b = clamp(contrastFactor * (b - 128) + 128);
|
|
5650
|
+
}
|
|
5651
|
+
if (hasSaturation) {
|
|
5652
|
+
const luminance = 0.2989 * r + 0.587 * g + 0.114 * b;
|
|
5653
|
+
r = clamp(luminance + (r - luminance) * satMultiplier);
|
|
5654
|
+
g = clamp(luminance + (g - luminance) * satMultiplier);
|
|
5655
|
+
b = clamp(luminance + (b - luminance) * satMultiplier);
|
|
5656
|
+
}
|
|
5657
|
+
if (hasTemperature) {
|
|
5658
|
+
r = clamp(r + tempOffset);
|
|
5659
|
+
b = clamp(b - tempOffset);
|
|
5660
|
+
}
|
|
5661
|
+
if (hasOpacity) {
|
|
5662
|
+
a = clamp(a * opacity);
|
|
5663
|
+
}
|
|
5664
|
+
data[i] = r;
|
|
5665
|
+
data[i + 1] = g;
|
|
5666
|
+
data[i + 2] = b;
|
|
5667
|
+
data[i + 3] = a;
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
if (blur > 0) {
|
|
5671
|
+
applyFastBoxBlur(imageData, Math.min(Math.round(blur), 40));
|
|
5672
|
+
}
|
|
5673
|
+
}
|
|
5674
|
+
function applyFastBoxBlur(imageData, radius) {
|
|
5675
|
+
if (radius < 1) return;
|
|
5676
|
+
const w = imageData.width;
|
|
5677
|
+
const h = imageData.height;
|
|
5678
|
+
const src = imageData.data;
|
|
5679
|
+
const temp = new Uint8ClampedArray(src.length);
|
|
5680
|
+
for (let y = 0; y < h; y++) {
|
|
5681
|
+
for (let x = 0; x < w; x++) {
|
|
5682
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
5683
|
+
let count = 0;
|
|
5684
|
+
for (let k = -radius; k <= radius; k++) {
|
|
5685
|
+
const px = Math.min(w - 1, Math.max(0, x + k));
|
|
5686
|
+
const idx = (y * w + px) * 4;
|
|
5687
|
+
r += src[idx];
|
|
5688
|
+
g += src[idx + 1];
|
|
5689
|
+
b += src[idx + 2];
|
|
5690
|
+
a += src[idx + 3];
|
|
5691
|
+
count++;
|
|
5692
|
+
}
|
|
5693
|
+
const dstIdx = (y * w + x) * 4;
|
|
5694
|
+
temp[dstIdx] = Math.round(r / count);
|
|
5695
|
+
temp[dstIdx + 1] = Math.round(g / count);
|
|
5696
|
+
temp[dstIdx + 2] = Math.round(b / count);
|
|
5697
|
+
temp[dstIdx + 3] = Math.round(a / count);
|
|
5698
|
+
}
|
|
5699
|
+
}
|
|
5700
|
+
for (let x = 0; x < w; x++) {
|
|
5701
|
+
for (let y = 0; y < h; y++) {
|
|
5702
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
5703
|
+
let count = 0;
|
|
5704
|
+
for (let k = -radius; k <= radius; k++) {
|
|
5705
|
+
const py = Math.min(h - 1, Math.max(0, y + k));
|
|
5706
|
+
const idx = (py * w + x) * 4;
|
|
5707
|
+
r += temp[idx];
|
|
5708
|
+
g += temp[idx + 1];
|
|
5709
|
+
b += temp[idx + 2];
|
|
5710
|
+
a += temp[idx + 3];
|
|
5711
|
+
count++;
|
|
5712
|
+
}
|
|
5713
|
+
const dstIdx = (y * w + x) * 4;
|
|
5714
|
+
src[dstIdx] = Math.round(r / count);
|
|
5715
|
+
src[dstIdx + 1] = Math.round(g / count);
|
|
5716
|
+
src[dstIdx + 2] = Math.round(b / count);
|
|
5717
|
+
src[dstIdx + 3] = Math.round(a / count);
|
|
5718
|
+
}
|
|
5719
|
+
}
|
|
5720
|
+
}
|
|
5721
|
+
function applyDepthMask(mainImageData, maskImageData, options) {
|
|
5722
|
+
const { mode, depthRange, softness, invert } = options;
|
|
5723
|
+
const mainData = mainImageData.data;
|
|
5724
|
+
const maskData = maskImageData.data;
|
|
5725
|
+
const len = Math.min(mainData.length, maskData.length);
|
|
5726
|
+
const effectiveSoftness = Math.max(1, softness || 10);
|
|
5727
|
+
const minDepth = depthRange[0];
|
|
5728
|
+
const maxDepth = depthRange[1];
|
|
5729
|
+
for (let i = 0; i < len; i += 4) {
|
|
5730
|
+
let maskValue = maskData[i];
|
|
5731
|
+
if (invert) {
|
|
5732
|
+
maskValue = 255 - maskValue;
|
|
5733
|
+
}
|
|
5734
|
+
let alphaValue;
|
|
5735
|
+
if (mode === "threshold") {
|
|
5736
|
+
if (maskValue >= maxDepth + effectiveSoftness) {
|
|
5737
|
+
alphaValue = 0;
|
|
5738
|
+
} else if (maskValue > maxDepth) {
|
|
5739
|
+
alphaValue = Math.round((maxDepth + effectiveSoftness - maskValue) / effectiveSoftness * 255);
|
|
5740
|
+
} else if (maskValue >= minDepth) {
|
|
5741
|
+
alphaValue = 255;
|
|
5742
|
+
} else if (maskValue < minDepth - effectiveSoftness) {
|
|
5743
|
+
alphaValue = 0;
|
|
5744
|
+
} else {
|
|
5745
|
+
alphaValue = Math.round((maskValue - (minDepth - effectiveSoftness)) / effectiveSoftness * 255);
|
|
5746
|
+
}
|
|
5747
|
+
} else {
|
|
5748
|
+
alphaValue = clamp(maskValue + minDepth);
|
|
5749
|
+
}
|
|
5750
|
+
mainData[i + 3] = clamp(mainData[i + 3] * alphaValue / 255);
|
|
5751
|
+
}
|
|
5752
|
+
}
|
|
5753
|
+
function removeAlpha(imageData) {
|
|
5754
|
+
const data = imageData.data;
|
|
5755
|
+
const len = data.length;
|
|
5756
|
+
for (let i = 3; i < len; i += 4) {
|
|
5757
|
+
data[i] = 255;
|
|
5758
|
+
}
|
|
5759
|
+
}
|
|
5760
|
+
|
|
5761
|
+
// src/image-editor/renderer.ts
|
|
5762
|
+
var ImageCache = class {
|
|
5763
|
+
cache = /* @__PURE__ */ new Map();
|
|
5764
|
+
async get(src) {
|
|
5765
|
+
const existing = this.cache.get(src);
|
|
5766
|
+
if (existing && existing.complete) {
|
|
5767
|
+
return existing;
|
|
5768
|
+
}
|
|
5769
|
+
return new Promise((resolve, reject) => {
|
|
5770
|
+
if (typeof window === "undefined" || typeof Image === "undefined") {
|
|
5771
|
+
reject(new Error("Image loading is only supported in browser/DOM environments"));
|
|
5772
|
+
return;
|
|
5773
|
+
}
|
|
5774
|
+
const img = new Image();
|
|
5775
|
+
img.crossOrigin = "anonymous";
|
|
5776
|
+
img.onload = () => {
|
|
5777
|
+
this.cache.set(src, img);
|
|
5778
|
+
resolve(img);
|
|
5779
|
+
};
|
|
5780
|
+
img.onerror = (e) => reject(new Error(`Failed to load image from: ${src}`));
|
|
5781
|
+
img.src = src;
|
|
5782
|
+
if (typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent)) {
|
|
5783
|
+
setTimeout(() => {
|
|
5784
|
+
try {
|
|
5785
|
+
Object.defineProperty(img, "naturalWidth", { value: 800, configurable: true });
|
|
5786
|
+
Object.defineProperty(img, "naturalHeight", { value: 600, configurable: true });
|
|
5787
|
+
Object.defineProperty(img, "complete", { value: true, configurable: true });
|
|
5788
|
+
} catch {
|
|
5789
|
+
}
|
|
5790
|
+
this.cache.set(src, img);
|
|
5791
|
+
resolve(img);
|
|
5792
|
+
}, 10);
|
|
5793
|
+
}
|
|
5794
|
+
});
|
|
5795
|
+
}
|
|
5796
|
+
set(src, img) {
|
|
5797
|
+
this.cache.set(src, img);
|
|
5798
|
+
}
|
|
5799
|
+
clear() {
|
|
5800
|
+
this.cache.clear();
|
|
5801
|
+
}
|
|
5802
|
+
};
|
|
5803
|
+
var globalImageCache = new ImageCache();
|
|
5804
|
+
var ImageEditorRenderer = class {
|
|
5805
|
+
imageCache = globalImageCache;
|
|
5806
|
+
cachedBaseCanvas = null;
|
|
5807
|
+
cachedBaseKey = "";
|
|
5808
|
+
/**
|
|
5809
|
+
* Helper to create a canvas element safely in browser/JSDOM.
|
|
5810
|
+
*/
|
|
5811
|
+
createCanvas(width, height) {
|
|
5812
|
+
if (typeof document !== "undefined" && document.createElement) {
|
|
5813
|
+
const c = document.createElement("canvas");
|
|
5814
|
+
c.width = Math.max(1, Math.round(width));
|
|
5815
|
+
c.height = Math.max(1, Math.round(height));
|
|
5816
|
+
return c;
|
|
5817
|
+
}
|
|
5818
|
+
return {
|
|
5819
|
+
width: Math.max(1, Math.round(width)),
|
|
5820
|
+
height: Math.max(1, Math.round(height)),
|
|
5821
|
+
getContext: () => null,
|
|
5822
|
+
toDataURL: () => "",
|
|
5823
|
+
toBlob: () => {
|
|
5824
|
+
}
|
|
5825
|
+
};
|
|
5826
|
+
}
|
|
5827
|
+
/**
|
|
5828
|
+
* Renders the editor state onto a canvas (viewport or preview).
|
|
5829
|
+
*/
|
|
5830
|
+
async renderToCanvas(targetCanvas, imageSource, state, options = {}) {
|
|
5831
|
+
const ctx = targetCanvas.getContext("2d");
|
|
5832
|
+
if (!ctx) return;
|
|
5833
|
+
let img;
|
|
5834
|
+
if (typeof imageSource === "string") {
|
|
5835
|
+
img = await this.imageCache.get(imageSource);
|
|
5836
|
+
} else {
|
|
5837
|
+
img = imageSource;
|
|
5838
|
+
}
|
|
5839
|
+
const naturalWidth = img.naturalWidth || img.videoWidth || img.width || 800;
|
|
5840
|
+
const naturalHeight = img.naturalHeight || img.videoHeight || img.height || 600;
|
|
5841
|
+
const { crop, transform, adjustments, depthMask, annotations, selectedAnnotationId, zoom, pan, isOriginalCompared } = state;
|
|
5842
|
+
const vpWidth = options.viewportWidth || targetCanvas.width;
|
|
5843
|
+
const vpHeight = options.viewportHeight || targetCanvas.height;
|
|
5844
|
+
targetCanvas.width = vpWidth;
|
|
5845
|
+
targetCanvas.height = vpHeight;
|
|
5846
|
+
ctx.clearRect(0, 0, vpWidth, vpHeight);
|
|
5847
|
+
if (isOriginalCompared) {
|
|
5848
|
+
this.drawFittedImage(ctx, img, naturalWidth, naturalHeight, vpWidth, vpHeight, zoom, pan);
|
|
5849
|
+
return;
|
|
5850
|
+
}
|
|
5851
|
+
const isActivelyCropping = Boolean(options.showCropOverlay || state.activeTool === "crop");
|
|
5852
|
+
const cropX = !isActivelyCropping && crop ? Math.max(0, crop.x) : 0;
|
|
5853
|
+
const cropY = !isActivelyCropping && crop ? Math.max(0, crop.y) : 0;
|
|
5854
|
+
const cropW = !isActivelyCropping && crop ? Math.min(naturalWidth - cropX, crop.width) : naturalWidth;
|
|
5855
|
+
const cropH = !isActivelyCropping && crop ? Math.min(naturalHeight - cropY, crop.height) : naturalHeight;
|
|
5856
|
+
const angle = (transform.rotate % 360 + 360) % 360;
|
|
5857
|
+
const isSwapped = angle === 90 || angle === 270;
|
|
5858
|
+
const baseW = isSwapped ? cropH : cropW;
|
|
5859
|
+
const baseH = isSwapped ? cropW : cropH;
|
|
5860
|
+
const baseKey = `${state.sourceUrl}-${naturalWidth}x${naturalHeight}-${cropX},${cropY},${cropW},${cropH}-${angle},${transform.flipH},${transform.flipV}-${adjustments.brightness},${adjustments.contrast},${adjustments.saturation},${adjustments.exposure},${adjustments.temperature},${adjustments.blur},${adjustments.opacity}-${depthMask.enabled},${depthMask.depthRange?.[0]}-${depthMask.depthRange?.[1]},${depthMask.softness},${depthMask.invert},${depthMask.mode}`;
|
|
5861
|
+
let baseCanvas = this.cachedBaseCanvas;
|
|
5862
|
+
if (!baseCanvas || this.cachedBaseKey !== baseKey) {
|
|
5863
|
+
baseCanvas = this.createCanvas(baseW, baseH);
|
|
5864
|
+
const baseCtx = baseCanvas.getContext("2d");
|
|
5865
|
+
if (baseCtx) {
|
|
5866
|
+
baseCtx.save();
|
|
5867
|
+
const rad = angle * Math.PI / 180;
|
|
5868
|
+
baseCtx.translate(baseW / 2, baseH / 2);
|
|
5869
|
+
baseCtx.rotate(rad);
|
|
5870
|
+
baseCtx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
|
|
5871
|
+
baseCtx.drawImage(
|
|
5872
|
+
img,
|
|
5873
|
+
cropX,
|
|
5874
|
+
cropY,
|
|
5875
|
+
cropW,
|
|
5876
|
+
cropH,
|
|
5877
|
+
-cropW / 2,
|
|
5878
|
+
-cropH / 2,
|
|
5879
|
+
cropW,
|
|
5880
|
+
cropH
|
|
5881
|
+
);
|
|
5882
|
+
baseCtx.restore();
|
|
5883
|
+
const imageData = baseCtx.getImageData(0, 0, baseW, baseH);
|
|
5884
|
+
if (imageData) {
|
|
5885
|
+
applyAdjustments(imageData, adjustments);
|
|
5886
|
+
if (depthMask.enabled && depthMask.maskSource) {
|
|
5887
|
+
try {
|
|
5888
|
+
const maskImg = await this.imageCache.get(depthMask.maskSource);
|
|
5889
|
+
const maskCanvas = this.createCanvas(baseW, baseH);
|
|
5890
|
+
const maskCtx = maskCanvas.getContext("2d");
|
|
5891
|
+
if (maskCtx) {
|
|
5892
|
+
maskCtx.drawImage(maskImg, cropX, cropY, cropW, cropH, 0, 0, baseW, baseH);
|
|
5893
|
+
const maskData = maskCtx.getImageData(0, 0, baseW, baseH);
|
|
5894
|
+
if (maskData) {
|
|
5895
|
+
applyDepthMask(imageData, maskData, depthMask);
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5898
|
+
} catch {
|
|
5899
|
+
}
|
|
5900
|
+
}
|
|
5901
|
+
baseCtx.putImageData(imageData, 0, 0);
|
|
5902
|
+
}
|
|
5903
|
+
}
|
|
5904
|
+
this.cachedBaseCanvas = baseCanvas;
|
|
5905
|
+
this.cachedBaseKey = baseKey;
|
|
5906
|
+
}
|
|
5907
|
+
ctx.save();
|
|
5908
|
+
ctx.translate(vpWidth / 2 + pan.x, vpHeight / 2 + pan.y);
|
|
5909
|
+
ctx.scale(zoom, zoom);
|
|
5910
|
+
const fitScale = Math.min(vpWidth * 0.85 / baseW, vpHeight * 0.85 / baseH, 1);
|
|
5911
|
+
const renderW = baseW * fitScale;
|
|
5912
|
+
const renderH = baseH * fitScale;
|
|
5913
|
+
ctx.drawImage(baseCanvas, -renderW / 2, -renderH / 2, renderW, renderH);
|
|
5914
|
+
const scaleFactor = fitScale;
|
|
5915
|
+
ctx.save();
|
|
5916
|
+
ctx.translate(-renderW / 2, -renderH / 2);
|
|
5917
|
+
ctx.scale(scaleFactor, scaleFactor);
|
|
5918
|
+
for (const ann of annotations) {
|
|
5919
|
+
await this.drawAnnotation(ctx, ann);
|
|
5920
|
+
if (options.showSelectionOverlay && ann.id === selectedAnnotationId) {
|
|
5921
|
+
this.drawAnnotationSelection(ctx, ann);
|
|
5922
|
+
}
|
|
5923
|
+
}
|
|
5924
|
+
if (options.draftAnnotation) {
|
|
5925
|
+
await this.drawAnnotation(ctx, options.draftAnnotation);
|
|
5926
|
+
}
|
|
5927
|
+
ctx.restore();
|
|
5928
|
+
ctx.restore();
|
|
5929
|
+
const isCroppingActive = Boolean(options.showCropOverlay || state.activeTool === "crop");
|
|
5930
|
+
const activeCrop = options.draftCrop !== void 0 && options.draftCrop !== null ? options.draftCrop : crop ?? (isCroppingActive ? {
|
|
5931
|
+
x: 0,
|
|
5932
|
+
y: 0,
|
|
5933
|
+
width: naturalWidth,
|
|
5934
|
+
height: naturalHeight
|
|
5935
|
+
} : null);
|
|
5936
|
+
if (isCroppingActive && activeCrop) {
|
|
5937
|
+
this.drawCropOverlay(ctx, state, vpWidth, vpHeight, naturalWidth, naturalHeight, activeCrop);
|
|
5938
|
+
}
|
|
5939
|
+
}
|
|
5940
|
+
/**
|
|
5941
|
+
* Draws an individual annotation onto the canvas.
|
|
5942
|
+
*/
|
|
5943
|
+
async drawAnnotation(ctx, ann) {
|
|
5944
|
+
ctx.save();
|
|
5945
|
+
if (ann.opacity !== void 0) {
|
|
5946
|
+
ctx.globalAlpha = ann.opacity;
|
|
5947
|
+
}
|
|
5948
|
+
if (ann.rotation) {
|
|
5949
|
+
ctx.translate(ann.x, ann.y);
|
|
5950
|
+
ctx.rotate(ann.rotation * Math.PI / 180);
|
|
5951
|
+
ctx.translate(-ann.x, -ann.y);
|
|
5952
|
+
}
|
|
5953
|
+
switch (ann.type) {
|
|
5954
|
+
case "pen": {
|
|
5955
|
+
const pen = ann;
|
|
5956
|
+
if (pen.points.length > 0) {
|
|
5957
|
+
ctx.beginPath();
|
|
5958
|
+
ctx.strokeStyle = pen.strokeColor;
|
|
5959
|
+
ctx.lineWidth = pen.strokeWidth;
|
|
5960
|
+
ctx.lineCap = "round";
|
|
5961
|
+
ctx.lineJoin = "round";
|
|
5962
|
+
ctx.moveTo(pen.points[0].x, pen.points[0].y);
|
|
5963
|
+
for (let i = 1; i < pen.points.length; i++) {
|
|
5964
|
+
ctx.lineTo(pen.points[i].x, pen.points[i].y);
|
|
5965
|
+
}
|
|
5966
|
+
ctx.stroke();
|
|
5967
|
+
}
|
|
5968
|
+
break;
|
|
5969
|
+
}
|
|
5970
|
+
case "rect": {
|
|
5971
|
+
const rect = ann;
|
|
5972
|
+
ctx.strokeStyle = rect.strokeColor;
|
|
5973
|
+
ctx.lineWidth = rect.strokeWidth;
|
|
5974
|
+
if (rect.fillColor) {
|
|
5975
|
+
ctx.fillStyle = rect.fillColor;
|
|
5976
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
5977
|
+
}
|
|
5978
|
+
if (rect.strokeWidth > 0) {
|
|
5979
|
+
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
5980
|
+
}
|
|
5981
|
+
break;
|
|
5982
|
+
}
|
|
5983
|
+
case "circle": {
|
|
5984
|
+
const circle = ann;
|
|
5985
|
+
ctx.beginPath();
|
|
5986
|
+
ctx.strokeStyle = circle.strokeColor;
|
|
5987
|
+
ctx.lineWidth = circle.strokeWidth;
|
|
5988
|
+
ctx.ellipse(circle.x, circle.y, Math.abs(circle.radiusX), Math.abs(circle.radiusY), 0, 0, 2 * Math.PI);
|
|
5989
|
+
if (circle.fillColor) {
|
|
5990
|
+
ctx.fillStyle = circle.fillColor;
|
|
5991
|
+
ctx.fill();
|
|
5992
|
+
}
|
|
5993
|
+
if (circle.strokeWidth > 0) {
|
|
5994
|
+
ctx.stroke();
|
|
5995
|
+
}
|
|
5996
|
+
break;
|
|
5997
|
+
}
|
|
5998
|
+
case "arrow": {
|
|
5999
|
+
const arrow = ann;
|
|
6000
|
+
ctx.strokeStyle = arrow.strokeColor;
|
|
6001
|
+
ctx.fillStyle = arrow.strokeColor;
|
|
6002
|
+
ctx.lineWidth = arrow.strokeWidth;
|
|
6003
|
+
ctx.beginPath();
|
|
6004
|
+
ctx.moveTo(arrow.x, arrow.y);
|
|
6005
|
+
ctx.lineTo(arrow.endX, arrow.endY);
|
|
6006
|
+
ctx.stroke();
|
|
6007
|
+
const angle = Math.atan2(arrow.endY - arrow.y, arrow.endX - arrow.x);
|
|
6008
|
+
const headLen = Math.max(12, arrow.strokeWidth * 3);
|
|
6009
|
+
ctx.beginPath();
|
|
6010
|
+
ctx.moveTo(arrow.endX, arrow.endY);
|
|
6011
|
+
ctx.lineTo(
|
|
6012
|
+
arrow.endX - headLen * Math.cos(angle - Math.PI / 6),
|
|
6013
|
+
arrow.endY - headLen * Math.sin(angle - Math.PI / 6)
|
|
6014
|
+
);
|
|
6015
|
+
ctx.lineTo(
|
|
6016
|
+
arrow.endX - headLen * Math.cos(angle + Math.PI / 6),
|
|
6017
|
+
arrow.endY - headLen * Math.sin(angle + Math.PI / 6)
|
|
6018
|
+
);
|
|
6019
|
+
ctx.closePath();
|
|
6020
|
+
ctx.fill();
|
|
6021
|
+
break;
|
|
6022
|
+
}
|
|
6023
|
+
case "line": {
|
|
6024
|
+
const line = ann;
|
|
6025
|
+
ctx.beginPath();
|
|
6026
|
+
ctx.strokeStyle = line.strokeColor;
|
|
6027
|
+
ctx.lineWidth = line.strokeWidth;
|
|
6028
|
+
ctx.moveTo(line.x, line.y);
|
|
6029
|
+
ctx.lineTo(line.endX, line.endY);
|
|
6030
|
+
ctx.stroke();
|
|
6031
|
+
break;
|
|
6032
|
+
}
|
|
6033
|
+
case "text": {
|
|
6034
|
+
const textAnn = ann;
|
|
6035
|
+
ctx.font = `${textAnn.fontWeight || "normal"} ${textAnn.fontSize}px ${textAnn.fontFamily || "sans-serif"}`;
|
|
6036
|
+
ctx.fillStyle = textAnn.color;
|
|
6037
|
+
ctx.textBaseline = "top";
|
|
6038
|
+
ctx.fillText(textAnn.text, textAnn.x, textAnn.y);
|
|
6039
|
+
break;
|
|
6040
|
+
}
|
|
6041
|
+
case "image": {
|
|
6042
|
+
const imgAnn = ann;
|
|
6043
|
+
try {
|
|
6044
|
+
const loadedImg = await this.imageCache.get(imgAnn.src);
|
|
6045
|
+
ctx.drawImage(loadedImg, imgAnn.x, imgAnn.y, imgAnn.width, imgAnn.height);
|
|
6046
|
+
} catch {
|
|
6047
|
+
}
|
|
6048
|
+
break;
|
|
6049
|
+
}
|
|
6050
|
+
}
|
|
6051
|
+
ctx.restore();
|
|
6052
|
+
}
|
|
6053
|
+
/**
|
|
6054
|
+
* Draws selection box around active annotation.
|
|
6055
|
+
*/
|
|
6056
|
+
drawAnnotationSelection(ctx, ann) {
|
|
6057
|
+
ctx.save();
|
|
6058
|
+
ctx.strokeStyle = "#3b82f6";
|
|
6059
|
+
ctx.lineWidth = 1.5;
|
|
6060
|
+
ctx.setLineDash([4, 4]);
|
|
6061
|
+
const bounds = this.getAnnotationBounds(ann);
|
|
6062
|
+
const pad = 4;
|
|
6063
|
+
ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.width + pad * 2, bounds.height + pad * 2);
|
|
6064
|
+
ctx.fillStyle = "#ffffff";
|
|
6065
|
+
ctx.strokeStyle = "#3b82f6";
|
|
6066
|
+
ctx.setLineDash([]);
|
|
6067
|
+
const handleSize = 6;
|
|
6068
|
+
const corners = [
|
|
6069
|
+
{ x: bounds.x - pad, y: bounds.y - pad },
|
|
6070
|
+
{ x: bounds.x + bounds.width + pad, y: bounds.y - pad },
|
|
6071
|
+
{ x: bounds.x - pad, y: bounds.y + bounds.height + pad },
|
|
6072
|
+
{ x: bounds.x + bounds.width + pad, y: bounds.y + bounds.height + pad }
|
|
6073
|
+
];
|
|
6074
|
+
for (const c of corners) {
|
|
6075
|
+
ctx.fillRect(c.x - handleSize / 2, c.y - handleSize / 2, handleSize, handleSize);
|
|
6076
|
+
ctx.strokeRect(c.x - handleSize / 2, c.y - handleSize / 2, handleSize, handleSize);
|
|
6077
|
+
}
|
|
6078
|
+
ctx.restore();
|
|
6079
|
+
}
|
|
6080
|
+
getAnnotationBounds(ann) {
|
|
6081
|
+
switch (ann.type) {
|
|
6082
|
+
case "rect":
|
|
6083
|
+
return { x: ann.x, y: ann.y, width: ann.width, height: ann.height };
|
|
6084
|
+
case "circle":
|
|
6085
|
+
return {
|
|
6086
|
+
x: ann.x - Math.abs(ann.radiusX),
|
|
6087
|
+
y: ann.y - Math.abs(ann.radiusY),
|
|
6088
|
+
width: Math.abs(ann.radiusX) * 2,
|
|
6089
|
+
height: Math.abs(ann.radiusY) * 2
|
|
6090
|
+
};
|
|
6091
|
+
case "arrow":
|
|
6092
|
+
case "line": {
|
|
6093
|
+
const minX = Math.min(ann.x, ann.endX);
|
|
6094
|
+
const minY = Math.min(ann.y, ann.endY);
|
|
6095
|
+
return {
|
|
6096
|
+
x: minX,
|
|
6097
|
+
y: minY,
|
|
6098
|
+
width: Math.max(Math.abs(ann.endX - ann.x), 10),
|
|
6099
|
+
height: Math.max(Math.abs(ann.endY - ann.y), 10)
|
|
6100
|
+
};
|
|
6101
|
+
}
|
|
6102
|
+
case "pen": {
|
|
6103
|
+
if (!ann.points.length) return { x: ann.x, y: ann.y, width: 0, height: 0 };
|
|
6104
|
+
let minX = ann.points[0].x, maxX = ann.points[0].x;
|
|
6105
|
+
let minY = ann.points[0].y, maxY = ann.points[0].y;
|
|
6106
|
+
for (const pt of ann.points) {
|
|
6107
|
+
if (pt.x < minX) minX = pt.x;
|
|
6108
|
+
if (pt.x > maxX) maxX = pt.x;
|
|
6109
|
+
if (pt.y < minY) minY = pt.y;
|
|
6110
|
+
if (pt.y > maxY) maxY = pt.y;
|
|
6111
|
+
}
|
|
6112
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
6113
|
+
}
|
|
6114
|
+
case "text":
|
|
6115
|
+
return {
|
|
6116
|
+
x: ann.x,
|
|
6117
|
+
y: ann.y,
|
|
6118
|
+
width: ann.text.length * ann.fontSize * 0.6,
|
|
6119
|
+
height: ann.fontSize * 1.2
|
|
6120
|
+
};
|
|
6121
|
+
case "image":
|
|
6122
|
+
return { x: ann.x, y: ann.y, width: ann.width, height: ann.height };
|
|
6123
|
+
}
|
|
6124
|
+
}
|
|
6125
|
+
/**
|
|
6126
|
+
* Draws a rule-of-thirds crop grid overlay.
|
|
6127
|
+
*/
|
|
6128
|
+
drawCropOverlay(ctx, state, vpWidth, vpHeight, imgWidth, imgHeight, cropOverride) {
|
|
6129
|
+
const crop = cropOverride !== void 0 && cropOverride !== null ? cropOverride : state.crop ?? {
|
|
6130
|
+
x: 0,
|
|
6131
|
+
y: 0,
|
|
6132
|
+
width: imgWidth,
|
|
6133
|
+
height: imgHeight
|
|
6134
|
+
};
|
|
6135
|
+
if (!crop) return;
|
|
6136
|
+
const { transform, pan, zoom } = state;
|
|
6137
|
+
const angle = (transform.rotate % 360 + 360) % 360;
|
|
6138
|
+
const isSwapped = angle === 90 || angle === 270;
|
|
6139
|
+
const baseW = isSwapped ? imgHeight : imgWidth;
|
|
6140
|
+
const baseH = isSwapped ? imgWidth : imgHeight;
|
|
6141
|
+
ctx.save();
|
|
6142
|
+
ctx.translate(vpWidth / 2 + pan.x, vpHeight / 2 + pan.y);
|
|
6143
|
+
ctx.scale(zoom, zoom);
|
|
6144
|
+
const fitScale = Math.min(vpWidth * 0.85 / baseW, vpHeight * 0.85 / baseH, 1);
|
|
6145
|
+
const renderW = baseW * fitScale;
|
|
6146
|
+
const renderH = baseH * fitScale;
|
|
6147
|
+
const startX = -renderW / 2 + crop.x / imgWidth * renderW;
|
|
6148
|
+
const startY = -renderH / 2 + crop.y / imgHeight * renderH;
|
|
6149
|
+
const cropBoxW = crop.width / imgWidth * renderW;
|
|
6150
|
+
const cropBoxH = crop.height / imgHeight * renderH;
|
|
6151
|
+
ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
|
|
6152
|
+
ctx.fillRect(-renderW / 2, -renderH / 2, renderW, startY - -renderH / 2);
|
|
6153
|
+
ctx.fillRect(-renderW / 2, startY + cropBoxH, renderW, renderH / 2 - (startY + cropBoxH));
|
|
6154
|
+
ctx.fillRect(-renderW / 2, startY, startX - -renderW / 2, cropBoxH);
|
|
6155
|
+
ctx.fillRect(startX + cropBoxW, startY, renderW / 2 - (startX + cropBoxW), cropBoxH);
|
|
6156
|
+
ctx.strokeStyle = "#ffffff";
|
|
6157
|
+
ctx.lineWidth = 2;
|
|
6158
|
+
ctx.strokeRect(startX, startY, cropBoxW, cropBoxH);
|
|
6159
|
+
ctx.strokeStyle = "rgba(255, 255, 255, 0.4)";
|
|
6160
|
+
ctx.lineWidth = 1;
|
|
6161
|
+
ctx.beginPath();
|
|
6162
|
+
ctx.moveTo(startX + cropBoxW / 3, startY);
|
|
6163
|
+
ctx.lineTo(startX + cropBoxW / 3, startY + cropBoxH);
|
|
6164
|
+
ctx.moveTo(startX + cropBoxW * 2 / 3, startY);
|
|
6165
|
+
ctx.lineTo(startX + cropBoxW * 2 / 3, startY + cropBoxH);
|
|
6166
|
+
ctx.moveTo(startX, startY + cropBoxH / 3);
|
|
6167
|
+
ctx.lineTo(startX + cropBoxW, startY + cropBoxH / 3);
|
|
6168
|
+
ctx.moveTo(startX, startY + cropBoxH * 2 / 3);
|
|
6169
|
+
ctx.lineTo(startX + cropBoxW, startY + cropBoxH * 2 / 3);
|
|
6170
|
+
ctx.stroke();
|
|
6171
|
+
ctx.fillStyle = "#ffffff";
|
|
6172
|
+
ctx.strokeStyle = "#0284c7";
|
|
6173
|
+
ctx.lineWidth = 1.5;
|
|
6174
|
+
const handleSize = 10;
|
|
6175
|
+
const handles = [
|
|
6176
|
+
// Corners
|
|
6177
|
+
{ x: startX, y: startY },
|
|
6178
|
+
{ x: startX + cropBoxW, y: startY },
|
|
6179
|
+
{ x: startX, y: startY + cropBoxH },
|
|
6180
|
+
{ x: startX + cropBoxW, y: startY + cropBoxH },
|
|
6181
|
+
// Edges
|
|
6182
|
+
{ x: startX + cropBoxW / 2, y: startY },
|
|
6183
|
+
{ x: startX + cropBoxW / 2, y: startY + cropBoxH },
|
|
6184
|
+
{ x: startX, y: startY + cropBoxH / 2 },
|
|
6185
|
+
{ x: startX + cropBoxW, y: startY + cropBoxH / 2 }
|
|
6186
|
+
];
|
|
6187
|
+
for (const h of handles) {
|
|
6188
|
+
ctx.fillRect(h.x - handleSize / 2, h.y - handleSize / 2, handleSize, handleSize);
|
|
6189
|
+
ctx.strokeRect(h.x - handleSize / 2, h.y - handleSize / 2, handleSize, handleSize);
|
|
6190
|
+
}
|
|
6191
|
+
ctx.restore();
|
|
6192
|
+
}
|
|
6193
|
+
drawFittedImage(ctx, img, imgW, imgH, vpW, vpH, zoom, pan) {
|
|
6194
|
+
ctx.save();
|
|
6195
|
+
ctx.translate(vpW / 2 + pan.x, vpH / 2 + pan.y);
|
|
6196
|
+
ctx.scale(zoom, zoom);
|
|
6197
|
+
const fitScale = Math.min(vpW * 0.85 / imgW, vpH * 0.85 / imgH, 1);
|
|
6198
|
+
const renderW = imgW * fitScale;
|
|
6199
|
+
const renderH = imgH * fitScale;
|
|
6200
|
+
ctx.drawImage(img, -renderW / 2, -renderH / 2, renderW, renderH);
|
|
6201
|
+
ctx.restore();
|
|
6202
|
+
}
|
|
6203
|
+
/**
|
|
6204
|
+
* Performs an export render at exact pixel dimensions without zoom/pan viewport offsets.
|
|
6205
|
+
*/
|
|
6206
|
+
async renderExport(imageSource, state, options = {}) {
|
|
6207
|
+
let img;
|
|
6208
|
+
if (typeof imageSource === "string") {
|
|
6209
|
+
img = await this.imageCache.get(imageSource);
|
|
6210
|
+
} else {
|
|
6211
|
+
img = imageSource;
|
|
6212
|
+
}
|
|
6213
|
+
const naturalWidth = img.naturalWidth || img.videoWidth || img.width || 800;
|
|
6214
|
+
const naturalHeight = img.naturalHeight || img.videoHeight || img.height || 600;
|
|
6215
|
+
const { crop, transform, adjustments, depthMask, annotations } = state;
|
|
6216
|
+
const cropX = crop ? Math.max(0, crop.x) : 0;
|
|
6217
|
+
const cropY = crop ? Math.max(0, crop.y) : 0;
|
|
6218
|
+
const cropW = crop ? Math.min(naturalWidth - cropX, crop.width) : naturalWidth;
|
|
6219
|
+
const cropH = crop ? Math.min(naturalHeight - cropY, crop.height) : naturalHeight;
|
|
6220
|
+
const angle = (transform.rotate % 360 + 360) % 360;
|
|
6221
|
+
const isSwapped = angle === 90 || angle === 270;
|
|
6222
|
+
const baseW = isSwapped ? cropH : cropW;
|
|
6223
|
+
const baseH = isSwapped ? cropW : cropH;
|
|
6224
|
+
const exportW = options.width || baseW;
|
|
6225
|
+
const exportH = options.height || baseH;
|
|
6226
|
+
const exportCanvas = this.createCanvas(exportW, exportH);
|
|
6227
|
+
const ctx = exportCanvas.getContext("2d");
|
|
6228
|
+
if (!ctx) return exportCanvas;
|
|
6229
|
+
const intermediateCanvas = this.createCanvas(baseW, baseH);
|
|
6230
|
+
const interCtx = intermediateCanvas.getContext("2d");
|
|
6231
|
+
if (interCtx) {
|
|
6232
|
+
interCtx.save();
|
|
6233
|
+
const rad = angle * Math.PI / 180;
|
|
6234
|
+
interCtx.translate(baseW / 2, baseH / 2);
|
|
6235
|
+
interCtx.rotate(rad);
|
|
6236
|
+
interCtx.scale(transform.flipH ? -1 : 1, transform.flipV ? -1 : 1);
|
|
6237
|
+
interCtx.drawImage(img, cropX, cropY, cropW, cropH, -cropW / 2, -cropH / 2, cropW, cropH);
|
|
6238
|
+
interCtx.restore();
|
|
6239
|
+
const imageData = interCtx.getImageData(0, 0, baseW, baseH);
|
|
6240
|
+
if (imageData) {
|
|
6241
|
+
applyAdjustments(imageData, adjustments);
|
|
6242
|
+
if (depthMask.enabled && depthMask.maskSource) {
|
|
6243
|
+
try {
|
|
6244
|
+
const maskImg = await this.imageCache.get(depthMask.maskSource);
|
|
6245
|
+
const maskCanvas = this.createCanvas(baseW, baseH);
|
|
6246
|
+
const maskCtx = maskCanvas.getContext("2d");
|
|
6247
|
+
if (maskCtx) {
|
|
6248
|
+
maskCtx.drawImage(maskImg, cropX, cropY, cropW, cropH, 0, 0, baseW, baseH);
|
|
6249
|
+
const maskData = maskCtx.getImageData(0, 0, baseW, baseH);
|
|
6250
|
+
if (maskData) {
|
|
6251
|
+
applyDepthMask(imageData, maskData, depthMask);
|
|
6252
|
+
}
|
|
6253
|
+
}
|
|
6254
|
+
} catch {
|
|
6255
|
+
}
|
|
6256
|
+
}
|
|
6257
|
+
interCtx.putImageData(imageData, 0, 0);
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
ctx.drawImage(intermediateCanvas, 0, 0, baseW, baseH, 0, 0, exportW, exportH);
|
|
6261
|
+
if (options.includeAnnotations !== false && annotations.length > 0) {
|
|
6262
|
+
const annScaleX = exportW / baseW;
|
|
6263
|
+
const annScaleY = exportH / baseH;
|
|
6264
|
+
ctx.save();
|
|
6265
|
+
ctx.scale(annScaleX, annScaleY);
|
|
6266
|
+
for (const ann of annotations) {
|
|
6267
|
+
await this.drawAnnotation(ctx, ann);
|
|
6268
|
+
}
|
|
6269
|
+
ctx.restore();
|
|
6270
|
+
}
|
|
6271
|
+
return exportCanvas;
|
|
6272
|
+
}
|
|
6273
|
+
/**
|
|
6274
|
+
* Generates a binary mask canvas (white for mask/selected area, black for unmasked).
|
|
6275
|
+
* Useful for AI inpainting and selective generative fill.
|
|
6276
|
+
*/
|
|
6277
|
+
async renderMask(state, options) {
|
|
6278
|
+
const naturalWidth = state.imageDimensions.width;
|
|
6279
|
+
const naturalHeight = state.imageDimensions.height;
|
|
6280
|
+
const maskCanvas = this.createCanvas(naturalWidth, naturalHeight);
|
|
6281
|
+
const ctx = maskCanvas.getContext("2d");
|
|
6282
|
+
if (!ctx) return maskCanvas;
|
|
6283
|
+
ctx.fillStyle = "#000000";
|
|
6284
|
+
ctx.fillRect(0, 0, naturalWidth, naturalHeight);
|
|
6285
|
+
const useCrop = options?.useCrop ?? true;
|
|
6286
|
+
const useAnnotations = options?.useAnnotations ?? true;
|
|
6287
|
+
if (useCrop && state.crop) {
|
|
6288
|
+
ctx.fillStyle = "#ffffff";
|
|
6289
|
+
ctx.fillRect(state.crop.x, state.crop.y, state.crop.width, state.crop.height);
|
|
6290
|
+
}
|
|
6291
|
+
if (useAnnotations && state.annotations.length > 0) {
|
|
6292
|
+
for (const ann of state.annotations) {
|
|
6293
|
+
ctx.save();
|
|
6294
|
+
if (ann.rotation) {
|
|
6295
|
+
ctx.translate(ann.x, ann.y);
|
|
6296
|
+
ctx.rotate(ann.rotation * Math.PI / 180);
|
|
6297
|
+
ctx.translate(-ann.x, -ann.y);
|
|
6298
|
+
}
|
|
6299
|
+
if (ann.type === "pen") {
|
|
6300
|
+
const pen = ann;
|
|
6301
|
+
if (pen.points.length > 0) {
|
|
6302
|
+
ctx.beginPath();
|
|
6303
|
+
ctx.strokeStyle = "#ffffff";
|
|
6304
|
+
ctx.lineWidth = Math.max(pen.strokeWidth, 8);
|
|
6305
|
+
ctx.lineCap = "round";
|
|
6306
|
+
ctx.lineJoin = "round";
|
|
6307
|
+
ctx.moveTo(pen.points[0].x, pen.points[0].y);
|
|
6308
|
+
for (let i = 1; i < pen.points.length; i++) {
|
|
6309
|
+
ctx.lineTo(pen.points[i].x, pen.points[i].y);
|
|
6310
|
+
}
|
|
6311
|
+
ctx.stroke();
|
|
6312
|
+
}
|
|
6313
|
+
} else if (ann.type === "rect") {
|
|
6314
|
+
const rect = ann;
|
|
6315
|
+
ctx.fillStyle = "#ffffff";
|
|
6316
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
6317
|
+
} else if (ann.type === "circle") {
|
|
6318
|
+
const circ = ann;
|
|
6319
|
+
ctx.fillStyle = "#ffffff";
|
|
6320
|
+
ctx.beginPath();
|
|
6321
|
+
ctx.ellipse(circ.x, circ.y, circ.radiusX, circ.radiusY, 0, 0, Math.PI * 2);
|
|
6322
|
+
ctx.fill();
|
|
6323
|
+
} else {
|
|
6324
|
+
ctx.strokeStyle = "#ffffff";
|
|
6325
|
+
ctx.fillStyle = "#ffffff";
|
|
6326
|
+
await this.drawAnnotation(ctx, ann);
|
|
6327
|
+
}
|
|
6328
|
+
ctx.restore();
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
return maskCanvas;
|
|
6332
|
+
}
|
|
6333
|
+
};
|
|
6334
|
+
|
|
6335
|
+
// src/image-editor/controller.ts
|
|
6336
|
+
var DEFAULT_ADJUSTMENTS = {
|
|
6337
|
+
brightness: 0,
|
|
6338
|
+
contrast: 0,
|
|
6339
|
+
saturation: 0,
|
|
6340
|
+
exposure: 0,
|
|
6341
|
+
temperature: 0,
|
|
6342
|
+
blur: 0,
|
|
6343
|
+
opacity: 1
|
|
6344
|
+
};
|
|
6345
|
+
var DEFAULT_TRANSFORM = {
|
|
6346
|
+
rotate: 0,
|
|
6347
|
+
flipH: false,
|
|
6348
|
+
flipV: false
|
|
6349
|
+
};
|
|
6350
|
+
var DEFAULT_DEPTH_MASK = {
|
|
6351
|
+
enabled: false,
|
|
6352
|
+
maskSource: null,
|
|
6353
|
+
mode: "threshold",
|
|
6354
|
+
depthRange: [0, 255],
|
|
6355
|
+
softness: 10,
|
|
6356
|
+
invert: false
|
|
6357
|
+
};
|
|
6358
|
+
var ImageEditorController = class {
|
|
6359
|
+
state;
|
|
6360
|
+
renderer;
|
|
6361
|
+
imageElement = null;
|
|
6362
|
+
listeners = /* @__PURE__ */ new Set();
|
|
6363
|
+
onStateChange;
|
|
6364
|
+
// History stacks
|
|
6365
|
+
undoStack = [];
|
|
6366
|
+
redoStack = [];
|
|
6367
|
+
maxHistoryDepth;
|
|
6368
|
+
isApplyingHistory = false;
|
|
6369
|
+
initialSnapshot;
|
|
6370
|
+
constructor(options = {}) {
|
|
6371
|
+
this.maxHistoryDepth = options.maxHistoryDepth ?? 50;
|
|
6372
|
+
this.onStateChange = options.onStateChange;
|
|
6373
|
+
this.renderer = new ImageEditorRenderer();
|
|
6374
|
+
const initialAdjustments = {
|
|
6375
|
+
...DEFAULT_ADJUSTMENTS,
|
|
6376
|
+
...options.initialState?.adjustments
|
|
6377
|
+
};
|
|
6378
|
+
const initialTransform = {
|
|
6379
|
+
...DEFAULT_TRANSFORM,
|
|
6380
|
+
...options.initialState?.transform
|
|
6381
|
+
};
|
|
6382
|
+
const initialDepthMask = {
|
|
6383
|
+
...DEFAULT_DEPTH_MASK,
|
|
6384
|
+
...options.initialState?.depthMask
|
|
6385
|
+
};
|
|
6386
|
+
this.state = {
|
|
6387
|
+
sourceUrl: typeof options.image === "string" ? options.image : null,
|
|
6388
|
+
imageDimensions: { width: 800, height: 600 },
|
|
6389
|
+
crop: options.initialState?.crop ?? null,
|
|
6390
|
+
transform: initialTransform,
|
|
6391
|
+
adjustments: initialAdjustments,
|
|
6392
|
+
depthMask: initialDepthMask,
|
|
6393
|
+
annotations: options.initialState?.annotations ?? [],
|
|
6394
|
+
selectedAnnotationId: null,
|
|
6395
|
+
activeTool: "select",
|
|
6396
|
+
zoom: 1,
|
|
6397
|
+
pan: { x: 0, y: 0 },
|
|
6398
|
+
isOriginalCompared: false
|
|
6399
|
+
};
|
|
6400
|
+
if (options.image instanceof Object && "width" in options.image) {
|
|
6401
|
+
this.imageElement = options.image;
|
|
6402
|
+
this.state.imageDimensions = {
|
|
6403
|
+
width: options.image.naturalWidth || options.image.width || 800,
|
|
6404
|
+
height: options.image.naturalHeight || options.image.height || 600
|
|
6405
|
+
};
|
|
6406
|
+
}
|
|
6407
|
+
this.initialSnapshot = this.serializeState();
|
|
6408
|
+
if (typeof options.image === "string") {
|
|
6409
|
+
this.loadImage(options.image);
|
|
6410
|
+
}
|
|
6411
|
+
}
|
|
6412
|
+
// --- Image Loading ---
|
|
6413
|
+
async loadImage(src) {
|
|
6414
|
+
if (typeof src === "string") {
|
|
6415
|
+
this.state.sourceUrl = src;
|
|
6416
|
+
try {
|
|
6417
|
+
const img = await this.renderer.imageCache.get(src);
|
|
6418
|
+
this.imageElement = img;
|
|
6419
|
+
this.state.imageDimensions = {
|
|
6420
|
+
width: img.naturalWidth || img.width || 800,
|
|
6421
|
+
height: img.naturalHeight || img.height || 600
|
|
6422
|
+
};
|
|
6423
|
+
} catch (err) {
|
|
6424
|
+
}
|
|
6425
|
+
} else {
|
|
6426
|
+
this.imageElement = src;
|
|
6427
|
+
this.state.sourceUrl = src.src || null;
|
|
6428
|
+
this.state.imageDimensions = {
|
|
6429
|
+
width: src.naturalWidth || src.width || 800,
|
|
6430
|
+
height: src.naturalHeight || src.height || 600
|
|
6431
|
+
};
|
|
6432
|
+
}
|
|
6433
|
+
this.initialSnapshot = this.serializeState();
|
|
6434
|
+
this.notify();
|
|
6435
|
+
}
|
|
6436
|
+
getImageElement() {
|
|
6437
|
+
return this.imageElement;
|
|
6438
|
+
}
|
|
6439
|
+
// --- State & Subscription ---
|
|
6440
|
+
getState() {
|
|
6441
|
+
return this.state;
|
|
6442
|
+
}
|
|
6443
|
+
subscribe(listener) {
|
|
6444
|
+
this.listeners.add(listener);
|
|
6445
|
+
listener(this.state);
|
|
6446
|
+
return () => {
|
|
6447
|
+
this.listeners.delete(listener);
|
|
6448
|
+
};
|
|
6449
|
+
}
|
|
6450
|
+
notify() {
|
|
6451
|
+
const currentState = { ...this.state };
|
|
6452
|
+
this.onStateChange?.(currentState);
|
|
6453
|
+
this.listeners.forEach((l) => l(currentState));
|
|
6454
|
+
}
|
|
6455
|
+
// --- History (Undo / Redo / Reset) ---
|
|
6456
|
+
recordHistory() {
|
|
6457
|
+
if (this.isApplyingHistory) return;
|
|
6458
|
+
this.undoStack.push(this.serializeState());
|
|
6459
|
+
if (this.undoStack.length > this.maxHistoryDepth) {
|
|
6460
|
+
this.undoStack.shift();
|
|
6461
|
+
}
|
|
6462
|
+
this.redoStack = [];
|
|
6463
|
+
}
|
|
6464
|
+
get canUndo() {
|
|
6465
|
+
return this.undoStack.length > 0;
|
|
6466
|
+
}
|
|
6467
|
+
get canRedo() {
|
|
6468
|
+
return this.redoStack.length > 0;
|
|
6469
|
+
}
|
|
6470
|
+
undo() {
|
|
6471
|
+
if (!this.canUndo) return false;
|
|
6472
|
+
this.isApplyingHistory = true;
|
|
6473
|
+
const current = this.serializeState();
|
|
6474
|
+
this.redoStack.push(current);
|
|
6475
|
+
const prev = this.undoStack.pop();
|
|
6476
|
+
this.applySerializedState(prev);
|
|
6477
|
+
this.isApplyingHistory = false;
|
|
6478
|
+
this.notify();
|
|
6479
|
+
return true;
|
|
6480
|
+
}
|
|
6481
|
+
redo() {
|
|
6482
|
+
if (!this.canRedo) return false;
|
|
6483
|
+
this.isApplyingHistory = true;
|
|
6484
|
+
const current = this.serializeState();
|
|
6485
|
+
this.undoStack.push(current);
|
|
6486
|
+
const next = this.redoStack.pop();
|
|
6487
|
+
this.applySerializedState(next);
|
|
6488
|
+
this.isApplyingHistory = false;
|
|
6489
|
+
this.notify();
|
|
6490
|
+
return true;
|
|
6491
|
+
}
|
|
6492
|
+
reset() {
|
|
6493
|
+
this.recordHistory();
|
|
6494
|
+
this.applySerializedState(this.initialSnapshot);
|
|
6495
|
+
this.state.zoom = 1;
|
|
6496
|
+
this.state.pan = { x: 0, y: 0 };
|
|
6497
|
+
this.state.activeTool = "select";
|
|
6498
|
+
this.state.selectedAnnotationId = null;
|
|
6499
|
+
this.notify();
|
|
6500
|
+
}
|
|
6501
|
+
// --- Active Tool ---
|
|
6502
|
+
setTool(tool) {
|
|
6503
|
+
if (this.state.activeTool === tool) return;
|
|
6504
|
+
this.state.activeTool = tool;
|
|
6505
|
+
if (tool !== "select") {
|
|
6506
|
+
this.state.selectedAnnotationId = null;
|
|
6507
|
+
}
|
|
6508
|
+
if (tool === "crop" && !this.state.crop) {
|
|
6509
|
+
this.state.crop = {
|
|
6510
|
+
x: 0,
|
|
6511
|
+
y: 0,
|
|
6512
|
+
width: this.state.imageDimensions.width,
|
|
6513
|
+
height: this.state.imageDimensions.height
|
|
6514
|
+
};
|
|
6515
|
+
}
|
|
6516
|
+
this.notify();
|
|
6517
|
+
}
|
|
6518
|
+
// --- Crop ---
|
|
6519
|
+
setCrop(crop, addToHistory = true) {
|
|
6520
|
+
if (addToHistory) {
|
|
6521
|
+
this.recordHistory();
|
|
6522
|
+
}
|
|
6523
|
+
this.state.crop = crop;
|
|
6524
|
+
this.notify();
|
|
6525
|
+
}
|
|
6526
|
+
applyCropPreset(ratio) {
|
|
6527
|
+
this.recordHistory();
|
|
6528
|
+
const { width, height } = this.state.imageDimensions;
|
|
6529
|
+
if (!ratio) {
|
|
6530
|
+
this.state.crop = { x: 0, y: 0, width, height };
|
|
6531
|
+
} else {
|
|
6532
|
+
let targetW = width;
|
|
6533
|
+
let targetH = targetW / ratio;
|
|
6534
|
+
if (targetH > height) {
|
|
6535
|
+
targetH = height;
|
|
6536
|
+
targetW = targetH * ratio;
|
|
6537
|
+
}
|
|
6538
|
+
const x = Math.max(0, Math.round((width - targetW) / 2));
|
|
6539
|
+
const y = Math.max(0, Math.round((height - targetH) / 2));
|
|
6540
|
+
this.state.crop = {
|
|
6541
|
+
x,
|
|
6542
|
+
y,
|
|
6543
|
+
width: Math.round(targetW),
|
|
6544
|
+
height: Math.round(targetH)
|
|
6545
|
+
};
|
|
6546
|
+
}
|
|
6547
|
+
this.notify();
|
|
6548
|
+
}
|
|
6549
|
+
resetCrop() {
|
|
6550
|
+
this.recordHistory();
|
|
6551
|
+
this.state.crop = null;
|
|
6552
|
+
this.notify();
|
|
6553
|
+
}
|
|
6554
|
+
// --- Transformations (Rotate / Flip) ---
|
|
6555
|
+
rotate(degrees) {
|
|
6556
|
+
this.recordHistory();
|
|
6557
|
+
const current = this.state.transform.rotate;
|
|
6558
|
+
this.state.transform.rotate = (current + degrees) % 360;
|
|
6559
|
+
this.notify();
|
|
6560
|
+
}
|
|
6561
|
+
setRotation(degrees) {
|
|
6562
|
+
this.recordHistory();
|
|
6563
|
+
this.state.transform.rotate = degrees % 360;
|
|
6564
|
+
this.notify();
|
|
6565
|
+
}
|
|
6566
|
+
flipHorizontal() {
|
|
6567
|
+
this.recordHistory();
|
|
6568
|
+
this.state.transform.flipH = !this.state.transform.flipH;
|
|
6569
|
+
this.notify();
|
|
6570
|
+
}
|
|
6571
|
+
flipVertical() {
|
|
6572
|
+
this.recordHistory();
|
|
6573
|
+
this.state.transform.flipV = !this.state.transform.flipV;
|
|
6574
|
+
this.notify();
|
|
6575
|
+
}
|
|
6576
|
+
// --- Adjustments ---
|
|
6577
|
+
setAdjustments(partial, commitToHistory = true) {
|
|
6578
|
+
if (commitToHistory) {
|
|
6579
|
+
this.recordHistory();
|
|
6580
|
+
}
|
|
6581
|
+
this.state.adjustments = {
|
|
6582
|
+
...this.state.adjustments,
|
|
6583
|
+
...partial
|
|
6584
|
+
};
|
|
6585
|
+
this.notify();
|
|
6586
|
+
}
|
|
6587
|
+
resetAdjustments() {
|
|
6588
|
+
this.recordHistory();
|
|
6589
|
+
this.state.adjustments = { ...DEFAULT_ADJUSTMENTS };
|
|
6590
|
+
this.notify();
|
|
6591
|
+
}
|
|
6592
|
+
// --- Depth Masking (Gleamforge) ---
|
|
6593
|
+
setDepthMask(partial) {
|
|
6594
|
+
this.recordHistory();
|
|
6595
|
+
this.state.depthMask = {
|
|
6596
|
+
...this.state.depthMask,
|
|
6597
|
+
...partial
|
|
6598
|
+
};
|
|
6599
|
+
this.notify();
|
|
6600
|
+
}
|
|
6601
|
+
resetDepthMask() {
|
|
6602
|
+
this.recordHistory();
|
|
6603
|
+
this.state.depthMask = { ...DEFAULT_DEPTH_MASK };
|
|
6604
|
+
this.notify();
|
|
6605
|
+
}
|
|
6606
|
+
// --- Annotations & Layers ---
|
|
6607
|
+
addAnnotation(annotation) {
|
|
6608
|
+
this.recordHistory();
|
|
6609
|
+
const id = `ann_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
6610
|
+
const fullAnnotation = { ...annotation, id };
|
|
6611
|
+
this.state.annotations = [...this.state.annotations, fullAnnotation];
|
|
6612
|
+
this.state.selectedAnnotationId = id;
|
|
6613
|
+
this.notify();
|
|
6614
|
+
return id;
|
|
6615
|
+
}
|
|
6616
|
+
updateAnnotation(id, partial, commitToHistory = true) {
|
|
6617
|
+
if (commitToHistory) {
|
|
6618
|
+
this.recordHistory();
|
|
6619
|
+
}
|
|
6620
|
+
this.state.annotations = this.state.annotations.map((ann) => {
|
|
6621
|
+
if (ann.id === id) {
|
|
6622
|
+
return { ...ann, ...partial };
|
|
6623
|
+
}
|
|
6624
|
+
return ann;
|
|
6625
|
+
});
|
|
6626
|
+
this.notify();
|
|
6627
|
+
}
|
|
6628
|
+
removeAnnotation(id) {
|
|
6629
|
+
this.recordHistory();
|
|
6630
|
+
this.state.annotations = this.state.annotations.filter((ann) => ann.id !== id);
|
|
6631
|
+
if (this.state.selectedAnnotationId === id) {
|
|
6632
|
+
this.state.selectedAnnotationId = null;
|
|
6633
|
+
}
|
|
6634
|
+
this.notify();
|
|
6635
|
+
}
|
|
6636
|
+
clearAnnotations() {
|
|
6637
|
+
this.recordHistory();
|
|
6638
|
+
this.state.annotations = [];
|
|
6639
|
+
this.state.selectedAnnotationId = null;
|
|
6640
|
+
this.notify();
|
|
6641
|
+
}
|
|
6642
|
+
selectAnnotation(id) {
|
|
6643
|
+
this.state.selectedAnnotationId = id;
|
|
6644
|
+
this.notify();
|
|
6645
|
+
}
|
|
6646
|
+
// --- Zoom & Pan ---
|
|
6647
|
+
setZoom(zoom) {
|
|
6648
|
+
this.state.zoom = Math.max(0.1, Math.min(10, zoom));
|
|
6649
|
+
this.notify();
|
|
6650
|
+
}
|
|
6651
|
+
zoomIn(factor = 1.2) {
|
|
6652
|
+
this.setZoom(this.state.zoom * factor);
|
|
6653
|
+
}
|
|
6654
|
+
zoomOut(factor = 1.2) {
|
|
6655
|
+
this.setZoom(this.state.zoom / factor);
|
|
6656
|
+
}
|
|
6657
|
+
resetZoom() {
|
|
6658
|
+
this.state.zoom = 1;
|
|
6659
|
+
this.state.pan = { x: 0, y: 0 };
|
|
6660
|
+
this.notify();
|
|
6661
|
+
}
|
|
6662
|
+
setPan(pan) {
|
|
6663
|
+
this.state.pan = pan;
|
|
6664
|
+
this.notify();
|
|
6665
|
+
}
|
|
6666
|
+
setComparing(comparing) {
|
|
6667
|
+
if (this.state.isOriginalCompared === comparing) return;
|
|
6668
|
+
this.state.isOriginalCompared = comparing;
|
|
6669
|
+
this.notify();
|
|
6670
|
+
}
|
|
6671
|
+
// --- Rendering & Export ---
|
|
6672
|
+
async render(targetCanvas, options) {
|
|
6673
|
+
const source = this.imageElement || this.state.sourceUrl;
|
|
6674
|
+
if (!source) return;
|
|
6675
|
+
try {
|
|
6676
|
+
await this.renderer.renderToCanvas(targetCanvas, source, this.state, options);
|
|
6677
|
+
} catch {
|
|
6678
|
+
}
|
|
6679
|
+
}
|
|
6680
|
+
async renderExport(options) {
|
|
6681
|
+
const source = this.imageElement || this.state.sourceUrl;
|
|
6682
|
+
if (!source) {
|
|
6683
|
+
return this.renderer.createCanvas(1, 1);
|
|
6684
|
+
}
|
|
6685
|
+
return this.renderer.renderExport(source, this.state, options);
|
|
6686
|
+
}
|
|
6687
|
+
async toDataURL(options = {}) {
|
|
6688
|
+
const canvas = await this.renderExport(options);
|
|
6689
|
+
const format = options.format || "image/png";
|
|
6690
|
+
const quality = options.quality ?? 0.92;
|
|
6691
|
+
return canvas.toDataURL(format, quality);
|
|
6692
|
+
}
|
|
6693
|
+
async toBlob(options = {}) {
|
|
6694
|
+
const canvas = await this.renderExport(options);
|
|
6695
|
+
const format = options.format || "image/png";
|
|
6696
|
+
const quality = options.quality ?? 0.92;
|
|
6697
|
+
return new Promise((resolve, reject) => {
|
|
6698
|
+
if (canvas.toBlob) {
|
|
6699
|
+
canvas.toBlob((blob) => {
|
|
6700
|
+
if (blob) resolve(blob);
|
|
6701
|
+
else reject(new Error("Failed to create Blob from canvas"));
|
|
6702
|
+
}, format, quality);
|
|
6703
|
+
} else {
|
|
6704
|
+
try {
|
|
6705
|
+
const dataUrl = canvas.toDataURL(format, quality);
|
|
6706
|
+
const parts = dataUrl.split(",");
|
|
6707
|
+
const byteStr = atob(parts[1]);
|
|
6708
|
+
const ab = new ArrayBuffer(byteStr.length);
|
|
6709
|
+
const ia = new Uint8Array(ab);
|
|
6710
|
+
for (let i = 0; i < byteStr.length; i++) {
|
|
6711
|
+
ia[i] = byteStr.charCodeAt(i);
|
|
6712
|
+
}
|
|
6713
|
+
resolve(new Blob([ab], { type: format }));
|
|
6714
|
+
} catch (err) {
|
|
6715
|
+
reject(err);
|
|
6716
|
+
}
|
|
6717
|
+
}
|
|
6718
|
+
});
|
|
6719
|
+
}
|
|
6720
|
+
// --- AI Masking & Transformation Helpers ---
|
|
6721
|
+
async renderMask(options) {
|
|
6722
|
+
return this.renderer.renderMask(this.state, options);
|
|
6723
|
+
}
|
|
6724
|
+
async toMaskDataURL(options) {
|
|
6725
|
+
const canvas = await this.renderMask(options);
|
|
6726
|
+
return canvas.toDataURL("image/png");
|
|
6727
|
+
}
|
|
6728
|
+
async applyInpaintedImage(newDataUrl) {
|
|
6729
|
+
this.recordHistory();
|
|
6730
|
+
await this.loadImage(newDataUrl);
|
|
6731
|
+
this.clearAnnotations();
|
|
6732
|
+
}
|
|
6733
|
+
async applyBackgroundRemovedImage(newDataUrl) {
|
|
6734
|
+
this.recordHistory();
|
|
6735
|
+
await this.loadImage(newDataUrl);
|
|
6736
|
+
}
|
|
6737
|
+
// --- Serialization ---
|
|
6738
|
+
toJSON() {
|
|
6739
|
+
return this.serializeState();
|
|
6740
|
+
}
|
|
6741
|
+
loadJSON(data) {
|
|
6742
|
+
this.recordHistory();
|
|
6743
|
+
this.applySerializedState(data);
|
|
6744
|
+
this.notify();
|
|
6745
|
+
}
|
|
6746
|
+
serializeState() {
|
|
6747
|
+
return {
|
|
6748
|
+
version: 1,
|
|
6749
|
+
crop: this.state.crop ? { ...this.state.crop } : null,
|
|
6750
|
+
transform: { ...this.state.transform },
|
|
6751
|
+
adjustments: { ...this.state.adjustments },
|
|
6752
|
+
depthMask: {
|
|
6753
|
+
...this.state.depthMask,
|
|
6754
|
+
depthRange: [...this.state.depthMask.depthRange]
|
|
6755
|
+
},
|
|
6756
|
+
annotations: JSON.parse(JSON.stringify(this.state.annotations))
|
|
6757
|
+
};
|
|
6758
|
+
}
|
|
6759
|
+
applySerializedState(data) {
|
|
6760
|
+
this.state.crop = data.crop ? { ...data.crop } : null;
|
|
6761
|
+
this.state.transform = { ...data.transform };
|
|
6762
|
+
this.state.adjustments = { ...data.adjustments };
|
|
6763
|
+
this.state.depthMask = {
|
|
6764
|
+
...data.depthMask,
|
|
6765
|
+
depthRange: [...data.depthMask.depthRange]
|
|
6766
|
+
};
|
|
6767
|
+
this.state.annotations = JSON.parse(JSON.stringify(data.annotations || []));
|
|
6768
|
+
}
|
|
6769
|
+
};
|
|
6770
|
+
|
|
6771
|
+
// src/image-editor/geometry.ts
|
|
6772
|
+
function getViewportMetrics(state, vpWidth, vpHeight, isActivelyCropping = false, cropOverride) {
|
|
6773
|
+
const { imageDimensions, crop: stateCrop, transform, zoom, pan } = state;
|
|
6774
|
+
const naturalWidth = imageDimensions.width || 800;
|
|
6775
|
+
const naturalHeight = imageDimensions.height || 600;
|
|
6776
|
+
const activeCrop = cropOverride !== void 0 && cropOverride !== null ? cropOverride : stateCrop ?? (isActivelyCropping ? { x: 0, y: 0, width: naturalWidth, height: naturalHeight } : null);
|
|
6777
|
+
const cropW = !isActivelyCropping && activeCrop ? activeCrop.width : naturalWidth;
|
|
6778
|
+
const cropH = !isActivelyCropping && activeCrop ? activeCrop.height : naturalHeight;
|
|
6779
|
+
const angle = (transform.rotate % 360 + 360) % 360;
|
|
6780
|
+
const isSwapped = angle === 90 || angle === 270;
|
|
6781
|
+
const baseW = isSwapped ? cropH : cropW;
|
|
6782
|
+
const baseH = isSwapped ? cropW : cropH;
|
|
6783
|
+
const fitScale = Math.min(vpWidth * 0.85 / baseW, vpHeight * 0.85 / baseH, 1);
|
|
6784
|
+
const renderW = baseW * fitScale;
|
|
6785
|
+
const renderH = baseH * fitScale;
|
|
6786
|
+
const totalScale = zoom * fitScale;
|
|
6787
|
+
const centerX = vpWidth / 2 + pan.x;
|
|
6788
|
+
const centerY = vpHeight / 2 + pan.y;
|
|
6789
|
+
const imageRect = {
|
|
6790
|
+
x: centerX - renderW * zoom / 2,
|
|
6791
|
+
y: centerY - renderH * zoom / 2,
|
|
6792
|
+
width: renderW * zoom,
|
|
6793
|
+
height: renderH * zoom
|
|
6794
|
+
};
|
|
6795
|
+
let cropRect = null;
|
|
6796
|
+
if (activeCrop) {
|
|
6797
|
+
const startX = -renderW / 2 + activeCrop.x / naturalWidth * renderW;
|
|
6798
|
+
const startY = -renderH / 2 + activeCrop.y / naturalHeight * renderH;
|
|
6799
|
+
const cropBoxW = activeCrop.width / naturalWidth * renderW;
|
|
6800
|
+
const cropBoxH = activeCrop.height / naturalHeight * renderH;
|
|
6801
|
+
cropRect = {
|
|
6802
|
+
x: centerX + zoom * startX,
|
|
6803
|
+
y: centerY + zoom * startY,
|
|
6804
|
+
width: zoom * cropBoxW,
|
|
6805
|
+
height: zoom * cropBoxH
|
|
6806
|
+
};
|
|
6807
|
+
}
|
|
6808
|
+
return {
|
|
6809
|
+
vpWidth,
|
|
6810
|
+
vpHeight,
|
|
6811
|
+
centerX,
|
|
6812
|
+
centerY,
|
|
6813
|
+
baseW,
|
|
6814
|
+
baseH,
|
|
6815
|
+
fitScale,
|
|
6816
|
+
renderW,
|
|
6817
|
+
renderH,
|
|
6818
|
+
totalScale,
|
|
6819
|
+
imageRect,
|
|
6820
|
+
cropRect
|
|
6821
|
+
};
|
|
6822
|
+
}
|
|
6823
|
+
function canvasToImagePoint(canvasPt, state, vpWidth, vpHeight, isActivelyCropping = false) {
|
|
6824
|
+
const metrics = getViewportMetrics(state, vpWidth, vpHeight, isActivelyCropping);
|
|
6825
|
+
const unzoomedX = (canvasPt.x - metrics.centerX) / state.zoom;
|
|
6826
|
+
const unzoomedY = (canvasPt.y - metrics.centerY) / state.zoom;
|
|
6827
|
+
const x = (unzoomedX + metrics.renderW / 2) / metrics.fitScale;
|
|
6828
|
+
const y = (unzoomedY + metrics.renderH / 2) / metrics.fitScale;
|
|
6829
|
+
return { x: Math.round(x), y: Math.round(y) };
|
|
6830
|
+
}
|
|
6831
|
+
function imageToCanvasPoint(imgPt, state, vpWidth, vpHeight, isActivelyCropping = false) {
|
|
6832
|
+
const metrics = getViewportMetrics(state, vpWidth, vpHeight, isActivelyCropping);
|
|
6833
|
+
const unzoomedX = (imgPt.x - metrics.baseW / 2) * metrics.fitScale;
|
|
6834
|
+
const unzoomedY = (imgPt.y - metrics.baseH / 2) * metrics.fitScale;
|
|
6835
|
+
const canvasX = metrics.centerX + unzoomedX * state.zoom;
|
|
6836
|
+
const canvasY = metrics.centerY + unzoomedY * state.zoom;
|
|
6837
|
+
return { x: Math.round(canvasX), y: Math.round(canvasY) };
|
|
6838
|
+
}
|
|
6839
|
+
function hitTestCrop(canvasPt, state, vpWidth, vpHeight, handleThreshold = 14, cropOverride) {
|
|
6840
|
+
const metrics = getViewportMetrics(state, vpWidth, vpHeight, true, cropOverride);
|
|
6841
|
+
if (!metrics.cropRect) return null;
|
|
6842
|
+
const { x, y, width: w, height: h } = metrics.cropRect;
|
|
6843
|
+
const px = canvasPt.x;
|
|
6844
|
+
const py = canvasPt.y;
|
|
6845
|
+
const dist = (x1, y1, x2, y2) => Math.hypot(x1 - x2, y1 - y2);
|
|
6846
|
+
if (dist(px, py, x, y) <= handleThreshold) return "nw";
|
|
6847
|
+
if (dist(px, py, x + w, y) <= handleThreshold) return "ne";
|
|
6848
|
+
if (dist(px, py, x + w, y + h) <= handleThreshold) return "se";
|
|
6849
|
+
if (dist(px, py, x, y + h) <= handleThreshold) return "sw";
|
|
6850
|
+
const nearX = px >= x - handleThreshold && px <= x + w + handleThreshold;
|
|
6851
|
+
const nearY = py >= y - handleThreshold && py <= y + h + handleThreshold;
|
|
6852
|
+
if (nearX && Math.abs(py - y) <= handleThreshold) return "n";
|
|
6853
|
+
if (nearX && Math.abs(py - (y + h)) <= handleThreshold) return "s";
|
|
6854
|
+
if (nearY && Math.abs(px - x) <= handleThreshold) return "w";
|
|
6855
|
+
if (nearY && Math.abs(px - (x + w)) <= handleThreshold) return "e";
|
|
6856
|
+
if (px >= x && px <= x + w && py >= y && py <= y + h) {
|
|
6857
|
+
return "inside";
|
|
6858
|
+
}
|
|
6859
|
+
return null;
|
|
6860
|
+
}
|
|
6861
|
+
function getCropCursor(handle) {
|
|
6862
|
+
switch (handle) {
|
|
6863
|
+
case "nw":
|
|
6864
|
+
case "se":
|
|
6865
|
+
return "nwse-resize";
|
|
6866
|
+
case "ne":
|
|
6867
|
+
case "sw":
|
|
6868
|
+
return "nesw-resize";
|
|
6869
|
+
case "n":
|
|
6870
|
+
case "s":
|
|
6871
|
+
return "ns-resize";
|
|
6872
|
+
case "e":
|
|
6873
|
+
case "w":
|
|
6874
|
+
return "ew-resize";
|
|
6875
|
+
case "inside":
|
|
6876
|
+
return "move";
|
|
6877
|
+
default:
|
|
6878
|
+
return "crosshair";
|
|
6879
|
+
}
|
|
6880
|
+
}
|
|
6881
|
+
function calculateCropDrag(startCrop, handle, startImgPt, currentImgPt, imgWidth, imgHeight, aspectRatio) {
|
|
6882
|
+
const dx = currentImgPt.x - startImgPt.x;
|
|
6883
|
+
const dy = currentImgPt.y - startImgPt.y;
|
|
6884
|
+
if (handle === "inside") {
|
|
6885
|
+
const maxX = Math.max(0, imgWidth - startCrop.width);
|
|
6886
|
+
const maxY = Math.max(0, imgHeight - startCrop.height);
|
|
6887
|
+
const newX = Math.max(0, Math.min(maxX, startCrop.x + dx));
|
|
6888
|
+
const newY = Math.max(0, Math.min(maxY, startCrop.y + dy));
|
|
6889
|
+
return {
|
|
6890
|
+
x: Math.round(newX),
|
|
6891
|
+
y: Math.round(newY),
|
|
6892
|
+
width: startCrop.width,
|
|
6893
|
+
height: startCrop.height
|
|
6894
|
+
};
|
|
6895
|
+
}
|
|
6896
|
+
let left = startCrop.x;
|
|
6897
|
+
let top = startCrop.y;
|
|
6898
|
+
let right = startCrop.x + startCrop.width;
|
|
6899
|
+
let bottom = startCrop.y + startCrop.height;
|
|
6900
|
+
const minSize = 20;
|
|
6901
|
+
if (handle === "e" || handle === "ne" || handle === "se") {
|
|
6902
|
+
right = Math.min(imgWidth, Math.max(left + minSize, startCrop.x + startCrop.width + dx));
|
|
6903
|
+
}
|
|
6904
|
+
if (handle === "w" || handle === "nw" || handle === "sw") {
|
|
6905
|
+
left = Math.max(0, Math.min(right - minSize, startCrop.x + dx));
|
|
6906
|
+
}
|
|
6907
|
+
if (handle === "s" || handle === "se" || handle === "sw") {
|
|
6908
|
+
bottom = Math.min(imgHeight, Math.max(top + minSize, startCrop.y + startCrop.height + dy));
|
|
6909
|
+
}
|
|
6910
|
+
if (handle === "n" || handle === "nw" || handle === "ne") {
|
|
6911
|
+
top = Math.max(0, Math.min(bottom - minSize, startCrop.y + dy));
|
|
6912
|
+
}
|
|
6913
|
+
if (aspectRatio && aspectRatio > 0) {
|
|
6914
|
+
let currentW = right - left;
|
|
6915
|
+
let currentH = bottom - top;
|
|
6916
|
+
if (handle === "e" || handle === "w") {
|
|
6917
|
+
currentH = Math.min(imgHeight, currentW / aspectRatio);
|
|
6918
|
+
bottom = Math.min(imgHeight, top + currentH);
|
|
6919
|
+
} else if (handle === "n" || handle === "s") {
|
|
6920
|
+
currentW = Math.min(imgWidth, currentH * aspectRatio);
|
|
6921
|
+
right = Math.min(imgWidth, left + currentW);
|
|
6922
|
+
} else {
|
|
6923
|
+
const targetH = currentW / aspectRatio;
|
|
6924
|
+
if (top + targetH <= imgHeight) {
|
|
6925
|
+
bottom = top + targetH;
|
|
6926
|
+
} else {
|
|
6927
|
+
const targetW = currentH * aspectRatio;
|
|
6928
|
+
right = Math.min(imgWidth, left + targetW);
|
|
6929
|
+
}
|
|
6930
|
+
}
|
|
6931
|
+
}
|
|
6932
|
+
return {
|
|
6933
|
+
x: Math.round(left),
|
|
6934
|
+
y: Math.round(top),
|
|
6935
|
+
width: Math.round(Math.max(minSize, right - left)),
|
|
6936
|
+
height: Math.round(Math.max(minSize, bottom - top))
|
|
6937
|
+
};
|
|
6938
|
+
}
|
|
6939
|
+
function getAnnotationBounds(ann) {
|
|
6940
|
+
switch (ann.type) {
|
|
6941
|
+
case "rect":
|
|
6942
|
+
return { x: ann.x, y: ann.y, width: ann.width, height: ann.height };
|
|
6943
|
+
case "circle":
|
|
6944
|
+
return {
|
|
6945
|
+
x: ann.x - ann.radiusX,
|
|
6946
|
+
y: ann.y - ann.radiusY,
|
|
6947
|
+
width: ann.radiusX * 2,
|
|
6948
|
+
height: ann.radiusY * 2
|
|
6949
|
+
};
|
|
6950
|
+
case "arrow":
|
|
6951
|
+
case "line": {
|
|
6952
|
+
const minX = Math.min(ann.x, ann.endX);
|
|
6953
|
+
const minY = Math.min(ann.y, ann.endY);
|
|
6954
|
+
return {
|
|
6955
|
+
x: minX,
|
|
6956
|
+
y: minY,
|
|
6957
|
+
width: Math.max(Math.abs(ann.endX - ann.x), 10),
|
|
6958
|
+
height: Math.max(Math.abs(ann.endY - ann.y), 10)
|
|
6959
|
+
};
|
|
6960
|
+
}
|
|
6961
|
+
case "pen": {
|
|
6962
|
+
if (!ann.points.length) return { x: ann.x, y: ann.y, width: 0, height: 0 };
|
|
6963
|
+
let minX = ann.points[0].x;
|
|
6964
|
+
let maxX = ann.points[0].x;
|
|
6965
|
+
let minY = ann.points[0].y;
|
|
6966
|
+
let maxY = ann.points[0].y;
|
|
6967
|
+
for (const pt of ann.points) {
|
|
6968
|
+
if (pt.x < minX) minX = pt.x;
|
|
6969
|
+
if (pt.x > maxX) maxX = pt.x;
|
|
6970
|
+
if (pt.y < minY) minY = pt.y;
|
|
6971
|
+
if (pt.y > maxY) maxY = pt.y;
|
|
6972
|
+
}
|
|
6973
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
6974
|
+
}
|
|
6975
|
+
case "text":
|
|
6976
|
+
return {
|
|
6977
|
+
x: ann.x,
|
|
6978
|
+
y: ann.y,
|
|
6979
|
+
width: ann.text.length * ann.fontSize * 0.6,
|
|
6980
|
+
height: ann.fontSize * 1.2
|
|
6981
|
+
};
|
|
6982
|
+
case "image":
|
|
6983
|
+
return { x: ann.x, y: ann.y, width: ann.width, height: ann.height };
|
|
6984
|
+
}
|
|
6985
|
+
}
|
|
6986
|
+
function hitTestAnnotation(imgPt, ann, padding = 10) {
|
|
6987
|
+
const b = getAnnotationBounds(ann);
|
|
6988
|
+
return imgPt.x >= b.x - padding && imgPt.x <= b.x + b.width + padding && imgPt.y >= b.y - padding && imgPt.y <= b.y + b.height + padding;
|
|
6989
|
+
}
|
|
5593
6990
|
// Annotate the CommonJS export names for ESM import in node:
|
|
5594
6991
|
0 && (module.exports = {
|
|
5595
6992
|
ColorHighlighter,
|
|
6993
|
+
DEFAULT_ADJUSTMENTS,
|
|
6994
|
+
DEFAULT_DEPTH_MASK,
|
|
5596
6995
|
DEFAULT_NODE_HEIGHT,
|
|
5597
6996
|
DEFAULT_NODE_WIDTH,
|
|
5598
6997
|
DEFAULT_PLACEMENT_GAP,
|
|
6998
|
+
DEFAULT_TRANSFORM,
|
|
5599
6999
|
DataSourceBinder,
|
|
5600
7000
|
DependencyGraph,
|
|
5601
7001
|
DirtyTracker,
|
|
@@ -5609,6 +7009,8 @@ var EDITOR_MCP_TOOLS = [
|
|
|
5609
7009
|
HistoryManager,
|
|
5610
7010
|
INSTRUCTION_REGEX,
|
|
5611
7011
|
Image,
|
|
7012
|
+
ImageEditorController,
|
|
7013
|
+
ImageEditorRenderer,
|
|
5612
7014
|
LoadingNode,
|
|
5613
7015
|
MARKDOWN_MENTION_SPLIT_REGEX,
|
|
5614
7016
|
MCP_TOOL_EDIT_CANVAS,
|
|
@@ -5626,11 +7028,15 @@ var EDITOR_MCP_TOOLS = [
|
|
|
5626
7028
|
TEMPLATE_REGEX,
|
|
5627
7029
|
TemplateSuggestions,
|
|
5628
7030
|
VARIABLE_REGEX,
|
|
7031
|
+
applyAdjustments,
|
|
7032
|
+
applyDepthMask,
|
|
5629
7033
|
blendLayout,
|
|
5630
7034
|
boundsOf,
|
|
7035
|
+
calculateCropDrag,
|
|
5631
7036
|
calculateDagreReflowDisplacement,
|
|
5632
7037
|
calculateDirectionalDisplacement,
|
|
5633
7038
|
canvasHeight,
|
|
7039
|
+
canvasToImagePoint,
|
|
5634
7040
|
centerNodes,
|
|
5635
7041
|
centre,
|
|
5636
7042
|
colNameToIndex,
|
|
@@ -5657,14 +7063,21 @@ var EDITOR_MCP_TOOLS = [
|
|
|
5657
7063
|
extractVariableNames,
|
|
5658
7064
|
findHexColors,
|
|
5659
7065
|
flowSide,
|
|
7066
|
+
getAnnotationBounds,
|
|
5660
7067
|
getCenteredNodePosition,
|
|
7068
|
+
getCropCursor,
|
|
5661
7069
|
getEditorMarkdown,
|
|
5662
7070
|
getEditorText,
|
|
5663
7071
|
getLatestVersion,
|
|
5664
7072
|
getLayoutedNodes,
|
|
7073
|
+
getViewportMetrics,
|
|
7074
|
+
globalImageCache,
|
|
5665
7075
|
handleImageInsertion,
|
|
7076
|
+
hitTestAnnotation,
|
|
7077
|
+
hitTestCrop,
|
|
5666
7078
|
htmlToPlainText,
|
|
5667
7079
|
imagePastePluginKey,
|
|
7080
|
+
imageToCanvasPoint,
|
|
5668
7081
|
indexToColName,
|
|
5669
7082
|
insertImageNode,
|
|
5670
7083
|
instructionRegex,
|
|
@@ -5691,6 +7104,7 @@ var EDITOR_MCP_TOOLS = [
|
|
|
5691
7104
|
preprocessMarkdownMentions,
|
|
5692
7105
|
realClock,
|
|
5693
7106
|
remapPastedNodes,
|
|
7107
|
+
removeAlpha,
|
|
5694
7108
|
removeImageNode,
|
|
5695
7109
|
repositionNodesTo,
|
|
5696
7110
|
resolveTemplates,
|