@sequent-org/moodboard 1.4.7 → 1.4.9
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/package.json +1 -1
- package/src/core/ApiClient.js +39 -44
- package/src/core/SaveManager.js +6 -10
- package/src/core/flows/ClipboardFlow.js +14 -24
- package/src/core/index.js +23 -40
- package/src/core/keyboard/KeyboardClipboardImagePaste.js +2 -4
- package/src/moodboard/ActionHandler.js +2 -3
- package/src/moodboard/integration/MoodBoardLoadApi.js +8 -0
- package/src/objects/ImageObject.js +1 -1
- package/src/services/AssetUrlPolicy.js +20 -2
- package/src/services/FileUploadService.js +20 -0
- package/src/services/ImageUploadService.js +14 -127
- package/src/tools/manager/ToolEventRouter.js +4 -7
- package/src/tools/object-tools/placement/PlacementPayloadFactory.js +1 -2
package/package.json
CHANGED
package/src/core/ApiClient.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { isV2ImageDownloadUrl } from '../services/AssetUrlPolicy.js';
|
|
2
|
-
|
|
3
1
|
// src/core/ApiClient.js
|
|
4
2
|
export class ApiClient {
|
|
5
3
|
constructor(baseUrl, authToken = null) {
|
|
@@ -66,6 +64,14 @@ export class ApiClient {
|
|
|
66
64
|
|
|
67
65
|
// Фильтруем объекты изображений и файлов - убираем избыточные данные
|
|
68
66
|
const cleanedData = this._cleanObjectData(payloadBoardData);
|
|
67
|
+
const objects = Array.isArray(cleanedData?.objects) ? cleanedData.objects : [];
|
|
68
|
+
const imageObjects = objects.filter((obj) => obj?.type === 'image');
|
|
69
|
+
const imageObjectsWithSrc = imageObjects.filter((obj) => typeof obj?.src === 'string' && obj.src.trim().length > 0);
|
|
70
|
+
console.log('history/save payload stats:', {
|
|
71
|
+
totalObjects: objects.length,
|
|
72
|
+
imageObjects: imageObjects.length,
|
|
73
|
+
imageObjectsWithSrc: imageObjectsWithSrc.length
|
|
74
|
+
});
|
|
69
75
|
|
|
70
76
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
71
77
|
|
|
@@ -146,39 +152,31 @@ export class ApiClient {
|
|
|
146
152
|
|
|
147
153
|
const cleanedObjects = boardData.objects.map(obj => {
|
|
148
154
|
if (obj.type === 'image') {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const hasForbiddenInlineSrc = /^data:/i.test(topSrc)
|
|
153
|
-
|| /^blob:/i.test(topSrc)
|
|
154
|
-
|| /^data:/i.test(propSrc)
|
|
155
|
-
|| /^blob:/i.test(propSrc);
|
|
155
|
+
const topSrcRaw = typeof obj.src === 'string' ? obj.src : '';
|
|
156
|
+
const propSrcRaw = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
|
|
157
|
+
const normalizedSrc = topSrcRaw.trim() || propSrcRaw.trim();
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
throw new Error(`Image object "${obj.id || 'unknown'}" has no imageId. Save is blocked.`);
|
|
159
|
+
if (!normalizedSrc) {
|
|
160
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" has no src. Save is blocked.`);
|
|
160
161
|
}
|
|
161
|
-
if (
|
|
162
|
+
if (/^data:/i.test(normalizedSrc) || /^blob:/i.test(normalizedSrc)) {
|
|
162
163
|
throw new Error(`Image object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
|
|
163
164
|
}
|
|
164
|
-
if (
|
|
165
|
-
throw new Error(`Image object "${obj.id || 'unknown'}" has
|
|
166
|
-
}
|
|
167
|
-
if (propSrc && !isV2ImageDownloadUrl(propSrc)) {
|
|
168
|
-
throw new Error(`Image object "${obj.id || 'unknown'}" has non-v2 properties.src URL. Save is blocked.`);
|
|
165
|
+
if (/^\/api\/images\//i.test(normalizedSrc)) {
|
|
166
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" has legacy src URL. Save is blocked.`);
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
const cleanedObj = {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
delete cleanedObj.src;
|
|
176
|
-
}
|
|
169
|
+
const cleanedObj = {
|
|
170
|
+
...obj,
|
|
171
|
+
src: normalizedSrc
|
|
172
|
+
};
|
|
177
173
|
if (cleanedObj.properties?.src) {
|
|
178
174
|
cleanedObj.properties = { ...cleanedObj.properties };
|
|
179
175
|
delete cleanedObj.properties.src;
|
|
180
176
|
}
|
|
181
|
-
|
|
177
|
+
if ('imageId' in cleanedObj) {
|
|
178
|
+
delete cleanedObj.imageId;
|
|
179
|
+
}
|
|
182
180
|
return cleanedObj;
|
|
183
181
|
}
|
|
184
182
|
|
|
@@ -216,7 +214,7 @@ export class ApiClient {
|
|
|
216
214
|
}
|
|
217
215
|
|
|
218
216
|
/**
|
|
219
|
-
*
|
|
217
|
+
* Нормализует URL ресурсов при загрузке
|
|
220
218
|
*/
|
|
221
219
|
async restoreObjectUrls(boardData) {
|
|
222
220
|
if (!boardData || !boardData.objects) {
|
|
@@ -226,25 +224,22 @@ export class ApiClient {
|
|
|
226
224
|
const restoredObjects = await Promise.all(
|
|
227
225
|
boardData.objects.map(async (obj) => {
|
|
228
226
|
if (obj.type === 'image') {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
};
|
|
242
|
-
} catch (error) {
|
|
243
|
-
console.warn(`Не удалось восстановить URL для изображения ${obj.imageId}:`, error);
|
|
244
|
-
return obj;
|
|
245
|
-
}
|
|
227
|
+
const topSrc = typeof obj.src === 'string' ? obj.src.trim() : '';
|
|
228
|
+
const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src.trim() : '';
|
|
229
|
+
const normalizedSrc = topSrc || propSrc;
|
|
230
|
+
if (!normalizedSrc) return obj;
|
|
231
|
+
const restoredObj = {
|
|
232
|
+
...obj,
|
|
233
|
+
src: normalizedSrc
|
|
234
|
+
};
|
|
235
|
+
if (restoredObj.properties?.src) {
|
|
236
|
+
restoredObj.properties = { ...restoredObj.properties };
|
|
237
|
+
delete restoredObj.properties.src;
|
|
246
238
|
}
|
|
247
|
-
|
|
239
|
+
if ('imageId' in restoredObj) {
|
|
240
|
+
delete restoredObj.imageId;
|
|
241
|
+
}
|
|
242
|
+
return restoredObj;
|
|
248
243
|
}
|
|
249
244
|
|
|
250
245
|
if (obj.type === 'file') {
|
package/src/core/SaveManager.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { Events } from './events/Events.js';
|
|
5
5
|
import { logMindmapCompoundDebug } from '../mindmap/MindmapCompoundContract.js';
|
|
6
|
-
import { isV2ImageDownloadUrl } from '../services/AssetUrlPolicy.js';
|
|
7
6
|
export class SaveManager {
|
|
8
7
|
constructor(eventBus, options = {}) {
|
|
9
8
|
this.eventBus = eventBus;
|
|
@@ -106,7 +105,7 @@ export class SaveManager {
|
|
|
106
105
|
}
|
|
107
106
|
|
|
108
107
|
// Жесткий контракт для сохранения картинок:
|
|
109
|
-
// - каждый image обязан иметь
|
|
108
|
+
// - каждый image обязан иметь src
|
|
110
109
|
// - data:/blob: URL в image запрещены
|
|
111
110
|
this._assertImageSaveContract(saveData);
|
|
112
111
|
|
|
@@ -236,21 +235,18 @@ export class SaveManager {
|
|
|
236
235
|
|
|
237
236
|
for (const obj of objects) {
|
|
238
237
|
if (!obj || obj.type !== 'image') continue;
|
|
239
|
-
const imageId = typeof obj.imageId === 'string' ? obj.imageId.trim() : '';
|
|
240
238
|
const topSrc = typeof obj.src === 'string' ? obj.src : '';
|
|
241
239
|
const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
|
|
240
|
+
const effectiveSrc = topSrc.trim() || propSrc.trim();
|
|
242
241
|
|
|
243
|
-
if (!
|
|
244
|
-
throw new Error(`Image object "${obj.id || 'unknown'}" has no
|
|
242
|
+
if (!effectiveSrc) {
|
|
243
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" has no src. Save is blocked.`);
|
|
245
244
|
}
|
|
246
245
|
if (/^data:/i.test(topSrc) || /^blob:/i.test(topSrc) || /^data:/i.test(propSrc) || /^blob:/i.test(propSrc)) {
|
|
247
246
|
throw new Error(`Image object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
|
|
248
247
|
}
|
|
249
|
-
if (
|
|
250
|
-
throw new Error(`Image object "${obj.id || 'unknown'}" has
|
|
251
|
-
}
|
|
252
|
-
if (propSrc && !isV2ImageDownloadUrl(propSrc)) {
|
|
253
|
-
throw new Error(`Image object "${obj.id || 'unknown'}" has non-v2 properties.src URL. Save is blocked.`);
|
|
248
|
+
if (/^\/api\/images\//i.test(effectiveSrc)) {
|
|
249
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" has legacy src URL. Save is blocked.`);
|
|
254
250
|
}
|
|
255
251
|
}
|
|
256
252
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Events } from '../events/Events.js';
|
|
2
2
|
import { PasteObjectCommand } from '../commands/index.js';
|
|
3
3
|
import { RevitScreenshotMetadataService } from '../../services/RevitScreenshotMetadataService.js';
|
|
4
|
-
import { isV2ImageDownloadUrl } from '../../services/AssetUrlPolicy.js';
|
|
5
4
|
|
|
6
5
|
export function setupClipboardFlow(core) {
|
|
7
6
|
const revitMetadataService = new RevitScreenshotMetadataService(console);
|
|
@@ -20,14 +19,10 @@ export function setupClipboardFlow(core) {
|
|
|
20
19
|
};
|
|
21
20
|
};
|
|
22
21
|
|
|
23
|
-
const ensureServerImage = async ({ src, name
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
alert('Некорректный адрес изображения. Изображение не добавлено.');
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
return { src: serverUrl, name, imageId };
|
|
22
|
+
const ensureServerImage = async ({ src, name }) => {
|
|
23
|
+
const srcValue = typeof src === 'string' ? src.trim() : '';
|
|
24
|
+
if (srcValue && !/^data:image\//i.test(srcValue) && !/^blob:/i.test(srcValue)) {
|
|
25
|
+
return { src: srcValue, name };
|
|
31
26
|
}
|
|
32
27
|
if (!core.imageUploadService) {
|
|
33
28
|
alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
|
|
@@ -46,13 +41,12 @@ export function setupClipboardFlow(core) {
|
|
|
46
41
|
uploadResult = await core.imageUploadService.uploadImage(blob, name || 'clipboard-image');
|
|
47
42
|
}
|
|
48
43
|
const serverUrl = typeof uploadResult.url === 'string' ? uploadResult.url.trim() : '';
|
|
49
|
-
if (!
|
|
44
|
+
if (!serverUrl || /^data:/i.test(serverUrl) || /^blob:/i.test(serverUrl)) {
|
|
50
45
|
throw new Error('Сервер вернул некорректный URL изображения');
|
|
51
46
|
}
|
|
52
47
|
return {
|
|
53
48
|
src: serverUrl,
|
|
54
|
-
name: uploadResult.name || name
|
|
55
|
-
imageId: uploadResult.imageId || uploadResult.id
|
|
49
|
+
name: uploadResult.name || name
|
|
56
50
|
};
|
|
57
51
|
} catch (error) {
|
|
58
52
|
console.error('Ошибка загрузки вставленного изображения на сервер:', error);
|
|
@@ -228,10 +222,10 @@ export function setupClipboardFlow(core) {
|
|
|
228
222
|
core._cursor.y = y;
|
|
229
223
|
});
|
|
230
224
|
|
|
231
|
-
core.eventBus.on(Events.UI.PasteImage, async ({ src, name
|
|
225
|
+
core.eventBus.on(Events.UI.PasteImage, async ({ src, name }) => {
|
|
232
226
|
if (!src) return;
|
|
233
|
-
const uploaded = await ensureServerImage({ src, name
|
|
234
|
-
if (!uploaded?.
|
|
227
|
+
const uploaded = await ensureServerImage({ src, name });
|
|
228
|
+
if (!uploaded?.src) return;
|
|
235
229
|
const view = core.pixi.app.view;
|
|
236
230
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
237
231
|
const s = world?.scale?.x || 1;
|
|
@@ -269,12 +263,10 @@ export function setupClipboardFlow(core) {
|
|
|
269
263
|
height: h,
|
|
270
264
|
...revitPayload.properties
|
|
271
265
|
};
|
|
272
|
-
const extraData = uploaded.imageId ? { imageId: uploaded.imageId } : {};
|
|
273
266
|
core.createObject(
|
|
274
267
|
revitPayload.type,
|
|
275
268
|
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
276
|
-
properties
|
|
277
|
-
extraData
|
|
269
|
+
properties
|
|
278
270
|
);
|
|
279
271
|
};
|
|
280
272
|
|
|
@@ -291,10 +283,10 @@ export function setupClipboardFlow(core) {
|
|
|
291
283
|
}
|
|
292
284
|
});
|
|
293
285
|
|
|
294
|
-
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name
|
|
286
|
+
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name }) => {
|
|
295
287
|
if (!src) return;
|
|
296
|
-
const uploaded = await ensureServerImage({ src, name
|
|
297
|
-
if (!uploaded?.
|
|
288
|
+
const uploaded = await ensureServerImage({ src, name });
|
|
289
|
+
if (!uploaded?.src) return;
|
|
298
290
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
299
291
|
const s = world?.scale?.x || 1;
|
|
300
292
|
const worldX = (x - (world?.x || 0)) / s;
|
|
@@ -319,12 +311,10 @@ export function setupClipboardFlow(core) {
|
|
|
319
311
|
height: h,
|
|
320
312
|
...revitPayload.properties
|
|
321
313
|
};
|
|
322
|
-
const extraData = uploaded.imageId ? { imageId: uploaded.imageId } : {};
|
|
323
314
|
core.createObject(
|
|
324
315
|
revitPayload.type,
|
|
325
316
|
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
326
|
-
properties
|
|
327
|
-
extraData
|
|
317
|
+
properties
|
|
328
318
|
);
|
|
329
319
|
};
|
|
330
320
|
|
package/src/core/index.js
CHANGED
|
@@ -416,8 +416,15 @@ export class CoreMoodBoard {
|
|
|
416
416
|
transform: {
|
|
417
417
|
pivotCompensated: false // Новые объекты еще не скомпенсированы
|
|
418
418
|
},
|
|
419
|
-
...extraData
|
|
419
|
+
...extraData
|
|
420
420
|
};
|
|
421
|
+
if ((type === 'image' || type === 'revit-screenshot-img') && typeof properties?.src === 'string') {
|
|
422
|
+
objectData.src = properties.src;
|
|
423
|
+
if (objectData.properties && objectData.properties.src) {
|
|
424
|
+
objectData.properties = { ...objectData.properties };
|
|
425
|
+
delete objectData.properties.src;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
421
428
|
objectData.properties = normalizeMindmapPropertiesForCreate({
|
|
422
429
|
type,
|
|
423
430
|
objectId: objectData.id,
|
|
@@ -517,6 +524,21 @@ export class CoreMoodBoard {
|
|
|
517
524
|
properties: objectData.properties || {},
|
|
518
525
|
existingObjects: this.state?.state?.objects || [],
|
|
519
526
|
});
|
|
527
|
+
if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img') {
|
|
528
|
+
const topSrc = typeof objectData.src === 'string' ? objectData.src.trim() : '';
|
|
529
|
+
const propSrc = typeof objectData.properties?.src === 'string' ? objectData.properties.src.trim() : '';
|
|
530
|
+
const normalizedSrc = topSrc || propSrc;
|
|
531
|
+
if (normalizedSrc) {
|
|
532
|
+
objectData.src = normalizedSrc;
|
|
533
|
+
}
|
|
534
|
+
if (objectData.properties?.src) {
|
|
535
|
+
objectData.properties = { ...objectData.properties };
|
|
536
|
+
delete objectData.properties.src;
|
|
537
|
+
}
|
|
538
|
+
if ('imageId' in objectData) {
|
|
539
|
+
delete objectData.imageId;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
520
542
|
if (objectData.type === 'mindmap') {
|
|
521
543
|
logMindmapCompoundDebug('core:load-object', {
|
|
522
544
|
id: objectData.id,
|
|
@@ -628,45 +650,6 @@ export class CoreMoodBoard {
|
|
|
628
650
|
return this.state.getObjects().find(o => o.id === objectId);
|
|
629
651
|
}
|
|
630
652
|
|
|
631
|
-
/**
|
|
632
|
-
* Очищает неиспользуемые изображения с сервера
|
|
633
|
-
* @returns {Promise<{deletedCount: number, errors: Array}>}
|
|
634
|
-
*/
|
|
635
|
-
async cleanupUnusedImages() {
|
|
636
|
-
try {
|
|
637
|
-
if (!this.imageUploadService) {
|
|
638
|
-
console.warn('ImageUploadService недоступен для очистки изображений');
|
|
639
|
-
return { deletedCount: 0, errors: ['ImageUploadService недоступен'] };
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const result = await this.imageUploadService.cleanupUnusedImages();
|
|
643
|
-
|
|
644
|
-
// Проверяем результат на корректность
|
|
645
|
-
if (!result || typeof result !== 'object') {
|
|
646
|
-
console.warn('Некорректный ответ от ImageUploadService:', result);
|
|
647
|
-
return { deletedCount: 0, errors: ['Некорректный ответ сервера'] };
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const deletedCount = Number(result.deletedCount) || 0;
|
|
651
|
-
const errors = Array.isArray(result.errors) ? result.errors : [];
|
|
652
|
-
|
|
653
|
-
if (deletedCount > 0) {
|
|
654
|
-
console.log(`Очищено ${deletedCount} неиспользуемых изображений`);
|
|
655
|
-
}
|
|
656
|
-
if (errors.length > 0) {
|
|
657
|
-
console.warn('Ошибки при очистке изображений:', errors);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return { deletedCount, errors };
|
|
661
|
-
} catch (error) {
|
|
662
|
-
console.error('Ошибка при автоматической очистке изображений:', error);
|
|
663
|
-
return {
|
|
664
|
-
deletedCount: 0,
|
|
665
|
-
errors: [error?.message || 'Неизвестная ошибка']
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
653
|
destroy() {
|
|
671
654
|
// Предотвращаем повторное уничтожение
|
|
672
655
|
if (this.destroyed) {
|
|
@@ -12,8 +12,7 @@ export class KeyboardClipboardImagePaste {
|
|
|
12
12
|
const uploadResult = await this.core.imageUploadService.uploadFromDataUrl(dataUrl, fileName);
|
|
13
13
|
this.eventBus.emit(Events.UI.PasteImage, {
|
|
14
14
|
src: uploadResult.url,
|
|
15
|
-
name: uploadResult.name
|
|
16
|
-
imageId: uploadResult.imageId || uploadResult.id
|
|
15
|
+
name: uploadResult.name
|
|
17
16
|
});
|
|
18
17
|
} else {
|
|
19
18
|
alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
|
|
@@ -30,8 +29,7 @@ export class KeyboardClipboardImagePaste {
|
|
|
30
29
|
const uploadResult = await this.core.imageUploadService.uploadImage(file, fileName);
|
|
31
30
|
this.eventBus.emit(Events.UI.PasteImage, {
|
|
32
31
|
src: uploadResult.url,
|
|
33
|
-
name: uploadResult.name
|
|
34
|
-
imageId: uploadResult.imageId || uploadResult.id
|
|
32
|
+
name: uploadResult.name
|
|
35
33
|
});
|
|
36
34
|
} else {
|
|
37
35
|
alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
|
|
@@ -24,9 +24,8 @@ export class ActionHandler {
|
|
|
24
24
|
case 'comment':
|
|
25
25
|
case 'file':
|
|
26
26
|
case 'mindmap':
|
|
27
|
-
//
|
|
28
|
-
const extraData = action.
|
|
29
|
-
action.fileId ? { fileId: action.fileId } : {};
|
|
27
|
+
// Для изображений используем только src; для файлов сохраняем fileId.
|
|
28
|
+
const extraData = action.fileId ? { fileId: action.fileId } : {};
|
|
30
29
|
return this.handleCreateObject(action.type, action.position, action.properties || {}, extraData);
|
|
31
30
|
|
|
32
31
|
case 'delete-object':
|
|
@@ -86,6 +86,14 @@ export async function loadExistingBoard(board, version = null, options = {}) {
|
|
|
86
86
|
|
|
87
87
|
if (apiResponse?.success && payload) {
|
|
88
88
|
const normalizedData = normalizeLoadedPayload(payload, boardId);
|
|
89
|
+
const loadedObjects = Array.isArray(normalizedData.objects) ? normalizedData.objects : [];
|
|
90
|
+
const imageObjects = loadedObjects.filter((obj) => obj?.type === 'image');
|
|
91
|
+
const imageObjectsWithSrc = imageObjects.filter((obj) => typeof obj?.src === 'string' && obj.src.trim().length > 0);
|
|
92
|
+
console.log('MoodBoard load image src diagnostics:', {
|
|
93
|
+
totalObjects: loadedObjects.length,
|
|
94
|
+
imageObjects: imageObjects.length,
|
|
95
|
+
imageObjectsWithSrc: imageObjectsWithSrc.length
|
|
96
|
+
});
|
|
89
97
|
const loadedVersion = Number(normalizedData.version) || null;
|
|
90
98
|
board.currentLoadedVersion = loadedVersion;
|
|
91
99
|
board.historyCursorVersion = loadedVersion;
|
|
@@ -6,7 +6,7 @@ import * as PIXI from 'pixi.js';
|
|
|
6
6
|
export class ImageObject {
|
|
7
7
|
constructor(objectData = {}) {
|
|
8
8
|
this.objectData = objectData;
|
|
9
|
-
let src = objectData.
|
|
9
|
+
let src = objectData.src;
|
|
10
10
|
const isEmojiIcon = !!objectData.properties?.isEmojiIcon;
|
|
11
11
|
// Не используем устаревшие blob: URL — они недолговечны и приводят к ERR_FILE_NOT_FOUND
|
|
12
12
|
if (typeof src === 'string' && src.startsWith('blob:')) {
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
export function isV2ImageDownloadUrl(url) {
|
|
2
|
-
|
|
2
|
+
if (typeof url !== 'string') return false;
|
|
3
|
+
const raw = url.trim();
|
|
4
|
+
if (!raw) return false;
|
|
5
|
+
if (/^\/api\/v2\/images\/[^/]+\/download$/i.test(raw)) return true;
|
|
6
|
+
try {
|
|
7
|
+
const parsed = new URL(raw);
|
|
8
|
+
return /^\/api\/v2\/images\/[^/]+\/download$/i.test(parsed.pathname);
|
|
9
|
+
} catch (_) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
3
12
|
}
|
|
4
13
|
|
|
5
14
|
export function isV2FileDownloadUrl(url) {
|
|
6
|
-
|
|
15
|
+
if (typeof url !== 'string') return false;
|
|
16
|
+
const raw = url.trim();
|
|
17
|
+
if (!raw) return false;
|
|
18
|
+
if (/^\/api\/v2\/files\/[^/]+\/download$/i.test(raw)) return true;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(raw);
|
|
21
|
+
return /^\/api\/v2\/files\/[^/]+\/download$/i.test(parsed.pathname);
|
|
22
|
+
} catch (_) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
7
25
|
}
|
|
8
26
|
|
|
@@ -102,6 +102,9 @@ export class FileUploadService {
|
|
|
102
102
|
if (!isV2FileDownloadUrl(serverUrl)) {
|
|
103
103
|
throw new Error('Некорректный URL файла от сервера. Ожидается /api/v2/files/{fileId}/download');
|
|
104
104
|
}
|
|
105
|
+
if (!this._matchesFileIdInUrl(serverUrl, fileId)) {
|
|
106
|
+
throw new Error('fileId не совпадает с URL файла от сервера.');
|
|
107
|
+
}
|
|
105
108
|
|
|
106
109
|
return {
|
|
107
110
|
id: fileId, // Используем fileId как основное поле, id для обратной совместимости
|
|
@@ -118,6 +121,23 @@ export class FileUploadService {
|
|
|
118
121
|
}
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
_matchesFileIdInUrl(url, fileId) {
|
|
125
|
+
const id = typeof fileId === 'string' ? fileId.trim() : '';
|
|
126
|
+
if (!id || typeof url !== 'string') return false;
|
|
127
|
+
const raw = url.trim();
|
|
128
|
+
const relativeMatch = raw.match(/^\/api\/v2\/files\/([^/]+)\/download$/i);
|
|
129
|
+
if (relativeMatch) {
|
|
130
|
+
return decodeURIComponent(relativeMatch[1]) === id;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const parsed = new URL(raw);
|
|
134
|
+
const absoluteMatch = parsed.pathname.match(/^\/api\/v2\/files\/([^/]+)\/download$/i);
|
|
135
|
+
return !!absoluteMatch && decodeURIComponent(absoluteMatch[1]) === id;
|
|
136
|
+
} catch (_) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
/**
|
|
122
142
|
* Обновляет метаданные файла на сервере
|
|
123
143
|
* @param {string} fileId - ID файла
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { isV2ImageDownloadUrl } from './AssetUrlPolicy.js';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Сервис для загрузки и управления изображениями на сервере
|
|
5
3
|
*/
|
|
@@ -7,7 +5,6 @@ export class ImageUploadService {
|
|
|
7
5
|
constructor(apiClient, options = {}) {
|
|
8
6
|
this.apiClient = apiClient;
|
|
9
7
|
this.uploadEndpoint = '/api/v2/images/upload';
|
|
10
|
-
this.deleteEndpoint = '/api/v2/images';
|
|
11
8
|
this.options = {
|
|
12
9
|
csrfToken: null, // Можно передать токен напрямую
|
|
13
10
|
csrfTokenSelector: 'meta[name="csrf-token"]', // Селектор для поиска токена в DOM
|
|
@@ -51,7 +48,7 @@ export class ImageUploadService {
|
|
|
51
48
|
* Загружает изображение на сервер
|
|
52
49
|
* @param {File|Blob} file - файл изображения
|
|
53
50
|
* @param {string} name - имя файла
|
|
54
|
-
* @returns {Promise<{
|
|
51
|
+
* @returns {Promise<{url: string, width: number, height: number}>}
|
|
55
52
|
*/
|
|
56
53
|
async uploadImage(file, name = null) {
|
|
57
54
|
try {
|
|
@@ -99,18 +96,13 @@ export class ImageUploadService {
|
|
|
99
96
|
throw new Error(result.message || 'Ошибка загрузки изображения');
|
|
100
97
|
}
|
|
101
98
|
|
|
102
|
-
const imageId = result.data.imageId || result.data.id;
|
|
103
99
|
const serverUrl = typeof result.data.url === 'string' ? result.data.url.trim() : '';
|
|
104
|
-
if (!
|
|
105
|
-
throw new Error('
|
|
106
|
-
}
|
|
107
|
-
if (!isV2ImageDownloadUrl(serverUrl)) {
|
|
108
|
-
throw new Error('Некорректный URL изображения от сервера. Ожидается /api/v2/images/{imageId}/download');
|
|
100
|
+
if (!this._isPersistedImageSrc(serverUrl)) {
|
|
101
|
+
throw new Error('Некорректный URL изображения от сервера. Ожидается непустой src без data:/blob:.');
|
|
109
102
|
}
|
|
103
|
+
console.log('Image upload response data.url:', serverUrl);
|
|
110
104
|
|
|
111
105
|
return {
|
|
112
|
-
id: imageId, // Используем imageId как основное поле, id для обратной совместимости
|
|
113
|
-
imageId, // Добавляем imageId для явного доступа
|
|
114
106
|
url: serverUrl,
|
|
115
107
|
width: result.data.width,
|
|
116
108
|
height: result.data.height,
|
|
@@ -124,131 +116,26 @@ export class ImageUploadService {
|
|
|
124
116
|
}
|
|
125
117
|
}
|
|
126
118
|
|
|
119
|
+
_isPersistedImageSrc(url) {
|
|
120
|
+
if (typeof url !== 'string') return false;
|
|
121
|
+
const raw = url.trim();
|
|
122
|
+
if (!raw) return false;
|
|
123
|
+
if (/^data:/i.test(raw) || /^blob:/i.test(raw)) return false;
|
|
124
|
+
if (/^\/api\/images\//i.test(raw)) return false;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
127
128
|
/**
|
|
128
129
|
* Загружает изображение из base64 DataURL
|
|
129
130
|
* @param {string} dataUrl - base64 DataURL
|
|
130
131
|
* @param {string} name - имя файла
|
|
131
|
-
* @returns {Promise<{
|
|
132
|
+
* @returns {Promise<{url: string, width: number, height: number}>}
|
|
132
133
|
*/
|
|
133
134
|
async uploadFromDataUrl(dataUrl, name = 'clipboard-image.png') {
|
|
134
135
|
const blob = await this._dataUrlToBlob(dataUrl);
|
|
135
136
|
return this.uploadImage(blob, name);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
/**
|
|
139
|
-
* Удаляет изображение с сервера
|
|
140
|
-
* @param {string} imageId - ID изображения
|
|
141
|
-
*/
|
|
142
|
-
async deleteImage(imageId) {
|
|
143
|
-
try {
|
|
144
|
-
const csrfToken = this._getCsrfToken();
|
|
145
|
-
|
|
146
|
-
const headers = {
|
|
147
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
148
|
-
'Accept': 'application/json'
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// Добавляем CSRF токен только если он есть
|
|
152
|
-
if (csrfToken) {
|
|
153
|
-
headers['X-CSRF-TOKEN'] = csrfToken;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const response = await fetch(`${this.deleteEndpoint}/${imageId}`, {
|
|
157
|
-
method: 'DELETE',
|
|
158
|
-
headers,
|
|
159
|
-
credentials: 'same-origin'
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
if (!response.ok) {
|
|
163
|
-
const errorData = await response.json().catch(() => null);
|
|
164
|
-
throw new Error(errorData?.message || `HTTP ${response.status}: ${response.statusText}`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const result = await response.json();
|
|
168
|
-
return result.success;
|
|
169
|
-
|
|
170
|
-
} catch (error) {
|
|
171
|
-
console.error('Ошибка удаления изображения:', error);
|
|
172
|
-
throw error;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Очищает неиспользуемые изображения с сервера
|
|
178
|
-
* @returns {Promise<{deletedCount: number, errors: Array}>}
|
|
179
|
-
*/
|
|
180
|
-
async cleanupUnusedImages() {
|
|
181
|
-
try {
|
|
182
|
-
const csrfToken = this._getCsrfToken();
|
|
183
|
-
|
|
184
|
-
const headers = {
|
|
185
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
186
|
-
'Accept': 'application/json'
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
// Добавляем CSRF токен только если он есть
|
|
190
|
-
if (csrfToken) {
|
|
191
|
-
headers['X-CSRF-TOKEN'] = csrfToken;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const response = await fetch(`${this.deleteEndpoint}/cleanup`, {
|
|
195
|
-
method: 'POST',
|
|
196
|
-
headers,
|
|
197
|
-
credentials: 'same-origin'
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
if (!response.ok) {
|
|
201
|
-
const errorData = await response.json().catch(() => null);
|
|
202
|
-
throw new Error(errorData?.message || `HTTP ${response.status}: ${response.statusText}`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const result = await response.json();
|
|
206
|
-
|
|
207
|
-
if (result.success) {
|
|
208
|
-
// Защитная проверка на существование result.data
|
|
209
|
-
const data = result.data || {};
|
|
210
|
-
return {
|
|
211
|
-
deletedCount: data.deleted_count || 0,
|
|
212
|
-
errors: data.errors || []
|
|
213
|
-
};
|
|
214
|
-
} else {
|
|
215
|
-
throw new Error(result.message || 'Ошибка очистки изображений');
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
} catch (error) {
|
|
219
|
-
console.error('Ошибка очистки неиспользуемых изображений:', error);
|
|
220
|
-
throw error;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Получает информацию об изображении
|
|
226
|
-
* @param {string} imageId - ID изображения
|
|
227
|
-
*/
|
|
228
|
-
async getImageInfo(imageId) {
|
|
229
|
-
try {
|
|
230
|
-
const response = await fetch(`${this.deleteEndpoint}/${imageId}`, {
|
|
231
|
-
method: 'GET',
|
|
232
|
-
headers: {
|
|
233
|
-
'Accept': 'application/json',
|
|
234
|
-
'X-Requested-With': 'XMLHttpRequest'
|
|
235
|
-
},
|
|
236
|
-
credentials: 'same-origin'
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
if (!response.ok) {
|
|
240
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const result = await response.json();
|
|
244
|
-
return result.data;
|
|
245
|
-
|
|
246
|
-
} catch (error) {
|
|
247
|
-
console.error('Ошибка получения информации об изображении:', error);
|
|
248
|
-
throw error;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
139
|
/**
|
|
253
140
|
* Получает размеры изображения из файла или blob
|
|
254
141
|
* @private
|
|
@@ -273,7 +273,7 @@ export class ToolEventRouter {
|
|
|
273
273
|
return { dx: direction.x * step * ring, dy: direction.y * step * ring };
|
|
274
274
|
};
|
|
275
275
|
|
|
276
|
-
const emitAt = (src, name,
|
|
276
|
+
const emitAt = (src, name, offsetIndex = 0) => {
|
|
277
277
|
if (!isCurrentDrop()) {
|
|
278
278
|
logDropDebug(diagnostics, 'emit_skipped_stale_drop', { route: 'image', offsetIndex });
|
|
279
279
|
return;
|
|
@@ -283,8 +283,7 @@ export class ToolEventRouter {
|
|
|
283
283
|
x: x + offset,
|
|
284
284
|
y: y + offset,
|
|
285
285
|
src,
|
|
286
|
-
name
|
|
287
|
-
imageId
|
|
286
|
+
name
|
|
288
287
|
});
|
|
289
288
|
};
|
|
290
289
|
|
|
@@ -329,13 +328,11 @@ export class ToolEventRouter {
|
|
|
329
328
|
return null;
|
|
330
329
|
}
|
|
331
330
|
logDropDebug(diagnostics, 'image_upload_success', {
|
|
332
|
-
fileName: uploadResult?.name || file.name || 'image'
|
|
333
|
-
imageId: uploadResult?.imageId || uploadResult?.id || null
|
|
331
|
+
fileName: uploadResult?.name || file.name || 'image'
|
|
334
332
|
});
|
|
335
333
|
return {
|
|
336
334
|
src: uploadResult.url,
|
|
337
335
|
name: uploadResult.name,
|
|
338
|
-
imageId: uploadResult.imageId || uploadResult.id || null,
|
|
339
336
|
index
|
|
340
337
|
};
|
|
341
338
|
} else {
|
|
@@ -367,7 +364,7 @@ export class ToolEventRouter {
|
|
|
367
364
|
});
|
|
368
365
|
for (const placement of imagePlacements) {
|
|
369
366
|
if (!placement) continue;
|
|
370
|
-
emitAt(placement.src, placement.name, placement.
|
|
367
|
+
emitAt(placement.src, placement.name, placement.index);
|
|
371
368
|
}
|
|
372
369
|
logDropDebug(diagnostics, 'drop_done', { route: 'image_files', itemsProcessed: imageFiles.length });
|
|
373
370
|
return;
|