@sequent-org/moodboard 1.4.32 → 1.4.34

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.
Files changed (137) hide show
  1. package/package.json +5 -1
  2. package/src/assets/fonts/inter/inter-cyrillic-400-normal.woff2 +0 -0
  3. package/src/assets/fonts/inter/inter-cyrillic-500-normal.woff2 +0 -0
  4. package/src/assets/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  5. package/src/assets/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  6. package/src/assets/icons/attachments.svg +3 -1
  7. package/src/assets/icons/comments.svg +2 -2
  8. package/src/assets/icons/connector.svg +6 -0
  9. package/src/assets/icons/emoji.svg +6 -1
  10. package/src/assets/icons/frame.svg +4 -1
  11. package/src/assets/icons/image.svg +5 -1
  12. package/src/assets/icons/laser.svg +1 -0
  13. package/src/assets/icons/lasso.svg +5 -0
  14. package/src/assets/icons/mindmap.svg +10 -2
  15. package/src/assets/icons/note.svg +4 -1
  16. package/src/assets/icons/pan.svg +5 -2
  17. package/src/assets/icons/pencil.svg +4 -1
  18. package/src/assets/icons/reactions.svg +5 -0
  19. package/src/assets/icons/redo.svg +3 -2
  20. package/src/assets/icons/select.svg +2 -8
  21. package/src/assets/icons/shapes.svg +5 -1
  22. package/src/assets/icons/text-add.svg +15 -1
  23. package/src/assets/icons/undo.svg +3 -2
  24. package/src/assets/reactions/1f44d.svg +20 -0
  25. package/src/assets/reactions/1f44e.svg +20 -0
  26. package/src/assets/reactions/2705.svg +20 -0
  27. package/src/assets/reactions/274c.svg +19 -0
  28. package/src/assets/reactions/2753.svg +20 -0
  29. package/src/assets/reactions/2764.svg +22 -0
  30. package/src/assets/reactions/2b50.svg +19 -0
  31. package/src/assets/reactions/plus-one.svg +25 -0
  32. package/src/core/PixiEngine.js +23 -0
  33. package/src/core/bootstrap/CoreInitializer.js +43 -0
  34. package/src/core/commands/GroupDeleteCommand.js +13 -1
  35. package/src/core/commands/UpdateShapeStyleCommand.js +121 -0
  36. package/src/core/commands/UpdateTextStyleCommand.js +17 -6
  37. package/src/core/commands/index.js +3 -0
  38. package/src/core/events/Events.js +22 -0
  39. package/src/core/flows/LayerAndViewportFlow.js +1 -0
  40. package/src/core/flows/ObjectLifecycleFlow.js +155 -7
  41. package/src/core/index.js +28 -1
  42. package/src/grid/CrossGridZoomPhases.js +3 -3
  43. package/src/initNoBundler.js +1 -1
  44. package/src/moodboard/DataManager.js +28 -0
  45. package/src/moodboard/MoodBoard.js +27 -0
  46. package/src/moodboard/bootstrap/MoodBoardInitializer.js +69 -1
  47. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +22 -4
  48. package/src/moodboard/integration/MoodBoardEventBindings.js +5 -1
  49. package/src/moodboard/integration/MoodBoardLoadApi.js +10 -1
  50. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +9 -0
  51. package/src/objects/ConnectorObject.js +2 -2
  52. package/src/objects/FrameObject.js +119 -59
  53. package/src/objects/ShapeObject.js +49 -74
  54. package/src/objects/shape/ShapeDrawer.js +210 -0
  55. package/src/services/ConnectorBindingResolver.js +112 -0
  56. package/src/services/ConnectorRouter.js +210 -0
  57. package/src/services/ai/ChatSessionController.js +14 -8
  58. package/src/services/comments/CommentService.js +344 -0
  59. package/src/tools/object-tools/CommentTool.js +85 -0
  60. package/src/tools/object-tools/DrawingTool.js +110 -10
  61. package/src/tools/object-tools/LaserPointerTool.js +121 -0
  62. package/src/tools/object-tools/SelectTool.js +25 -1
  63. package/src/tools/object-tools/TextTool.js +6 -1
  64. package/src/tools/object-tools/connector/ConnectorDragController.js +50 -3
  65. package/src/tools/object-tools/connector/connectorGesture.js +33 -19
  66. package/src/tools/object-tools/placement/PlacementInputRouter.js +22 -1
  67. package/src/tools/object-tools/selection/BoxSelectController.js +24 -2
  68. package/src/tools/object-tools/selection/FrameTitleInlineEditorController.js +139 -0
  69. package/src/tools/object-tools/selection/InlineEditorController.js +12 -0
  70. package/src/tools/object-tools/selection/InlineEditorDomFactory.js +36 -0
  71. package/src/tools/object-tools/selection/LassoSelectController.js +125 -0
  72. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +1 -0
  73. package/src/tools/object-tools/selection/SelectInputRouter.js +64 -5
  74. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +11 -1
  75. package/src/tools/object-tools/selection/SelectToolSetup.js +13 -1
  76. package/src/tools/object-tools/selection/TextEditorInteractionController.js +46 -12
  77. package/src/tools/object-tools/selection/TextEditorSyncService.js +1 -0
  78. package/src/tools/object-tools/selection/TextInlineEditorController.js +65 -6
  79. package/src/ui/CommentPopover.js +6 -0
  80. package/src/ui/CommentsBar.js +91 -0
  81. package/src/ui/ConnectorPropertiesPanel.js +150 -0
  82. package/src/ui/ContextMenu.js +25 -0
  83. package/src/ui/DrawingPropertiesPanel.js +362 -0
  84. package/src/ui/FilePropertiesPanel.js +5 -0
  85. package/src/ui/FramePropertiesPanel.js +5 -0
  86. package/src/ui/HtmlTextLayer.js +246 -66
  87. package/src/ui/NotePropertiesPanel.js +6 -0
  88. package/src/ui/ShapePropertiesPanel.js +307 -0
  89. package/src/ui/TextPropertiesPanel.js +100 -1
  90. package/src/ui/Toolbar.js +25 -2
  91. package/src/ui/Topbar.js +2 -2
  92. package/src/ui/animation/HoverLiftController.js +6 -7
  93. package/src/ui/chat/ChatComposer.js +63 -9
  94. package/src/ui/chat/ChatWindow.js +329 -166
  95. package/src/ui/comments/CommentListPanel.js +213 -0
  96. package/src/ui/comments/CommentPinLayer.js +448 -0
  97. package/src/ui/comments/CommentThreadPopover.js +539 -0
  98. package/src/ui/comments/commentFormat.js +32 -0
  99. package/src/ui/connector-properties/ConnectorPropertiesPanelBindings.js +223 -0
  100. package/src/ui/connector-properties/ConnectorPropertiesPanelEventBridge.js +114 -0
  101. package/src/ui/connector-properties/ConnectorPropertiesPanelMapper.js +144 -0
  102. package/src/ui/connector-properties/ConnectorPropertiesPanelRenderer.js +447 -0
  103. package/src/ui/connector-properties/ConnectorPropertiesPanelState.js +61 -0
  104. package/src/ui/connectors/ConnectionAnchorsLayer.js +1 -0
  105. package/src/ui/connectors/ConnectorHandlesLayer.js +321 -0
  106. package/src/ui/connectors/ConnectorLabelLayer.js +334 -0
  107. package/src/ui/connectors/ConnectorLayer.js +264 -57
  108. package/src/ui/handles/HandlesDomRenderer.js +5 -13
  109. package/src/ui/handles/HandlesEventBridge.js +1 -0
  110. package/src/ui/handles/SingleSelectionHandlesController.js +4 -0
  111. package/src/ui/mindmap/MindmapCollapseLayer.js +1 -0
  112. package/src/ui/mindmap/MindmapConnectionLayer.js +1 -0
  113. package/src/ui/mindmap/MindmapHtmlTextLayer.js +6 -0
  114. package/src/ui/shape-properties/ShapePropertiesPanelDom.js +533 -0
  115. package/src/ui/shape-properties/ShapePropertiesPanelSync.js +132 -0
  116. package/src/ui/styles/chat.css +710 -18
  117. package/src/ui/styles/index.css +1 -0
  118. package/src/ui/styles/panels.css +112 -2
  119. package/src/ui/styles/shape-properties-panel.css +250 -0
  120. package/src/ui/styles/toolbar.css +7 -2
  121. package/src/ui/styles/topbar.css +1 -1
  122. package/src/ui/styles/workspace.css +257 -6
  123. package/src/ui/text-properties/TextFormatControls.js +88 -0
  124. package/src/ui/text-properties/TextListRenderer.js +137 -0
  125. package/src/ui/text-properties/TextPropertiesPanelBindings.js +27 -0
  126. package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +3 -1
  127. package/src/ui/text-properties/TextPropertiesPanelMapper.js +56 -0
  128. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +24 -0
  129. package/src/ui/text-properties/TextPropertiesPanelState.js +8 -0
  130. package/src/ui/toolbar/ReactionsPopupController.js +88 -0
  131. package/src/ui/toolbar/ToolbarActionRouter.js +71 -5
  132. package/src/ui/toolbar/ToolbarPopupsController.js +120 -118
  133. package/src/ui/toolbar/ToolbarRenderer.js +9 -1
  134. package/src/ui/toolbar/ToolbarStateController.js +4 -1
  135. package/src/utils/iconLoader.js +17 -16
  136. package/src/utils/markdown.js +14 -0
  137. package/src/utils/richText.js +125 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Чистая геометрия вычисления маршрутов коннекторов.
3
+ * Без зависимостей PIXI — только координатная математика.
4
+ */
5
+
6
+ const BEZIER_CP_RATIO = 0.4;
7
+ const BEZIER_SAMPLES = 20;
8
+ const ELBOW_STUB = 24; // минимальный выход перпендикулярно грани до первого поворота
9
+
10
+ /**
11
+ * Ортогональные точки излома для elbow-маршрута.
12
+ * Доминирующая ось определяет схему H-V-H или V-H-V.
13
+ *
14
+ * @param {{ x: number, y: number }} start
15
+ * @param {{ x: number, y: number }} end
16
+ * @returns {Array<{x:number,y:number}>}
17
+ */
18
+ export function computeElbowWaypoints(start, end) {
19
+ const dx = end.x - start.x;
20
+ const dy = end.y - start.y;
21
+ if (Math.abs(dx) >= Math.abs(dy)) {
22
+ const mx = Math.round((start.x + end.x) / 2);
23
+ return [start, { x: mx, y: start.y }, { x: mx, y: end.y }, end];
24
+ }
25
+ const my = Math.round((start.y + end.y) / 2);
26
+ return [start, { x: start.x, y: my }, { x: end.x, y: my }, end];
27
+ }
28
+
29
+ /**
30
+ * Ортогональный маршрут с перпендикулярным выходом из грани и минимумом изгибов.
31
+ * S' = startPt + startDir*ELBOW_STUB; E' = endPt + endDir*ELBOW_STUB.
32
+ * Перпендикулярные грани → L-shape (1 изгиб); одна ось → Z-shape (навстречу)
33
+ * или U-shape (в одну сторону), оба по 2 изгиба.
34
+ *
35
+ * @param {{ x:number, y:number }} startPt
36
+ * @param {{ x:number, y:number }} startDir единичная внешняя нормаль грани старта
37
+ * @param {{ x:number, y:number }} endPt
38
+ * @param {{ x:number, y:number }} endDir единичная внешняя нормаль грани конца
39
+ * @returns {Array<{x:number,y:number}>}
40
+ */
41
+ function computeElbowWithDirs(startPt, startDir, endPt, endDir) {
42
+ const S = {
43
+ x: Math.round(startPt.x + startDir.x * ELBOW_STUB),
44
+ y: Math.round(startPt.y + startDir.y * ELBOW_STUB),
45
+ };
46
+ const E = {
47
+ x: Math.round(endPt.x + endDir.x * ELBOW_STUB),
48
+ y: Math.round(endPt.y + endDir.y * ELBOW_STUB),
49
+ };
50
+
51
+ const startHoriz = Math.abs(startDir.x) >= Math.abs(startDir.y);
52
+ const endHoriz = Math.abs(endDir.x) >= Math.abs(endDir.y);
53
+
54
+ // Цель — минимум изгибов: L-shape = 1 изгиб, Z/U-shape = 2 изгиба.
55
+ let corners;
56
+ if (startHoriz === endHoriz) {
57
+ // Грани на одной оси
58
+ if (startHoriz) {
59
+ if (Math.sign(startDir.x) === Math.sign(endDir.x)) {
60
+ // Смотрят в одну сторону → U-shape: общий вынос по дальней кромке (2 изгиба)
61
+ const outerX = startDir.x >= 0 ? Math.max(S.x, E.x) : Math.min(S.x, E.x);
62
+ corners = [{ x: outerX, y: S.y }, { x: outerX, y: E.y }];
63
+ } else {
64
+ // Смотрят противоположно по горизонтали — различаем навстречу и врозь.
65
+ // Навстречу: стабы сходятся, признак: (E.x - S.x) * startDir.x > 0.
66
+ const facingEachOther = (E.x - S.x) * startDir.x > 0;
67
+ if (facingEachOther) {
68
+ // Z-shape: одно вертикальное пересечение посередине (2 изгиба)
69
+ const midX = Math.round((S.x + E.x) / 2);
70
+ corners = [{ x: midX, y: S.y }, { x: midX, y: E.y }];
71
+ } else {
72
+ // Грани смотрят врозь → C-shape: уходим вертикально (перпендикулярно нормали),
73
+ // огибаем сверху или снизу.
74
+ const outerY = S.y <= E.y
75
+ ? Math.min(S.y, E.y) - ELBOW_STUB
76
+ : Math.max(S.y, E.y) + ELBOW_STUB;
77
+ corners = [{ x: S.x, y: outerY }, { x: E.x, y: outerY }];
78
+ }
79
+ }
80
+ } else if (Math.sign(startDir.y) === Math.sign(endDir.y)) {
81
+ const outerY = startDir.y >= 0 ? Math.max(S.y, E.y) : Math.min(S.y, E.y);
82
+ corners = [{ x: S.x, y: outerY }, { x: E.x, y: outerY }];
83
+ } else {
84
+ // Смотрят противоположно по вертикали — различаем навстречу и врозь.
85
+ // Навстречу: (E.y - S.y) * startDir.y > 0.
86
+ const facingEachOther = (E.y - S.y) * startDir.y > 0;
87
+ if (facingEachOther) {
88
+ const midY = Math.round((S.y + E.y) / 2);
89
+ corners = [{ x: S.x, y: midY }, { x: E.x, y: midY }];
90
+ } else {
91
+ // Грани смотрят врозь → C-shape: уходим горизонтально (перпендикулярно нормали),
92
+ // огибаем слева или справа.
93
+ const outerX = S.x <= E.x
94
+ ? Math.min(S.x, E.x) - ELBOW_STUB
95
+ : Math.max(S.x, E.x) + ELBOW_STUB;
96
+ corners = [{ x: outerX, y: S.y }, { x: outerX, y: E.y }];
97
+ }
98
+ }
99
+ } else {
100
+ // Перпендикулярные оси → L-shape (1 изгиб).
101
+ // Угол стыкуем к стабу: первый сегмент продолжает выход, чтобы не плодить изломы.
102
+ if (startHoriz) {
103
+ corners = [{ x: E.x, y: S.y }];
104
+ } else {
105
+ corners = [{ x: S.x, y: E.y }];
106
+ }
107
+ }
108
+
109
+ // Дедупликация соседних точек (вырожденные нулевые сегменты)
110
+ const raw = [startPt, S, ...corners, E, endPt];
111
+ const result = [{ x: Math.round(raw[0].x), y: Math.round(raw[0].y) }];
112
+ for (let i = 1; i < raw.length; i++) {
113
+ const p = raw[i];
114
+ const prev = result[result.length - 1];
115
+ if (Math.abs(p.x - prev.x) > 0.5 || Math.abs(p.y - prev.y) > 0.5) {
116
+ result.push({ x: Math.round(p.x), y: Math.round(p.y) });
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+
123
+ /**
124
+ * Сэмплирует кубическую кривую Безье при параметре t∈[0,1].
125
+ *
126
+ * @param {{ x: number, y: number }} s начало
127
+ * @param {{ x: number, y: number }} cp1 контрольная точка 1
128
+ * @param {{ x: number, y: number }} cp2 контрольная точка 2
129
+ * @param {{ x: number, y: number }} e конец
130
+ * @param {number} t параметр [0,1]
131
+ * @returns {{ x: number, y: number }}
132
+ */
133
+ export function sampleBezier(s, cp1, cp2, e, t) {
134
+ const mt = 1 - t;
135
+ return {
136
+ x: mt * mt * mt * s.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * e.x,
137
+ y: mt * mt * mt * s.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * e.y,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Контрольные точки кубической кривой Безье.
143
+ * Если переданы dir-векторы — отводятся вдоль нормали грани (перпендикулярный заход).
144
+ * Иначе — вдоль доминирующей оси bbox (старый fallback для straight).
145
+ *
146
+ * @param {{ x: number, y: number }} start
147
+ * @param {{ x: number, y: number }} end
148
+ * @param {{ x: number, y: number }|null} startDir
149
+ * @param {{ x: number, y: number }|null} endDir
150
+ * @returns {{ cp1: {x:number,y:number}, cp2: {x:number,y:number} }}
151
+ */
152
+ export function bezierControlPoints(start, end, startDir = null, endDir = null) {
153
+ if (startDir && endDir) {
154
+ const dist = Math.hypot(end.x - start.x, end.y - start.y);
155
+ const offset = dist * BEZIER_CP_RATIO;
156
+ return {
157
+ cp1: { x: start.x + startDir.x * offset, y: start.y + startDir.y * offset },
158
+ cp2: { x: end.x + endDir.x * offset, y: end.y + endDir.y * offset },
159
+ };
160
+ }
161
+ const dx = end.x - start.x;
162
+ const dy = end.y - start.y;
163
+ if (Math.abs(dx) >= Math.abs(dy)) {
164
+ const offset = Math.abs(dx) * BEZIER_CP_RATIO;
165
+ return {
166
+ cp1: { x: start.x + offset, y: start.y },
167
+ cp2: { x: end.x - offset, y: end.y },
168
+ };
169
+ }
170
+ const offset = Math.abs(dy) * BEZIER_CP_RATIO;
171
+ return {
172
+ cp1: { x: start.x, y: start.y + offset },
173
+ cp2: { x: end.x, y: end.y - offset },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Строит массив опорных точек пути по двум world-точкам и route.
179
+ *
180
+ * straight → [start, end]
181
+ * elbow → перпендикулярный выход через стаб + ортогональные изломы
182
+ * bezier → BEZIER_SAMPLES+1 сэмплов вдоль кривой (для hitTest и направления головы)
183
+ *
184
+ * @param {{ x: number, y: number }} start
185
+ * @param {{ x: number, y: number }} end
186
+ * @param {string} route 'straight'|'elbow'|'bezier'
187
+ * @param {{ x: number, y: number }|null} startDir внешняя нормаль грани старта
188
+ * @param {{ x: number, y: number }|null} endDir внешняя нормаль грани конца
189
+ * @returns {Array<{x:number,y:number}>}
190
+ */
191
+ export function buildPath(start, end, route, startDir = null, endDir = null) {
192
+ if (route === 'elbow') {
193
+ if (startDir && endDir) {
194
+ return computeElbowWithDirs(start, startDir, end, endDir);
195
+ }
196
+ return computeElbowWaypoints(start, end);
197
+ }
198
+ if (route === 'bezier') {
199
+ const { cp1, cp2 } = bezierControlPoints(start, end, startDir, endDir);
200
+ const pts = [];
201
+ for (let i = 0; i <= BEZIER_SAMPLES; i++) {
202
+ const p = sampleBezier(start, cp1, cp2, end, i / BEZIER_SAMPLES);
203
+ pts.push({ x: Math.round(p.x), y: Math.round(p.y) });
204
+ }
205
+ return pts;
206
+ }
207
+ return [start, end];
208
+ }
209
+
210
+ export { BEZIER_CP_RATIO, BEZIER_SAMPLES, ELBOW_STUB };
@@ -41,6 +41,7 @@ export class ChatSessionController {
41
41
  this._settingsStorage = settingsStorage || (typeof localStorage !== 'undefined' ? localStorage : null);
42
42
  this._listeners = new Set();
43
43
  this._abort = null;
44
+ this._aborts = new Map();
44
45
 
45
46
  this._state = {
46
47
  messages: this._history.load().map((m) => (m.pending ? { ...m, pending: false, error: m.error || 'Прервано' } : m)),
@@ -103,10 +104,11 @@ export class ChatSessionController {
103
104
  }
104
105
 
105
106
  abort() {
106
- if (this._abort) {
107
- try { this._abort.abort(); } catch { /* noop */ }
108
- this._abort = null;
107
+ for (const controller of this._aborts.values()) {
108
+ try { controller.abort(); } catch { /* noop */ }
109
109
  }
110
+ this._aborts.clear();
111
+ this._abort = null;
110
112
  }
111
113
 
112
114
  /**
@@ -116,14 +118,15 @@ export class ChatSessionController {
116
118
  */
117
119
  async send(text, options = {}) {
118
120
  const trimmed = (text || '').trim();
119
- if (!trimmed || this._state.status === 'streaming') return;
121
+ if (!trimmed) return;
120
122
 
121
123
  const imageCount = normalizeImageCount(options.imageCount);
124
+ const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
122
125
  const userMsg = makeMessage('user', trimmed);
123
126
  const assistantMsgs = Array.from({ length: imageCount }, (_, index) => makeMessage(
124
127
  'assistant',
125
128
  imageCount > 1 ? `Генерируется изображение ${index + 1} из ${imageCount}…` : '',
126
- { provider: 'yandex-art', pending: true, kind: 'image' }
129
+ { provider: 'yandex-art', pending: true, kind: 'image', batchId }
127
130
  ));
128
131
 
129
132
  this._state = {
@@ -136,6 +139,7 @@ export class ChatSessionController {
136
139
  this._emit();
137
140
 
138
141
  const abort = new AbortController();
142
+ this._aborts.set(batchId, abort);
139
143
  this._abort = abort;
140
144
  let lastError = null;
141
145
 
@@ -174,11 +178,13 @@ export class ChatSessionController {
174
178
  })
175
179
  );
176
180
  } finally {
177
- this._abort = null;
181
+ this._aborts.delete(batchId);
182
+ this._abort = this._aborts.size > 0 ? [...this._aborts.values()][this._aborts.size - 1] : null;
183
+ const stillStreaming = this._state.messages.some((m) => m.pending);
178
184
  this._state = {
179
185
  ...this._state,
180
- status: lastError ? 'error' : 'idle',
181
- error: lastError
186
+ status: stillStreaming ? 'streaming' : (lastError ? 'error' : 'idle'),
187
+ error: stillStreaming ? this._state.error : lastError
182
188
  };
183
189
  this._history.save(this._state.messages);
184
190
  this._emit();
@@ -0,0 +1,344 @@
1
+ import { Events } from '../../core/events/Events.js';
2
+
3
+ /**
4
+ * Хранилище тредов/сообщений и вызовы инжектированного CommentsAdapter.
5
+ * Без HTTP — только adapter из options.
6
+ */
7
+ export class CommentService {
8
+ constructor({ eventBus, boardId, adapter, currentUser }) {
9
+ this.eventBus = eventBus;
10
+ this.boardId = boardId;
11
+ this.adapter = adapter;
12
+ this.currentUser = currentUser || null;
13
+ /** @type {Map<number, object>} */
14
+ this.threads = new Map();
15
+ this._appliedMessageIds = new Set();
16
+ this._appliedThreadIds = new Set();
17
+ this._onObjectDeleted = this._onObjectDeleted.bind(this);
18
+ }
19
+
20
+ attach() {
21
+ this.eventBus.on(Events.Object.Deleted, this._onObjectDeleted);
22
+ }
23
+
24
+ detach() {
25
+ this.eventBus.off(Events.Object.Deleted, this._onObjectDeleted);
26
+ }
27
+
28
+ destroy() {
29
+ this.detach();
30
+ this.threads.clear();
31
+ this._appliedMessageIds.clear();
32
+ this._appliedThreadIds.clear();
33
+ }
34
+
35
+ getThread(threadId) {
36
+ return this.threads.get(Number(threadId)) || null;
37
+ }
38
+
39
+ getAllThreads() {
40
+ return Array.from(this.threads.values());
41
+ }
42
+
43
+ async loadInitial() {
44
+ if (!this.adapter?.loadThreads) return;
45
+ const data = await this.adapter.loadThreads(this.boardId, {
46
+ include_resolved: true,
47
+ });
48
+ const items = data?.items || [];
49
+ for (const item of items) {
50
+ this._upsertThread(item);
51
+ if (item.id != null) this._appliedThreadIds.add(Number(item.id));
52
+ for (const msg of item.messages?.items || []) {
53
+ if (msg?.id != null) this._appliedMessageIds.add(Number(msg.id));
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {{ x: number, y: number, anchor_object_id?: string, anchor_dx?: number, anchor_dy?: number, content: string }} payload
60
+ */
61
+ async createThread(payload) {
62
+ if (!this.adapter?.createThread) {
63
+ throw new Error('CommentsAdapter.createThread is not configured');
64
+ }
65
+ const result = await this.adapter.createThread(this.boardId, payload);
66
+ const thread = result?.thread;
67
+ if (thread) {
68
+ this._upsertThread(thread);
69
+ if (thread.id != null) this._appliedThreadIds.add(Number(thread.id));
70
+ for (const msg of thread.messages?.items || []) {
71
+ if (msg?.id != null) this._appliedMessageIds.add(Number(msg.id));
72
+ }
73
+ this.eventBus.emit(Events.Comment.PinCreated, {
74
+ threadId: thread.id,
75
+ x: thread.x,
76
+ y: thread.y,
77
+ anchorObjectId: thread.anchor_object_id || undefined,
78
+ });
79
+ }
80
+ return thread;
81
+ }
82
+
83
+ async addReply(threadId, content) {
84
+ const message = await this.adapter.addReply(this.boardId, threadId, content);
85
+ this._applyMessageToThread(threadId, message);
86
+ this.eventBus.emit(Events.Comment.MessageAdded, { threadId, message });
87
+ if (message?.id != null) this._appliedMessageIds.add(Number(message.id));
88
+ return message;
89
+ }
90
+
91
+ async resolveThread(threadId, resolved) {
92
+ const thread = await this.adapter.resolveThread(this.boardId, threadId, resolved);
93
+ if (thread) {
94
+ this._upsertThread({ ...this.getThread(threadId), ...thread });
95
+ this.eventBus.emit(Events.Comment.Resolved, {
96
+ threadId,
97
+ resolved: !!thread.resolved,
98
+ resolvedBy: thread.resolved_by ?? undefined,
99
+ });
100
+ }
101
+ return thread;
102
+ }
103
+
104
+ async moveThread(threadId, payload) {
105
+ if (!this.adapter?.moveThread) {
106
+ throw new Error('CommentsAdapter.moveThread is not configured');
107
+ }
108
+ const prev = this.getThread(threadId);
109
+ if (!prev) return null;
110
+ this._upsertThread({ ...prev, ...payload });
111
+ try {
112
+ const thread = await this.adapter.moveThread(this.boardId, threadId, payload);
113
+ if (thread) {
114
+ this._upsertThread({ ...this.getThread(threadId), ...thread });
115
+ this.eventBus.emit(Events.Comment.RemoteUpdated, {
116
+ action: 'thread.updated',
117
+ boardId: this.boardId,
118
+ thread,
119
+ message: null,
120
+ changes: payload,
121
+ });
122
+ }
123
+ return thread;
124
+ } catch (err) {
125
+ console.error('[CommentService] moveThread error', err);
126
+ this._upsertThread(prev);
127
+ this.eventBus.emit(Events.Comment.RemoteUpdated, {
128
+ action: 'thread.updated',
129
+ boardId: this.boardId,
130
+ thread: prev,
131
+ message: null,
132
+ changes: null,
133
+ });
134
+ return null;
135
+ }
136
+ }
137
+
138
+ async setThreadColor(threadId, color) {
139
+ if (!this.adapter?.setThreadColor) {
140
+ throw new Error('CommentsAdapter.setThreadColor is not configured');
141
+ }
142
+ const prev = this.getThread(threadId);
143
+ if (!prev) return null;
144
+ this._upsertThread({ ...prev, color });
145
+ this.eventBus.emit(Events.Comment.ColorChanged, { threadId, color });
146
+ try {
147
+ const thread = await this.adapter.setThreadColor(this.boardId, threadId, color);
148
+ if (thread) this._upsertThread({ ...this.getThread(threadId), ...thread });
149
+ return thread;
150
+ } catch (err) {
151
+ console.error('[CommentService] setThreadColor error', err);
152
+ this._upsertThread(prev);
153
+ this.eventBus.emit(Events.Comment.ColorChanged, { threadId, color: prev.color ?? null });
154
+ return null;
155
+ }
156
+ }
157
+
158
+ async deleteThread(threadId) {
159
+ if (!this.adapter?.deleteThread) {
160
+ throw new Error('CommentsAdapter.deleteThread is not configured');
161
+ }
162
+ await this.adapter.deleteThread(this.boardId, threadId);
163
+ const id = Number(threadId);
164
+ const thread = this.threads.get(id);
165
+ this.threads.delete(id);
166
+ this._appliedThreadIds.delete(id);
167
+ if (thread?.messages?.items) {
168
+ for (const m of thread.messages.items) {
169
+ if (m?.id != null) this._appliedMessageIds.delete(Number(m.id));
170
+ }
171
+ }
172
+ this.eventBus.emit(Events.Comment.ThreadDeleted, { threadId });
173
+ }
174
+
175
+ async updateMessage(messageId, content) {
176
+ return this.adapter.updateMessage(this.boardId, messageId, content);
177
+ }
178
+
179
+ async deleteMessage(messageId) {
180
+ const data = await this.adapter.deleteMessage(this.boardId, messageId);
181
+ const threadId = data?.thread_id;
182
+ const thread = threadId != null ? this.getThread(threadId) : null;
183
+ if (thread?.messages?.items) {
184
+ thread.messages.items = thread.messages.items.filter((m) => m.id !== messageId);
185
+ thread.messages.count = thread.messages.items.length;
186
+ }
187
+ this.eventBus.emit(Events.Comment.Deleted, { threadId, messageId });
188
+ return data;
189
+ }
190
+
191
+ openThread(threadId) {
192
+ this.eventBus.emit(Events.Comment.ThreadOpened, { threadId });
193
+ }
194
+
195
+ /**
196
+ * Payload из broadcastWith() §3.3
197
+ */
198
+ applyRemote(event) {
199
+ if (!event) return;
200
+ if (event.board_id != null && String(event.board_id) !== String(this.boardId)) return;
201
+ const action = event.action;
202
+ switch (action) {
203
+ case 'thread.created': {
204
+ const thread = event.thread;
205
+ if (thread?.id != null && this._appliedThreadIds.has(Number(thread.id))) break;
206
+ if (thread) {
207
+ this._upsertThread(thread);
208
+ if (thread.id != null) this._appliedThreadIds.add(Number(thread.id));
209
+ }
210
+ break;
211
+ }
212
+ case 'thread.updated': {
213
+ const thread = event.thread;
214
+ if (thread) {
215
+ const prev = this.getThread(thread.id);
216
+ this._upsertThread({ ...prev, ...thread, ...(event.changes || {}) });
217
+ }
218
+ break;
219
+ }
220
+ case 'thread.resolved': {
221
+ const thread = event.thread;
222
+ if (thread) {
223
+ const prev = this.getThread(thread.id);
224
+ this._upsertThread({ ...prev, ...thread });
225
+ }
226
+ break;
227
+ }
228
+ case 'comment.created': {
229
+ const message = event.message;
230
+ if (message?.id != null && this._appliedMessageIds.has(Number(message.id))) break;
231
+ if (message?.thread_id != null) {
232
+ this._applyMessageToThread(message.thread_id, message);
233
+ if (message.id != null) this._appliedMessageIds.add(Number(message.id));
234
+ }
235
+ if (event.thread) {
236
+ const prev = this.getThread(event.thread.id);
237
+ this._upsertThread({ ...prev, ...event.thread });
238
+ }
239
+ break;
240
+ }
241
+ case 'comment.updated': {
242
+ const message = event.message;
243
+ if (message?.thread_id != null) {
244
+ this._replaceMessageInThread(message.thread_id, message);
245
+ }
246
+ break;
247
+ }
248
+ case 'comment.deleted': {
249
+ const message = event.message;
250
+ if (message?.thread_id != null && message?.id != null) {
251
+ const thread = this.getThread(message.thread_id);
252
+ if (thread?.messages?.items) {
253
+ thread.messages.items = thread.messages.items.filter((m) => m.id !== message.id);
254
+ thread.messages.count = thread.messages.items.length;
255
+ }
256
+ }
257
+ break;
258
+ }
259
+ case 'thread.deleted': {
260
+ const deletedId = event.thread?.id;
261
+ if (deletedId != null) {
262
+ const nid = Number(deletedId);
263
+ this.threads.delete(nid);
264
+ this._appliedThreadIds.delete(nid);
265
+ this.eventBus.emit(Events.Comment.ThreadDeleted, { threadId: deletedId });
266
+ }
267
+ break;
268
+ }
269
+ default:
270
+ break;
271
+ }
272
+ this.eventBus.emit(Events.Comment.RemoteUpdated, {
273
+ action: event.action,
274
+ boardId: event.board_id,
275
+ thread: event.thread,
276
+ message: event.message,
277
+ changes: event.changes,
278
+ });
279
+ }
280
+
281
+ _upsertThread(thread) {
282
+ if (!thread || thread.id == null) return;
283
+ const id = Number(thread.id);
284
+ const prev = this.threads.get(id);
285
+ const messages = thread.messages || prev?.messages;
286
+ this.threads.set(id, { ...prev, ...thread, messages });
287
+ }
288
+
289
+ _applyMessageToThread(threadId, message) {
290
+ const thread = this.getThread(threadId);
291
+ if (!thread) return;
292
+ if (!thread.messages) {
293
+ thread.messages = { count: 0, has_more: false, items: [] };
294
+ }
295
+ const items = thread.messages.items || [];
296
+ const idx = items.findIndex((m) => m.id === message.id);
297
+ if (idx >= 0) items[idx] = message;
298
+ else items.push(message);
299
+ thread.messages.items = items;
300
+ thread.messages.count = items.length;
301
+ thread.messages_count = items.length;
302
+ }
303
+
304
+ _replaceMessageInThread(threadId, message) {
305
+ const thread = this.getThread(threadId);
306
+ if (!thread?.messages?.items) return;
307
+ const idx = thread.messages.items.findIndex((m) => m.id === message.id);
308
+ if (idx >= 0) thread.messages.items[idx] = message;
309
+ }
310
+
311
+ _onObjectDeleted({ objectId }) {
312
+ if (!objectId) return;
313
+ for (const thread of this.threads.values()) {
314
+ if (thread.detached || thread.anchor_object_id !== objectId) continue;
315
+ const wx = thread.x;
316
+ const wy = thread.y;
317
+ thread.detached = true;
318
+ thread.x = wx;
319
+ thread.y = wy;
320
+ thread.anchor_object_id = null;
321
+ thread.anchor_dx = null;
322
+ thread.anchor_dy = null;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * World-позиция пина для отрисовки
328
+ */
329
+ getThreadWorldPosition(thread, core) {
330
+ if (!thread) return null;
331
+ if (thread.detached || !thread.anchor_object_id) {
332
+ return { x: thread.x, y: thread.y };
333
+ }
334
+ const pos = { objectId: thread.anchor_object_id, position: null };
335
+ this.eventBus.emit(Events.Tool.GetObjectPosition, pos);
336
+ if (!pos.position) {
337
+ return { x: thread.x, y: thread.y };
338
+ }
339
+ return {
340
+ x: pos.position.x + (thread.anchor_dx || 0),
341
+ y: pos.position.y + (thread.anchor_dy || 0),
342
+ };
343
+ }
344
+ }