@sequent-org/moodboard 1.4.30 → 1.4.32
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 +3 -1
- package/src/core/PixiEngine.js +34 -5
- package/src/core/bootstrap/CoreInitializer.js +4 -0
- package/src/core/commands/CreateConnectorCommand.js +25 -0
- package/src/core/commands/GroupMoveCommand.js +2 -2
- package/src/core/commands/MoveObjectCommand.js +1 -1
- package/src/core/commands/UpdateConnectorCommand.js +38 -0
- package/src/core/events/Events.js +1 -0
- package/src/mindmap/MindmapCompoundContract.js +1 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +14 -0
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +18 -0
- package/src/objects/ConnectorObject.js +85 -0
- package/src/objects/DrawingObject.js +47 -0
- package/src/objects/MindmapObject.js +21 -3
- package/src/objects/NoteObject.js +16 -8
- package/src/objects/ObjectFactory.js +3 -1
- package/src/objects/ShapeObject.js +1 -1
- package/src/services/ConnectorBindingResolver.js +204 -0
- package/src/services/ai/AiClient.js +30 -2
- package/src/services/ai/ChatSessionController.js +1 -0
- package/src/tools/ToolManager.js +3 -0
- package/src/tools/manager/PointerGestureController.js +206 -0
- package/src/tools/manager/ToolEventRouter.js +10 -0
- package/src/tools/manager/ToolManagerGuards.js +3 -1
- package/src/tools/manager/ToolManagerLifecycle.js +70 -58
- package/src/tools/object-tools/ConnectorTool.js +147 -0
- package/src/tools/object-tools/PlacementTool.js +2 -2
- package/src/tools/object-tools/connector/ConnectorDragController.js +296 -0
- package/src/tools/object-tools/connector/connectorGesture.js +108 -0
- package/src/tools/object-tools/placement/GhostController.js +4 -4
- package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -2
- package/src/tools/object-tools/placement/PlacementInputRouter.js +5 -5
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +11 -2
- package/src/tools/object-tools/selection/SelectInputRouter.js +33 -4
- package/src/tools/object-tools/selection/SelectToolLifecycleController.js +12 -0
- package/src/tools/object-tools/selection/SelectToolSetup.js +3 -0
- package/src/tools/object-tools/selection/TextEditorDomFactory.js +1 -2
- package/src/tools/object-tools/selection/TextEditorSyncService.js +4 -4
- package/src/tools/object-tools/selection/TextInlineEditorController.js +21 -3
- package/src/tools/object-tools/selection/TransformInteractionController.js +4 -6
- package/src/ui/HtmlTextLayer.js +212 -5
- package/src/ui/animation/HoverLiftController.js +395 -0
- package/src/ui/chat/ChatComposer.js +1 -10
- package/src/ui/chat/ChatExtendedPromptModal.js +1 -12
- package/src/ui/chat/ChatWindow.js +167 -36
- package/src/ui/chat/ChatWindowRenderer.js +1 -8
- package/src/ui/chat/icons.js +17 -5
- package/src/ui/connectors/ConnectionAnchorsLayer.js +231 -0
- package/src/ui/connectors/ConnectorLayer.js +251 -0
- package/src/ui/handles/HandlesDomRenderer.js +11 -7
- package/src/ui/handles/HandlesInteractionController.js +65 -34
- package/src/ui/handles/HandlesPositioningService.js +41 -6
- package/src/ui/mindmap/MindmapCollapseGraph.js +169 -0
- package/src/ui/mindmap/MindmapCollapseLayer.js +380 -0
- package/src/ui/mindmap/MindmapConnectionLayer.js +50 -25
- package/src/ui/mindmap/MindmapHtmlTextLayer.js +223 -2
- package/src/ui/mindmap/MindmapLayoutConfig.js +12 -0
- package/src/ui/styles/chat.css +2 -37
- package/src/ui/styles/toolbar.css +6 -0
- package/src/ui/styles/workspace.css +83 -21
- package/src/ui/toolbar/ToolbarPopupsController.js +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectorBindingResolver — чистая логика преобразования терминала в world-точку.
|
|
3
|
+
*
|
|
4
|
+
* Реализует алгоритм из раздела 4 CONNECTORS.md:
|
|
5
|
+
* 1. isPrecise=false → центр объекта
|
|
6
|
+
* 2. isPrecise=true → worldAnchor = topLeft + { anchor.x·w, anchor.y·h }
|
|
7
|
+
* 3. isExact=false → проекция на кромку AABB через Liang–Barsky;
|
|
8
|
+
* для повёрнутого объекта: луч переводится в локальные координаты.
|
|
9
|
+
* 4. isExact=true → точная world-точка без отсечения
|
|
10
|
+
* 5. Свободный терминал { point } → возвращается как есть
|
|
11
|
+
*
|
|
12
|
+
* Нет зависимостей от PIXI; только чистые математические операции.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Вспомогательные функции
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Поворачивает вектор на угол (в радианах).
|
|
20
|
+
* @param {{ x: number, y: number }} pt
|
|
21
|
+
* @param {number} angle радианы, положительный = CCW
|
|
22
|
+
* @returns {{ x: number, y: number }}
|
|
23
|
+
*/
|
|
24
|
+
function rotateVector(pt, angle) {
|
|
25
|
+
const cos = Math.cos(angle);
|
|
26
|
+
const sin = Math.sin(angle);
|
|
27
|
+
return {
|
|
28
|
+
x: pt.x * cos - pt.y * sin,
|
|
29
|
+
y: pt.x * sin + pt.y * cos,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Liang–Barsky для AABB с центром в начале координат (half-widths hw × hh).
|
|
35
|
+
*
|
|
36
|
+
* `from` — точка внутри (или на границе) прямоугольника [-hw..hw] × [-hh..hh].
|
|
37
|
+
* `to` — произвольная точка; возвращает точку выхода из прямоугольника по лучу from→to.
|
|
38
|
+
*
|
|
39
|
+
* Если from === to, возвращает from (на границе) через simple clamp.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ x: number, y: number }} from
|
|
42
|
+
* @param {{ x: number, y: number }} to
|
|
43
|
+
* @param {number} hw half-width (> 0)
|
|
44
|
+
* @param {number} hh half-height (> 0)
|
|
45
|
+
* @returns {{ x: number, y: number }}
|
|
46
|
+
*/
|
|
47
|
+
function clipRayToAABB(from, to, hw, hh) {
|
|
48
|
+
const dx = to.x - from.x;
|
|
49
|
+
const dy = to.y - from.y;
|
|
50
|
+
|
|
51
|
+
if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) {
|
|
52
|
+
// Вырожденный луч — clamp from на границу
|
|
53
|
+
const cx = Math.max(-hw, Math.min(hw, from.x));
|
|
54
|
+
const cy = Math.max(-hh, Math.min(hh, from.y));
|
|
55
|
+
return { x: cx, y: cy };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Минимальный t такой, что p(t) = from + t*(to−from) выходит за AABB
|
|
59
|
+
let tExit = 1.0;
|
|
60
|
+
|
|
61
|
+
if (Math.abs(dx) > 1e-10) {
|
|
62
|
+
const tEdge = (dx > 0 ? hw - from.x : -hw - from.x) / dx;
|
|
63
|
+
if (tEdge >= 0) tExit = Math.min(tExit, tEdge);
|
|
64
|
+
}
|
|
65
|
+
if (Math.abs(dy) > 1e-10) {
|
|
66
|
+
const tEdge = (dy > 0 ? hh - from.y : -hh - from.y) / dy;
|
|
67
|
+
if (tEdge >= 0) tExit = Math.min(tExit, tEdge);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
x: from.x + tExit * dx,
|
|
72
|
+
y: from.y + tExit * dy,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export class ConnectorBindingResolver {
|
|
79
|
+
/**
|
|
80
|
+
* Разрешает терминал в world-point.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} terminal
|
|
83
|
+
* Привязанный: { boundId, anchor:{x,y}, isPrecise, isExact }
|
|
84
|
+
* Свободный: { point:{x,y} }
|
|
85
|
+
* @param {Object|null} target
|
|
86
|
+
* Объект из state.objects с полями: position:{x,y}, width, height, rotation?
|
|
87
|
+
* @param {{ x: number, y: number }|null} otherTerminalWorld
|
|
88
|
+
* Уже разрешённая world-точка противоположного конца.
|
|
89
|
+
* Используется при isExact=false для проекции на кромку.
|
|
90
|
+
* Если null — отсечение не производится, возвращается precisePoint.
|
|
91
|
+
* @returns {{ x: number, y: number }}
|
|
92
|
+
*/
|
|
93
|
+
static resolve(terminal, target, otherTerminalWorld = null) {
|
|
94
|
+
// --- Свободный терминал ---
|
|
95
|
+
if (!terminal?.boundId) {
|
|
96
|
+
return { x: terminal?.point?.x ?? 0, y: terminal?.point?.y ?? 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!target) {
|
|
100
|
+
return { x: 0, y: 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const left = target.position?.x ?? 0;
|
|
104
|
+
const top = target.position?.y ?? 0;
|
|
105
|
+
const width = target.width ?? target.properties?.width ?? 0;
|
|
106
|
+
const height = target.height ?? target.properties?.height ?? 0;
|
|
107
|
+
const angle = target.rotation ?? target.properties?.rotation ?? 0;
|
|
108
|
+
|
|
109
|
+
const cx = left + width / 2;
|
|
110
|
+
const cy = top + height / 2;
|
|
111
|
+
const hw = width / 2;
|
|
112
|
+
const hh = height / 2;
|
|
113
|
+
|
|
114
|
+
// --- Точка привязки в локальных координатах (начало в центре объекта) ---
|
|
115
|
+
// isPrecise=false → центр объекта (0, 0) в локальных
|
|
116
|
+
// isPrecise=true → anchor.x·w − w/2, anchor.y·h − h/2
|
|
117
|
+
let localAnchorX = 0;
|
|
118
|
+
let localAnchorY = 0;
|
|
119
|
+
if (terminal.isPrecise) {
|
|
120
|
+
const ax = terminal.anchor?.x ?? 0.5;
|
|
121
|
+
const ay = terminal.anchor?.y ?? 0.5;
|
|
122
|
+
localAnchorX = ax * width - hw;
|
|
123
|
+
localAnchorY = ay * height - hh;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- precisePoint в world-space ---
|
|
127
|
+
const worldPrecise = angle !== 0
|
|
128
|
+
? {
|
|
129
|
+
x: cx + rotateVector({ x: localAnchorX, y: localAnchorY }, angle).x,
|
|
130
|
+
y: cy + rotateVector({ x: localAnchorX, y: localAnchorY }, angle).y,
|
|
131
|
+
}
|
|
132
|
+
: { x: cx + localAnchorX, y: cy + localAnchorY };
|
|
133
|
+
|
|
134
|
+
// --- isExact=true → возвращаем точную точку без отсечения ---
|
|
135
|
+
if (terminal.isExact) {
|
|
136
|
+
return worldPrecise;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- isExact=false → проекция на кромку AABB ---
|
|
140
|
+
if (!otherTerminalWorld) {
|
|
141
|
+
// Нет информации о другом конце — вернуть precisePoint
|
|
142
|
+
return worldPrecise;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (hw <= 0 || hh <= 0) {
|
|
146
|
+
return worldPrecise;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Переводим противоположный терминал в локальную систему координат цели
|
|
150
|
+
const ox = otherTerminalWorld.x - cx;
|
|
151
|
+
const oy = otherTerminalWorld.y - cy;
|
|
152
|
+
const localOther = angle !== 0
|
|
153
|
+
? rotateVector({ x: ox, y: oy }, -angle)
|
|
154
|
+
: { x: ox, y: oy };
|
|
155
|
+
|
|
156
|
+
// Обрезаем луч localAnchor → localOther по AABB
|
|
157
|
+
const exitLocal = clipRayToAABB(
|
|
158
|
+
{ x: localAnchorX, y: localAnchorY },
|
|
159
|
+
localOther,
|
|
160
|
+
hw,
|
|
161
|
+
hh
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Возвращаем в world-space
|
|
165
|
+
const worldExit = angle !== 0
|
|
166
|
+
? {
|
|
167
|
+
x: cx + rotateVector(exitLocal, angle).x,
|
|
168
|
+
y: cy + rotateVector(exitLocal, angle).y,
|
|
169
|
+
}
|
|
170
|
+
: { x: cx + exitLocal.x, y: cy + exitLocal.y };
|
|
171
|
+
|
|
172
|
+
return worldExit;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Расстояние от точки до отрезка [a, b].
|
|
180
|
+
* Используется в ConnectorLayer.hitTest (Фаза 2).
|
|
181
|
+
*
|
|
182
|
+
* @param {{ x: number, y: number }} point
|
|
183
|
+
* @param {{ x: number, y: number }} a
|
|
184
|
+
* @param {{ x: number, y: number }} b
|
|
185
|
+
* @returns {number}
|
|
186
|
+
*/
|
|
187
|
+
export function distanceToSegment(point, a, b) {
|
|
188
|
+
const dx = b.x - a.x;
|
|
189
|
+
const dy = b.y - a.y;
|
|
190
|
+
const lenSq = dx * dx + dy * dy;
|
|
191
|
+
|
|
192
|
+
if (lenSq < 1e-10) {
|
|
193
|
+
return Math.hypot(point.x - a.x, point.y - a.y);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const t = Math.max(0, Math.min(1,
|
|
197
|
+
((point.x - a.x) * dx + (point.y - a.y) * dy) / lenSq
|
|
198
|
+
));
|
|
199
|
+
|
|
200
|
+
return Math.hypot(
|
|
201
|
+
point.x - (a.x + t * dx),
|
|
202
|
+
point.y - (a.y + t * dy)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -115,17 +115,20 @@ export class AiClient {
|
|
|
115
115
|
* @param {number} [args.seed]
|
|
116
116
|
* @param {string} [args.mimeType]
|
|
117
117
|
* @param {string} [args.model]
|
|
118
|
+
* @param {File[]} [args.referenceImages]
|
|
118
119
|
* @param {AbortSignal} [args.signal]
|
|
119
120
|
* @returns {Promise<{operationId: string, imageBase64: string, mimeType: string}>}
|
|
120
121
|
*/
|
|
121
|
-
async generateImage({ signal, ...payload }) {
|
|
122
|
+
async generateImage({ signal, referenceImages: files, ...payload }) {
|
|
123
|
+
const referenceImages = await filesToBase64(files);
|
|
124
|
+
const body = referenceImages ? { ...payload, referenceImages } : payload;
|
|
122
125
|
const res = await this._fetch(`${this._baseUrl}/yandex-art/image`, {
|
|
123
126
|
method: 'POST',
|
|
124
127
|
headers: {
|
|
125
128
|
'Content-Type': 'application/json',
|
|
126
129
|
'Accept': 'application/json'
|
|
127
130
|
},
|
|
128
|
-
body: JSON.stringify(
|
|
131
|
+
body: JSON.stringify(body),
|
|
129
132
|
signal
|
|
130
133
|
});
|
|
131
134
|
if (!res.ok) {
|
|
@@ -222,3 +225,28 @@ async function safeReadError(res) {
|
|
|
222
225
|
return res.statusText;
|
|
223
226
|
}
|
|
224
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Конвертирует массив File в [{mimeType, data}] с base64-encoded данными.
|
|
231
|
+
* Возвращает undefined, если массив пустой или не передан.
|
|
232
|
+
*
|
|
233
|
+
* @param {File[]|undefined} files
|
|
234
|
+
* @returns {Promise<Array<{mimeType: string, data: string}>|undefined>}
|
|
235
|
+
*/
|
|
236
|
+
async function filesToBase64(files) {
|
|
237
|
+
if (!Array.isArray(files) || files.length === 0) return undefined;
|
|
238
|
+
return Promise.all(
|
|
239
|
+
files.map(async (file) => {
|
|
240
|
+
const buffer = await file.arrayBuffer();
|
|
241
|
+
const bytes = new Uint8Array(buffer);
|
|
242
|
+
let binary = '';
|
|
243
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
244
|
+
binary += String.fromCharCode(bytes[i]);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
mimeType: file.type || 'image/png',
|
|
248
|
+
data: btoa(binary)
|
|
249
|
+
};
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
}
|
package/src/tools/ToolManager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Events } from '../core/events/Events.js';
|
|
2
2
|
import cursorDefaultSvg from '../assets/icons/cursor-default.svg?raw';
|
|
3
|
+
import { PointerGestureController } from './manager/PointerGestureController.js';
|
|
3
4
|
import { ToolActivationController } from './manager/ToolActivationController.js';
|
|
4
5
|
import { ToolEventRouter } from './manager/ToolEventRouter.js';
|
|
5
6
|
import { ToolManagerGuards } from './manager/ToolManagerGuards.js';
|
|
@@ -42,12 +43,14 @@ export class ToolManager {
|
|
|
42
43
|
this.lastMousePos = null;
|
|
43
44
|
this.isMouseOverContainer = false;
|
|
44
45
|
this._originalPixiCursorStyles = null;
|
|
46
|
+
this._lastPointerType = null;
|
|
45
47
|
|
|
46
48
|
// Устанавливаем курсор по умолчанию на контейнер, если инструмент ещё не активирован
|
|
47
49
|
if (this.container) {
|
|
48
50
|
this.container.style.cursor = DEFAULT_CURSOR; // пусто → берётся глобальный CSS-курсор
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
this.gestures = new PointerGestureController(this);
|
|
51
54
|
this.initEventListeners();
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Events } from '../../core/events/Events.js';
|
|
2
|
+
|
|
3
|
+
const PINCH_SCALE_MIN = 0.02;
|
|
4
|
+
const PINCH_SCALE_MAX = 5;
|
|
5
|
+
const LONG_PRESS_MS = 500;
|
|
6
|
+
const LONG_PRESS_MOVE_THRESHOLD = 10;
|
|
7
|
+
const DOUBLE_TAP_MS = 300;
|
|
8
|
+
const DOUBLE_TAP_DIST = 24;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Единый роутер ввода для мыши / пера / тача через Pointer Events API.
|
|
12
|
+
* Добавляет pinch-zoom, двухпальцевый pan и long-press для тача,
|
|
13
|
+
* не меняя контракты существующих тул-методов.
|
|
14
|
+
*/
|
|
15
|
+
export class PointerGestureController {
|
|
16
|
+
constructor(manager) {
|
|
17
|
+
this.manager = manager;
|
|
18
|
+
/** @type {Map<number, {x: number, y: number}>} — активные нажатые указатели */
|
|
19
|
+
this.pointers = new Map();
|
|
20
|
+
/** Подавлять одиночный указатель во время мультитач-жеста */
|
|
21
|
+
this.suppressSingle = false;
|
|
22
|
+
|
|
23
|
+
this._pinchPrevDist = null;
|
|
24
|
+
this._pinchPrevMid = null;
|
|
25
|
+
|
|
26
|
+
this._longPressTimer = null;
|
|
27
|
+
this._longPressDownPos = null;
|
|
28
|
+
|
|
29
|
+
this._lastTapTime = 0;
|
|
30
|
+
this._lastTapPos = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_dist(p1, p2) {
|
|
34
|
+
const dx = p2.x - p1.x;
|
|
35
|
+
const dy = p2.y - p1.y;
|
|
36
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_mid(p1, p2) {
|
|
40
|
+
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_clearLongPress() {
|
|
44
|
+
if (this._longPressTimer !== null) {
|
|
45
|
+
clearTimeout(this._longPressTimer);
|
|
46
|
+
this._longPressTimer = null;
|
|
47
|
+
}
|
|
48
|
+
this._longPressDownPos = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_screenPos(e) {
|
|
52
|
+
const rect = this.manager.container.getBoundingClientRect();
|
|
53
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onPointerDown(e) {
|
|
57
|
+
const manager = this.manager;
|
|
58
|
+
manager._lastPointerType = e.pointerType;
|
|
59
|
+
|
|
60
|
+
const pos = this._screenPos(e);
|
|
61
|
+
this.pointers.set(e.pointerId, pos);
|
|
62
|
+
|
|
63
|
+
if (this.pointers.size === 2) {
|
|
64
|
+
this._clearLongPress();
|
|
65
|
+
// Безопасно завершить одиночное взаимодействие до начала жеста
|
|
66
|
+
if (manager.activeTool && typeof manager.activeTool.onMouseUp === 'function') {
|
|
67
|
+
manager.activeTool.onMouseUp({ x: pos.x, y: pos.y, button: 0, originalEvent: e });
|
|
68
|
+
}
|
|
69
|
+
this.suppressSingle = true;
|
|
70
|
+
const pts = Array.from(this.pointers.values());
|
|
71
|
+
this._pinchPrevDist = this._dist(pts[0], pts[1]);
|
|
72
|
+
this._pinchPrevMid = this._mid(pts[0], pts[1]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (this.suppressSingle) return;
|
|
77
|
+
|
|
78
|
+
// Long-press: таймер только для тача
|
|
79
|
+
if (e.pointerType === 'touch') {
|
|
80
|
+
this._longPressDownPos = pos;
|
|
81
|
+
this._longPressTimer = setTimeout(() => {
|
|
82
|
+
this._longPressTimer = null;
|
|
83
|
+
this._longPressDownPos = null;
|
|
84
|
+
if (manager.activeTool && typeof manager.activeTool.onContextMenu === 'function') {
|
|
85
|
+
manager.activeTool.onContextMenu({ x: pos.x, y: pos.y, originalEvent: e });
|
|
86
|
+
}
|
|
87
|
+
}, LONG_PRESS_MS);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
manager.handleMouseDown(e);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onPointerMove(e) {
|
|
94
|
+
const manager = this.manager;
|
|
95
|
+
const isTracked = this.pointers.has(e.pointerId);
|
|
96
|
+
|
|
97
|
+
// Тач без зарегистрированного pointerdown — пропускаем
|
|
98
|
+
if (e.pointerType === 'touch' && !isTracked) return;
|
|
99
|
+
|
|
100
|
+
const pos = this._screenPos(e);
|
|
101
|
+
if (isTracked) {
|
|
102
|
+
this.pointers.set(e.pointerId, pos);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (this.pointers.size >= 2) {
|
|
106
|
+
if (!isTracked) return;
|
|
107
|
+
const pts = Array.from(this.pointers.values());
|
|
108
|
+
const curDist = this._dist(pts[0], pts[1]);
|
|
109
|
+
const curMid = this._mid(pts[0], pts[1]);
|
|
110
|
+
|
|
111
|
+
if (this._pinchPrevDist !== null) {
|
|
112
|
+
const world = manager.core?.pixi?.worldLayer || manager.core?.pixi?.app?.stage;
|
|
113
|
+
if (world) {
|
|
114
|
+
const oldScale = world.scale.x || 1;
|
|
115
|
+
const factor = curDist / this._pinchPrevDist;
|
|
116
|
+
const newScale = Math.max(PINCH_SCALE_MIN, Math.min(PINCH_SCALE_MAX, oldScale * factor));
|
|
117
|
+
|
|
118
|
+
// Мировая точка под серединой пальцев — она должна остаться на месте
|
|
119
|
+
const worldX = (curMid.x - world.x) / oldScale;
|
|
120
|
+
const worldY = (curMid.y - world.y) / oldScale;
|
|
121
|
+
// Дельта двухпальцевого pan
|
|
122
|
+
const panDx = this._pinchPrevMid ? curMid.x - this._pinchPrevMid.x : 0;
|
|
123
|
+
const panDy = this._pinchPrevMid ? curMid.y - this._pinchPrevMid.y : 0;
|
|
124
|
+
|
|
125
|
+
world.scale.set(newScale);
|
|
126
|
+
// integer-контракт: round screen-space координаты
|
|
127
|
+
world.x = Math.round(curMid.x - worldX * newScale + panDx);
|
|
128
|
+
world.y = Math.round(curMid.y - worldY * newScale + panDy);
|
|
129
|
+
|
|
130
|
+
manager.eventBus.emit(Events.UI.ZoomPercent, { percentage: Math.round(newScale * 100) });
|
|
131
|
+
manager.eventBus.emit(Events.Viewport.Changed);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this._pinchPrevDist = curDist;
|
|
136
|
+
this._pinchPrevMid = curMid;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (this.suppressSingle) return;
|
|
141
|
+
|
|
142
|
+
// Отменить long-press если палец сдвинулся более чем на порог
|
|
143
|
+
if (isTracked && e.pointerType === 'touch' && this._longPressDownPos) {
|
|
144
|
+
const dx = pos.x - this._longPressDownPos.x;
|
|
145
|
+
const dy = pos.y - this._longPressDownPos.y;
|
|
146
|
+
if (Math.sqrt(dx * dx + dy * dy) > LONG_PRESS_MOVE_THRESHOLD) {
|
|
147
|
+
this._clearLongPress();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
manager.handleMouseMove(e);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onPointerUp(e) {
|
|
155
|
+
const manager = this.manager;
|
|
156
|
+
this._clearLongPress();
|
|
157
|
+
|
|
158
|
+
const hadPointer = this.pointers.has(e.pointerId);
|
|
159
|
+
this.pointers.delete(e.pointerId);
|
|
160
|
+
|
|
161
|
+
const wasSuppressed = this.suppressSingle;
|
|
162
|
+
if (this.pointers.size === 0) {
|
|
163
|
+
this.suppressSingle = false;
|
|
164
|
+
this._pinchPrevDist = null;
|
|
165
|
+
this._pinchPrevMid = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (wasSuppressed || !hadPointer) return;
|
|
169
|
+
|
|
170
|
+
manager.handleMouseUp(e);
|
|
171
|
+
|
|
172
|
+
// Double-tap для тача: синтетический doubleClick после обычного up
|
|
173
|
+
if (e.pointerType === 'touch') {
|
|
174
|
+
const pos = this._screenPos(e);
|
|
175
|
+
const now = performance.now();
|
|
176
|
+
const elapsed = now - this._lastTapTime;
|
|
177
|
+
if (
|
|
178
|
+
elapsed < DOUBLE_TAP_MS &&
|
|
179
|
+
this._lastTapPos &&
|
|
180
|
+
this._dist(pos, this._lastTapPos) < DOUBLE_TAP_DIST
|
|
181
|
+
) {
|
|
182
|
+
if (manager.activeTool && typeof manager.activeTool.onDoubleClick === 'function') {
|
|
183
|
+
// Если вторым тапом был случайно запущен resize через PIXI hitTest — отменяем до открытия редактора
|
|
184
|
+
if (manager.activeTool.isResizing || manager.activeTool.isGroupResizing) {
|
|
185
|
+
manager.activeTool.onMouseUp({ x: pos.x, y: pos.y, button: 0, originalEvent: e });
|
|
186
|
+
}
|
|
187
|
+
manager.activeTool.onDoubleClick({ x: pos.x, y: pos.y, originalEvent: e, target: e.target });
|
|
188
|
+
}
|
|
189
|
+
this._lastTapTime = 0;
|
|
190
|
+
this._lastTapPos = null;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this._lastTapTime = now;
|
|
194
|
+
this._lastTapPos = pos;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
destroy() {
|
|
199
|
+
this._clearLongPress();
|
|
200
|
+
this.pointers.clear();
|
|
201
|
+
this._pinchPrevDist = null;
|
|
202
|
+
this._pinchPrevMid = null;
|
|
203
|
+
this._lastTapTime = 0;
|
|
204
|
+
this._lastTapPos = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -188,6 +188,8 @@ export class ToolEventRouter {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
static handleDoubleClick(manager, event) {
|
|
191
|
+
// Нативный dblclick на тач-устройствах гасим — его заменяет синтетический double-tap из PointerGestureController
|
|
192
|
+
if (manager._lastPointerType === 'touch') return;
|
|
191
193
|
if (!manager.activeTool) return;
|
|
192
194
|
|
|
193
195
|
const toolEvent = createPointerEvent(manager, event, {
|
|
@@ -559,6 +561,10 @@ export class ToolEventRouter {
|
|
|
559
561
|
static handleKeyDown(manager, event) {
|
|
560
562
|
this.handleHotkeys(manager, event);
|
|
561
563
|
|
|
564
|
+
if (ToolManagerGuards.shouldIgnoreHotkeys(event)) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
562
568
|
if (!manager.activeTool) return;
|
|
563
569
|
|
|
564
570
|
const toolEvent = {
|
|
@@ -578,6 +584,10 @@ export class ToolEventRouter {
|
|
|
578
584
|
}
|
|
579
585
|
|
|
580
586
|
static handleKeyUp(manager, event) {
|
|
587
|
+
if (ToolManagerGuards.shouldIgnoreHotkeys(event)) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
581
591
|
if (!manager.activeTool) return;
|
|
582
592
|
|
|
583
593
|
const toolEvent = {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isInputElement } from '../../core/keyboard/KeyboardContextGuards.js';
|
|
2
|
+
|
|
1
3
|
export class ToolManagerGuards {
|
|
2
4
|
static isCursorLockedToActiveTool(manager) {
|
|
3
5
|
return !!manager.activeTool && manager.activeTool.name !== 'select';
|
|
@@ -18,7 +20,7 @@ export class ToolManagerGuards {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
static shouldIgnoreHotkeys(event) {
|
|
21
|
-
return event.target
|
|
23
|
+
return isInputElement(event.target);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
static isAuxPanStart(manager, event) {
|