@sequent-org/moodboard 1.3.1 → 1.3.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.3.1",
3
+ "version": "1.3.4",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -65,6 +65,7 @@ export const Events = {
65
65
  ZoomFit: 'ui:zoom:fit',
66
66
  ZoomSelection: 'ui:zoom:selection',
67
67
  ZoomPercent: 'ui:zoom:percent',
68
+ RevitShowInModel: 'ui:revit:show-in-model',
68
69
  MinimapGetData: 'ui:minimap:get-data',
69
70
  MinimapCenterOn: 'ui:minimap:center-on',
70
71
  TextEditStart: 'ui:text:edit:start',
@@ -1,7 +1,24 @@
1
1
  import { Events } from '../events/Events.js';
2
2
  import { PasteObjectCommand } from '../commands/index.js';
3
+ import { RevitScreenshotMetadataService } from '../../services/RevitScreenshotMetadataService.js';
3
4
 
4
5
  export function setupClipboardFlow(core) {
6
+ const revitMetadataService = new RevitScreenshotMetadataService(console);
7
+
8
+ const resolveRevitImagePayload = async (src, context = {}) => {
9
+ const meta = await revitMetadataService.extractFromImageSource(src, context);
10
+ if (meta.hasMetadata && meta.payload) {
11
+ return {
12
+ type: 'revit-screenshot-img',
13
+ properties: { view: meta.payload }
14
+ };
15
+ }
16
+ return {
17
+ type: 'image',
18
+ properties: {}
19
+ };
20
+ };
21
+
5
22
  core.eventBus.on(Events.UI.CopyObject, ({ objectId }) => {
6
23
  if (!objectId) return;
7
24
  core.copyObject(objectId);
@@ -189,7 +206,7 @@ export function setupClipboardFlow(core) {
189
206
  const worldX = (screenX - (world?.x || 0)) / s;
190
207
  const worldY = (screenY - (world?.y || 0)) / s;
191
208
 
192
- const placeWithAspect = (natW, natH) => {
209
+ const placeWithAspect = async (natW, natH) => {
193
210
  let w = 300;
194
211
  let h = 200;
195
212
  if (natW > 0 && natH > 0) {
@@ -197,19 +214,34 @@ export function setupClipboardFlow(core) {
197
214
  w = 300;
198
215
  h = Math.max(1, Math.round(w / ar));
199
216
  }
200
- const properties = { src, name, width: w, height: h };
217
+ const revitPayload = await resolveRevitImagePayload(src, {
218
+ source: 'clipboard:paste-image',
219
+ name
220
+ });
221
+ const properties = {
222
+ src,
223
+ name,
224
+ width: w,
225
+ height: h,
226
+ ...revitPayload.properties
227
+ };
201
228
  const extraData = imageId ? { imageId } : {};
202
- core.createObject('image', { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) }, properties, extraData);
229
+ core.createObject(
230
+ revitPayload.type,
231
+ { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
232
+ properties,
233
+ extraData
234
+ );
203
235
  };
204
236
 
205
237
  try {
206
238
  const img = new Image();
207
239
  img.decoding = 'async';
208
- img.onload = () => placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0);
209
- img.onerror = () => placeWithAspect(0, 0);
240
+ img.onload = () => { void placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0); };
241
+ img.onerror = () => { void placeWithAspect(0, 0); };
210
242
  img.src = src;
211
243
  } catch (_) {
212
- placeWithAspect(0, 0);
244
+ void placeWithAspect(0, 0);
213
245
  }
214
246
  });
215
247
 
@@ -220,7 +252,7 @@ export function setupClipboardFlow(core) {
220
252
  const worldX = (x - (world?.x || 0)) / s;
221
253
  const worldY = (y - (world?.y || 0)) / s;
222
254
 
223
- const placeWithAspect = (natW, natH) => {
255
+ const placeWithAspect = async (natW, natH) => {
224
256
  let w = 300;
225
257
  let h = 200;
226
258
  if (natW > 0 && natH > 0) {
@@ -228,19 +260,34 @@ export function setupClipboardFlow(core) {
228
260
  w = 300;
229
261
  h = Math.max(1, Math.round(w / ar));
230
262
  }
231
- const properties = { src, name, width: w, height: h };
263
+ const revitPayload = await resolveRevitImagePayload(src, {
264
+ source: 'clipboard:paste-image-at',
265
+ name
266
+ });
267
+ const properties = {
268
+ src,
269
+ name,
270
+ width: w,
271
+ height: h,
272
+ ...revitPayload.properties
273
+ };
232
274
  const extraData = imageId ? { imageId } : {};
233
- core.createObject('image', { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) }, properties, extraData);
275
+ core.createObject(
276
+ revitPayload.type,
277
+ { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
278
+ properties,
279
+ extraData
280
+ );
234
281
  };
235
282
 
236
283
  try {
237
284
  const img = new Image();
238
285
  img.decoding = 'async';
239
- img.onload = () => placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0);
240
- img.onerror = () => placeWithAspect(0, 0);
286
+ img.onload = () => { void placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0); };
287
+ img.onerror = () => { void placeWithAspect(0, 0); };
241
288
  img.src = src;
242
289
  } catch (_) {
243
- placeWithAspect(0, 0);
290
+ void placeWithAspect(0, 0);
244
291
  }
245
292
  });
246
293
 
@@ -0,0 +1,15 @@
1
+ import { Events } from '../events/Events.js';
2
+ import { RevitNavigationService } from '../../services/RevitNavigationService.js';
3
+
4
+ export function setupRevitFlow(core) {
5
+ const navigationService = new RevitNavigationService();
6
+
7
+ core.eventBus.on(Events.UI.RevitShowInModel, async ({ objectId, view }) => {
8
+ if (!view || typeof view !== 'string') {
9
+ return;
10
+ }
11
+
12
+ await navigationService.showInModel(view, { objectId });
13
+ });
14
+ }
15
+
package/src/core/index.js CHANGED
@@ -15,6 +15,7 @@ import { setupTransformFlow } from './flows/TransformFlow.js';
15
15
  import { setupClipboardFlow, setupClipboardKeyboardFlow } from './flows/ClipboardFlow.js';
16
16
  import { setupObjectLifecycleFlow } from './flows/ObjectLifecycleFlow.js';
17
17
  import { setupLayerAndViewportFlow } from './flows/LayerAndViewportFlow.js';
18
+ import { setupRevitFlow } from './flows/RevitFlow.js';
18
19
  import { setupSaveFlow } from './flows/SaveFlow.js';
19
20
 
20
21
  export class CoreMoodBoard {
@@ -123,6 +124,7 @@ export class CoreMoodBoard {
123
124
  setupLayerAndViewportFlow(this);
124
125
  setupTransformFlow(this);
125
126
  setupObjectLifecycleFlow(this);
127
+ setupRevitFlow(this);
126
128
  }
127
129
 
128
130
  /**
@@ -20,6 +20,7 @@ export class ActionHandler {
20
20
  case 'drawing':
21
21
  case 'emoji':
22
22
  case 'image':
23
+ case 'revit-screenshot-img':
23
24
  case 'comment':
24
25
  case 'file':
25
26
  // Передаем imageId как extraData для изображений, fileId для файлов
@@ -31,10 +31,16 @@ export class ImageObject {
31
31
  const sy = this.height / texH;
32
32
  this.sprite.scale.set(sx, sy);
33
33
  // Обновим метаданные базовых размеров
34
+ const prevMb = this.sprite._mb || {};
34
35
  this.sprite._mb = {
35
- ...(this.sprite._mb || {}),
36
- type: 'image',
37
- properties: { src, baseW: texW, baseH: texH }
36
+ ...prevMb,
37
+ type: this.objectData?.type || prevMb.type || 'image',
38
+ properties: {
39
+ ...(prevMb.properties || {}),
40
+ src,
41
+ baseW: texW,
42
+ baseH: texH
43
+ }
38
44
  };
39
45
  };
40
46
 
@@ -4,6 +4,7 @@ import { DrawingObject } from './DrawingObject.js';
4
4
  import { TextObject } from './TextObject.js';
5
5
  import { EmojiObject } from './EmojiObject.js';
6
6
  import { ImageObject } from './ImageObject.js';
7
+ import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
7
8
  import { CommentObject } from './CommentObject.js';
8
9
  import { NoteObject } from './NoteObject.js';
9
10
  import { FileObject } from './FileObject.js';
@@ -21,6 +22,7 @@ export class ObjectFactory {
21
22
  ['simple-text', TextObject],
22
23
  ['emoji', EmojiObject],
23
24
  ['image', ImageObject],
25
+ ['revit-screenshot-img', RevitScreenshotImageObject],
24
26
  ['comment', CommentObject],
25
27
  ['note', NoteObject],
26
28
  ['file', FileObject]
@@ -0,0 +1,9 @@
1
+ import { ImageObject } from './ImageObject.js';
2
+
3
+ /**
4
+ * RevitScreenshotImageObject
5
+ * Специализированный тип для скриншотов Revit.
6
+ * Визуально и по трансформациям полностью повторяет ImageObject.
7
+ */
8
+ export class RevitScreenshotImageObject extends ImageObject {}
9
+
@@ -0,0 +1,59 @@
1
+ export class RevitNavigationService {
2
+ constructor(_logger = console, options = {}) {
3
+ this.host = options.host || '127.0.0.1';
4
+ this.portStart = Number.isFinite(options.portStart) ? options.portStart : 11210;
5
+ this.portEnd = Number.isFinite(options.portEnd) ? options.portEnd : 11220;
6
+ this.requestTimeoutMs = Number.isFinite(options.requestTimeoutMs) ? options.requestTimeoutMs : 1500;
7
+ }
8
+
9
+ async showInModel(payload, _context = {}) {
10
+ if (!payload || typeof payload !== 'string') {
11
+ return { ok: false, reason: 'invalid-payload', attempts: [] };
12
+ }
13
+
14
+ const attempts = [];
15
+ for (let port = this.portStart; port <= this.portEnd; port += 1) {
16
+ const url = `http://${this.host}:${port}`;
17
+ const result = await this._postWithTimeout(url, payload);
18
+ attempts.push({ port, ...result });
19
+ if (result.ok) {
20
+ return { ok: true, port, attempts };
21
+ }
22
+ }
23
+
24
+ return { ok: false, reason: 'no-port-accepted', attempts };
25
+ }
26
+
27
+ async _postWithTimeout(url, payload) {
28
+ const controller = new AbortController();
29
+ const timeoutId = setTimeout(() => {
30
+ try { controller.abort(); } catch (_) {}
31
+ }, this.requestTimeoutMs);
32
+
33
+ try {
34
+ const response = await fetch(url, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'text/plain;charset=utf-8'
38
+ },
39
+ body: payload,
40
+ signal: controller.signal
41
+ });
42
+ const responseText = await response.text().catch(() => '');
43
+ return {
44
+ ok: response.ok,
45
+ status: response.status,
46
+ responsePreview: responseText.slice(0, 120)
47
+ };
48
+ } catch (error) {
49
+ return {
50
+ ok: false,
51
+ status: null,
52
+ error: error?.message || String(error)
53
+ };
54
+ } finally {
55
+ clearTimeout(timeoutId);
56
+ }
57
+ }
58
+ }
59
+
@@ -0,0 +1,112 @@
1
+ export class RevitScreenshotMetadataService {
2
+ constructor(_logger = console, _options = {}) {
3
+ this.startToken = 'START_TEXT_';
4
+ this.endToken = '_END_TEXT';
5
+ }
6
+
7
+ async extractFromFile(file, _context = {}) {
8
+ if (!file) return this._emptyResult('no-file');
9
+ return this.extractFromBlob(file);
10
+ }
11
+
12
+ async extractFromImageSource(src, _context = {}) {
13
+ if (!src || typeof src !== 'string') return this._emptyResult('no-src');
14
+ try {
15
+ const response = await fetch(src);
16
+ if (!response.ok) {
17
+ return this._emptyResult('source-fetch-failed');
18
+ }
19
+ const blob = await response.blob();
20
+ return this.extractFromBlob(blob);
21
+ } catch (_) {
22
+ return this._emptyResult('source-fetch-error');
23
+ }
24
+ }
25
+
26
+ async extractFromBlob(blob) {
27
+ if (!blob) return this._emptyResult('no-blob');
28
+
29
+ let bitmap = null;
30
+ let canvas = null;
31
+ try {
32
+ bitmap = await createImageBitmap(blob);
33
+ canvas = document.createElement('canvas');
34
+ canvas.width = bitmap.width;
35
+ canvas.height = bitmap.height;
36
+
37
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
38
+ if (!ctx) return this._emptyResult('no-2d-context');
39
+ ctx.drawImage(bitmap, 0, 0);
40
+
41
+ const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
42
+ return this._extractPayloadFromRgba(data, canvas.width, canvas.height);
43
+ } catch (_) {
44
+ return this._emptyResult('decode-error');
45
+ } finally {
46
+ if (bitmap && typeof bitmap.close === 'function') {
47
+ try { bitmap.close(); } catch (_) {}
48
+ }
49
+ if (canvas) {
50
+ canvas.width = 0;
51
+ canvas.height = 0;
52
+ }
53
+ }
54
+ }
55
+
56
+ _extractPayloadFromRgba(rgbaData, width, height) {
57
+ let stream = '';
58
+ for (let i = 0; i < rgbaData.length; i += 4) {
59
+ const r = rgbaData[i];
60
+ const g = rgbaData[i + 1];
61
+ const b = rgbaData[i + 2];
62
+ const byte = (r & 0x07) | ((g & 0x07) << 3) | ((b & 0x03) << 6);
63
+ if (byte === 0) continue;
64
+ stream += String.fromCharCode(byte);
65
+ }
66
+
67
+ const start = stream.indexOf(this.startToken);
68
+ const end = stream.indexOf(this.endToken, start + this.startToken.length);
69
+
70
+ if (start === -1 || end === -1 || end <= start) {
71
+ return this._emptyResult('tokens-not-found', { start, end, width, height });
72
+ }
73
+
74
+ const rawPayload = stream.slice(start + this.startToken.length, end);
75
+ if (!rawPayload || rawPayload.length === 0) {
76
+ return this._emptyResult('empty-payload', { start, end, width, height });
77
+ }
78
+
79
+ const bytes = Uint8Array.from([...rawPayload].map((ch) => ch.charCodeAt(0)));
80
+ let payload = rawPayload;
81
+ try {
82
+ payload = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
83
+ } catch (_) {
84
+ payload = rawPayload;
85
+ }
86
+
87
+ return {
88
+ hasMetadata: true,
89
+ payload,
90
+ payloadLength: payload.length,
91
+ start,
92
+ end,
93
+ width,
94
+ height,
95
+ reason: null
96
+ };
97
+ }
98
+
99
+ _emptyResult(reason, extra = {}) {
100
+ return {
101
+ hasMetadata: false,
102
+ payload: null,
103
+ payloadLength: 0,
104
+ start: extra.start ?? -1,
105
+ end: extra.end ?? -1,
106
+ width: extra.width ?? null,
107
+ height: extra.height ?? null,
108
+ reason
109
+ };
110
+ }
111
+ }
112
+
@@ -17,6 +17,85 @@ function rememberCursorPosition(manager, event) {
17
17
  manager.eventBus.emit(Events.UI.CursorMove, { x: event.x, y: event.y });
18
18
  }
19
19
 
20
+ function nowMs() {
21
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
22
+ return performance.now();
23
+ }
24
+ return Date.now();
25
+ }
26
+
27
+ function isDropDebugEnabled() {
28
+ try {
29
+ if (typeof window === 'undefined') return false;
30
+ if (window.__MB_DND_DEBUG__ === true) return true;
31
+ if (window.localStorage && typeof window.localStorage.getItem === 'function') {
32
+ return window.localStorage.getItem('mb:dnd:debug') === '1';
33
+ }
34
+ } catch (_) {}
35
+ return false;
36
+ }
37
+
38
+ function createDropDiagnostics() {
39
+ return {
40
+ enabled: isDropDebugEnabled(),
41
+ dropId: `drop-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
42
+ startedAt: nowMs()
43
+ };
44
+ }
45
+
46
+ function logDropDebug(diagnostics, stage, payload = {}) {
47
+ if (!diagnostics?.enabled) return;
48
+ try {
49
+ const elapsedMs = Math.round(nowMs() - diagnostics.startedAt);
50
+ console.debug('[moodboard:dnd]', {
51
+ dropId: diagnostics.dropId,
52
+ stage,
53
+ elapsedMs,
54
+ ...payload
55
+ });
56
+ } catch (_) {}
57
+ }
58
+
59
+ const DROP_LIMITS = {
60
+ maxFilesPerDrop: 50,
61
+ maxFileSizeBytes: 50 * 1024 * 1024
62
+ };
63
+
64
+ function showDropWarning(manager, message, diagnostics, extra = {}) {
65
+ logDropDebug(diagnostics, 'drop_warning', { message, ...extra });
66
+ try {
67
+ const ws = (typeof window !== 'undefined' && window.moodboard && window.moodboard.workspaceManager)
68
+ ? window.moodboard.workspaceManager
69
+ : null;
70
+ if (ws && typeof ws.showNotification === 'function') {
71
+ ws.showNotification(message);
72
+ }
73
+ } catch (_) {}
74
+ }
75
+
76
+ async function mapWithConcurrency(items, limit, iterator) {
77
+ const safeLimit = Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 1);
78
+ const results = new Array(items.length);
79
+ let nextIndex = 0;
80
+
81
+ const worker = async () => {
82
+ while (true) {
83
+ const currentIndex = nextIndex;
84
+ nextIndex += 1;
85
+ if (currentIndex >= items.length) return;
86
+ results[currentIndex] = await iterator(items[currentIndex], currentIndex);
87
+ }
88
+ };
89
+
90
+ const workers = [];
91
+ const workersCount = Math.min(safeLimit, items.length);
92
+ for (let i = 0; i < workersCount; i += 1) {
93
+ workers.push(worker());
94
+ }
95
+ await Promise.all(workers);
96
+ return results;
97
+ }
98
+
20
99
  export class ToolEventRouter {
21
100
  static handleMouseDown(manager, event) {
22
101
  if (!manager.activeTool) return;
@@ -142,6 +221,10 @@ export class ToolEventRouter {
142
221
 
143
222
  static async handleDrop(manager, event) {
144
223
  event.preventDefault();
224
+ const diagnostics = createDropDiagnostics();
225
+ manager._dropSessionSeq = (manager._dropSessionSeq || 0) + 1;
226
+ const dropSession = manager._dropSessionSeq;
227
+ const isCurrentDrop = () => manager._dropSessionSeq === dropSession;
145
228
 
146
229
  const rect = manager.container.getBoundingClientRect();
147
230
  const x = event.clientX - rect.left;
@@ -150,9 +233,51 @@ export class ToolEventRouter {
150
233
  manager.eventBus.emit(Events.UI.CursorMove, { x, y });
151
234
 
152
235
  const dt = event.dataTransfer;
153
- if (!dt) return;
236
+ if (!dt) {
237
+ logDropDebug(diagnostics, 'drop_no_data_transfer');
238
+ return;
239
+ }
240
+ logDropDebug(diagnostics, 'drop_received', {
241
+ localX: Math.round(x),
242
+ localY: Math.round(y),
243
+ filesCount: dt.files ? dt.files.length : 0
244
+ });
245
+
246
+ const toWorldPosition = (screenX, screenY) => {
247
+ const world = manager.core?.pixi?.worldLayer || manager.core?.pixi?.app?.stage;
248
+ const scale = world?.scale?.x || 1;
249
+ if (!world) {
250
+ return { x: Math.round(screenX), y: Math.round(screenY) };
251
+ }
252
+ return {
253
+ x: Math.round((screenX - (world.x || 0)) / scale),
254
+ y: Math.round((screenY - (world.y || 0)) / scale)
255
+ };
256
+ };
257
+ const getFanOffset = (offsetIndex) => {
258
+ if (offsetIndex <= 0) return { dx: 0, dy: 0 };
259
+ const step = 25;
260
+ const directions = [
261
+ { x: 1, y: 0 },
262
+ { x: 1, y: 1 },
263
+ { x: 0, y: 1 },
264
+ { x: -1, y: 1 },
265
+ { x: -1, y: 0 },
266
+ { x: -1, y: -1 },
267
+ { x: 0, y: -1 },
268
+ { x: 1, y: -1 }
269
+ ];
270
+ const index = offsetIndex - 1;
271
+ const direction = directions[index % directions.length];
272
+ const ring = Math.floor(index / directions.length) + 1;
273
+ return { dx: direction.x * step * ring, dy: direction.y * step * ring };
274
+ };
154
275
 
155
276
  const emitAt = (src, name, imageId = null, offsetIndex = 0) => {
277
+ if (!isCurrentDrop()) {
278
+ logDropDebug(diagnostics, 'emit_skipped_stale_drop', { route: 'image', offsetIndex });
279
+ return;
280
+ }
156
281
  const offset = 25 * offsetIndex;
157
282
  manager.eventBus.emit(Events.UI.PasteImageAt, {
158
283
  x: x + offset,
@@ -164,45 +289,123 @@ export class ToolEventRouter {
164
289
  };
165
290
 
166
291
  const files = dt.files ? Array.from(dt.files) : [];
167
- const imageFiles = files.filter((file) => file.type && file.type.startsWith('image/'));
292
+ let limitedFiles = files;
293
+ if (limitedFiles.length > DROP_LIMITS.maxFilesPerDrop) {
294
+ showDropWarning(
295
+ manager,
296
+ `Обработаны первые ${DROP_LIMITS.maxFilesPerDrop} файлов из ${limitedFiles.length}`,
297
+ diagnostics,
298
+ { filesCount: limitedFiles.length, maxFilesPerDrop: DROP_LIMITS.maxFilesPerDrop }
299
+ );
300
+ limitedFiles = limitedFiles.slice(0, DROP_LIMITS.maxFilesPerDrop);
301
+ }
302
+ const oversized = limitedFiles.filter((file) => (file?.size || 0) > DROP_LIMITS.maxFileSizeBytes);
303
+ if (oversized.length > 0) {
304
+ showDropWarning(
305
+ manager,
306
+ `Пропущено ${oversized.length} файлов: размер каждого должен быть не более 50 МБ`,
307
+ diagnostics,
308
+ { oversizedCount: oversized.length, maxFileSizeBytes: DROP_LIMITS.maxFileSizeBytes }
309
+ );
310
+ limitedFiles = limitedFiles.filter((file) => (file?.size || 0) <= DROP_LIMITS.maxFileSizeBytes);
311
+ }
312
+
313
+ const imageFiles = limitedFiles.filter((file) => file.type && file.type.startsWith('image/'));
168
314
  if (imageFiles.length > 0) {
169
- let index = 0;
170
- for (const file of imageFiles) {
315
+ logDropDebug(diagnostics, 'route_image_files', { count: imageFiles.length });
316
+ const imagePlacements = await mapWithConcurrency(imageFiles, 2, async (file, index) => {
317
+ logDropDebug(diagnostics, 'image_upload_start', {
318
+ fileName: file.name || 'image',
319
+ fileSize: file.size || 0,
320
+ mimeType: file.type || null
321
+ });
171
322
  try {
172
323
  if (manager.core && manager.core.imageUploadService) {
173
324
  const uploadResult = await manager.core.imageUploadService.uploadImage(file, file.name || 'image');
174
- emitAt(uploadResult.url, uploadResult.name, uploadResult.imageId || uploadResult.id, index++);
325
+ if (!isCurrentDrop()) {
326
+ logDropDebug(diagnostics, 'image_upload_stale_drop_ignored', {
327
+ fileName: uploadResult?.name || file.name || 'image'
328
+ });
329
+ return null;
330
+ }
331
+ logDropDebug(diagnostics, 'image_upload_success', {
332
+ fileName: uploadResult?.name || file.name || 'image',
333
+ imageId: uploadResult?.imageId || uploadResult?.id || null
334
+ });
335
+ return {
336
+ src: uploadResult.url,
337
+ name: uploadResult.name,
338
+ imageId: uploadResult.imageId || uploadResult.id || null,
339
+ index
340
+ };
175
341
  } else {
176
- await new Promise((resolve) => {
342
+ const localSrc = await new Promise((resolve) => {
177
343
  const reader = new FileReader();
178
344
  reader.onload = () => {
179
- emitAt(reader.result, file.name || 'image', null, index++);
180
- resolve();
345
+ resolve(reader.result);
181
346
  };
182
347
  reader.readAsDataURL(file);
183
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
+ };
184
364
  }
185
365
  } catch (error) {
186
366
  console.warn('Ошибка загрузки изображения через drag-and-drop:', error);
187
- await new Promise((resolve) => {
367
+ logDropDebug(diagnostics, 'image_upload_error', {
368
+ fileName: file.name || 'image',
369
+ message: error?.message || String(error)
370
+ });
371
+ const fallbackSrc = await new Promise((resolve) => {
188
372
  const reader = new FileReader();
189
373
  reader.onload = () => {
190
- emitAt(reader.result, file.name || 'image', null, index++);
191
- resolve();
374
+ resolve(reader.result);
192
375
  };
193
376
  reader.readAsDataURL(file);
194
377
  });
378
+ if (!isCurrentDrop()) {
379
+ logDropDebug(diagnostics, 'image_error_fallback_stale_drop_ignored', {
380
+ fileName: file.name || 'image'
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
+ };
195
393
  }
394
+ });
395
+ for (const placement of imagePlacements) {
396
+ if (!placement) continue;
397
+ emitAt(placement.src, placement.name, placement.imageId, placement.index);
196
398
  }
399
+ logDropDebug(diagnostics, 'drop_done', { route: 'image_files', itemsProcessed: imageFiles.length });
197
400
  return;
198
401
  }
199
402
 
200
- const nonImageFiles = files.filter((file) => !file.type || !file.type.startsWith('image/'));
403
+ const nonImageFiles = limitedFiles.filter((file) => !file.type || !file.type.startsWith('image/'));
201
404
  if (nonImageFiles.length > 0) {
202
- let index = 0;
203
- for (const file of nonImageFiles) {
204
- const offset = 25 * index++;
205
- const position = { x: x + offset, y: y + offset };
405
+ logDropDebug(diagnostics, 'route_non_image_files', { count: nonImageFiles.length });
406
+ const filePlacements = await mapWithConcurrency(nonImageFiles, 2, async (file, index) => {
407
+ const fanOffset = getFanOffset(index);
408
+ const worldPoint = toWorldPosition(x + fanOffset.dx, y + fanOffset.dy);
206
409
  const fallbackProps = {
207
410
  fileName: file.name || 'file',
208
411
  fileSize: file.size || 0,
@@ -212,13 +415,40 @@ export class ToolEventRouter {
212
415
  width: 120,
213
416
  height: 140
214
417
  };
418
+ const position = {
419
+ x: Math.round(worldPoint.x - fallbackProps.width / 2),
420
+ y: Math.round(worldPoint.y - fallbackProps.height / 2)
421
+ };
422
+ logDropDebug(diagnostics, 'file_prepare', {
423
+ fileName: fallbackProps.fileName,
424
+ worldX: worldPoint.x,
425
+ worldY: worldPoint.y,
426
+ placeX: position.x,
427
+ placeY: position.y
428
+ });
215
429
  try {
216
430
  if (manager.core && manager.core.fileUploadService) {
217
431
  const uploadResult = await manager.core.fileUploadService.uploadFile(file, file.name || 'file');
218
- manager.eventBus.emit(Events.UI.ToolbarAction, {
432
+ if (!isCurrentDrop()) {
433
+ logDropDebug(diagnostics, 'file_upload_stale_drop_ignored', {
434
+ fileName: uploadResult?.name || fallbackProps.fileName
435
+ });
436
+ return null;
437
+ }
438
+ const objectWidth = fallbackProps.width;
439
+ const objectHeight = fallbackProps.height;
440
+ const centeredPosition = {
441
+ x: Math.round(worldPoint.x - objectWidth / 2),
442
+ y: Math.round(worldPoint.y - objectHeight / 2)
443
+ };
444
+ logDropDebug(diagnostics, 'file_upload_success', {
445
+ fileName: uploadResult?.name || fallbackProps.fileName,
446
+ fileId: uploadResult?.fileId || uploadResult?.id || null
447
+ });
448
+ return {
219
449
  type: 'file',
220
450
  id: 'file',
221
- position,
451
+ position: centeredPosition,
222
452
  properties: {
223
453
  fileName: uploadResult.name,
224
454
  fileSize: uploadResult.size,
@@ -229,35 +459,65 @@ export class ToolEventRouter {
229
459
  height: 140
230
460
  },
231
461
  fileId: uploadResult.fileId || uploadResult.id || null
232
- });
462
+ };
233
463
  } else {
234
- manager.eventBus.emit(Events.UI.ToolbarAction, {
464
+ if (!isCurrentDrop()) {
465
+ logDropDebug(diagnostics, 'file_local_fallback_stale_drop_ignored', {
466
+ fileName: fallbackProps.fileName
467
+ });
468
+ return null;
469
+ }
470
+ logDropDebug(diagnostics, 'file_local_fallback_success', {
471
+ fileName: fallbackProps.fileName
472
+ });
473
+ return {
235
474
  type: 'file',
236
475
  id: 'file',
237
476
  position,
238
477
  properties: fallbackProps
239
- });
478
+ };
240
479
  }
241
480
  } catch (error) {
242
481
  console.warn('Ошибка загрузки файла через drag-and-drop:', error);
243
- manager.eventBus.emit(Events.UI.ToolbarAction, {
482
+ logDropDebug(diagnostics, 'file_upload_error', {
483
+ fileName: fallbackProps.fileName,
484
+ message: error?.message || String(error)
485
+ });
486
+ if (!isCurrentDrop()) {
487
+ logDropDebug(diagnostics, 'file_error_fallback_stale_drop_ignored', {
488
+ fileName: fallbackProps.fileName
489
+ });
490
+ return null;
491
+ }
492
+ logDropDebug(diagnostics, 'file_error_fallback_success', {
493
+ fileName: fallbackProps.fileName
494
+ });
495
+ return {
244
496
  type: 'file',
245
497
  id: 'file',
246
498
  position,
247
499
  properties: fallbackProps
248
- });
500
+ };
249
501
  }
502
+ });
503
+ for (const actionPayload of filePlacements) {
504
+ if (!actionPayload) continue;
505
+ if (!isCurrentDrop()) break;
506
+ manager.eventBus.emit(Events.UI.ToolbarAction, actionPayload);
250
507
  }
508
+ logDropDebug(diagnostics, 'drop_done', { route: 'non_image_files', itemsProcessed: nonImageFiles.length });
251
509
  return;
252
510
  }
253
511
 
254
512
  const html = dt.getData('text/html');
255
513
  if (html && html.includes('<img')) {
514
+ logDropDebug(diagnostics, 'route_html_image');
256
515
  const match = html.match(/<img[^>]*src\s*=\s*"([^"]+)"/i);
257
516
  if (match && match[1]) {
258
517
  const url = match[1];
259
518
  if (/^data:image\//i.test(url)) {
260
519
  emitAt(url, 'clipboard-image.png');
520
+ logDropDebug(diagnostics, 'drop_done', { route: 'html_data_image' });
261
521
  return;
262
522
  }
263
523
  if (/^https?:\/\//i.test(url)) {
@@ -270,8 +530,10 @@ export class ToolEventRouter {
270
530
  reader.readAsDataURL(blob);
271
531
  });
272
532
  emitAt(dataUrl, url.split('/').pop() || 'image');
533
+ logDropDebug(diagnostics, 'drop_done', { route: 'html_http_image_fetched' });
273
534
  } catch (_) {
274
535
  emitAt(url, url.split('/').pop() || 'image');
536
+ logDropDebug(diagnostics, 'drop_done', { route: 'html_http_image_direct' });
275
537
  }
276
538
  return;
277
539
  }
@@ -280,6 +542,7 @@ export class ToolEventRouter {
280
542
 
281
543
  const uriList = dt.getData('text/uri-list') || '';
282
544
  if (uriList) {
545
+ logDropDebug(diagnostics, 'route_uri_list');
283
546
  const lines = uriList.split('\n').filter((line) => !!line && !line.startsWith('#'));
284
547
  const urls = lines.filter((line) => /^https?:\/\//i.test(line));
285
548
  let index = 0;
@@ -299,17 +562,22 @@ export class ToolEventRouter {
299
562
  emitAt(url, url.split('/').pop() || 'image', index++);
300
563
  }
301
564
  }
302
- if (index > 0) return;
565
+ if (index > 0) {
566
+ logDropDebug(diagnostics, 'drop_done', { route: 'uri_list_images', itemsProcessed: index });
567
+ return;
568
+ }
303
569
  }
304
570
 
305
571
  const text = dt.getData('text/plain') || '';
306
572
  if (text) {
573
+ logDropDebug(diagnostics, 'route_text_plain');
307
574
  const trimmed = text.trim();
308
575
  const isDataUrl = /^data:image\//i.test(trimmed);
309
576
  const isHttpUrl = /^https?:\/\//i.test(trimmed);
310
577
  const looksLikeImage = /(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(trimmed);
311
578
  if (isDataUrl) {
312
579
  emitAt(trimmed, 'clipboard-image.png');
580
+ logDropDebug(diagnostics, 'drop_done', { route: 'text_data_image' });
313
581
  return;
314
582
  }
315
583
  if (isHttpUrl && looksLikeImage) {
@@ -322,11 +590,14 @@ export class ToolEventRouter {
322
590
  reader.readAsDataURL(blob);
323
591
  });
324
592
  emitAt(dataUrl, trimmed.split('/').pop() || 'image');
593
+ logDropDebug(diagnostics, 'drop_done', { route: 'text_http_image_fetched' });
325
594
  } catch (_) {
326
595
  emitAt(trimmed, trimmed.split('/').pop() || 'image');
596
+ logDropDebug(diagnostics, 'drop_done', { route: 'text_http_image_direct' });
327
597
  }
328
598
  }
329
599
  }
600
+ logDropDebug(diagnostics, 'drop_done', { route: 'no_supported_payload' });
330
601
  }
331
602
 
332
603
  static handleKeyDown(manager, event) {
@@ -17,6 +17,7 @@ const _scaledICursorSvg = (() => {
17
17
 
18
18
  const TEXT_CURSOR = `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(_scaledICursorSvg)}") 0 0, text`;
19
19
  import { Events } from '../../core/events/Events.js';
20
+ import { RevitScreenshotMetadataService } from '../../services/RevitScreenshotMetadataService.js';
20
21
  import { GhostController } from './placement/GhostController.js';
21
22
  import { PlacementPayloadFactory } from './placement/PlacementPayloadFactory.js';
22
23
  import { PlacementInputRouter } from './placement/PlacementInputRouter.js';
@@ -42,6 +43,7 @@ export class PlacementTool extends BaseTool {
42
43
  this.eventsBridge = new PlacementEventsBridge(this);
43
44
  this.sessionStore = new PlacementSessionStore(this);
44
45
  this.coordinateResolver = new PlacementCoordinateResolver(this);
46
+ this.revitMetadataService = new RevitScreenshotMetadataService(console);
45
47
  this.sessionStore.initialize();
46
48
  // Оригинальные стили курсора PIXI, чтобы можно было временно переопределить pointer/default для текстового инструмента
47
49
  this._origCursorStyles = null;
@@ -297,6 +299,13 @@ export class PlacementTool extends BaseTool {
297
299
  if (!this.selectedImage) return;
298
300
 
299
301
  const worldPoint = this._toWorld(event.x, event.y);
302
+ const metadataResult = await this.revitMetadataService.extractFromFile(this.selectedImage.file, {
303
+ source: 'place:image-tool',
304
+ fileName: this.selectedImage.fileName
305
+ });
306
+ const isRevitScreenshot = metadataResult.hasMetadata && !!metadataResult.payload;
307
+ const objectType = isRevitScreenshot ? 'revit-screenshot-img' : 'image';
308
+ const extraProperties = isRevitScreenshot ? { view: metadataResult.payload } : {};
300
309
 
301
310
  try {
302
311
  // Загружаем изображение на сервер
@@ -319,7 +328,7 @@ export class PlacementTool extends BaseTool {
319
328
  };
320
329
 
321
330
  // Создаем объект изображения с данными с сервера
322
- this.payloadFactory.emitImageUploaded(position, uploadResult, targetW, targetH);
331
+ this.payloadFactory.emitImageUploaded(position, uploadResult, targetW, targetH, objectType, extraProperties);
323
332
 
324
333
  } catch (uploadError) {
325
334
  console.error('Ошибка загрузки изображения на сервер:', uploadError);
@@ -336,7 +345,7 @@ export class PlacementTool extends BaseTool {
336
345
  y: Math.round(worldPoint.y - halfH)
337
346
  };
338
347
 
339
- this.payloadFactory.emitImageFallback(position, imageUrl, this.selectedImage.fileName, targetW, targetH);
348
+ this.payloadFactory.emitImageFallback(position, imageUrl, this.selectedImage.fileName, targetW, targetH, objectType, extraProperties);
340
349
 
341
350
  // Показываем предупреждение пользователю
342
351
  alert('Ошибка загрузки изображения на сервер. Изображение добавлено локально.');
@@ -47,31 +47,33 @@ export class PlacementPayloadFactory {
47
47
  });
48
48
  }
49
49
 
50
- emitImageUploaded(position, uploadResult, width, height) {
50
+ emitImageUploaded(position, uploadResult, width, height, objectType = 'image', extraProperties = {}) {
51
51
  this.host.eventBus.emit(Events.UI.ToolbarAction, {
52
- type: 'image',
53
- id: 'image',
52
+ type: objectType,
53
+ id: objectType,
54
54
  position,
55
55
  properties: {
56
56
  src: uploadResult.url,
57
57
  name: uploadResult.name,
58
58
  width,
59
- height
59
+ height,
60
+ ...extraProperties
60
61
  },
61
62
  imageId: uploadResult.imageId || uploadResult.id
62
63
  });
63
64
  }
64
65
 
65
- emitImageFallback(position, imageUrl, fileName, width, height) {
66
+ emitImageFallback(position, imageUrl, fileName, width, height, objectType = 'image', extraProperties = {}) {
66
67
  this.host.eventBus.emit(Events.UI.ToolbarAction, {
67
- type: 'image',
68
- id: 'image',
68
+ type: objectType,
69
+ id: objectType,
69
70
  position,
70
71
  properties: {
71
72
  src: imageUrl,
72
73
  name: fileName,
73
74
  width,
74
- height
75
+ height,
76
+ ...extraProperties
75
77
  }
76
78
  });
77
79
  }
@@ -2,6 +2,7 @@ import { Events } from '../../core/events/Events.js';
2
2
  import { createRotatedResizeCursor } from '../../tools/object-tools/selection/CursorController.js';
3
3
 
4
4
  const HANDLES_ACCENT_COLOR = '#80D8FF';
5
+ const REVIT_SHOW_IN_MODEL_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true" focusable="false"><path d="M384 64C366.3 64 352 78.3 352 96C352 113.7 366.3 128 384 128L466.7 128L265.3 329.4C252.8 341.9 252.8 362.2 265.3 374.7C277.8 387.2 298.1 387.2 310.6 374.7L512 173.3L512 256C512 273.7 526.3 288 544 288C561.7 288 576 273.7 576 256L576 96C576 78.3 561.7 64 544 64L384 64zM144 160C99.8 160 64 195.8 64 240L64 496C64 540.2 99.8 576 144 576L400 576C444.2 576 480 540.2 480 496L480 416C480 398.3 465.7 384 448 384C430.3 384 416 398.3 416 416L416 496C416 504.8 408.8 512 400 512L144 512C135.2 512 128 504.8 128 496L128 240C128 231.2 135.2 224 144 224L224 224C241.7 224 256 209.7 256 192C256 174.3 241.7 160 224 160L144 160z"/></svg>';
5
6
 
6
7
  export class HandlesDomRenderer {
7
8
  constructor(host, rotateIconSvg) {
@@ -35,12 +36,16 @@ export class HandlesDomRenderer {
35
36
 
36
37
  let isFileTarget = false;
37
38
  let isFrameTarget = false;
39
+ let isRevitScreenshotTarget = false;
40
+ let revitViewPayload = null;
38
41
  if (id !== '__group__') {
39
42
  const req = { objectId: id, pixiObject: null };
40
43
  this.host.eventBus.emit(Events.Tool.GetObjectPixi, req);
41
44
  const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
42
45
  isFileTarget = mbType === 'file';
43
46
  isFrameTarget = mbType === 'frame';
47
+ isRevitScreenshotTarget = mbType === 'revit-screenshot-img';
48
+ revitViewPayload = req.pixiObject?._mb?.properties?.view || null;
44
49
  }
45
50
 
46
51
  const left = cssRect.left;
@@ -185,6 +190,28 @@ export class HandlesDomRenderer {
185
190
  }
186
191
  box.appendChild(rotateHandle);
187
192
 
193
+ if (isRevitScreenshotTarget && typeof revitViewPayload === 'string' && revitViewPayload.length > 0) {
194
+ const showInModelButton = document.createElement('button');
195
+ showInModelButton.type = 'button';
196
+ showInModelButton.className = 'mb-revit-show-in-model';
197
+ showInModelButton.innerHTML = `${REVIT_SHOW_IN_MODEL_ICON_SVG}<span>показать в моделе</span>`;
198
+ showInModelButton.style.left = `${Math.round(left + width / 2)}px`;
199
+ showInModelButton.style.top = `${Math.round(top - 34)}px`;
200
+ showInModelButton.addEventListener('mousedown', (evt) => {
201
+ evt.preventDefault();
202
+ evt.stopPropagation();
203
+ });
204
+ showInModelButton.addEventListener('click', (evt) => {
205
+ evt.preventDefault();
206
+ evt.stopPropagation();
207
+ this.host.eventBus.emit(Events.UI.RevitShowInModel, {
208
+ objectId: id,
209
+ view: revitViewPayload
210
+ });
211
+ });
212
+ this.host.layer.appendChild(showInModelButton);
213
+ }
214
+
188
215
  this.host.visible = true;
189
216
  this.host.target = { type: id === '__group__' ? 'group' : 'single', id, bounds: worldBounds };
190
217
  }
@@ -297,6 +297,39 @@
297
297
  justify-content: center;
298
298
  }
299
299
 
300
+ .mb-revit-show-in-model {
301
+ position: absolute;
302
+ transform: translate(-50%, -100%);
303
+ height: 28px;
304
+ padding: 0 10px;
305
+ display: inline-flex;
306
+ align-items: center;
307
+ gap: 6px;
308
+ border: 1px solid #bcd5e2;
309
+ border-radius: 8px;
310
+ background: #ffffff;
311
+ color: #0f3b52;
312
+ font-size: 12px;
313
+ font-weight: 500;
314
+ white-space: nowrap;
315
+ pointer-events: auto;
316
+ z-index: 18;
317
+ cursor: pointer;
318
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.14);
319
+ }
320
+
321
+ .mb-revit-show-in-model svg {
322
+ width: 12px;
323
+ height: 12px;
324
+ display: block;
325
+ fill: currentColor;
326
+ flex: 0 0 auto;
327
+ }
328
+
329
+ .mb-revit-show-in-model:hover {
330
+ background: #f1f8fc;
331
+ }
332
+
300
333
  /* Text editor overlay */
301
334
  .moodboard-text-editor {
302
335
  position: absolute;