@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 +1 -1
- package/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +59 -12
- package/src/core/flows/RevitFlow.js +15 -0
- package/src/core/index.js +2 -0
- package/src/moodboard/ActionHandler.js +1 -0
- package/src/objects/ImageObject.js +9 -3
- package/src/objects/ObjectFactory.js +2 -0
- package/src/objects/RevitScreenshotImageObject.js +9 -0
- package/src/services/RevitNavigationService.js +59 -0
- package/src/services/RevitScreenshotMetadataService.js +112 -0
- package/src/tools/manager/ToolEventRouter.js +295 -24
- package/src/tools/object-tools/PlacementTool.js +11 -2
- package/src/tools/object-tools/placement/PlacementPayloadFactory.js +10 -8
- package/src/ui/handles/HandlesDomRenderer.js +27 -0
- package/src/ui/styles/workspace.css +33 -0
package/package.json
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
/**
|
|
@@ -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
|
-
...
|
|
36
|
-
type: 'image',
|
|
37
|
-
properties: {
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
403
|
+
const nonImageFiles = limitedFiles.filter((file) => !file.type || !file.type.startsWith('image/'));
|
|
201
404
|
if (nonImageFiles.length > 0) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
53
|
-
id:
|
|
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:
|
|
68
|
-
id:
|
|
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;
|