aspect-grid-collageify 1.0.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/LICENSE +21 -0
- package/README.md +259 -0
- package/README_EN.md +246 -0
- package/dist/index.d.mts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.global.js +794 -0
- package/dist/index.js +796 -0
- package/dist/index.mjs +771 -0
- package/package.json +48 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var AspectGridCollageify = class {
|
|
3
|
+
constructor(config, canvasElement) {
|
|
4
|
+
this.canvas = null;
|
|
5
|
+
this.ctx = null;
|
|
6
|
+
// Cache for loaded images
|
|
7
|
+
this.imageCache = /* @__PURE__ */ new Map();
|
|
8
|
+
this.loadingImages = /* @__PURE__ */ new Set();
|
|
9
|
+
// Interactive states
|
|
10
|
+
this.activeImageId = null;
|
|
11
|
+
this.draggedImageId = null;
|
|
12
|
+
this.dragStartOffset = { x: 0, y: 0 };
|
|
13
|
+
this.dragCurrentPos = { x: 0, y: 0 };
|
|
14
|
+
this.draggedOverCell = null;
|
|
15
|
+
this.hoveredPlaceholder = null;
|
|
16
|
+
this.isDragging = false;
|
|
17
|
+
// Callback events
|
|
18
|
+
this.changeCallbacks = [];
|
|
19
|
+
this.activeCallbacks = [];
|
|
20
|
+
this.clickCallbacks = [];
|
|
21
|
+
this.config = {
|
|
22
|
+
showGridlines: true,
|
|
23
|
+
placementSize: "medium",
|
|
24
|
+
containerBgColor: "#ffffff",
|
|
25
|
+
useTransparentBg: false,
|
|
26
|
+
imageBorderRadius2K: 24,
|
|
27
|
+
imageShadowBlur2K: 0,
|
|
28
|
+
imageShadowOffset2K: 0,
|
|
29
|
+
imageShadowOpacity: 0.2,
|
|
30
|
+
...config
|
|
31
|
+
};
|
|
32
|
+
if (canvasElement) {
|
|
33
|
+
this.canvas = canvasElement;
|
|
34
|
+
this.ctx = canvasElement.getContext("2d");
|
|
35
|
+
this.initEvents();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// --- API Configuration Get/Set ---
|
|
39
|
+
getConfig() {
|
|
40
|
+
return this.config;
|
|
41
|
+
}
|
|
42
|
+
updateConfig(newConfig) {
|
|
43
|
+
this.config = { ...this.config, ...newConfig };
|
|
44
|
+
this.render();
|
|
45
|
+
}
|
|
46
|
+
getImages() {
|
|
47
|
+
return this.config.images;
|
|
48
|
+
}
|
|
49
|
+
setImages(images) {
|
|
50
|
+
this.config.images = images;
|
|
51
|
+
this.triggerChange();
|
|
52
|
+
this.render();
|
|
53
|
+
}
|
|
54
|
+
getActiveImageId() {
|
|
55
|
+
return this.activeImageId;
|
|
56
|
+
}
|
|
57
|
+
setActiveImageId(id) {
|
|
58
|
+
this.activeImageId = id;
|
|
59
|
+
this.triggerActiveChange();
|
|
60
|
+
this.render();
|
|
61
|
+
}
|
|
62
|
+
// --- Event Callbacks registration ---
|
|
63
|
+
onImagesChanged(callback) {
|
|
64
|
+
this.changeCallbacks.push(callback);
|
|
65
|
+
}
|
|
66
|
+
onActiveImageChanged(callback) {
|
|
67
|
+
this.activeCallbacks.push(callback);
|
|
68
|
+
}
|
|
69
|
+
onCellClicked(callback) {
|
|
70
|
+
this.clickCallbacks.push(callback);
|
|
71
|
+
}
|
|
72
|
+
triggerChange() {
|
|
73
|
+
this.changeCallbacks.forEach((cb) => cb(this.config.images));
|
|
74
|
+
}
|
|
75
|
+
triggerActiveChange() {
|
|
76
|
+
this.activeCallbacks.forEach((cb) => cb(this.activeImageId));
|
|
77
|
+
}
|
|
78
|
+
triggerCellClick(x, y) {
|
|
79
|
+
this.clickCallbacks.forEach((cb) => cb(x, y));
|
|
80
|
+
}
|
|
81
|
+
// --- Image Cache helpers ---
|
|
82
|
+
getOrLoadImage(src) {
|
|
83
|
+
if (this.imageCache.has(src)) {
|
|
84
|
+
return this.imageCache.get(src);
|
|
85
|
+
}
|
|
86
|
+
if (this.loadingImages.has(src)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
this.loadingImages.add(src);
|
|
90
|
+
const img = new Image();
|
|
91
|
+
img.crossOrigin = "anonymous";
|
|
92
|
+
img.onload = () => {
|
|
93
|
+
this.imageCache.set(src, img);
|
|
94
|
+
this.loadingImages.delete(src);
|
|
95
|
+
this.render();
|
|
96
|
+
};
|
|
97
|
+
img.onerror = () => {
|
|
98
|
+
this.loadingImages.delete(src);
|
|
99
|
+
console.error("Failed to load image:", src);
|
|
100
|
+
};
|
|
101
|
+
img.src = src;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
async preloadAllImages() {
|
|
105
|
+
const promises = this.config.images.map((imgData) => {
|
|
106
|
+
if (this.imageCache.has(imgData.src)) {
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const img = new Image();
|
|
111
|
+
img.crossOrigin = "anonymous";
|
|
112
|
+
img.onload = () => {
|
|
113
|
+
this.imageCache.set(imgData.src, img);
|
|
114
|
+
resolve();
|
|
115
|
+
};
|
|
116
|
+
img.onerror = () => {
|
|
117
|
+
console.error("Preload failed:", imgData.src);
|
|
118
|
+
resolve();
|
|
119
|
+
};
|
|
120
|
+
img.src = imgData.src;
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
await Promise.all(promises);
|
|
124
|
+
}
|
|
125
|
+
// --- Grid placement collision validation ---
|
|
126
|
+
canPlaceImage(imgId, x, y, spanSize, gridRows) {
|
|
127
|
+
if (x + spanSize > this.config.gridColumns || y + spanSize > gridRows || x < 0 || y < 0) return false;
|
|
128
|
+
for (const img of this.config.images) {
|
|
129
|
+
if (imgId && img.id === imgId) continue;
|
|
130
|
+
const overlap = !(x + spanSize <= img.gridX || img.gridX + img.span <= x || y + spanSize <= img.gridY || img.gridY + img.span <= y);
|
|
131
|
+
if (overlap) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// --- Placed Images Editing Actions ---
|
|
136
|
+
addImage(img) {
|
|
137
|
+
this.config.images = [...this.config.images, img];
|
|
138
|
+
this.activeImageId = img.id;
|
|
139
|
+
this.triggerChange();
|
|
140
|
+
this.triggerActiveChange();
|
|
141
|
+
this.render();
|
|
142
|
+
}
|
|
143
|
+
removeImage(imgId) {
|
|
144
|
+
this.config.images = this.config.images.filter((img) => img.id !== imgId);
|
|
145
|
+
if (this.activeImageId === imgId) {
|
|
146
|
+
this.activeImageId = null;
|
|
147
|
+
this.triggerActiveChange();
|
|
148
|
+
}
|
|
149
|
+
this.triggerChange();
|
|
150
|
+
this.render();
|
|
151
|
+
}
|
|
152
|
+
updateImage(imgId, updates) {
|
|
153
|
+
this.config.images = this.config.images.map(
|
|
154
|
+
(img) => img.id === imgId ? { ...img, ...updates } : img
|
|
155
|
+
);
|
|
156
|
+
this.triggerChange();
|
|
157
|
+
this.render();
|
|
158
|
+
}
|
|
159
|
+
modifyImageSpan(imgId, delta, gridRows) {
|
|
160
|
+
const img = this.config.images.find((i) => i.id === imgId);
|
|
161
|
+
if (!img) return false;
|
|
162
|
+
const newSpan = Math.max(1, Math.min(this.config.gridColumns, img.span + delta));
|
|
163
|
+
if (newSpan === img.span) return false;
|
|
164
|
+
if (this.canPlaceImage(imgId, img.gridX, img.gridY, newSpan, gridRows)) {
|
|
165
|
+
this.config.images = this.config.images.map(
|
|
166
|
+
(i) => i.id === imgId ? { ...i, span: newSpan } : i
|
|
167
|
+
);
|
|
168
|
+
this.triggerChange();
|
|
169
|
+
this.render();
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
stepImagePosition(imgId, dir, gridRows) {
|
|
175
|
+
const img = this.config.images.find((i) => i.id === imgId);
|
|
176
|
+
if (!img) return false;
|
|
177
|
+
let targetX = img.gridX;
|
|
178
|
+
let targetY = img.gridY;
|
|
179
|
+
if (dir === "up") targetY = Math.max(0, targetY - 1);
|
|
180
|
+
if (dir === "down") targetY = Math.min(gridRows - img.span, targetY + 1);
|
|
181
|
+
if (dir === "left") targetX = Math.max(0, targetX - 1);
|
|
182
|
+
if (dir === "right") targetX = Math.min(this.config.gridColumns - img.span, targetX + 1);
|
|
183
|
+
if (targetX === img.gridX && targetY === img.gridY) return false;
|
|
184
|
+
if (this.canPlaceImage(imgId, targetX, targetY, img.span, gridRows)) {
|
|
185
|
+
this.config.images = this.config.images.map(
|
|
186
|
+
(i) => i.id === imgId ? { ...i, gridX: targetX, gridY: targetY } : i
|
|
187
|
+
);
|
|
188
|
+
this.triggerChange();
|
|
189
|
+
this.render();
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
pushDownBelow(imgId, gridRows) {
|
|
195
|
+
const activeImg = this.config.images.find((i) => i.id === imgId);
|
|
196
|
+
if (!activeImg) return false;
|
|
197
|
+
const boundaryY = activeImg.gridY + activeImg.span;
|
|
198
|
+
const targets = this.config.images.filter((img) => img.id !== imgId && img.gridY >= boundaryY);
|
|
199
|
+
const canShiftAll = targets.every((img) => img.gridY + img.span + 1 <= gridRows);
|
|
200
|
+
if (!canShiftAll) return false;
|
|
201
|
+
this.config.images = this.config.images.map((img) => {
|
|
202
|
+
if (img.id !== imgId && img.gridY >= boundaryY) {
|
|
203
|
+
return { ...img, gridY: img.gridY + 1 };
|
|
204
|
+
}
|
|
205
|
+
return img;
|
|
206
|
+
});
|
|
207
|
+
this.triggerChange();
|
|
208
|
+
this.render();
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
pullUpBelow(imgId) {
|
|
212
|
+
const activeImg = this.config.images.find((i) => i.id === imgId);
|
|
213
|
+
if (!activeImg) return false;
|
|
214
|
+
const boundaryY = activeImg.gridY + activeImg.span;
|
|
215
|
+
const tempImages = this.config.images.map((img) => {
|
|
216
|
+
if (img.id !== imgId && img.gridY >= boundaryY) {
|
|
217
|
+
return { ...img, gridY: img.gridY - 1 };
|
|
218
|
+
}
|
|
219
|
+
return img;
|
|
220
|
+
});
|
|
221
|
+
let isValid = true;
|
|
222
|
+
for (let i = 0; i < tempImages.length; i++) {
|
|
223
|
+
const img = tempImages[i];
|
|
224
|
+
if (img.gridY < 0) {
|
|
225
|
+
isValid = false;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
for (let j = i + 1; j < tempImages.length; j++) {
|
|
229
|
+
const other = tempImages[j];
|
|
230
|
+
const overlap = !(img.gridX + img.span <= other.gridX || other.gridX + other.span <= img.gridX || img.gridY + img.span <= other.gridY || other.gridY + other.span <= img.gridY);
|
|
231
|
+
if (overlap) {
|
|
232
|
+
isValid = false;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!isValid) break;
|
|
237
|
+
}
|
|
238
|
+
if (!isValid) return false;
|
|
239
|
+
this.config.images = tempImages;
|
|
240
|
+
this.triggerChange();
|
|
241
|
+
this.render();
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
// --- Layout Calculations (Geometric Math) ---
|
|
245
|
+
calculateLayout(width, height) {
|
|
246
|
+
const containerRatioVal = (() => {
|
|
247
|
+
if (this.config.containerRatio === "custom") {
|
|
248
|
+
return (this.config.customContainerW || 3) / (this.config.customContainerH || 4);
|
|
249
|
+
}
|
|
250
|
+
const parts = this.config.containerRatio.split(":");
|
|
251
|
+
return Number(parts[0]) / Number(parts[1]);
|
|
252
|
+
})();
|
|
253
|
+
const imageRatioVal = (() => {
|
|
254
|
+
if (this.config.imageRatio === "custom") {
|
|
255
|
+
return (this.config.customImageW || 16) / (this.config.customImageH || 9);
|
|
256
|
+
}
|
|
257
|
+
const parts = this.config.imageRatio.split(":");
|
|
258
|
+
return Number(parts[0]) / Number(parts[1]);
|
|
259
|
+
})();
|
|
260
|
+
const scale = width / 2048;
|
|
261
|
+
const padding = this.config.padding2K * scale;
|
|
262
|
+
const gap = this.config.gap2K * scale;
|
|
263
|
+
const contentW = Math.max(50, width - padding * 2);
|
|
264
|
+
const contentH = Math.max(50, height - padding * 2);
|
|
265
|
+
const cellW = (contentW - (this.config.gridColumns - 1) * gap) / this.config.gridColumns;
|
|
266
|
+
const cellH = (cellW + gap) / imageRatioVal - gap;
|
|
267
|
+
const gridRows = Math.floor((contentH + gap) / (cellH + gap));
|
|
268
|
+
const gridW = this.config.gridColumns * cellW + (this.config.gridColumns - 1) * gap;
|
|
269
|
+
const gridH = gridRows * cellH + (gridRows - 1) * gap;
|
|
270
|
+
const offsetX = padding + (contentW - gridW) / 2;
|
|
271
|
+
const offsetY = padding + (contentH - gridH) / 2;
|
|
272
|
+
return {
|
|
273
|
+
scale,
|
|
274
|
+
padding,
|
|
275
|
+
gap,
|
|
276
|
+
cellW,
|
|
277
|
+
cellH,
|
|
278
|
+
gridRows,
|
|
279
|
+
offsetX,
|
|
280
|
+
offsetY,
|
|
281
|
+
gridW,
|
|
282
|
+
gridH,
|
|
283
|
+
containerRatioVal,
|
|
284
|
+
imageRatioVal
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// --- Visual mode mouse interaction listeners ---
|
|
288
|
+
initEvents() {
|
|
289
|
+
if (!this.canvas) return;
|
|
290
|
+
const getMouseCoords = (e) => {
|
|
291
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
292
|
+
return {
|
|
293
|
+
x: e.clientX - rect.left,
|
|
294
|
+
y: e.clientY - rect.top
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
this.canvas.addEventListener("mousedown", (e) => {
|
|
298
|
+
const mouse = getMouseCoords(e);
|
|
299
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
300
|
+
const layout = this.calculateLayout(rect.width || this.canvas.width, rect.height || this.canvas.height);
|
|
301
|
+
const hitImg = this.hitTest(mouse.x, mouse.y, layout);
|
|
302
|
+
if (hitImg) {
|
|
303
|
+
this.activeImageId = hitImg.id;
|
|
304
|
+
this.draggedImageId = hitImg.id;
|
|
305
|
+
this.isDragging = true;
|
|
306
|
+
const imgRect = this.getImageRect(hitImg, layout);
|
|
307
|
+
this.dragStartOffset = {
|
|
308
|
+
x: mouse.x - imgRect.x,
|
|
309
|
+
y: mouse.y - imgRect.y
|
|
310
|
+
};
|
|
311
|
+
this.dragCurrentPos = { x: mouse.x, y: mouse.y };
|
|
312
|
+
this.triggerActiveChange();
|
|
313
|
+
this.render();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const targetSpan = this.getCurrentTargetSpan();
|
|
317
|
+
const placeholders = this.getPlaceholders(targetSpan, layout.gridRows);
|
|
318
|
+
const hitPlaceholder = placeholders.find((slot) => {
|
|
319
|
+
const slotX = layout.offsetX + slot.gridX * (layout.cellW + layout.gap);
|
|
320
|
+
const slotY = layout.offsetY + slot.gridY * (layout.cellH + layout.gap);
|
|
321
|
+
const slotW = slot.span * layout.cellW + (slot.span - 1) * layout.gap;
|
|
322
|
+
const slotH = slot.span * layout.cellH + (slot.span - 1) * layout.gap;
|
|
323
|
+
return mouse.x >= slotX && mouse.x <= slotX + slotW && mouse.y >= slotY && mouse.y <= slotY + slotH;
|
|
324
|
+
});
|
|
325
|
+
if (hitPlaceholder) {
|
|
326
|
+
this.triggerCellClick(hitPlaceholder.gridX, hitPlaceholder.gridY);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (this.activeImageId) {
|
|
330
|
+
this.activeImageId = null;
|
|
331
|
+
this.triggerActiveChange();
|
|
332
|
+
this.render();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
this.canvas.addEventListener("mousemove", (e) => {
|
|
336
|
+
const mouse = getMouseCoords(e);
|
|
337
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
338
|
+
const layout = this.calculateLayout(rect.width || this.canvas.width, rect.height || this.canvas.height);
|
|
339
|
+
if (this.isDragging && this.draggedImageId) {
|
|
340
|
+
this.dragCurrentPos = { x: mouse.x, y: mouse.y };
|
|
341
|
+
const snapX = mouse.x - this.dragStartOffset.x;
|
|
342
|
+
const snapY = mouse.y - this.dragStartOffset.y;
|
|
343
|
+
const col = Math.round((snapX - layout.offsetX) / (layout.cellW + layout.gap));
|
|
344
|
+
const row = Math.round((snapY - layout.offsetY) / (layout.cellH + layout.gap));
|
|
345
|
+
const draggedImg = this.config.images.find((img) => img.id === this.draggedImageId);
|
|
346
|
+
if (draggedImg) {
|
|
347
|
+
const targetX = Math.max(0, Math.min(this.config.gridColumns - draggedImg.span, col));
|
|
348
|
+
const targetY = Math.max(0, Math.min(layout.gridRows - draggedImg.span, row));
|
|
349
|
+
if (!this.draggedOverCell || this.draggedOverCell.x !== targetX || this.draggedOverCell.y !== targetY) {
|
|
350
|
+
this.draggedOverCell = { x: targetX, y: targetY };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.render();
|
|
354
|
+
} else {
|
|
355
|
+
const targetSpan = this.getCurrentTargetSpan();
|
|
356
|
+
const placeholders = this.getPlaceholders(targetSpan, layout.gridRows);
|
|
357
|
+
const hitPlaceholder = placeholders.find((slot) => {
|
|
358
|
+
const slotX = layout.offsetX + slot.gridX * (layout.cellW + layout.gap);
|
|
359
|
+
const slotY = layout.offsetY + slot.gridY * (layout.cellH + layout.gap);
|
|
360
|
+
const slotW = slot.span * layout.cellW + (slot.span - 1) * layout.gap;
|
|
361
|
+
const slotH = slot.span * layout.cellH + (slot.span - 1) * layout.gap;
|
|
362
|
+
return mouse.x >= slotX && mouse.x <= slotX + slotW && mouse.y >= slotY && mouse.y <= slotY + slotH;
|
|
363
|
+
});
|
|
364
|
+
if (hitPlaceholder) {
|
|
365
|
+
if (!this.hoveredPlaceholder || this.hoveredPlaceholder.gridX !== hitPlaceholder.gridX || this.hoveredPlaceholder.gridY !== hitPlaceholder.gridY) {
|
|
366
|
+
this.hoveredPlaceholder = { gridX: hitPlaceholder.gridX, gridY: hitPlaceholder.gridY };
|
|
367
|
+
this.canvas.style.cursor = "pointer";
|
|
368
|
+
this.render();
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
if (this.hoveredPlaceholder) {
|
|
372
|
+
this.hoveredPlaceholder = null;
|
|
373
|
+
this.canvas.style.cursor = "default";
|
|
374
|
+
this.render();
|
|
375
|
+
}
|
|
376
|
+
const hitImg = this.hitTest(mouse.x, mouse.y, layout);
|
|
377
|
+
this.canvas.style.cursor = hitImg ? "move" : "default";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
window.addEventListener("mouseup", () => {
|
|
382
|
+
if (this.isDragging && this.draggedImageId && this.draggedOverCell) {
|
|
383
|
+
const sourceImg = this.config.images.find((img) => img.id === this.draggedImageId);
|
|
384
|
+
if (sourceImg) {
|
|
385
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
386
|
+
const layout = this.calculateLayout(rect.width || this.canvas.width, rect.height || this.canvas.height);
|
|
387
|
+
const targetX = this.draggedOverCell.x;
|
|
388
|
+
const targetY = this.draggedOverCell.y;
|
|
389
|
+
if (this.canPlaceImage(this.draggedImageId, targetX, targetY, sourceImg.span, layout.gridRows)) {
|
|
390
|
+
this.config.images = this.config.images.map(
|
|
391
|
+
(img) => img.id === this.draggedImageId ? { ...img, gridX: targetX, gridY: targetY } : img
|
|
392
|
+
);
|
|
393
|
+
this.triggerChange();
|
|
394
|
+
} else {
|
|
395
|
+
const targetImg = this.config.images.find((img) => img.gridX === targetX && img.gridY === targetY);
|
|
396
|
+
if (targetImg && targetImg.span === sourceImg.span && this.canPlaceImage(this.draggedImageId, targetImg.gridX, targetImg.gridY, sourceImg.span, layout.gridRows)) {
|
|
397
|
+
this.config.images = this.config.images.map((img) => {
|
|
398
|
+
if (img.id === sourceImg.id) return { ...img, gridX: targetImg.gridX, gridY: targetImg.gridY };
|
|
399
|
+
if (img.id === targetImg.id) return { ...img, gridX: sourceImg.gridX, gridY: sourceImg.gridY };
|
|
400
|
+
return img;
|
|
401
|
+
});
|
|
402
|
+
this.triggerChange();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
this.isDragging = false;
|
|
408
|
+
this.draggedImageId = null;
|
|
409
|
+
this.draggedOverCell = null;
|
|
410
|
+
this.render();
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
hitTest(x, y, layout) {
|
|
414
|
+
for (let i = this.config.images.length - 1; i >= 0; i--) {
|
|
415
|
+
const img = this.config.images[i];
|
|
416
|
+
const rect = this.getImageRect(img, layout);
|
|
417
|
+
if (x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) {
|
|
418
|
+
return img;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
getImageRect(img, layout) {
|
|
424
|
+
const w = img.span * layout.cellW + (img.span - 1) * layout.gap;
|
|
425
|
+
const h = img.span * layout.cellH + (img.span - 1) * layout.gap;
|
|
426
|
+
const x = layout.offsetX + img.gridX * (layout.cellW + layout.gap);
|
|
427
|
+
const y = layout.offsetY + img.gridY * (layout.cellH + layout.gap);
|
|
428
|
+
return { x, y, w, h };
|
|
429
|
+
}
|
|
430
|
+
getCurrentTargetSpan() {
|
|
431
|
+
const size = this.config.placementSize || "medium";
|
|
432
|
+
const gridColumns = this.config.gridColumns;
|
|
433
|
+
switch (gridColumns) {
|
|
434
|
+
case 4:
|
|
435
|
+
return { small: 1, medium: 2, large: 3 }[size];
|
|
436
|
+
case 6:
|
|
437
|
+
return { small: 1, medium: 2, large: 3 }[size];
|
|
438
|
+
case 8:
|
|
439
|
+
return { small: 2, medium: 3, large: 4 }[size];
|
|
440
|
+
case 12:
|
|
441
|
+
return { small: 3, medium: 4, large: 6 }[size];
|
|
442
|
+
default:
|
|
443
|
+
const s = Math.max(1, Math.floor(gridColumns / 4));
|
|
444
|
+
const m = Math.min(gridColumns, Math.max(s + 1, Math.floor(gridColumns / 3)));
|
|
445
|
+
const l = Math.min(gridColumns, Math.max(m + 1, Math.floor(gridColumns / 2)));
|
|
446
|
+
return { small: s, medium: m, large: l }[size];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
getPlaceholders(targetSpan, gridRows) {
|
|
450
|
+
const gridColumns = this.config.gridColumns;
|
|
451
|
+
const occupied = Array.from({ length: gridColumns }, () => Array(gridRows).fill(false));
|
|
452
|
+
for (const img of this.config.images) {
|
|
453
|
+
for (let dx = 0; dx < img.span; dx++) {
|
|
454
|
+
for (let dy = 0; dy < img.span; dy++) {
|
|
455
|
+
const cx = img.gridX + dx;
|
|
456
|
+
const ry = img.gridY + dy;
|
|
457
|
+
if (cx < gridColumns && ry < gridRows) {
|
|
458
|
+
occupied[cx][ry] = true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const result = [];
|
|
464
|
+
const tempOccupied = occupied.map((row) => [...row]);
|
|
465
|
+
for (let r = 0; r <= gridRows - targetSpan; r++) {
|
|
466
|
+
for (let c = 0; c <= gridColumns - targetSpan; c++) {
|
|
467
|
+
let isFree = true;
|
|
468
|
+
for (let dx = 0; dx < targetSpan; dx++) {
|
|
469
|
+
for (let dy = 0; dy < targetSpan; dy++) {
|
|
470
|
+
if (tempOccupied[c + dx][r + dy]) {
|
|
471
|
+
isFree = false;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (!isFree) break;
|
|
476
|
+
}
|
|
477
|
+
if (isFree) {
|
|
478
|
+
result.push({ gridX: c, gridY: r, span: targetSpan });
|
|
479
|
+
for (let dx = 0; dx < targetSpan; dx++) {
|
|
480
|
+
for (let dy = 0; dy < targetSpan; dy++) {
|
|
481
|
+
tempOccupied[c + dx][r + dy] = true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
// --- Rendering Pipeline ---
|
|
490
|
+
render(drawUI = true) {
|
|
491
|
+
if (!this.canvas || !this.ctx) return;
|
|
492
|
+
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
493
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
494
|
+
const w = rect.width > 0 ? rect.width : this.canvas.width / dpr || 300;
|
|
495
|
+
const h = rect.height > 0 ? rect.height : this.canvas.height / dpr || 400;
|
|
496
|
+
const targetWidth = Math.round(w * dpr);
|
|
497
|
+
const targetHeight = Math.round(h * dpr);
|
|
498
|
+
if (this.canvas.width !== targetWidth || this.canvas.height !== targetHeight) {
|
|
499
|
+
this.canvas.width = targetWidth;
|
|
500
|
+
this.canvas.height = targetHeight;
|
|
501
|
+
this.canvas.style.width = `${w}px`;
|
|
502
|
+
this.canvas.style.height = `${h}px`;
|
|
503
|
+
}
|
|
504
|
+
this.ctx.save();
|
|
505
|
+
this.ctx.clearRect(0, 0, targetWidth, targetHeight);
|
|
506
|
+
this.ctx.scale(dpr, dpr);
|
|
507
|
+
this.draw(this.ctx, w, h, drawUI);
|
|
508
|
+
this.ctx.restore();
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The master draw function: compiles background, images, and overlays.
|
|
512
|
+
* Can be shared between interactive Canvas on the page and high-res offscreen Canvas.
|
|
513
|
+
*/
|
|
514
|
+
draw(ctx, width, height, drawUI) {
|
|
515
|
+
const layout = this.calculateLayout(width, height);
|
|
516
|
+
if (!this.config.useTransparentBg) {
|
|
517
|
+
ctx.fillStyle = this.config.containerBgColor || "#ffffff";
|
|
518
|
+
ctx.fillRect(0, 0, width, height);
|
|
519
|
+
} else {
|
|
520
|
+
ctx.clearRect(0, 0, width, height);
|
|
521
|
+
}
|
|
522
|
+
let draggedImgData = null;
|
|
523
|
+
let draggedImgRect = null;
|
|
524
|
+
for (const img of this.config.images) {
|
|
525
|
+
const rect = this.getImageRect(img, layout);
|
|
526
|
+
const isDraggedSelf = this.isDragging && this.draggedImageId === img.id;
|
|
527
|
+
const borderRadius = (img.borderRadius2K ?? this.config.imageBorderRadius2K ?? 24) * layout.scale;
|
|
528
|
+
if (isDraggedSelf && drawUI) {
|
|
529
|
+
draggedImgData = img;
|
|
530
|
+
draggedImgRect = rect;
|
|
531
|
+
ctx.save();
|
|
532
|
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.4)";
|
|
533
|
+
ctx.lineWidth = 1.5;
|
|
534
|
+
ctx.setLineDash([4, 4]);
|
|
535
|
+
ctx.fillStyle = "rgba(241, 245, 249, 0.05)";
|
|
536
|
+
ctx.beginPath();
|
|
537
|
+
ctx.roundRect(rect.x, rect.y, rect.w, rect.h, borderRadius);
|
|
538
|
+
ctx.fill();
|
|
539
|
+
ctx.stroke();
|
|
540
|
+
ctx.restore();
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
ctx.save();
|
|
544
|
+
const shadowBlur = (img.shadowBlur2K ?? this.config.imageShadowBlur2K ?? 0) * layout.scale;
|
|
545
|
+
const shadowOffset = (img.shadowOffset2K ?? this.config.imageShadowOffset2K ?? 0) * layout.scale;
|
|
546
|
+
const shadowOpacity = img.shadowOpacity ?? this.config.imageShadowOpacity ?? 0.2;
|
|
547
|
+
if (shadowBlur > 0 || shadowOffset > 0) {
|
|
548
|
+
ctx.save();
|
|
549
|
+
ctx.shadowColor = `rgba(15, 23, 42, ${shadowOpacity})`;
|
|
550
|
+
ctx.shadowBlur = shadowBlur;
|
|
551
|
+
ctx.shadowOffsetX = shadowOffset;
|
|
552
|
+
ctx.shadowOffsetY = shadowOffset;
|
|
553
|
+
ctx.fillStyle = "#ffffff";
|
|
554
|
+
ctx.beginPath();
|
|
555
|
+
ctx.roundRect(rect.x, rect.y, rect.w, rect.h, borderRadius);
|
|
556
|
+
ctx.fill();
|
|
557
|
+
ctx.restore();
|
|
558
|
+
}
|
|
559
|
+
ctx.save();
|
|
560
|
+
ctx.beginPath();
|
|
561
|
+
ctx.roundRect(rect.x, rect.y, rect.w, rect.h, borderRadius);
|
|
562
|
+
ctx.clip();
|
|
563
|
+
const imgElement = this.getOrLoadImage(img.src);
|
|
564
|
+
if (imgElement) {
|
|
565
|
+
drawImageCover(ctx, imgElement, rect.x, rect.y, rect.w, rect.h);
|
|
566
|
+
} else {
|
|
567
|
+
ctx.fillStyle = "#e2e8f0";
|
|
568
|
+
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
569
|
+
ctx.fillStyle = "#94a3b8";
|
|
570
|
+
ctx.font = `bold 12px sans-serif`;
|
|
571
|
+
ctx.textAlign = "center";
|
|
572
|
+
ctx.textBaseline = "middle";
|
|
573
|
+
ctx.fillText("\u52A0\u8F7D\u4E2D...", rect.x + rect.w / 2, rect.y + rect.h / 2);
|
|
574
|
+
}
|
|
575
|
+
ctx.restore();
|
|
576
|
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.4)";
|
|
577
|
+
ctx.lineWidth = Math.max(1, 1 * layout.scale);
|
|
578
|
+
ctx.beginPath();
|
|
579
|
+
ctx.roundRect(rect.x, rect.y, rect.w, rect.h, borderRadius);
|
|
580
|
+
ctx.stroke();
|
|
581
|
+
ctx.restore();
|
|
582
|
+
}
|
|
583
|
+
if (drawUI) {
|
|
584
|
+
ctx.strokeStyle = "rgba(79, 70, 229, 0.15)";
|
|
585
|
+
ctx.lineWidth = 1.5;
|
|
586
|
+
ctx.setLineDash([6, 6]);
|
|
587
|
+
ctx.beginPath();
|
|
588
|
+
ctx.roundRect(layout.padding, layout.padding, width - layout.padding * 2, height - layout.padding * 2, 12);
|
|
589
|
+
ctx.stroke();
|
|
590
|
+
ctx.setLineDash([]);
|
|
591
|
+
if (this.config.showGridlines) {
|
|
592
|
+
ctx.strokeStyle = "rgba(203, 213, 225, 0.35)";
|
|
593
|
+
ctx.lineWidth = 1;
|
|
594
|
+
ctx.setLineDash([4, 4]);
|
|
595
|
+
for (let i = 1; i < this.config.gridColumns; i++) {
|
|
596
|
+
const lineX = layout.offsetX + i * layout.cellW + (i - 1) * layout.gap + layout.gap / 2;
|
|
597
|
+
ctx.beginPath();
|
|
598
|
+
ctx.moveTo(lineX, layout.padding);
|
|
599
|
+
ctx.lineTo(lineX, layout.padding + layout.gridH);
|
|
600
|
+
ctx.stroke();
|
|
601
|
+
}
|
|
602
|
+
for (let i = 1; i < layout.gridRows; i++) {
|
|
603
|
+
const lineY = layout.offsetY + i * layout.cellH + (i - 1) * layout.gap + layout.gap / 2;
|
|
604
|
+
ctx.beginPath();
|
|
605
|
+
ctx.moveTo(layout.padding, lineY);
|
|
606
|
+
ctx.lineTo(layout.padding + layout.gridW, lineY);
|
|
607
|
+
ctx.stroke();
|
|
608
|
+
}
|
|
609
|
+
ctx.setLineDash([]);
|
|
610
|
+
}
|
|
611
|
+
const targetSpan = this.getCurrentTargetSpan();
|
|
612
|
+
const placeholders = this.getPlaceholders(targetSpan, layout.gridRows);
|
|
613
|
+
for (const slot of placeholders) {
|
|
614
|
+
const slotX = layout.offsetX + slot.gridX * (layout.cellW + layout.gap);
|
|
615
|
+
const slotY = layout.offsetY + slot.gridY * (layout.cellH + layout.gap);
|
|
616
|
+
const slotW = slot.span * layout.cellW + (slot.span - 1) * layout.gap;
|
|
617
|
+
const slotH = slot.span * layout.cellH + (slot.span - 1) * layout.gap;
|
|
618
|
+
const isHovered = this.hoveredPlaceholder && this.hoveredPlaceholder.gridX === slot.gridX && this.hoveredPlaceholder.gridY === slot.gridY;
|
|
619
|
+
ctx.save();
|
|
620
|
+
ctx.strokeStyle = isHovered ? "rgba(79, 70, 229, 0.5)" : "rgba(79, 70, 229, 0.2)";
|
|
621
|
+
ctx.lineWidth = isHovered ? 2 : 1.5;
|
|
622
|
+
ctx.setLineDash([5, 5]);
|
|
623
|
+
ctx.fillStyle = isHovered ? "rgba(79, 70, 229, 0.05)" : "transparent";
|
|
624
|
+
const slotRadius = Math.max(6, (this.config.imageBorderRadius2K ?? 24) * layout.scale);
|
|
625
|
+
ctx.beginPath();
|
|
626
|
+
ctx.roundRect(slotX, slotY, slotW, slotH, slotRadius);
|
|
627
|
+
ctx.fill();
|
|
628
|
+
ctx.stroke();
|
|
629
|
+
const color = isHovered ? "rgba(79, 70, 229, 0.8)" : "rgba(148, 163, 184, 0.5)";
|
|
630
|
+
ctx.strokeStyle = color;
|
|
631
|
+
ctx.lineWidth = 2;
|
|
632
|
+
ctx.setLineDash([]);
|
|
633
|
+
const showText = slotW >= 90 && slotH >= 65;
|
|
634
|
+
const centerCX = slotX + slotW / 2;
|
|
635
|
+
const centerCY = showText ? slotY + slotH / 2 - 10 : slotY + slotH / 2;
|
|
636
|
+
const plusSize = 7;
|
|
637
|
+
ctx.beginPath();
|
|
638
|
+
ctx.moveTo(centerCX - plusSize, centerCY);
|
|
639
|
+
ctx.lineTo(centerCX + plusSize, centerCY);
|
|
640
|
+
ctx.moveTo(centerCX, centerCY - plusSize);
|
|
641
|
+
ctx.lineTo(centerCX, centerCY + plusSize);
|
|
642
|
+
ctx.stroke();
|
|
643
|
+
if (showText) {
|
|
644
|
+
ctx.fillStyle = isHovered ? "rgba(79, 70, 229, 1)" : "rgba(148, 163, 184, 0.7)";
|
|
645
|
+
ctx.font = `500 12px sans-serif`;
|
|
646
|
+
ctx.textAlign = "center";
|
|
647
|
+
ctx.textBaseline = "top";
|
|
648
|
+
ctx.fillText("\u70B9\u51FB\u4E0A\u4F20\u6216\u62D6\u5165", slotX + slotW / 2, centerCY + plusSize + 6);
|
|
649
|
+
ctx.fillStyle = "rgba(148, 163, 184, 0.4)";
|
|
650
|
+
ctx.font = `10px monospace`;
|
|
651
|
+
ctx.fillText(`(${slot.gridX + 1}, ${slot.gridY + 1})`, slotX + slotW / 2, centerCY + plusSize + 22);
|
|
652
|
+
}
|
|
653
|
+
ctx.restore();
|
|
654
|
+
}
|
|
655
|
+
if (this.activeImageId) {
|
|
656
|
+
const activeImg = this.config.images.find((img) => img.id === this.activeImageId);
|
|
657
|
+
if (activeImg) {
|
|
658
|
+
const rect = this.getImageRect(activeImg, layout);
|
|
659
|
+
ctx.save();
|
|
660
|
+
ctx.strokeStyle = "rgba(79, 70, 229, 1)";
|
|
661
|
+
ctx.lineWidth = 3;
|
|
662
|
+
ctx.beginPath();
|
|
663
|
+
const offset = 3;
|
|
664
|
+
const activeBorderRadius = (activeImg.borderRadius2K ?? this.config.imageBorderRadius2K ?? 24) * layout.scale;
|
|
665
|
+
const ringRadius = activeBorderRadius + offset;
|
|
666
|
+
ctx.roundRect(
|
|
667
|
+
rect.x - offset,
|
|
668
|
+
rect.y - offset,
|
|
669
|
+
rect.w + offset * 2,
|
|
670
|
+
rect.h + offset * 2,
|
|
671
|
+
ringRadius
|
|
672
|
+
);
|
|
673
|
+
ctx.stroke();
|
|
674
|
+
ctx.restore();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (this.isDragging && this.draggedOverCell && this.draggedImageId) {
|
|
678
|
+
const draggedImg = this.config.images.find((img) => img.id === this.draggedImageId);
|
|
679
|
+
if (draggedImg) {
|
|
680
|
+
const previewX = layout.offsetX + this.draggedOverCell.x * (layout.cellW + layout.gap);
|
|
681
|
+
const previewY = layout.offsetY + this.draggedOverCell.y * (layout.cellH + layout.gap);
|
|
682
|
+
const previewW = draggedImg.span * layout.cellW + (draggedImg.span - 1) * layout.gap;
|
|
683
|
+
const previewH = draggedImg.span * layout.cellH + (draggedImg.span - 1) * layout.gap;
|
|
684
|
+
ctx.save();
|
|
685
|
+
ctx.strokeStyle = "rgba(79, 70, 229, 1)";
|
|
686
|
+
ctx.lineWidth = 1.5;
|
|
687
|
+
ctx.setLineDash([6, 4]);
|
|
688
|
+
ctx.fillStyle = "rgba(79, 70, 229, 0.12)";
|
|
689
|
+
const previewRadius = (draggedImg.borderRadius2K ?? this.config.imageBorderRadius2K ?? 24) * layout.scale;
|
|
690
|
+
ctx.beginPath();
|
|
691
|
+
ctx.roundRect(previewX, previewY, previewW, previewH, previewRadius);
|
|
692
|
+
ctx.fill();
|
|
693
|
+
ctx.stroke();
|
|
694
|
+
ctx.restore();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (this.isDragging && draggedImgData && draggedImgRect) {
|
|
698
|
+
const floatX = this.dragCurrentPos.x - this.dragStartOffset.x;
|
|
699
|
+
const floatY = this.dragCurrentPos.y - this.dragStartOffset.y;
|
|
700
|
+
const floatW = draggedImgRect.w;
|
|
701
|
+
const floatH = draggedImgRect.h;
|
|
702
|
+
const borderRadius = (draggedImgData.borderRadius2K ?? this.config.imageBorderRadius2K ?? 24) * layout.scale;
|
|
703
|
+
ctx.save();
|
|
704
|
+
ctx.beginPath();
|
|
705
|
+
ctx.roundRect(floatX, floatY, floatW, floatH, borderRadius);
|
|
706
|
+
ctx.shadowColor = "rgba(15, 23, 42, 0.4)";
|
|
707
|
+
ctx.shadowBlur = 16;
|
|
708
|
+
ctx.shadowOffsetX = 0;
|
|
709
|
+
ctx.shadowOffsetY = 8;
|
|
710
|
+
ctx.fillStyle = "#ffffff";
|
|
711
|
+
ctx.fill();
|
|
712
|
+
ctx.restore();
|
|
713
|
+
ctx.save();
|
|
714
|
+
ctx.globalAlpha = 0.85;
|
|
715
|
+
ctx.beginPath();
|
|
716
|
+
ctx.roundRect(floatX, floatY, floatW, floatH, borderRadius);
|
|
717
|
+
ctx.clip();
|
|
718
|
+
const imgElement = this.getOrLoadImage(draggedImgData.src);
|
|
719
|
+
if (imgElement) {
|
|
720
|
+
drawImageCover(ctx, imgElement, floatX, floatY, floatW, floatH);
|
|
721
|
+
} else {
|
|
722
|
+
ctx.fillStyle = "#e2e8f0";
|
|
723
|
+
ctx.fillRect(floatX, floatY, floatW, floatH);
|
|
724
|
+
}
|
|
725
|
+
ctx.restore();
|
|
726
|
+
ctx.save();
|
|
727
|
+
ctx.strokeStyle = "rgba(79, 70, 229, 0.9)";
|
|
728
|
+
ctx.lineWidth = 2;
|
|
729
|
+
ctx.beginPath();
|
|
730
|
+
ctx.roundRect(floatX, floatY, floatW, floatH, borderRadius);
|
|
731
|
+
ctx.stroke();
|
|
732
|
+
ctx.restore();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// --- Headless Offscreen Export PNG ---
|
|
737
|
+
async exportPNG(targetWidth = 2048) {
|
|
738
|
+
const containerRatioVal = (() => {
|
|
739
|
+
if (this.config.containerRatio === "custom") {
|
|
740
|
+
return (this.config.customContainerW || 3) / (this.config.customContainerH || 4);
|
|
741
|
+
}
|
|
742
|
+
const parts = this.config.containerRatio.split(":");
|
|
743
|
+
return Number(parts[0]) / Number(parts[1]);
|
|
744
|
+
})();
|
|
745
|
+
const exportHeight = Math.round(targetWidth / containerRatioVal);
|
|
746
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
747
|
+
offscreenCanvas.width = targetWidth;
|
|
748
|
+
offscreenCanvas.height = exportHeight;
|
|
749
|
+
const offscreenCtx = offscreenCanvas.getContext("2d");
|
|
750
|
+
if (!offscreenCtx) throw new Error("Could not create 2D offscreen canvas context");
|
|
751
|
+
await this.preloadAllImages();
|
|
752
|
+
this.draw(offscreenCtx, targetWidth, exportHeight, false);
|
|
753
|
+
return offscreenCanvas.toDataURL("image/png");
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
function drawImageCover(ctx, img, x, y, w, h) {
|
|
757
|
+
const imgRatio = img.width / img.height;
|
|
758
|
+
const targetRatio = w / h;
|
|
759
|
+
let sx = 0, sy = 0, sw = img.width, sh = img.height;
|
|
760
|
+
if (imgRatio > targetRatio) {
|
|
761
|
+
sw = img.height * targetRatio;
|
|
762
|
+
sx = (img.width - sw) / 2;
|
|
763
|
+
} else {
|
|
764
|
+
sh = img.width / targetRatio;
|
|
765
|
+
sy = (img.height - sh) / 2;
|
|
766
|
+
}
|
|
767
|
+
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
|
|
768
|
+
}
|
|
769
|
+
export {
|
|
770
|
+
AspectGridCollageify
|
|
771
|
+
};
|