@sequent-org/moodboard 1.4.4 → 1.4.5
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 +27 -83
- package/src/core/HistoryManager.js +0 -102
- package/src/core/SaveManager.js +27 -0
- package/src/core/events/Events.js +0 -2
- package/src/core/flows/ClipboardFlow.js +50 -14
- package/src/core/flows/SaveFlow.js +5 -0
- package/src/core/index.js +32 -0
- package/src/core/keyboard/KeyboardClipboardImagePaste.js +6 -30
- package/src/tools/manager/ToolEventRouter.js +34 -74
- package/src/tools/object-tools/PlacementTool.js +5 -29
- package/src/tools/object-tools/placement/PlacementInputRouter.js +1 -6
- package/src/tools/object-tools/placement/PlacementPayloadFactory.js +0 -29
package/package.json
CHANGED
package/src/core/ApiClient.js
CHANGED
|
@@ -144,90 +144,55 @@ export class ApiClient {
|
|
|
144
144
|
|
|
145
145
|
const cleanedObjects = boardData.objects.map(obj => {
|
|
146
146
|
if (obj.type === 'image') {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
147
|
+
const imageId = typeof obj.imageId === 'string' ? obj.imageId.trim() : '';
|
|
148
|
+
const topSrc = typeof obj.src === 'string' ? obj.src : '';
|
|
149
|
+
const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
|
|
150
|
+
const hasForbiddenInlineSrc = /^data:/i.test(topSrc)
|
|
151
|
+
|| /^blob:/i.test(topSrc)
|
|
152
|
+
|| /^data:/i.test(propSrc)
|
|
153
|
+
|| /^blob:/i.test(propSrc);
|
|
154
|
+
|
|
155
|
+
// Жесткий контракт v2: сохраняем image только через server imageId.
|
|
156
|
+
if (!imageId) {
|
|
157
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" has no imageId. Save is blocked.`);
|
|
158
|
+
}
|
|
159
|
+
if (hasForbiddenInlineSrc) {
|
|
160
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
156
163
|
const cleanedObj = { ...obj };
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// Убираем src с верхнего уровня
|
|
163
|
-
if (cleanedObj.src) {
|
|
164
|
-
delete cleanedObj.src;
|
|
165
|
-
console.log('🧹 DEBUG: удален src с верхнего уровня');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Убираем src из properties
|
|
169
|
-
if (cleanedObj.properties?.src) {
|
|
170
|
-
cleanedObj.properties = { ...cleanedObj.properties };
|
|
171
|
-
delete cleanedObj.properties.src;
|
|
172
|
-
console.log('🧹 DEBUG: удален src из properties');
|
|
173
|
-
}
|
|
164
|
+
|
|
165
|
+
// imageId валиден — src можно безопасно убрать из history payload.
|
|
166
|
+
if (cleanedObj.src) {
|
|
167
|
+
delete cleanedObj.src;
|
|
174
168
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (cleanedObj.properties?.src && cleanedObj.properties.src.startsWith('data:')) {
|
|
179
|
-
console.warn('❌ Изображение сохраняется с base64 в properties, так как нет imageId:', cleanedObj.id);
|
|
180
|
-
}
|
|
181
|
-
if (cleanedObj.src && cleanedObj.src.startsWith('data:')) {
|
|
182
|
-
console.warn('❌ Изображение сохраняется с base64 в src, так как нет imageId:', cleanedObj.id);
|
|
183
|
-
}
|
|
184
|
-
if (!obj.imageId) {
|
|
185
|
-
console.warn('❌ У изображения отсутствует imageId:', cleanedObj.id);
|
|
186
|
-
}
|
|
169
|
+
if (cleanedObj.properties?.src) {
|
|
170
|
+
cleanedObj.properties = { ...cleanedObj.properties };
|
|
171
|
+
delete cleanedObj.properties.src;
|
|
187
172
|
}
|
|
188
|
-
|
|
173
|
+
|
|
189
174
|
return cleanedObj;
|
|
190
175
|
}
|
|
191
176
|
|
|
192
177
|
if (obj.type === 'file') {
|
|
193
|
-
console.log('🧹 DEBUG _cleanObjectData: обрабатываем файл:', {
|
|
194
|
-
id: obj.id,
|
|
195
|
-
fileId: obj.fileId,
|
|
196
|
-
hasContent: !!obj.content,
|
|
197
|
-
hasPropertiesContent: !!obj.properties?.content
|
|
198
|
-
});
|
|
199
|
-
|
|
200
178
|
const cleanedObj = { ...obj };
|
|
201
179
|
|
|
202
180
|
// Если есть fileId, убираем content для экономии места
|
|
203
181
|
if (obj.fileId && typeof obj.fileId === 'string' && obj.fileId.trim().length > 0) {
|
|
204
|
-
console.log('🧹 DEBUG _cleanObjectData: у файла есть fileId, убираем content');
|
|
205
|
-
|
|
206
182
|
// Убираем content с верхнего уровня
|
|
207
183
|
if (cleanedObj.content) {
|
|
208
184
|
delete cleanedObj.content;
|
|
209
|
-
console.log('🧹 DEBUG: удален content с верхнего уровня');
|
|
210
185
|
}
|
|
211
186
|
|
|
212
187
|
// Убираем content из properties
|
|
213
188
|
if (cleanedObj.properties?.content) {
|
|
214
189
|
cleanedObj.properties = { ...cleanedObj.properties };
|
|
215
190
|
delete cleanedObj.properties.content;
|
|
216
|
-
console.log('🧹 DEBUG: удален content из properties');
|
|
217
191
|
}
|
|
218
192
|
}
|
|
219
193
|
// Если нет fileId, предупреждаем о наличии content
|
|
220
194
|
else {
|
|
221
|
-
|
|
222
|
-
if (cleanedObj.properties?.content) {
|
|
223
|
-
console.warn('❌ Файл сохраняется с content в properties, так как нет fileId:', cleanedObj.id);
|
|
224
|
-
}
|
|
225
|
-
if (cleanedObj.content) {
|
|
226
|
-
console.warn('❌ Файл сохраняется с content, так как нет fileId:', cleanedObj.id);
|
|
227
|
-
}
|
|
228
|
-
if (!obj.fileId) {
|
|
229
|
-
console.warn('❌ У файла отсутствует fileId:', cleanedObj.id);
|
|
230
|
-
}
|
|
195
|
+
// Для файлов сейчас сохраняем поведение: без fileId не модифицируем объект.
|
|
231
196
|
}
|
|
232
197
|
|
|
233
198
|
return cleanedObj;
|
|
@@ -253,15 +218,7 @@ export class ApiClient {
|
|
|
253
218
|
const restoredObjects = await Promise.all(
|
|
254
219
|
boardData.objects.map(async (obj) => {
|
|
255
220
|
if (obj.type === 'image') {
|
|
256
|
-
console.log('🔗 DEBUG restoreImageUrls: обрабатываем изображение:', {
|
|
257
|
-
id: obj.id,
|
|
258
|
-
imageId: obj.imageId,
|
|
259
|
-
hasSrc: !!obj.src,
|
|
260
|
-
hasPropertiesSrc: !!obj.properties?.src
|
|
261
|
-
});
|
|
262
|
-
|
|
263
221
|
if (obj.imageId && (!obj.src && !obj.properties?.src)) {
|
|
264
|
-
console.log('🔗 DEBUG: восстанавливаем URL для изображения');
|
|
265
222
|
try {
|
|
266
223
|
// Формируем URL изображения
|
|
267
224
|
const imageUrl = `/api/images/${obj.imageId}/file`;
|
|
@@ -278,22 +235,12 @@ export class ApiClient {
|
|
|
278
235
|
console.warn(`Не удалось восстановить URL для изображения ${obj.imageId}:`, error);
|
|
279
236
|
return obj;
|
|
280
237
|
}
|
|
281
|
-
} else {
|
|
282
|
-
console.log('🔗 DEBUG: изображение уже имеет URL или нет imageId, оставляем как есть');
|
|
283
|
-
return obj;
|
|
284
238
|
}
|
|
239
|
+
return obj;
|
|
285
240
|
}
|
|
286
241
|
|
|
287
242
|
if (obj.type === 'file') {
|
|
288
|
-
console.log('🔗 DEBUG restoreObjectUrls: обрабатываем файл:', {
|
|
289
|
-
id: obj.id,
|
|
290
|
-
fileId: obj.fileId,
|
|
291
|
-
hasUrl: !!obj.url,
|
|
292
|
-
hasPropertiesUrl: !!obj.properties?.url
|
|
293
|
-
});
|
|
294
|
-
|
|
295
243
|
if (obj.fileId) {
|
|
296
|
-
console.log('🔗 DEBUG: восстанавливаем данные для файла');
|
|
297
244
|
try {
|
|
298
245
|
// Формируем URL файла для скачивания
|
|
299
246
|
const fileUrl = `/api/files/${obj.fileId}/download`;
|
|
@@ -323,7 +270,6 @@ export class ApiClient {
|
|
|
323
270
|
if (response.ok) {
|
|
324
271
|
const result = await response.json();
|
|
325
272
|
if (result.success && result.data) {
|
|
326
|
-
console.log('🔄 Обновляем метаданные файла с сервера:', result.data);
|
|
327
273
|
// Эмитим событие для обновления метаданных файла в состоянии
|
|
328
274
|
// (это будет обработано в core, если EventBus доступен)
|
|
329
275
|
if (typeof window !== 'undefined' && window.moodboardEventBus) {
|
|
@@ -345,10 +291,8 @@ export class ApiClient {
|
|
|
345
291
|
console.warn(`Не удалось восстановить данные для файла ${obj.fileId}:`, error);
|
|
346
292
|
return obj;
|
|
347
293
|
}
|
|
348
|
-
} else {
|
|
349
|
-
console.log('🔗 DEBUG: файл не имеет fileId, оставляем как есть');
|
|
350
|
-
return obj;
|
|
351
294
|
}
|
|
295
|
+
return obj;
|
|
352
296
|
}
|
|
353
297
|
|
|
354
298
|
return obj;
|
|
@@ -15,8 +15,6 @@ export class HistoryManager {
|
|
|
15
15
|
this.history = [];
|
|
16
16
|
// Текущая позиция в истории
|
|
17
17
|
this.currentIndex = -1;
|
|
18
|
-
// Флаг для предотвращения зацикливания при undo/redo
|
|
19
|
-
this.isExecutingCommand = false;
|
|
20
18
|
this._listenersAttached = false;
|
|
21
19
|
this._onDebug = () => this.debugHistory();
|
|
22
20
|
|
|
@@ -34,14 +32,6 @@ export class HistoryManager {
|
|
|
34
32
|
* Выполнить команду и добавить в историю
|
|
35
33
|
*/
|
|
36
34
|
executeCommand(command) {
|
|
37
|
-
if (this.isExecutingCommand) {
|
|
38
|
-
// Если мы в процессе undo/redo, не добавляем в историю
|
|
39
|
-
this._executeCommandSafely(command);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
35
|
// Проверяем, можно ли объединить с последней командой
|
|
46
36
|
const lastCommand = this.getLastCommand();
|
|
47
37
|
if (lastCommand &&
|
|
@@ -51,8 +41,6 @@ export class HistoryManager {
|
|
|
51
41
|
lastCommand.mergeWith(command);
|
|
52
42
|
this._executeCommandSafely(lastCommand);
|
|
53
43
|
this.eventBus.emit('history:changed', {
|
|
54
|
-
canUndo: this.canUndo(),
|
|
55
|
-
canRedo: this.canRedo(),
|
|
56
44
|
historySize: this.history.length
|
|
57
45
|
});
|
|
58
46
|
return;
|
|
@@ -78,8 +66,6 @@ export class HistoryManager {
|
|
|
78
66
|
|
|
79
67
|
// Уведомляем об изменении истории
|
|
80
68
|
this.eventBus.emit(Events.History.Changed, {
|
|
81
|
-
canUndo: this.canUndo(),
|
|
82
|
-
canRedo: this.canRedo(),
|
|
83
69
|
historySize: this.history.length,
|
|
84
70
|
currentCommand: command.toString()
|
|
85
71
|
});
|
|
@@ -105,89 +91,6 @@ export class HistoryManager {
|
|
|
105
91
|
}
|
|
106
92
|
}
|
|
107
93
|
|
|
108
|
-
/**
|
|
109
|
-
* Отменить последнюю команду
|
|
110
|
-
*/
|
|
111
|
-
undo() {
|
|
112
|
-
if (!this.canUndo()) {
|
|
113
|
-
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const command = this.history[this.currentIndex];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.isExecutingCommand = true;
|
|
121
|
-
try {
|
|
122
|
-
command.undo();
|
|
123
|
-
this.currentIndex--;
|
|
124
|
-
|
|
125
|
-
this.eventBus.emit(Events.History.Changed, {
|
|
126
|
-
canUndo: this.canUndo(),
|
|
127
|
-
canRedo: this.canRedo(),
|
|
128
|
-
historySize: this.history.length,
|
|
129
|
-
lastUndone: command.toString()
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return true;
|
|
134
|
-
} catch (error) {
|
|
135
|
-
console.error('❌ Ошибка при отмене команды:', error);
|
|
136
|
-
return false;
|
|
137
|
-
} finally {
|
|
138
|
-
this.isExecutingCommand = false;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Повторить отмененную команду
|
|
144
|
-
*/
|
|
145
|
-
redo() {
|
|
146
|
-
if (!this.canRedo()) {
|
|
147
|
-
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
this.currentIndex++;
|
|
152
|
-
const command = this.history[this.currentIndex];
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.isExecutingCommand = true;
|
|
156
|
-
try {
|
|
157
|
-
this._executeCommandSafely(command);
|
|
158
|
-
|
|
159
|
-
this.eventBus.emit(Events.History.Changed, {
|
|
160
|
-
canUndo: this.canUndo(),
|
|
161
|
-
canRedo: this.canRedo(),
|
|
162
|
-
historySize: this.history.length,
|
|
163
|
-
lastRedone: command.toString()
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return true;
|
|
168
|
-
} catch (error) {
|
|
169
|
-
console.error('❌ Ошибка при повторе команды:', error);
|
|
170
|
-
this.currentIndex--; // Откатываем индекс при ошибке
|
|
171
|
-
return false;
|
|
172
|
-
} finally {
|
|
173
|
-
this.isExecutingCommand = false;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Можно ли отменить команду
|
|
179
|
-
*/
|
|
180
|
-
canUndo() {
|
|
181
|
-
return this.currentIndex >= 0;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Можно ли повторить команду
|
|
186
|
-
*/
|
|
187
|
-
canRedo() {
|
|
188
|
-
return this.currentIndex < this.history.length - 1;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
94
|
/**
|
|
192
95
|
* Получить последнюю команду
|
|
193
96
|
*/
|
|
@@ -204,8 +107,6 @@ export class HistoryManager {
|
|
|
204
107
|
this.currentIndex = -1;
|
|
205
108
|
|
|
206
109
|
this.eventBus.emit(Events.History.Changed, {
|
|
207
|
-
canUndo: false,
|
|
208
|
-
canRedo: false,
|
|
209
110
|
historySize: 0
|
|
210
111
|
});
|
|
211
112
|
|
|
@@ -219,8 +120,6 @@ export class HistoryManager {
|
|
|
219
120
|
return {
|
|
220
121
|
totalCommands: this.history.length,
|
|
221
122
|
currentIndex: this.currentIndex,
|
|
222
|
-
canUndo: this.canUndo(),
|
|
223
|
-
canRedo: this.canRedo(),
|
|
224
123
|
commands: this.history.map((cmd, index) => ({
|
|
225
124
|
index,
|
|
226
125
|
isCurrent: index === this.currentIndex,
|
|
@@ -238,7 +137,6 @@ export class HistoryManager {
|
|
|
238
137
|
console.group('📚 История команд');
|
|
239
138
|
console.table(info.commands);
|
|
240
139
|
console.log(`Позиция: ${this.currentIndex + 1}/${this.history.length}`);
|
|
241
|
-
console.log(`Undo: ${this.canUndo()}, Redo: ${this.canRedo()}`);
|
|
242
140
|
console.groupEnd();
|
|
243
141
|
}
|
|
244
142
|
|
package/src/core/SaveManager.js
CHANGED
|
@@ -103,6 +103,11 @@ export class SaveManager {
|
|
|
103
103
|
sample: mindmapNodes.slice(0, 5),
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
// Жесткий контракт для сохранения картинок:
|
|
108
|
+
// - каждый image обязан иметь imageId
|
|
109
|
+
// - data:/blob: URL в image запрещены
|
|
110
|
+
this._assertImageSaveContract(saveData);
|
|
106
111
|
|
|
107
112
|
// Проверяем, изменились ли данные с последнего сохранения
|
|
108
113
|
if (this.lastSavedData && JSON.stringify(saveData) === JSON.stringify(this.lastSavedData)) {
|
|
@@ -221,6 +226,28 @@ export class SaveManager {
|
|
|
221
226
|
return await response.json();
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
_assertImageSaveContract(saveData) {
|
|
230
|
+
const objects = Array.isArray(saveData?.boardData?.objects)
|
|
231
|
+
? saveData.boardData.objects
|
|
232
|
+
: Array.isArray(saveData?.objects)
|
|
233
|
+
? saveData.objects
|
|
234
|
+
: [];
|
|
235
|
+
|
|
236
|
+
for (const obj of objects) {
|
|
237
|
+
if (!obj || obj.type !== 'image') continue;
|
|
238
|
+
const imageId = typeof obj.imageId === 'string' ? obj.imageId.trim() : '';
|
|
239
|
+
const topSrc = typeof obj.src === 'string' ? obj.src : '';
|
|
240
|
+
const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
|
|
241
|
+
|
|
242
|
+
if (!imageId) {
|
|
243
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" has no imageId. Save is blocked.`);
|
|
244
|
+
}
|
|
245
|
+
if (/^data:/i.test(topSrc) || /^blob:/i.test(topSrc) || /^data:/i.test(propSrc) || /^blob:/i.test(propSrc)) {
|
|
246
|
+
throw new Error(`Image object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
224
251
|
_buildSavePayload(boardId, data, csrfToken = undefined) {
|
|
225
252
|
return {
|
|
226
253
|
moodboardId: boardId,
|
|
@@ -19,6 +19,38 @@ export function setupClipboardFlow(core) {
|
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
const ensureServerImage = async ({ src, name, imageId }) => {
|
|
23
|
+
if (imageId) {
|
|
24
|
+
return { src, name, imageId };
|
|
25
|
+
}
|
|
26
|
+
if (!core.imageUploadService) {
|
|
27
|
+
alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
let uploadResult = null;
|
|
32
|
+
if (typeof src === 'string' && /^data:image\//i.test(src)) {
|
|
33
|
+
uploadResult = await core.imageUploadService.uploadFromDataUrl(src, name || 'clipboard-image.png');
|
|
34
|
+
} else {
|
|
35
|
+
const response = await fetch(src);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
38
|
+
}
|
|
39
|
+
const blob = await response.blob();
|
|
40
|
+
uploadResult = await core.imageUploadService.uploadImage(blob, name || 'clipboard-image');
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
src: uploadResult.url,
|
|
44
|
+
name: uploadResult.name || name,
|
|
45
|
+
imageId: uploadResult.imageId || uploadResult.id
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('Ошибка загрузки вставленного изображения на сервер:', error);
|
|
49
|
+
alert('Ошибка загрузки изображения на сервер. Изображение не добавлено.');
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
22
54
|
core.eventBus.on(Events.UI.CopyObject, ({ objectId }) => {
|
|
23
55
|
if (!objectId) return;
|
|
24
56
|
core.copyObject(objectId);
|
|
@@ -186,8 +218,10 @@ export function setupClipboardFlow(core) {
|
|
|
186
218
|
core._cursor.y = y;
|
|
187
219
|
});
|
|
188
220
|
|
|
189
|
-
core.eventBus.on(Events.UI.PasteImage, ({ src, name, imageId }) => {
|
|
221
|
+
core.eventBus.on(Events.UI.PasteImage, async ({ src, name, imageId }) => {
|
|
190
222
|
if (!src) return;
|
|
223
|
+
const uploaded = await ensureServerImage({ src, name, imageId });
|
|
224
|
+
if (!uploaded?.imageId) return;
|
|
191
225
|
const view = core.pixi.app.view;
|
|
192
226
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
193
227
|
const s = world?.scale?.x || 1;
|
|
@@ -214,18 +248,18 @@ export function setupClipboardFlow(core) {
|
|
|
214
248
|
w = 300;
|
|
215
249
|
h = Math.max(1, Math.round(w / ar));
|
|
216
250
|
}
|
|
217
|
-
const revitPayload = await resolveRevitImagePayload(src, {
|
|
251
|
+
const revitPayload = await resolveRevitImagePayload(uploaded.src, {
|
|
218
252
|
source: 'clipboard:paste-image',
|
|
219
|
-
name
|
|
253
|
+
name: uploaded.name
|
|
220
254
|
});
|
|
221
255
|
const properties = {
|
|
222
|
-
src,
|
|
223
|
-
name,
|
|
256
|
+
src: uploaded.src,
|
|
257
|
+
name: uploaded.name,
|
|
224
258
|
width: w,
|
|
225
259
|
height: h,
|
|
226
260
|
...revitPayload.properties
|
|
227
261
|
};
|
|
228
|
-
const extraData = imageId ? { imageId } : {};
|
|
262
|
+
const extraData = uploaded.imageId ? { imageId: uploaded.imageId } : {};
|
|
229
263
|
core.createObject(
|
|
230
264
|
revitPayload.type,
|
|
231
265
|
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
@@ -239,14 +273,16 @@ export function setupClipboardFlow(core) {
|
|
|
239
273
|
img.decoding = 'async';
|
|
240
274
|
img.onload = () => { void placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0); };
|
|
241
275
|
img.onerror = () => { void placeWithAspect(0, 0); };
|
|
242
|
-
img.src = src;
|
|
276
|
+
img.src = uploaded.src;
|
|
243
277
|
} catch (_) {
|
|
244
278
|
void placeWithAspect(0, 0);
|
|
245
279
|
}
|
|
246
280
|
});
|
|
247
281
|
|
|
248
|
-
core.eventBus.on(Events.UI.PasteImageAt, ({ x, y, src, name, imageId }) => {
|
|
282
|
+
core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, imageId }) => {
|
|
249
283
|
if (!src) return;
|
|
284
|
+
const uploaded = await ensureServerImage({ src, name, imageId });
|
|
285
|
+
if (!uploaded?.imageId) return;
|
|
250
286
|
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
251
287
|
const s = world?.scale?.x || 1;
|
|
252
288
|
const worldX = (x - (world?.x || 0)) / s;
|
|
@@ -260,18 +296,18 @@ export function setupClipboardFlow(core) {
|
|
|
260
296
|
w = 300;
|
|
261
297
|
h = Math.max(1, Math.round(w / ar));
|
|
262
298
|
}
|
|
263
|
-
const revitPayload = await resolveRevitImagePayload(src, {
|
|
299
|
+
const revitPayload = await resolveRevitImagePayload(uploaded.src, {
|
|
264
300
|
source: 'clipboard:paste-image-at',
|
|
265
|
-
name
|
|
301
|
+
name: uploaded.name
|
|
266
302
|
});
|
|
267
303
|
const properties = {
|
|
268
|
-
src,
|
|
269
|
-
name,
|
|
304
|
+
src: uploaded.src,
|
|
305
|
+
name: uploaded.name,
|
|
270
306
|
width: w,
|
|
271
307
|
height: h,
|
|
272
308
|
...revitPayload.properties
|
|
273
309
|
};
|
|
274
|
-
const extraData = imageId ? { imageId } : {};
|
|
310
|
+
const extraData = uploaded.imageId ? { imageId: uploaded.imageId } : {};
|
|
275
311
|
core.createObject(
|
|
276
312
|
revitPayload.type,
|
|
277
313
|
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
@@ -285,7 +321,7 @@ export function setupClipboardFlow(core) {
|
|
|
285
321
|
img.decoding = 'async';
|
|
286
322
|
img.onload = () => { void placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0); };
|
|
287
323
|
img.onerror = () => { void placeWithAspect(0, 0); };
|
|
288
|
-
img.src = src;
|
|
324
|
+
img.src = uploaded.src;
|
|
289
325
|
} catch (_) {
|
|
290
326
|
void placeWithAspect(0, 0);
|
|
291
327
|
}
|
|
@@ -23,6 +23,11 @@ export function setupSaveFlow(core) {
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
core.eventBus.on(Events.Save.Success, async () => {
|
|
26
|
+
if (typeof core.revealPendingObjectsAfterSave === 'function') {
|
|
27
|
+
core.revealPendingObjectsAfterSave();
|
|
28
|
+
} else if (typeof core.revealPendingImageObjectsAfterSave === 'function') {
|
|
29
|
+
core.revealPendingImageObjectsAfterSave();
|
|
30
|
+
}
|
|
26
31
|
// ВРЕМЕННО ОТКЛЮЧЕНО:
|
|
27
32
|
// cleanup-фича требует доработки контракта и серверной поддержки.
|
|
28
33
|
// Автоматический вызов удален, чтобы не запускать cleanup после сохранения.
|
package/src/core/index.js
CHANGED
|
@@ -66,6 +66,8 @@ export class CoreMoodBoard {
|
|
|
66
66
|
csrfToken: this.options.csrfToken
|
|
67
67
|
});
|
|
68
68
|
this.gridSnapResolver = new GridSnapResolver(this);
|
|
69
|
+
// Объекты, требующие подтверждения сохранения (image/file), показываем только после save:success.
|
|
70
|
+
this._pendingPersistAckVisibilityIds = new Set();
|
|
69
71
|
|
|
70
72
|
// Связываем SaveManager с ApiClient для правильной обработки изображений
|
|
71
73
|
this.saveManager.setApiClient(this.apiClient);
|
|
@@ -435,9 +437,39 @@ export class CoreMoodBoard {
|
|
|
435
437
|
const command = new CreateObjectCommand(this, objectData);
|
|
436
438
|
this.history.executeCommand(command);
|
|
437
439
|
|
|
440
|
+
// Строгий UX-контракт: image/file появляются только после успешного сохранения.
|
|
441
|
+
if (this._isPersistAckRequiredType(type)) {
|
|
442
|
+
this._pendingPersistAckVisibilityIds.add(objectData.id);
|
|
443
|
+
this._setObjectVisibility(objectData.id, false);
|
|
444
|
+
}
|
|
445
|
+
|
|
438
446
|
return objectData;
|
|
439
447
|
}
|
|
440
448
|
|
|
449
|
+
_isPersistAckRequiredType(type) {
|
|
450
|
+
return type === 'image' || type === 'revit-screenshot-img' || type === 'file';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_setObjectVisibility(objectId, visible) {
|
|
454
|
+
const pixiObject = this.pixi?.objects?.get?.(objectId);
|
|
455
|
+
if (pixiObject) {
|
|
456
|
+
pixiObject.visible = !!visible;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
revealPendingObjectsAfterSave() {
|
|
461
|
+
if (!this._pendingPersistAckVisibilityIds || this._pendingPersistAckVisibilityIds.size === 0) return;
|
|
462
|
+
for (const objectId of this._pendingPersistAckVisibilityIds) {
|
|
463
|
+
this._setObjectVisibility(objectId, true);
|
|
464
|
+
}
|
|
465
|
+
this._pendingPersistAckVisibilityIds.clear();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Backward-compat alias for tests/integrations created in previous step.
|
|
469
|
+
revealPendingImageObjectsAfterSave() {
|
|
470
|
+
this.revealPendingObjectsAfterSave();
|
|
471
|
+
}
|
|
472
|
+
|
|
441
473
|
// === Прикрепления к фреймам ===
|
|
442
474
|
// Логика фреймов перенесена в FrameService
|
|
443
475
|
|
|
@@ -16,11 +16,11 @@ export class KeyboardClipboardImagePaste {
|
|
|
16
16
|
imageId: uploadResult.imageId || uploadResult.id
|
|
17
17
|
});
|
|
18
18
|
} else {
|
|
19
|
-
|
|
19
|
+
alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
|
|
20
20
|
}
|
|
21
21
|
} catch (error) {
|
|
22
22
|
console.error('Ошибка загрузки изображения:', error);
|
|
23
|
-
|
|
23
|
+
alert('Ошибка загрузки изображения на сервер. Изображение не добавлено.');
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -34,29 +34,11 @@ export class KeyboardClipboardImagePaste {
|
|
|
34
34
|
imageId: uploadResult.imageId || uploadResult.id
|
|
35
35
|
});
|
|
36
36
|
} else {
|
|
37
|
-
|
|
38
|
-
reader.onload = () => {
|
|
39
|
-
this.eventBus.emit(Events.UI.PasteImage, {
|
|
40
|
-
src: reader.result,
|
|
41
|
-
name: fileName
|
|
42
|
-
});
|
|
43
|
-
};
|
|
44
|
-
reader.readAsDataURL(file);
|
|
37
|
+
alert('Сервис загрузки изображений недоступен. Изображение не добавлено.');
|
|
45
38
|
}
|
|
46
39
|
} catch (error) {
|
|
47
40
|
console.error('Ошибка загрузки файла изображения:', error);
|
|
48
|
-
|
|
49
|
-
const reader = new FileReader();
|
|
50
|
-
reader.onload = () => {
|
|
51
|
-
this.eventBus.emit(Events.UI.PasteImage, {
|
|
52
|
-
src: reader.result,
|
|
53
|
-
name: fileName
|
|
54
|
-
});
|
|
55
|
-
};
|
|
56
|
-
reader.readAsDataURL(file);
|
|
57
|
-
} catch (fallbackError) {
|
|
58
|
-
console.error('Критическая ошибка при чтении файла:', fallbackError);
|
|
59
|
-
}
|
|
41
|
+
alert('Ошибка загрузки изображения на сервер. Изображение не добавлено.');
|
|
60
42
|
}
|
|
61
43
|
}
|
|
62
44
|
|
|
@@ -105,10 +87,7 @@ export class KeyboardClipboardImagePaste {
|
|
|
105
87
|
const dataUrl = await this._blobToDataUrl(blob);
|
|
106
88
|
this.handleImageUpload(dataUrl, srcInHtml.split('/').pop() || 'image');
|
|
107
89
|
} catch (_) {
|
|
108
|
-
|
|
109
|
-
src: srcInHtml,
|
|
110
|
-
name: srcInHtml.split('/').pop() || 'image'
|
|
111
|
-
});
|
|
90
|
+
alert('Не удалось загрузить изображение из URL. Изображение не добавлено.');
|
|
112
91
|
}
|
|
113
92
|
return;
|
|
114
93
|
}
|
|
@@ -151,10 +130,7 @@ export class KeyboardClipboardImagePaste {
|
|
|
151
130
|
this.handleImageUpload(dataUrl, trimmed.split('/').pop() || 'image');
|
|
152
131
|
return;
|
|
153
132
|
} catch (_) {
|
|
154
|
-
|
|
155
|
-
src: trimmed,
|
|
156
|
-
name: trimmed.split('/').pop() || 'image'
|
|
157
|
-
});
|
|
133
|
+
alert('Не удалось загрузить изображение из URL. Изображение не добавлено.');
|
|
158
134
|
return;
|
|
159
135
|
}
|
|
160
136
|
}
|
|
@@ -339,28 +339,13 @@ export class ToolEventRouter {
|
|
|
339
339
|
index
|
|
340
340
|
};
|
|
341
341
|
} else {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (!isCurrentDrop()) {
|
|
350
|
-
logDropDebug(diagnostics, 'image_local_fallback_stale_drop_ignored', {
|
|
351
|
-
fileName: file.name || 'image'
|
|
352
|
-
});
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
355
|
-
logDropDebug(diagnostics, 'image_local_fallback_success', {
|
|
356
|
-
fileName: file.name || 'image'
|
|
357
|
-
});
|
|
358
|
-
return {
|
|
359
|
-
src: localSrc,
|
|
360
|
-
name: file.name || 'image',
|
|
361
|
-
imageId: null,
|
|
362
|
-
index
|
|
363
|
-
};
|
|
342
|
+
showDropWarning(
|
|
343
|
+
manager,
|
|
344
|
+
`Не удалось добавить "${file.name || 'image'}": сервис загрузки изображений недоступен`,
|
|
345
|
+
diagnostics,
|
|
346
|
+
{ fileName: file.name || 'image' }
|
|
347
|
+
);
|
|
348
|
+
return null;
|
|
364
349
|
}
|
|
365
350
|
} catch (error) {
|
|
366
351
|
console.warn('Ошибка загрузки изображения через drag-and-drop:', error);
|
|
@@ -368,28 +353,16 @@ export class ToolEventRouter {
|
|
|
368
353
|
fileName: file.name || 'image',
|
|
369
354
|
message: error?.message || String(error)
|
|
370
355
|
});
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
});
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
logDropDebug(diagnostics, 'image_error_fallback_success', {
|
|
385
|
-
fileName: file.name || 'image'
|
|
386
|
-
});
|
|
387
|
-
return {
|
|
388
|
-
src: fallbackSrc,
|
|
389
|
-
name: file.name || 'image',
|
|
390
|
-
imageId: null,
|
|
391
|
-
index
|
|
392
|
-
};
|
|
356
|
+
showDropWarning(
|
|
357
|
+
manager,
|
|
358
|
+
`Не удалось загрузить "${file.name || 'image'}" на сервер. Изображение не добавлено.`,
|
|
359
|
+
diagnostics,
|
|
360
|
+
{
|
|
361
|
+
fileName: file.name || 'image',
|
|
362
|
+
message: error?.message || String(error)
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
return null;
|
|
393
366
|
}
|
|
394
367
|
});
|
|
395
368
|
for (const placement of imagePlacements) {
|
|
@@ -461,21 +434,13 @@ export class ToolEventRouter {
|
|
|
461
434
|
fileId: uploadResult.fileId || uploadResult.id || null
|
|
462
435
|
};
|
|
463
436
|
} else {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
fileName: fallbackProps.fileName
|
|
472
|
-
});
|
|
473
|
-
return {
|
|
474
|
-
type: 'file',
|
|
475
|
-
id: 'file',
|
|
476
|
-
position,
|
|
477
|
-
properties: fallbackProps
|
|
478
|
-
};
|
|
437
|
+
showDropWarning(
|
|
438
|
+
manager,
|
|
439
|
+
`Не удалось добавить "${fallbackProps.fileName}": сервис загрузки файлов недоступен`,
|
|
440
|
+
diagnostics,
|
|
441
|
+
{ fileName: fallbackProps.fileName }
|
|
442
|
+
);
|
|
443
|
+
return null;
|
|
479
444
|
}
|
|
480
445
|
} catch (error) {
|
|
481
446
|
console.warn('Ошибка загрузки файла через drag-and-drop:', error);
|
|
@@ -483,21 +448,16 @@ export class ToolEventRouter {
|
|
|
483
448
|
fileName: fallbackProps.fileName,
|
|
484
449
|
message: error?.message || String(error)
|
|
485
450
|
});
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return
|
|
496
|
-
type: 'file',
|
|
497
|
-
id: 'file',
|
|
498
|
-
position,
|
|
499
|
-
properties: fallbackProps
|
|
500
|
-
};
|
|
451
|
+
showDropWarning(
|
|
452
|
+
manager,
|
|
453
|
+
`Не удалось загрузить "${fallbackProps.fileName}" на сервер. Файл не добавлен.`,
|
|
454
|
+
diagnostics,
|
|
455
|
+
{
|
|
456
|
+
fileName: fallbackProps.fileName,
|
|
457
|
+
message: error?.message || String(error)
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
return null;
|
|
501
461
|
}
|
|
502
462
|
});
|
|
503
463
|
for (const actionPayload of filePlacements) {
|
|
@@ -225,18 +225,8 @@ export class PlacementTool extends BaseTool {
|
|
|
225
225
|
|
|
226
226
|
} catch (uploadError) {
|
|
227
227
|
console.error('Ошибка загрузки файла на сервер:', uploadError);
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
position,
|
|
231
|
-
this.selectedFile.fileName,
|
|
232
|
-
this.selectedFile.fileSize,
|
|
233
|
-
this.selectedFile.mimeType,
|
|
234
|
-
props.width || 120,
|
|
235
|
-
props.height || 140
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
// Показываем предупреждение пользователю
|
|
239
|
-
alert('Ошибка загрузки файла на сервер. Файл добавлен локально.');
|
|
228
|
+
// Строгий режим: локальное сохранение файлов запрещено.
|
|
229
|
+
alert('Ошибка загрузки файла на сервер. Файл не добавлен.');
|
|
240
230
|
}
|
|
241
231
|
|
|
242
232
|
// Убираем призрак и возвращаемся к инструменту выделения
|
|
@@ -341,23 +331,9 @@ export class PlacementTool extends BaseTool {
|
|
|
341
331
|
|
|
342
332
|
} catch (uploadError) {
|
|
343
333
|
console.error('Ошибка загрузки изображения на сервер:', uploadError);
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
const targetW = this.selectedImage.properties.width || 300;
|
|
348
|
-
const targetH = this.selectedImage.properties.height || 200;
|
|
349
|
-
|
|
350
|
-
const halfW = targetW / 2;
|
|
351
|
-
const halfH = targetH / 2;
|
|
352
|
-
const position = {
|
|
353
|
-
x: Math.round(worldPoint.x - halfW),
|
|
354
|
-
y: Math.round(worldPoint.y - halfH)
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
this.payloadFactory.emitImageFallback(position, imageUrl, this.selectedImage.fileName, targetW, targetH, objectType, extraProperties);
|
|
358
|
-
|
|
359
|
-
// Показываем предупреждение пользователю
|
|
360
|
-
alert('Ошибка загрузки изображения на сервер. Изображение добавлено локально.');
|
|
334
|
+
|
|
335
|
+
// Строгий режим: локальное сохранение изображений запрещено.
|
|
336
|
+
alert('Ошибка загрузки изображения на сервер. Изображение не добавлено.');
|
|
361
337
|
}
|
|
362
338
|
|
|
363
339
|
// Убираем призрак и возвращаемся к инструменту выделения
|
|
@@ -227,13 +227,8 @@ export class PlacementInputRouter {
|
|
|
227
227
|
host.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
|
|
228
228
|
} catch (uploadError) {
|
|
229
229
|
console.error('Ошибка загрузки файла на сервер:', uploadError);
|
|
230
|
-
const fileName = file.name;
|
|
231
|
-
const fileSize = file.size;
|
|
232
|
-
const mimeType = file.type;
|
|
233
|
-
|
|
234
|
-
host.payloadFactory.emitFileFallback(position, fileName, fileSize, mimeType, props.width || 120, props.height || 140);
|
|
235
230
|
host.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
|
|
236
|
-
alert('Ошибка загрузки файла на сервер. Файл
|
|
231
|
+
alert('Ошибка загрузки файла на сервер. Файл не добавлен.');
|
|
237
232
|
}
|
|
238
233
|
} catch (error) {
|
|
239
234
|
console.error('Ошибка при выборе файла:', error);
|
|
@@ -63,21 +63,6 @@ export class PlacementPayloadFactory {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
emitImageFallback(position, imageUrl, fileName, width, height, objectType = 'image', extraProperties = {}) {
|
|
67
|
-
this.host.eventBus.emit(Events.UI.ToolbarAction, {
|
|
68
|
-
type: objectType,
|
|
69
|
-
id: objectType,
|
|
70
|
-
position,
|
|
71
|
-
properties: {
|
|
72
|
-
src: imageUrl,
|
|
73
|
-
name: fileName,
|
|
74
|
-
width,
|
|
75
|
-
height,
|
|
76
|
-
...extraProperties
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
66
|
emitFileUploaded(position, uploadResult, width, height) {
|
|
82
67
|
this.host.eventBus.emit(Events.UI.ToolbarAction, {
|
|
83
68
|
type: 'file',
|
|
@@ -96,18 +81,4 @@ export class PlacementPayloadFactory {
|
|
|
96
81
|
});
|
|
97
82
|
}
|
|
98
83
|
|
|
99
|
-
emitFileFallback(position, fileName, fileSize, mimeType, width, height) {
|
|
100
|
-
this.host.eventBus.emit(Events.UI.ToolbarAction, {
|
|
101
|
-
type: 'file',
|
|
102
|
-
id: 'file',
|
|
103
|
-
position,
|
|
104
|
-
properties: {
|
|
105
|
-
fileName,
|
|
106
|
-
fileSize,
|
|
107
|
-
mimeType,
|
|
108
|
-
width,
|
|
109
|
-
height
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
84
|
}
|