@sequent-org/moodboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +44 -0
- package/src/assets/icons/README.md +105 -0
- package/src/assets/icons/attachments.svg +3 -0
- package/src/assets/icons/clear.svg +5 -0
- package/src/assets/icons/comments.svg +3 -0
- package/src/assets/icons/emoji.svg +6 -0
- package/src/assets/icons/frame.svg +3 -0
- package/src/assets/icons/image.svg +3 -0
- package/src/assets/icons/note.svg +3 -0
- package/src/assets/icons/pan.svg +3 -0
- package/src/assets/icons/pencil.svg +3 -0
- package/src/assets/icons/redo.svg +3 -0
- package/src/assets/icons/select.svg +9 -0
- package/src/assets/icons/shapes.svg +3 -0
- package/src/assets/icons/text-add.svg +3 -0
- package/src/assets/icons/topbar/README.md +39 -0
- package/src/assets/icons/topbar/grid-cross.svg +6 -0
- package/src/assets/icons/topbar/grid-dot.svg +3 -0
- package/src/assets/icons/topbar/grid-line.svg +3 -0
- package/src/assets/icons/topbar/grid-off.svg +3 -0
- package/src/assets/icons/topbar/paint.svg +3 -0
- package/src/assets/icons/undo.svg +3 -0
- package/src/core/ApiClient.js +309 -0
- package/src/core/EventBus.js +42 -0
- package/src/core/HistoryManager.js +261 -0
- package/src/core/KeyboardManager.js +710 -0
- package/src/core/PixiEngine.js +439 -0
- package/src/core/SaveManager.js +381 -0
- package/src/core/StateManager.js +64 -0
- package/src/core/commands/BaseCommand.js +68 -0
- package/src/core/commands/CopyObjectCommand.js +44 -0
- package/src/core/commands/CreateObjectCommand.js +46 -0
- package/src/core/commands/DeleteObjectCommand.js +146 -0
- package/src/core/commands/EditFileNameCommand.js +107 -0
- package/src/core/commands/GroupMoveCommand.js +47 -0
- package/src/core/commands/GroupReorderZCommand.js +74 -0
- package/src/core/commands/GroupResizeCommand.js +37 -0
- package/src/core/commands/GroupRotateCommand.js +41 -0
- package/src/core/commands/MoveObjectCommand.js +89 -0
- package/src/core/commands/PasteObjectCommand.js +103 -0
- package/src/core/commands/ReorderZCommand.js +45 -0
- package/src/core/commands/ResizeObjectCommand.js +135 -0
- package/src/core/commands/RotateObjectCommand.js +70 -0
- package/src/core/commands/index.js +14 -0
- package/src/core/events/Events.js +147 -0
- package/src/core/index.js +1632 -0
- package/src/core/rendering/GeometryUtils.js +89 -0
- package/src/core/rendering/HitTestManager.js +186 -0
- package/src/core/rendering/LayerManager.js +137 -0
- package/src/core/rendering/ObjectRenderer.js +363 -0
- package/src/core/rendering/PixiRenderer.js +140 -0
- package/src/core/rendering/index.js +9 -0
- package/src/grid/BaseGrid.js +164 -0
- package/src/grid/CrossGrid.js +75 -0
- package/src/grid/DotGrid.js +148 -0
- package/src/grid/GridFactory.js +173 -0
- package/src/grid/LineGrid.js +115 -0
- package/src/index.js +2 -0
- package/src/moodboard/ActionHandler.js +114 -0
- package/src/moodboard/DataManager.js +114 -0
- package/src/moodboard/MoodBoard.js +359 -0
- package/src/moodboard/WorkspaceManager.js +103 -0
- package/src/objects/BaseObject.js +1 -0
- package/src/objects/CommentObject.js +115 -0
- package/src/objects/DrawingObject.js +114 -0
- package/src/objects/EmojiObject.js +98 -0
- package/src/objects/FileObject.js +318 -0
- package/src/objects/FrameObject.js +127 -0
- package/src/objects/ImageObject.js +72 -0
- package/src/objects/NoteObject.js +227 -0
- package/src/objects/ObjectFactory.js +61 -0
- package/src/objects/ShapeObject.js +134 -0
- package/src/objects/StampObject.js +0 -0
- package/src/objects/StickerObject.js +0 -0
- package/src/objects/TextObject.js +123 -0
- package/src/services/BoardService.js +85 -0
- package/src/services/FileUploadService.js +398 -0
- package/src/services/FrameService.js +138 -0
- package/src/services/ImageUploadService.js +246 -0
- package/src/services/ZOrderManager.js +50 -0
- package/src/services/ZoomPanController.js +78 -0
- package/src/src.7z +0 -0
- package/src/src.zip +0 -0
- package/src/src2.zip +0 -0
- package/src/tools/AlignmentGuides.js +326 -0
- package/src/tools/BaseTool.js +257 -0
- package/src/tools/ResizeHandles.js +381 -0
- package/src/tools/ToolManager.js +580 -0
- package/src/tools/board-tools/PanTool.js +43 -0
- package/src/tools/board-tools/ZoomTool.js +393 -0
- package/src/tools/object-tools/DrawingTool.js +404 -0
- package/src/tools/object-tools/PlacementTool.js +1005 -0
- package/src/tools/object-tools/SelectTool.js +2183 -0
- package/src/tools/object-tools/TextTool.js +416 -0
- package/src/tools/object-tools/selection/BoxSelectController.js +105 -0
- package/src/tools/object-tools/selection/GeometryUtils.js +101 -0
- package/src/tools/object-tools/selection/GroupDragController.js +61 -0
- package/src/tools/object-tools/selection/GroupResizeController.js +90 -0
- package/src/tools/object-tools/selection/GroupRotateController.js +61 -0
- package/src/tools/object-tools/selection/HandlesSync.js +96 -0
- package/src/tools/object-tools/selection/ResizeController.js +68 -0
- package/src/tools/object-tools/selection/RotateController.js +58 -0
- package/src/tools/object-tools/selection/SelectionModel.js +42 -0
- package/src/tools/object-tools/selection/SimpleDragController.js +45 -0
- package/src/ui/CommentPopover.js +187 -0
- package/src/ui/ContextMenu.js +340 -0
- package/src/ui/FilePropertiesPanel.js +298 -0
- package/src/ui/FramePropertiesPanel.js +462 -0
- package/src/ui/HtmlHandlesLayer.js +778 -0
- package/src/ui/HtmlTextLayer.js +279 -0
- package/src/ui/MapPanel.js +290 -0
- package/src/ui/NotePropertiesPanel.js +502 -0
- package/src/ui/SaveStatus.js +250 -0
- package/src/ui/TextPropertiesPanel.js +911 -0
- package/src/ui/Toolbar.js +1118 -0
- package/src/ui/Topbar.js +220 -0
- package/src/ui/ZoomPanel.js +116 -0
- package/src/ui/styles/workspace.css +854 -0
- package/src/utils/colors.js +0 -0
- package/src/utils/geometry.js +0 -0
- package/src/utils/iconLoader.js +270 -0
- package/src/utils/objectIdGenerator.js +17 -0
- package/src/utils/topbarIconLoader.js +114 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Events } from '../core/events/Events.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HtmlTextLayer — рисует текст как HTML-элементы поверх PIXI для максимальной чёткости
|
|
5
|
+
* Синхронизирует позицию/размер/масштаб с миром (worldLayer) и состоянием объектов
|
|
6
|
+
*/
|
|
7
|
+
export class HtmlTextLayer {
|
|
8
|
+
constructor(container, eventBus, core) {
|
|
9
|
+
this.container = container; // DOM-элемент, где находится canvas
|
|
10
|
+
this.eventBus = eventBus;
|
|
11
|
+
this.core = core; // CoreMoodBoard, нужен доступ к pixi/state
|
|
12
|
+
this.layer = null;
|
|
13
|
+
this.idToEl = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
attach() {
|
|
17
|
+
// Создаем слой поверх канвы
|
|
18
|
+
this.layer = document.createElement('div');
|
|
19
|
+
this.layer.className = 'moodboard-html-layer';
|
|
20
|
+
Object.assign(this.layer.style, {
|
|
21
|
+
position: 'absolute',
|
|
22
|
+
inset: '0',
|
|
23
|
+
overflow: 'hidden',
|
|
24
|
+
pointerEvents: 'none',
|
|
25
|
+
zIndex: 10, // выше canvas, ниже тулбаров
|
|
26
|
+
});
|
|
27
|
+
// Вставляем рядом с canvas (в том же контейнере)
|
|
28
|
+
this.container.appendChild(this.layer);
|
|
29
|
+
|
|
30
|
+
// Подписки
|
|
31
|
+
this.eventBus.on(Events.Object.Created, ({ objectId, objectData }) => {
|
|
32
|
+
if (!objectData) return;
|
|
33
|
+
if (objectData.type === 'text' || objectData.type === 'simple-text') {
|
|
34
|
+
this._ensureTextEl(objectId, objectData);
|
|
35
|
+
this.updateOne(objectId);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
|
|
39
|
+
this._removeTextEl(objectId);
|
|
40
|
+
});
|
|
41
|
+
this.eventBus.on(Events.Object.TransformUpdated, ({ objectId }) => {
|
|
42
|
+
this.updateOne(objectId);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Прятать HTML-текст во время редактирования (textarea)
|
|
46
|
+
this.eventBus.on(Events.UI.TextEditStart, ({ objectId }) => {
|
|
47
|
+
const el = this.idToEl.get(objectId);
|
|
48
|
+
if (el) el.style.visibility = 'hidden';
|
|
49
|
+
});
|
|
50
|
+
this.eventBus.on(Events.UI.TextEditEnd, ({ objectId }) => {
|
|
51
|
+
const el = this.idToEl.get(objectId);
|
|
52
|
+
if (el) el.style.visibility = '';
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Обработка событий скрытия/показа текста от SelectTool
|
|
56
|
+
this.eventBus.on(Events.Tool.HideObjectText, ({ objectId }) => {
|
|
57
|
+
console.log(`🔍 HtmlTextLayer: скрываю текст для объекта ${objectId}`);
|
|
58
|
+
const el = this.idToEl.get(objectId);
|
|
59
|
+
if (el) {
|
|
60
|
+
el.style.visibility = 'hidden';
|
|
61
|
+
console.log(`🔍 HtmlTextLayer: текст ${objectId} скрыт (visibility: hidden)`);
|
|
62
|
+
} else {
|
|
63
|
+
console.warn(`❌ HtmlTextLayer: HTML-элемент для объекта ${objectId} не найден`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
this.eventBus.on(Events.Tool.ShowObjectText, ({ objectId }) => {
|
|
67
|
+
console.log(`🔍 HtmlTextLayer: показываю текст для объекта ${objectId}`);
|
|
68
|
+
const el = this.idToEl.get(objectId);
|
|
69
|
+
if (el) {
|
|
70
|
+
el.style.visibility = '';
|
|
71
|
+
console.log(`🔍 HtmlTextLayer: текст ${objectId} показан (visibility: visible)`);
|
|
72
|
+
} else {
|
|
73
|
+
console.warn(`❌ HtmlTextLayer: HTML-элемент для объекта ${objectId} не найден`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Обработка обновления содержимого текста
|
|
78
|
+
this.eventBus.on(Events.Tool.UpdateObjectContent, ({ objectId, content }) => {
|
|
79
|
+
console.log(`🔍 HtmlTextLayer: обновляю содержимое для объекта ${objectId}:`, content);
|
|
80
|
+
const el = this.idToEl.get(objectId);
|
|
81
|
+
if (el && typeof content === 'string') {
|
|
82
|
+
el.textContent = content;
|
|
83
|
+
console.log(`🔍 HtmlTextLayer: содержимое обновлено для ${objectId}:`, content);
|
|
84
|
+
} else {
|
|
85
|
+
console.warn(`❌ HtmlTextLayer: не удалось обновить содержимое для ${objectId}:`, { el: !!el, content });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Обработка изменения состояния объекта (для fontFamily и других свойств)
|
|
90
|
+
this.eventBus.on(Events.Object.StateChanged, ({ objectId, updates }) => {
|
|
91
|
+
const el = this.idToEl.get(objectId);
|
|
92
|
+
if (el && updates) {
|
|
93
|
+
if (updates.fontFamily) {
|
|
94
|
+
el.style.fontFamily = updates.fontFamily;
|
|
95
|
+
console.log(`🔍 HtmlTextLayer: обновлен шрифт для ${objectId}:`, updates.fontFamily);
|
|
96
|
+
}
|
|
97
|
+
if (updates.fontSize) {
|
|
98
|
+
el.style.fontSize = `${updates.fontSize}px`;
|
|
99
|
+
console.log(`🔍 HtmlTextLayer: обновлен размер шрифта для ${objectId}:`, updates.fontSize);
|
|
100
|
+
}
|
|
101
|
+
if (updates.color) {
|
|
102
|
+
el.style.color = updates.color;
|
|
103
|
+
console.log(`🔍 HtmlTextLayer: обновлен цвет для ${objectId}:`, updates.color);
|
|
104
|
+
}
|
|
105
|
+
if (updates.backgroundColor !== undefined) {
|
|
106
|
+
el.style.backgroundColor = updates.backgroundColor === 'transparent' ? '' : updates.backgroundColor;
|
|
107
|
+
console.log(`🔍 HtmlTextLayer: обновлен фон для ${objectId}:`, updates.backgroundColor);
|
|
108
|
+
}
|
|
109
|
+
// Здесь можно добавить обработку других свойств текста
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// На все операции зума/пэна — полное обновление
|
|
114
|
+
this.eventBus.on(Events.UI.ZoomPercent, () => this.updateAll());
|
|
115
|
+
this.eventBus.on(Events.Tool.PanUpdate, () => this.updateAll());
|
|
116
|
+
// Обновления в реальном времени при перетаскивании/ресайзе/повороте
|
|
117
|
+
this.eventBus.on(Events.Tool.DragUpdate, ({ object }) => this.updateOne(object));
|
|
118
|
+
this.eventBus.on(Events.Tool.ResizeUpdate, ({ object }) => this.updateOne(object));
|
|
119
|
+
this.eventBus.on(Events.Tool.RotateUpdate, ({ object }) => this.updateOne(object));
|
|
120
|
+
this.eventBus.on(Events.Tool.GroupDragUpdate, ({ objects }) => {
|
|
121
|
+
const ids = Array.isArray(objects) ? objects : [];
|
|
122
|
+
ids.forEach(id => this.updateOne(id));
|
|
123
|
+
});
|
|
124
|
+
this.eventBus.on(Events.Tool.GroupResizeUpdate, ({ objects }) => {
|
|
125
|
+
const ids = Array.isArray(objects) ? objects : [];
|
|
126
|
+
ids.forEach(id => this.updateOne(id));
|
|
127
|
+
});
|
|
128
|
+
this.eventBus.on(Events.Tool.GroupRotateUpdate, ({ objects }) => {
|
|
129
|
+
const ids = Array.isArray(objects) ? objects : [];
|
|
130
|
+
ids.forEach(id => this.updateOne(id));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Первичная отрисовка
|
|
134
|
+
this.rebuildFromState();
|
|
135
|
+
this.updateAll();
|
|
136
|
+
|
|
137
|
+
// Хелпер: при каждом обновлении ручек — обновляем HTML блок
|
|
138
|
+
const world = this.core?.pixi?.worldLayer || this.core?.pixi?.app?.stage;
|
|
139
|
+
if (world) {
|
|
140
|
+
world.on('child:updated', () => this.updateAll()); // на случай внешних обновлений
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
destroy() {
|
|
145
|
+
if (this.layer) this.layer.remove();
|
|
146
|
+
this.layer = null;
|
|
147
|
+
this.idToEl.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
rebuildFromState() {
|
|
151
|
+
if (!this.core?.state) return;
|
|
152
|
+
const objs = this.core.state.state.objects || [];
|
|
153
|
+
console.log(`🔍 HtmlTextLayer: rebuildFromState, найдено объектов:`, objs.length);
|
|
154
|
+
|
|
155
|
+
objs.forEach((o) => {
|
|
156
|
+
if (o.type === 'text' || o.type === 'simple-text') {
|
|
157
|
+
console.log(`🔍 HtmlTextLayer: создаю HTML-элемент для текстового объекта:`, o);
|
|
158
|
+
this._ensureTextEl(o.id, o);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
this.updateAll();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_ensureTextEl(objectId, objectData) {
|
|
165
|
+
if (!this.layer || !objectId) return;
|
|
166
|
+
if (this.idToEl.has(objectId)) return;
|
|
167
|
+
|
|
168
|
+
console.log(`🔍 HtmlTextLayer: создаю HTML-элемент для текста ${objectId}:`, objectData);
|
|
169
|
+
|
|
170
|
+
const el = document.createElement('div');
|
|
171
|
+
el.className = 'mb-text';
|
|
172
|
+
el.dataset.id = objectId;
|
|
173
|
+
// Получаем свойства из properties объекта
|
|
174
|
+
const fontFamily = objectData.fontFamily || objectData.properties?.fontFamily || 'Arial, sans-serif';
|
|
175
|
+
const color = objectData.color || objectData.properties?.color || '#000000';
|
|
176
|
+
const backgroundColor = objectData.backgroundColor || objectData.properties?.backgroundColor || 'transparent';
|
|
177
|
+
|
|
178
|
+
Object.assign(el.style, {
|
|
179
|
+
position: 'absolute',
|
|
180
|
+
transformOrigin: 'top left',
|
|
181
|
+
color: color,
|
|
182
|
+
whiteSpace: 'pre-wrap',
|
|
183
|
+
pointerEvents: 'none', // всё взаимодействие остаётся на PIXI
|
|
184
|
+
userSelect: 'none',
|
|
185
|
+
fontFamily: fontFamily,
|
|
186
|
+
backgroundColor: backgroundColor === 'transparent' ? '' : backgroundColor
|
|
187
|
+
});
|
|
188
|
+
const content = objectData.content || objectData.properties?.content || '';
|
|
189
|
+
el.textContent = content;
|
|
190
|
+
// Базовые размеры сохраняем в dataset
|
|
191
|
+
const fs = objectData.fontSize || objectData.properties?.fontSize || 16;
|
|
192
|
+
const bw = Math.max(1, objectData.width || objectData.properties?.baseW || 160);
|
|
193
|
+
const bh = Math.max(1, objectData.height || objectData.properties?.baseH || 36);
|
|
194
|
+
el.dataset.baseFontSize = String(fs);
|
|
195
|
+
el.dataset.baseW = String(bw);
|
|
196
|
+
el.dataset.baseH = String(bh);
|
|
197
|
+
this.layer.appendChild(el);
|
|
198
|
+
this.idToEl.set(objectId, el);
|
|
199
|
+
|
|
200
|
+
console.log(`🔍 HtmlTextLayer: HTML-элемент создан и добавлен в DOM:`, el);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_removeTextEl(objectId) {
|
|
204
|
+
const el = this.idToEl.get(objectId);
|
|
205
|
+
if (el) el.remove();
|
|
206
|
+
this.idToEl.delete(objectId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
updateAll() {
|
|
210
|
+
if (!this.core?.pixi) return;
|
|
211
|
+
for (const id of this.idToEl.keys()) this.updateOne(id);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
updateOne(objectId) {
|
|
215
|
+
const el = this.idToEl.get(objectId);
|
|
216
|
+
if (!el || !this.core) return;
|
|
217
|
+
|
|
218
|
+
console.log(`🔍 HtmlTextLayer: обновляю позицию для текста ${objectId}`);
|
|
219
|
+
|
|
220
|
+
const world = this.core.pixi.worldLayer || this.core.pixi.app.stage;
|
|
221
|
+
const s = world?.scale?.x || 1;
|
|
222
|
+
const tx = world?.x || 0;
|
|
223
|
+
const ty = world?.y || 0;
|
|
224
|
+
const res = (this.core?.pixi?.app?.renderer?.resolution) || 1;
|
|
225
|
+
const obj = (this.core.state.state.objects || []).find(o => o.id === objectId);
|
|
226
|
+
if (!obj) return;
|
|
227
|
+
const x = obj.position?.x || 0;
|
|
228
|
+
const y = obj.position?.y || 0;
|
|
229
|
+
const w = obj.width || 0;
|
|
230
|
+
const h = obj.height || 0;
|
|
231
|
+
const angle = obj.rotation || obj.transform?.rotation || 0;
|
|
232
|
+
|
|
233
|
+
// Чёткая отрисовка: меняем реальный font-size, учитывая зум и изменение размеров
|
|
234
|
+
const baseFS = parseFloat(el.dataset.baseFontSize || '16') || 16;
|
|
235
|
+
const baseW = parseFloat(el.dataset.baseW || '160') || 160;
|
|
236
|
+
const baseH = parseFloat(el.dataset.baseH || '36') || 36;
|
|
237
|
+
const scaleX = w && baseW ? (w / baseW) : 1;
|
|
238
|
+
const scaleY = h && baseH ? (h / baseH) : 1;
|
|
239
|
+
const sObj = Math.min(scaleX, scaleY);
|
|
240
|
+
const sCss = s / res;
|
|
241
|
+
const fontSizePx = Math.max(1, baseFS * sObj * sCss);
|
|
242
|
+
el.style.fontSize = `${fontSizePx}px`;
|
|
243
|
+
|
|
244
|
+
// Позиция и габариты в экранных координатах
|
|
245
|
+
const left = (tx + s * x) / res;
|
|
246
|
+
const top = (ty + s * y) / res;
|
|
247
|
+
el.style.left = `${left}px`;
|
|
248
|
+
el.style.top = `${top}px`;
|
|
249
|
+
if (w && h) {
|
|
250
|
+
el.style.width = `${Math.max(1, (w * s) / res)}px`;
|
|
251
|
+
el.style.height = `${Math.max(1, (h * s) / res)}px`;
|
|
252
|
+
}
|
|
253
|
+
// Поворот вокруг top-left
|
|
254
|
+
if (angle) {
|
|
255
|
+
el.style.transform = `rotate(${angle}deg)`;
|
|
256
|
+
} else {
|
|
257
|
+
el.style.transform = '';
|
|
258
|
+
}
|
|
259
|
+
// Текст
|
|
260
|
+
const content = obj.content || obj.properties?.content;
|
|
261
|
+
if (typeof content === 'string') {
|
|
262
|
+
el.textContent = content;
|
|
263
|
+
console.log(`🔍 HtmlTextLayer: содержимое обновлено в updateOne для ${objectId}:`, content);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log(`🔍 HtmlTextLayer: позиция обновлена для ${objectId}:`, {
|
|
267
|
+
left: `${left}px`,
|
|
268
|
+
top: `${top}px`,
|
|
269
|
+
width: `${Math.max(1, (w * s) / res)}px`,
|
|
270
|
+
height: `${Math.max(1, (h * s) / res)}px`,
|
|
271
|
+
fontSize: `${fontSizePx}px`,
|
|
272
|
+
content: content,
|
|
273
|
+
visibility: el.style.visibility,
|
|
274
|
+
textContent: el.textContent
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { Events } from '../core/events/Events.js';
|
|
2
|
+
|
|
3
|
+
export class MapPanel {
|
|
4
|
+
constructor(container, eventBus) {
|
|
5
|
+
this.container = container;
|
|
6
|
+
this.eventBus = eventBus;
|
|
7
|
+
this.element = null;
|
|
8
|
+
this.popupEl = null;
|
|
9
|
+
this.create();
|
|
10
|
+
this.attach();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
create() {
|
|
14
|
+
this.element = document.createElement('div');
|
|
15
|
+
this.element.className = 'moodboard-mapbar';
|
|
16
|
+
|
|
17
|
+
const btn = document.createElement('button');
|
|
18
|
+
btn.className = 'moodboard-mapbar__button';
|
|
19
|
+
btn.title = 'Карта';
|
|
20
|
+
btn.textContent = '🗺️';
|
|
21
|
+
btn.dataset.action = 'toggle-map';
|
|
22
|
+
|
|
23
|
+
this.element.appendChild(btn);
|
|
24
|
+
this.container.appendChild(this.element);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
attach() {
|
|
28
|
+
// Клик по кнопке — открыть/закрыть всплывающую панель
|
|
29
|
+
this.element.addEventListener('click', (e) => {
|
|
30
|
+
const btn = e.target.closest('.moodboard-mapbar__button');
|
|
31
|
+
if (!btn) return;
|
|
32
|
+
e.stopPropagation();
|
|
33
|
+
if (this.popupEl) this.hidePopup();
|
|
34
|
+
else this.showPopup();
|
|
35
|
+
this.eventBus.emit(Events.UI.MapToggle);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Закрытие по клику вне панели
|
|
39
|
+
document.addEventListener('mousedown', (e) => {
|
|
40
|
+
if (!this.popupEl) return;
|
|
41
|
+
if (this.element.contains(e.target)) return;
|
|
42
|
+
this.hidePopup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Колесо для зума внутри миникарты
|
|
46
|
+
this.element.addEventListener('wheel', (e) => {
|
|
47
|
+
if (!this.popupEl) return;
|
|
48
|
+
if (!this.popupEl.contains(e.target)) return;
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
// Масштабируем вокруг точки под курсором в миникарте
|
|
51
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
52
|
+
const mx = e.clientX - rect.left;
|
|
53
|
+
const my = e.clientY - rect.top;
|
|
54
|
+
// Переводим в мировые координаты
|
|
55
|
+
const { worldX, worldY } = this.miniToWorld(mx, my);
|
|
56
|
+
// Переводим мировые координаты в экранные, чтобы использовать существующий зум ядра
|
|
57
|
+
const req = {};
|
|
58
|
+
this.eventBus.emit(Events.UI.MinimapGetData, req);
|
|
59
|
+
const { world } = req;
|
|
60
|
+
const screenX = worldX * (world.scale || 1) + world.x;
|
|
61
|
+
const screenY = worldY * (world.scale || 1) + world.y;
|
|
62
|
+
const delta = e.deltaY;
|
|
63
|
+
this.eventBus.emit(Events.Tool.WheelZoom, { x: screenX, y: screenY, delta });
|
|
64
|
+
}, { passive: false });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
destroy() {
|
|
68
|
+
if (this.element) this.element.remove();
|
|
69
|
+
this.element = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Показ всплывающей панели (20% ширины/высоты экрана, над кнопкой)
|
|
73
|
+
showPopup() {
|
|
74
|
+
if (this.popupEl) return;
|
|
75
|
+
const popup = document.createElement('div');
|
|
76
|
+
popup.className = 'moodboard-mapbar__popup';
|
|
77
|
+
|
|
78
|
+
// Canvas миникарты
|
|
79
|
+
const canvas = document.createElement('canvas');
|
|
80
|
+
canvas.className = 'moodboard-minimap-canvas';
|
|
81
|
+
popup.appendChild(canvas);
|
|
82
|
+
|
|
83
|
+
this.element.appendChild(popup);
|
|
84
|
+
this.popupEl = popup;
|
|
85
|
+
this.canvas = canvas;
|
|
86
|
+
this.ctx = canvas.getContext('2d');
|
|
87
|
+
|
|
88
|
+
this.layoutCanvas();
|
|
89
|
+
this.renderMinimap();
|
|
90
|
+
|
|
91
|
+
// Перерисовка при ресайзе окна
|
|
92
|
+
this._onResize = () => {
|
|
93
|
+
this.layoutCanvas();
|
|
94
|
+
this.renderMinimap();
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener('resize', this._onResize);
|
|
97
|
+
|
|
98
|
+
// Интеракция: клик/перетаскивание
|
|
99
|
+
this._dragging = false;
|
|
100
|
+
this.canvas.addEventListener('mousedown', (e) => {
|
|
101
|
+
this._dragging = true;
|
|
102
|
+
this.handlePointer(e);
|
|
103
|
+
});
|
|
104
|
+
document.addEventListener('mousemove', this._onMove = (e) => {
|
|
105
|
+
if (!this._dragging) return;
|
|
106
|
+
this.handlePointer(e);
|
|
107
|
+
});
|
|
108
|
+
document.addEventListener('mouseup', this._onUp = () => {
|
|
109
|
+
this._dragging = false;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Регулярно обновлять представление (можно оптимизировать на событиях)
|
|
113
|
+
this._raf = () => {
|
|
114
|
+
if (!this.popupEl) return;
|
|
115
|
+
this.renderMinimap();
|
|
116
|
+
this._rafId = requestAnimationFrame(this._raf);
|
|
117
|
+
};
|
|
118
|
+
this._rafId = requestAnimationFrame(this._raf);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Скрыть всплывающую панель
|
|
122
|
+
hidePopup() {
|
|
123
|
+
if (!this.popupEl) return;
|
|
124
|
+
this.popupEl.remove();
|
|
125
|
+
this.popupEl = null;
|
|
126
|
+
this.canvas = null;
|
|
127
|
+
this.ctx = null;
|
|
128
|
+
window.removeEventListener('resize', this._onResize);
|
|
129
|
+
document.removeEventListener('mousemove', this._onMove);
|
|
130
|
+
document.removeEventListener('mouseup', this._onUp);
|
|
131
|
+
if (this._rafId) cancelAnimationFrame(this._rafId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
layoutCanvas() {
|
|
135
|
+
if (!this.popupEl || !this.canvas) return;
|
|
136
|
+
const rect = this.popupEl.getBoundingClientRect();
|
|
137
|
+
const dpr = window.devicePixelRatio || 1;
|
|
138
|
+
this.canvas.width = Math.max(10, Math.floor(rect.width * dpr));
|
|
139
|
+
this.canvas.height = Math.max(10, Math.floor(rect.height * dpr));
|
|
140
|
+
this.canvas.style.width = rect.width + 'px';
|
|
141
|
+
this.canvas.style.height = rect.height + 'px';
|
|
142
|
+
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Обратное преобразование: миникарта -> мир (используем bbox объектов)
|
|
146
|
+
miniToWorld(miniX, miniY) {
|
|
147
|
+
const req = {};
|
|
148
|
+
this.eventBus.emit(Events.UI.MinimapGetData, req);
|
|
149
|
+
const { view, objects } = req;
|
|
150
|
+
// Рассчитываем bbox по объектам
|
|
151
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
152
|
+
if (objects && objects.length > 0) {
|
|
153
|
+
for (const o of objects) {
|
|
154
|
+
minX = Math.min(minX, o.x);
|
|
155
|
+
minY = Math.min(minY, o.y);
|
|
156
|
+
maxX = Math.max(maxX, o.x + (o.width || 0));
|
|
157
|
+
maxY = Math.max(maxY, o.y + (o.height || 0));
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
minX = 0; minY = 0; maxX = view.width; maxY = view.height;
|
|
161
|
+
}
|
|
162
|
+
const bboxW = Math.max(1, maxX - minX);
|
|
163
|
+
const bboxH = Math.max(1, maxY - minY);
|
|
164
|
+
const scale = Math.min(this.canvas.clientWidth / bboxW, this.canvas.clientHeight / bboxH);
|
|
165
|
+
const offsetX = (this.canvas.clientWidth - bboxW * scale) / 2;
|
|
166
|
+
const offsetY = (this.canvas.clientHeight - bboxH * scale) / 2;
|
|
167
|
+
const worldX = minX + (miniX - offsetX) / scale;
|
|
168
|
+
const worldY = minY + (miniY - offsetY) / scale;
|
|
169
|
+
return { worldX, worldY };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
handlePointer(e) {
|
|
173
|
+
if (!this.canvas) return;
|
|
174
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
175
|
+
const x = e.clientX - rect.left;
|
|
176
|
+
const y = e.clientY - rect.top;
|
|
177
|
+
// Центрируем основной вид на точке
|
|
178
|
+
const { worldX, worldY } = this.miniToWorld(x, y);
|
|
179
|
+
this.eventBus.emit(Events.UI.MinimapCenterOn, { worldX, worldY });
|
|
180
|
+
this.renderMinimap();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
renderMinimap() {
|
|
184
|
+
if (!this.canvas || !this.ctx) return;
|
|
185
|
+
const ctx = this.ctx;
|
|
186
|
+
const { width, height } = this.canvas;
|
|
187
|
+
ctx.save();
|
|
188
|
+
// canvas.width/height в CSS-пикселях уже учтены через setTransform
|
|
189
|
+
ctx.clearRect(0, 0, width, height);
|
|
190
|
+
|
|
191
|
+
// Получаем данные
|
|
192
|
+
const req = {};
|
|
193
|
+
this.eventBus.emit(Events.UI.MinimapGetData, req);
|
|
194
|
+
const { world, view, objects } = req;
|
|
195
|
+
if (!view || !world) return;
|
|
196
|
+
// Вычисляем bbox объектов
|
|
197
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
198
|
+
if (objects && objects.length > 0) {
|
|
199
|
+
for (const o of objects) {
|
|
200
|
+
minX = Math.min(minX, o.x);
|
|
201
|
+
minY = Math.min(minY, o.y);
|
|
202
|
+
maxX = Math.max(maxX, o.x + (o.width || 0));
|
|
203
|
+
maxY = Math.max(maxY, o.y + (o.height || 0));
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
minX = 0; minY = 0; maxX = view.width; maxY = view.height;
|
|
207
|
+
}
|
|
208
|
+
const bboxW = Math.max(1, maxX - minX);
|
|
209
|
+
const bboxH = Math.max(1, maxY - minY);
|
|
210
|
+
const scale = Math.min(this.canvas.clientWidth / bboxW, this.canvas.clientHeight / bboxH);
|
|
211
|
+
const offsetX = (this.canvas.clientWidth - bboxW * scale) / 2;
|
|
212
|
+
const offsetY = (this.canvas.clientHeight - bboxH * scale) / 2;
|
|
213
|
+
|
|
214
|
+
// Фон
|
|
215
|
+
ctx.fillStyle = '#ffffff';
|
|
216
|
+
ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
|
|
217
|
+
|
|
218
|
+
// Объекты в мировых координатах, приведенные по bbox
|
|
219
|
+
ctx.save();
|
|
220
|
+
ctx.translate(offsetX, offsetY);
|
|
221
|
+
ctx.scale(scale, scale);
|
|
222
|
+
// Подготовим множество выделенных объектов
|
|
223
|
+
const selReq = { selection: [] };
|
|
224
|
+
this.eventBus.emit(Events.Tool.GetSelection, selReq);
|
|
225
|
+
const selectedSet = new Set(selReq.selection || []);
|
|
226
|
+
|
|
227
|
+
// Сначала рисуем все объекты бледным
|
|
228
|
+
ctx.strokeStyle = '#7b8794';
|
|
229
|
+
ctx.lineWidth = Math.max(0.5, 1 / Math.max(scale, 0.0001));
|
|
230
|
+
for (const o of objects) {
|
|
231
|
+
const x = o.x - minX;
|
|
232
|
+
const y = o.y - minY;
|
|
233
|
+
const w = Math.max(2, o.width);
|
|
234
|
+
const h = Math.max(2, o.height);
|
|
235
|
+
if (o.rotation) {
|
|
236
|
+
ctx.save();
|
|
237
|
+
const cx = x + w / 2;
|
|
238
|
+
const cy = y + h / 2;
|
|
239
|
+
ctx.translate(cx, cy);
|
|
240
|
+
ctx.rotate((o.rotation * Math.PI) / 180);
|
|
241
|
+
ctx.strokeRect(-w / 2, -h / 2, w, h);
|
|
242
|
+
ctx.restore();
|
|
243
|
+
} else {
|
|
244
|
+
ctx.strokeRect(x, y, w, h);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Поверх подсвечиваем выделенные объекты
|
|
249
|
+
if (selectedSet.size > 0) {
|
|
250
|
+
ctx.strokeStyle = '#3B82F6';
|
|
251
|
+
ctx.lineWidth = Math.max(1.5, 2 / Math.max(scale, 0.0001));
|
|
252
|
+
for (const o of objects) {
|
|
253
|
+
if (!selectedSet.has(o.id)) continue;
|
|
254
|
+
const x = o.x - minX;
|
|
255
|
+
const y = o.y - minY;
|
|
256
|
+
const w = Math.max(2, o.width);
|
|
257
|
+
const h = Math.max(2, o.height);
|
|
258
|
+
if (o.rotation) {
|
|
259
|
+
ctx.save();
|
|
260
|
+
const cx = x + w / 2;
|
|
261
|
+
const cy = y + h / 2;
|
|
262
|
+
ctx.translate(cx, cy);
|
|
263
|
+
ctx.rotate((o.rotation * Math.PI) / 180);
|
|
264
|
+
ctx.strokeRect(-w / 2, -h / 2, w, h);
|
|
265
|
+
ctx.restore();
|
|
266
|
+
} else {
|
|
267
|
+
ctx.strokeRect(x, y, w, h);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
ctx.restore();
|
|
272
|
+
|
|
273
|
+
// Рамка видимой области: конвертируем текущий экран в мировые координаты и затем в миникарту
|
|
274
|
+
const s = world.scale || 1;
|
|
275
|
+
const worldLeft = -world.x / s;
|
|
276
|
+
const worldTop = -world.y / s;
|
|
277
|
+
const worldRight = (view.width - world.x) / s;
|
|
278
|
+
const worldBottom = (view.height - world.y) / s;
|
|
279
|
+
const rx = offsetX + (worldLeft - minX) * scale;
|
|
280
|
+
const ry = offsetY + (worldTop - minY) * scale;
|
|
281
|
+
const rw = (worldRight - worldLeft) * scale;
|
|
282
|
+
const rh = (worldBottom - worldTop) * scale;
|
|
283
|
+
ctx.strokeStyle = '#1e90ff';
|
|
284
|
+
ctx.lineWidth = 2;
|
|
285
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
286
|
+
ctx.restore();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|