@sequent-org/moodboard 1.4.32 → 1.4.33

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 (136) 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/comments/CommentService.js +344 -0
  58. package/src/tools/object-tools/CommentTool.js +85 -0
  59. package/src/tools/object-tools/DrawingTool.js +110 -10
  60. package/src/tools/object-tools/LaserPointerTool.js +121 -0
  61. package/src/tools/object-tools/SelectTool.js +25 -1
  62. package/src/tools/object-tools/TextTool.js +6 -1
  63. package/src/tools/object-tools/connector/ConnectorDragController.js +50 -3
  64. package/src/tools/object-tools/connector/connectorGesture.js +33 -19
  65. package/src/tools/object-tools/placement/PlacementInputRouter.js +22 -1
  66. package/src/tools/object-tools/selection/BoxSelectController.js +24 -2
  67. package/src/tools/object-tools/selection/FrameTitleInlineEditorController.js +139 -0
  68. package/src/tools/object-tools/selection/InlineEditorController.js +12 -0
  69. package/src/tools/object-tools/selection/InlineEditorDomFactory.js +36 -0
  70. package/src/tools/object-tools/selection/LassoSelectController.js +125 -0
  71. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +1 -0
  72. package/src/tools/object-tools/selection/SelectInputRouter.js +64 -5
  73. package/src/tools/object-tools/selection/SelectToolLifecycleController.js +11 -1
  74. package/src/tools/object-tools/selection/SelectToolSetup.js +13 -1
  75. package/src/tools/object-tools/selection/TextEditorInteractionController.js +46 -12
  76. package/src/tools/object-tools/selection/TextEditorSyncService.js +1 -0
  77. package/src/tools/object-tools/selection/TextInlineEditorController.js +65 -6
  78. package/src/ui/CommentPopover.js +6 -0
  79. package/src/ui/CommentsBar.js +91 -0
  80. package/src/ui/ConnectorPropertiesPanel.js +150 -0
  81. package/src/ui/ContextMenu.js +25 -0
  82. package/src/ui/DrawingPropertiesPanel.js +362 -0
  83. package/src/ui/FilePropertiesPanel.js +5 -0
  84. package/src/ui/FramePropertiesPanel.js +5 -0
  85. package/src/ui/HtmlTextLayer.js +246 -66
  86. package/src/ui/NotePropertiesPanel.js +6 -0
  87. package/src/ui/ShapePropertiesPanel.js +307 -0
  88. package/src/ui/TextPropertiesPanel.js +100 -1
  89. package/src/ui/Toolbar.js +25 -2
  90. package/src/ui/Topbar.js +2 -2
  91. package/src/ui/animation/HoverLiftController.js +6 -7
  92. package/src/ui/chat/ChatComposer.js +58 -7
  93. package/src/ui/chat/ChatWindow.js +60 -143
  94. package/src/ui/comments/CommentListPanel.js +213 -0
  95. package/src/ui/comments/CommentPinLayer.js +448 -0
  96. package/src/ui/comments/CommentThreadPopover.js +539 -0
  97. package/src/ui/comments/commentFormat.js +32 -0
  98. package/src/ui/connector-properties/ConnectorPropertiesPanelBindings.js +223 -0
  99. package/src/ui/connector-properties/ConnectorPropertiesPanelEventBridge.js +114 -0
  100. package/src/ui/connector-properties/ConnectorPropertiesPanelMapper.js +144 -0
  101. package/src/ui/connector-properties/ConnectorPropertiesPanelRenderer.js +447 -0
  102. package/src/ui/connector-properties/ConnectorPropertiesPanelState.js +61 -0
  103. package/src/ui/connectors/ConnectionAnchorsLayer.js +1 -0
  104. package/src/ui/connectors/ConnectorHandlesLayer.js +321 -0
  105. package/src/ui/connectors/ConnectorLabelLayer.js +334 -0
  106. package/src/ui/connectors/ConnectorLayer.js +264 -57
  107. package/src/ui/handles/HandlesDomRenderer.js +5 -13
  108. package/src/ui/handles/HandlesEventBridge.js +1 -0
  109. package/src/ui/handles/SingleSelectionHandlesController.js +4 -0
  110. package/src/ui/mindmap/MindmapCollapseLayer.js +1 -0
  111. package/src/ui/mindmap/MindmapConnectionLayer.js +1 -0
  112. package/src/ui/mindmap/MindmapHtmlTextLayer.js +6 -0
  113. package/src/ui/shape-properties/ShapePropertiesPanelDom.js +533 -0
  114. package/src/ui/shape-properties/ShapePropertiesPanelSync.js +132 -0
  115. package/src/ui/styles/chat.css +709 -19
  116. package/src/ui/styles/index.css +1 -0
  117. package/src/ui/styles/panels.css +112 -2
  118. package/src/ui/styles/shape-properties-panel.css +250 -0
  119. package/src/ui/styles/toolbar.css +7 -2
  120. package/src/ui/styles/topbar.css +1 -1
  121. package/src/ui/styles/workspace.css +257 -6
  122. package/src/ui/text-properties/TextFormatControls.js +88 -0
  123. package/src/ui/text-properties/TextListRenderer.js +137 -0
  124. package/src/ui/text-properties/TextPropertiesPanelBindings.js +27 -0
  125. package/src/ui/text-properties/TextPropertiesPanelEventBridge.js +3 -1
  126. package/src/ui/text-properties/TextPropertiesPanelMapper.js +56 -0
  127. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +24 -0
  128. package/src/ui/text-properties/TextPropertiesPanelState.js +8 -0
  129. package/src/ui/toolbar/ReactionsPopupController.js +88 -0
  130. package/src/ui/toolbar/ToolbarActionRouter.js +71 -5
  131. package/src/ui/toolbar/ToolbarPopupsController.js +120 -118
  132. package/src/ui/toolbar/ToolbarRenderer.js +9 -1
  133. package/src/ui/toolbar/ToolbarStateController.js +4 -1
  134. package/src/utils/iconLoader.js +17 -16
  135. package/src/utils/markdown.js +14 -0
  136. package/src/utils/richText.js +125 -0
@@ -146,10 +146,11 @@ export function createTextEditorFinalize(controller, {
146
146
  });
147
147
  } else {
148
148
  if (isNewCreation) {
149
- controller.eventBus.emit(Events.Tool.UpdateObjectContent, { objectId, content: value });
150
- controller.eventBus.emit(Events.Object.StateChanged, {
149
+ const oldContent = typeof initialContent === 'string' ? initialContent : '';
150
+ controller.eventBus.emit(Events.Object.ContentChange, {
151
151
  objectId,
152
- updates: { properties: { content: value } },
152
+ oldContent,
153
+ newContent: value,
153
154
  });
154
155
  } else {
155
156
  const oldContent = typeof initialContent === 'string' ? initialContent : '';
@@ -167,9 +168,11 @@ export function bindTextEditorInteractions(controller, {
167
168
  textarea,
168
169
  isNewCreation,
169
170
  isNote,
171
+ isShape,
170
172
  autoSize,
171
173
  updateNoteEditor,
172
174
  finalize,
175
+ listType,
173
176
  }) {
174
177
  const blurHandler = () => {
175
178
  const editorObjectId = controller?.textEditor?.objectId || null;
@@ -190,18 +193,48 @@ export function bindTextEditorInteractions(controller, {
190
193
  };
191
194
 
192
195
  const keydownHandler = (e) => {
193
- if (e.key === 'Enter' && !e.shiftKey) {
194
- e.preventDefault();
195
- finalize(true);
196
+ const isList = listType && listType !== 'none';
197
+ if (e.key === 'Enter') {
198
+ if (isList && !e.shiftKey) {
199
+ e.preventDefault();
200
+ const start = textarea.selectionStart;
201
+ const end = textarea.selectionEnd;
202
+ const value = textarea.value;
203
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1;
204
+ const lineEnd = value.indexOf('\n', start);
205
+ const curLineEnd = lineEnd === -1 ? value.length : lineEnd;
206
+ const currentLine = value.slice(lineStart, curLineEnd);
207
+ if (currentLine.trim() === '' && start === end) {
208
+ finalize(true);
209
+ } else {
210
+ textarea.setRangeText('\n', start, end, 'end');
211
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
212
+ }
213
+ } else if (isList && e.shiftKey) {
214
+ e.preventDefault();
215
+ finalize(true);
216
+ } else if (!e.shiftKey) {
217
+ e.preventDefault();
218
+ finalize(true);
219
+ }
220
+ // Shift+Enter в режиме без списка: нативный перевод строки
196
221
  } else if (e.key === 'Escape') {
197
222
  e.preventDefault();
198
223
  finalize(false);
199
224
  }
200
225
  };
201
226
 
202
- const inputHandler = !isNote
203
- ? autoSize
204
- : () => { try { if (updateNoteEditor) updateNoteEditor(); } catch (_) {} };
227
+ // Записка и фигура имеют фиксированные границы — autoSize по вводу им не нужен.
228
+ // Для фигуры autoSize раздувал поле до minWBound (120px) и при textAlign:center
229
+ // уносил текст вправо от фигуры. Поэтому ввод в фигуре границы не меняет.
230
+ let inputHandler;
231
+ if (isNote) {
232
+ inputHandler = () => { try { if (updateNoteEditor) updateNoteEditor(); } catch (_) {} };
233
+ } else if (isShape) {
234
+ inputHandler = () => {};
235
+ } else {
236
+ inputHandler = autoSize;
237
+ }
205
238
 
206
239
  textarea.addEventListener('blur', blurHandler);
207
240
  textarea.addEventListener('keydown', keydownHandler);
@@ -292,10 +325,11 @@ export function closeTextEditorFromState(controller, commit) {
292
325
  });
293
326
  } else {
294
327
  if (isNewCreation) {
295
- controller.eventBus.emit(Events.Tool.UpdateObjectContent, { objectId, content: value });
296
- controller.eventBus.emit(Events.Object.StateChanged, {
328
+ const oldContent = typeof initialContent === 'string' ? initialContent : '';
329
+ controller.eventBus.emit(Events.Object.ContentChange, {
297
330
  objectId,
298
- updates: { properties: { content: value } },
331
+ oldContent,
332
+ newContent: value,
299
333
  });
300
334
  } else {
301
335
  controller.eventBus.emit(Events.Object.ContentChange, {
@@ -95,6 +95,7 @@ export function registerNoteEditorSync(controller, { objectId, updateNoteEditor
95
95
  const listeners = [
96
96
  [Events.UI.ZoomPercent, onZoom],
97
97
  [Events.Tool.PanUpdate, onPan],
98
+ [Events.Viewport.Changed, onPan],
98
99
  [Events.Tool.DragUpdate, onDrag],
99
100
  [Events.Tool.ResizeUpdate, onResize],
100
101
  [Events.Tool.RotateUpdate, onRotate],
@@ -51,6 +51,7 @@ export function openTextEditor(object, create = false) {
51
51
 
52
52
  // Определяем тип объекта
53
53
  const isNote = objectType === 'note';
54
+ const isShape = objectType === 'shape';
54
55
 
55
56
  // Проверяем, что position существует
56
57
  if (!position) {
@@ -156,6 +157,9 @@ export function openTextEditor(object, create = false) {
156
157
 
157
158
  // Вычисляем межстрочный интервал; подгоняем к реальным значениям HTML-отображения
158
159
  let lhInitial = computeTextEditorLineHeightPx(effectiveFontPx);
160
+ if (typeof properties.lineHeight === 'number') {
161
+ lhInitial = Math.round(effectiveFontPx * properties.lineHeight);
162
+ }
159
163
  try {
160
164
  if (objectId) {
161
165
  if (objectType === 'note') {
@@ -207,6 +211,15 @@ export function openTextEditor(object, create = false) {
207
211
  effectiveFontPx,
208
212
  lineHeightPx: lhInitial,
209
213
  });
214
+ if (properties.textAlign) {
215
+ textarea.style.textAlign = properties.textAlign;
216
+ }
217
+
218
+ // Shape: текст по центру, textarea покрывает весь bounds фигуры
219
+ if (isShape) {
220
+ textarea.style.textAlign = 'center';
221
+ textarea.placeholder = '';
222
+ }
210
223
 
211
224
  wrapper.appendChild(textarea);
212
225
  // Убрана зелёная рамка вокруг поля ввода по требованию
@@ -311,6 +324,49 @@ export function openTextEditor(object, create = false) {
311
324
  toScreen,
312
325
  });
313
326
  updateNoteEditor = noteSetup.updateNoteEditor;
327
+ } else if (isShape) {
328
+ // Shape: редактор занимает весь bounds фигуры, текст вертикально центрирован
329
+ const viewResShape = (this.app?.renderer?.resolution) ||
330
+ (view.width && view.clientWidth ? view.width / view.clientWidth : 1);
331
+ const worldLayerShape = this.textEditor.world || this.app?.stage;
332
+ const sShape = worldLayerShape?.scale?.x || 1;
333
+ const sCssShape = sShape / viewResShape;
334
+
335
+ let shapeW = 100, shapeH = 100;
336
+ if (initialSize) {
337
+ shapeW = initialSize.width;
338
+ shapeH = initialSize.height;
339
+ } else if (objectId) {
340
+ const sizeData = { objectId, size: null };
341
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
342
+ if (sizeData.size) { shapeW = sizeData.size.width; shapeH = sizeData.size.height; }
343
+ }
344
+
345
+ const shapeCssW = Math.max(1, Math.round(shapeW * sCssShape));
346
+ const shapeCssH = Math.max(1, Math.round(shapeH * sCssShape));
347
+ // Центрируем каретку по вертикали: padding-top = (высота фигуры - высота одной строки) / 2
348
+ const oneLinePx = Math.max(1, Math.round(effectiveFontPx * 1.4));
349
+ const paddingTopShape = Math.max(0, Math.round((shapeCssH - oneLinePx) / 2));
350
+
351
+ wrapper.style.left = `${Math.round(screenPos.x)}px`;
352
+ wrapper.style.top = `${Math.round(screenPos.y)}px`;
353
+ wrapper.style.width = `${shapeCssW}px`;
354
+ wrapper.style.height = `${shapeCssH}px`;
355
+
356
+ textarea.style.width = `${shapeCssW}px`;
357
+ textarea.style.height = `${shapeCssH}px`;
358
+ textarea.style.boxSizing = 'border-box';
359
+ // display:block убирает baseline-смещение inline-block textarea внутри wrapper'а
360
+ // (wrapper наследует line-height ~24px от body, из-за чего на сильном отдалении
361
+ // textarea съезжает вниз от фигуры на фиксированные ~11px независимо от зума).
362
+ // padding-bottom:0 перебивает CSS .moodboard-text-input (5px), чтобы не раздувать
363
+ // высоту поля относительно крошечной фигуры.
364
+ textarea.style.display = 'block';
365
+ textarea.style.paddingTop = `${paddingTopShape}px`;
366
+ textarea.style.paddingBottom = '0px';
367
+
368
+ this.textEditor._cssLeftPx = Math.round(screenPos.x);
369
+ this.textEditor._cssTopPx = Math.round(screenPos.y);
314
370
  } else {
315
371
  const {
316
372
  leftPx,
@@ -377,7 +433,7 @@ export function openTextEditor(object, create = false) {
377
433
  }
378
434
 
379
435
  // Если создаём новый текст — длина поля ровно как placeholder
380
- if (create && !isNote) {
436
+ if (create && !isNote && !isShape) {
381
437
  // +25% — запас на Caveat vs Arial: при незагруженном Caveat span рендерится в Arial,
382
438
  // а Caveat (рукописный шрифт) заметно шире для кириллицы.
383
439
  const startWidth = Math.max(1, Math.ceil(measureTextEditorPlaceholderWidth(textarea, 'Напишите что-нибудь') * 1.25));
@@ -391,8 +447,8 @@ export function openTextEditor(object, create = false) {
391
447
  minHBound = startHeight;
392
448
  }
393
449
 
394
- // Для записок размеры уже установлены выше, пропускаем эту логику
395
- if (!isNote && !create) {
450
+ // Для записок и фигур размеры уже установлены выше, пропускаем эту логику
451
+ if (!isNote && !isShape && !create) {
396
452
  if (initialWpx) {
397
453
  textarea.style.width = `${initialWpx}px`;
398
454
  wrapper.style.width = `${initialWpx}px`;
@@ -403,7 +459,7 @@ export function openTextEditor(object, create = false) {
403
459
  }
404
460
  }
405
461
  // Автоподгон
406
- const syncRegularTextSizeToObject = !isNote && objectId
462
+ const syncRegularTextSizeToObject = !isNote && !isShape && objectId
407
463
  ? ({ widthPx, heightPx }) => {
408
464
  try {
409
465
  const scaleX = (worldLayerRef?.scale?.x) || 1;
@@ -428,8 +484,8 @@ export function openTextEditor(object, create = false) {
428
484
  onSizeChange: syncRegularTextSizeToObject,
429
485
  });
430
486
 
431
- // Вызываем autoSize только для обычного текста
432
- if (!isNote) {
487
+ // autoSize только для обычного текста; shape имеет фиксированные bounds фигуры
488
+ if (!isNote && !isShape) {
433
489
  autoSize();
434
490
  }
435
491
  textarea.focus();
@@ -448,6 +504,7 @@ export function openTextEditor(object, create = false) {
448
504
  position,
449
505
  properties: { fontSize },
450
506
  objectType,
507
+ listType: properties.listType || 'none',
451
508
  _phStyle: styleEl,
452
509
  initialContent: content,
453
510
  isNewCreation: !!create,
@@ -488,9 +545,11 @@ export function openTextEditor(object, create = false) {
488
545
  textarea,
489
546
  isNewCreation,
490
547
  isNote,
548
+ isShape,
491
549
  autoSize,
492
550
  updateNoteEditor,
493
551
  finalize,
552
+ listType: properties.listType || 'none',
494
553
  });
495
554
  }
496
555
 
@@ -40,6 +40,8 @@ export class CommentPopover {
40
40
  this.eventBus.on(Events.Tool.RotateUpdate, () => this.reposition());
41
41
  this.eventBus.on(Events.UI.ZoomPercent, () => this.reposition());
42
42
  this.eventBus.on(Events.Tool.PanUpdate, () => this.reposition());
43
+ this._onViewportChanged = () => this.reposition();
44
+ this.eventBus.on(Events.Viewport.Changed, this._onViewportChanged);
43
45
  this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
44
46
  if (this.currentId && objectId === this.currentId) this.hide();
45
47
  // По желанию можно очистить: this.commentsById.delete(objectId);
@@ -48,6 +50,10 @@ export class CommentPopover {
48
50
 
49
51
  destroy() {
50
52
  this.hide();
53
+ if (this._onViewportChanged && this.eventBus?.off) {
54
+ this.eventBus.off(Events.Viewport.Changed, this._onViewportChanged);
55
+ this._onViewportChanged = null;
56
+ }
51
57
  if (this.layer) this.layer.remove();
52
58
  this.layer = null;
53
59
  }
@@ -0,0 +1,91 @@
1
+ import { Events } from '../core/events/Events.js';
2
+
3
+ const ICONS = {
4
+ eye: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
5
+ eyeOff: '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>',
6
+ messages: '<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/>',
7
+ };
8
+
9
+ function svg(paths) {
10
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
11
+ }
12
+
13
+ /**
14
+ * Нижняя правая панель управления комментариями.
15
+ * Источник правды для UI-фильтра «показывать решённые»: эмитит
16
+ * Comment.ResolvedFilterChanged, CommentPinLayer применяет фильтр к пинам.
17
+ */
18
+ export class CommentsBar {
19
+ constructor(container, eventBus) {
20
+ this.container = container;
21
+ this.eventBus = eventBus;
22
+ this.element = null;
23
+ this.filterBtn = null;
24
+ this.filterIcon = null;
25
+ this.filterLabel = null;
26
+ /** true — решённые комментарии видны (по умолчанию) */
27
+ this.showResolved = true;
28
+ }
29
+
30
+ attach() {
31
+ this.element = document.createElement('div');
32
+ this.element.className = 'moodboard-commentsbar';
33
+
34
+ this.filterBtn = document.createElement('button');
35
+ this.filterBtn.type = 'button';
36
+ this.filterBtn.className = 'moodboard-commentsbar__filter';
37
+ this.filterIcon = document.createElement('span');
38
+ this.filterIcon.className = 'moodboard-commentsbar__icon';
39
+ this.filterLabel = document.createElement('span');
40
+ this.filterLabel.className = 'moodboard-commentsbar__label';
41
+ this.filterBtn.appendChild(this.filterIcon);
42
+ this.filterBtn.appendChild(this.filterLabel);
43
+ this.filterBtn.addEventListener('click', () => this._toggleResolved());
44
+
45
+ const divider = document.createElement('span');
46
+ divider.className = 'moodboard-commentsbar__divider';
47
+
48
+ this.listBtn = document.createElement('button');
49
+ this.listBtn.type = 'button';
50
+ this.listBtn.className = 'moodboard-commentsbar__button';
51
+ this.listBtn.title = 'Все комментарии';
52
+ this.listBtn.setAttribute('aria-label', 'Все комментарии');
53
+ this.listBtn.innerHTML = svg(ICONS.messages);
54
+ this.listBtn.addEventListener('click', () => {
55
+ this.eventBus.emit(Events.Comment.ListOpened, {});
56
+ });
57
+
58
+ this.element.appendChild(this.filterBtn);
59
+ this.element.appendChild(divider);
60
+ this.element.appendChild(this.listBtn);
61
+ this.container.appendChild(this.element);
62
+
63
+ this._syncFilter();
64
+ }
65
+
66
+ _toggleResolved() {
67
+ this.showResolved = !this.showResolved;
68
+ this._syncFilter();
69
+ this.eventBus.emit(Events.Comment.ResolvedFilterChanged, {
70
+ showResolved: this.showResolved,
71
+ });
72
+ }
73
+
74
+ _syncFilter() {
75
+ if (!this.filterBtn) return;
76
+ const label = this.showResolved ? 'Скрыть решённые' : 'Показать все';
77
+ this.filterIcon.innerHTML = svg(this.showResolved ? ICONS.eyeOff : ICONS.eye);
78
+ this.filterLabel.textContent = label;
79
+ this.filterBtn.title = label;
80
+ this.filterBtn.setAttribute('aria-pressed', String(!this.showResolved));
81
+ }
82
+
83
+ destroy() {
84
+ if (this.element) this.element.remove();
85
+ this.element = null;
86
+ this.filterBtn = null;
87
+ this.filterIcon = null;
88
+ this.filterLabel = null;
89
+ this.listBtn = null;
90
+ }
91
+ }
@@ -0,0 +1,150 @@
1
+ import { Events } from '../core/events/Events.js';
2
+ import {
3
+ createConnectorPropertiesPanelState,
4
+ clearConnectorPropertiesPanelState,
5
+ } from './connector-properties/ConnectorPropertiesPanelState.js';
6
+ import { createConnectorPropertiesPanelDom, updateConnectorPanelControls, updateLabelRow } from './connector-properties/ConnectorPropertiesPanelRenderer.js';
7
+ import { bindConnectorPropertiesPanelControls, unbindConnectorPropertiesPanelControls } from './connector-properties/ConnectorPropertiesPanelBindings.js';
8
+ import { attachConnectorPropertiesPanelEventBridge, detachConnectorPropertiesPanelEventBridge } from './connector-properties/ConnectorPropertiesPanelEventBridge.js';
9
+ import {
10
+ getSelectedConnectorId,
11
+ getConnectorData,
12
+ getConnectorMidpointScreen,
13
+ getConnectorStyle,
14
+ buildStyleUpdate,
15
+ } from './connector-properties/ConnectorPropertiesPanelMapper.js';
16
+
17
+ /**
18
+ * Панель свойств коннектора.
19
+ * Всплывает над серединой выделенной линии.
20
+ */
21
+ export class ConnectorPropertiesPanel {
22
+ constructor(eventBus, container, core) {
23
+ Object.assign(this, createConnectorPropertiesPanelState());
24
+ this.eventBus = eventBus;
25
+ this.container = container;
26
+ this.core = core;
27
+
28
+ createConnectorPropertiesPanelDom(this);
29
+ container.appendChild(this.panel);
30
+
31
+ bindConnectorPropertiesPanelControls(this);
32
+ attachConnectorPropertiesPanelEventBridge(this);
33
+ }
34
+
35
+ // ── Публичное API ────────────────────────────────────────────────────────
36
+
37
+ updateFromSelection() {
38
+ const id = getSelectedConnectorId(this.core);
39
+ if (!id) { this.hide(); return; }
40
+
41
+ if (this.currentId === id && this.panel.style.display !== 'none') {
42
+ this.reposition();
43
+ return;
44
+ }
45
+
46
+ this.currentId = id;
47
+ this.panel.style.display = 'flex';
48
+ this._updateControlsFromObject();
49
+ this.reposition();
50
+ }
51
+
52
+ hide() {
53
+ this.currentId = null;
54
+ this.panel.style.display = 'none';
55
+ this._closeDropdowns();
56
+ }
57
+
58
+ reposition() {
59
+ if (!this.panel || !this.currentId || this.panel.style.display === 'none') return;
60
+
61
+ // Проверяем, что коннектор всё ещё выделен
62
+ const stillSelected = getSelectedConnectorId(this.core) === this.currentId;
63
+ if (!stillSelected) { this.hide(); return; }
64
+
65
+ const mid = getConnectorMidpointScreen(this.core, this.currentId);
66
+ if (!mid) return;
67
+
68
+ const panelW = this.panel.offsetWidth || 480;
69
+ const panelH = this.panel.offsetHeight || 40;
70
+ const GAP = 18;
71
+
72
+ let px = mid.x - Math.round(panelW / 2);
73
+ let py = mid.y - panelH - GAP;
74
+
75
+ // Если уходит вверх за контейнер — переносим ниже середины
76
+ if (py < 0) py = mid.y + GAP;
77
+
78
+ // Clamp по ширине контейнера
79
+ const cw = this.container.offsetWidth || window.innerWidth;
80
+ px = Math.max(8, Math.min(px, cw - panelW - 8));
81
+ py = Math.max(8, py);
82
+
83
+ this.panel.style.left = `${Math.round(px)}px`;
84
+ this.panel.style.top = `${Math.round(py)}px`;
85
+ }
86
+
87
+ destroy() {
88
+ detachConnectorPropertiesPanelEventBridge(this);
89
+ unbindConnectorPropertiesPanelControls(this);
90
+
91
+ if (this.panel?.parentNode) {
92
+ this.panel.parentNode.removeChild(this.panel);
93
+ }
94
+ clearConnectorPropertiesPanelState(this);
95
+ this.panel = null;
96
+ this.eventBus = null;
97
+ this.container = null;
98
+ this.core = null;
99
+ }
100
+
101
+ // ── Внутренние методы ────────────────────────────────────────────────────
102
+
103
+ _getConnector() {
104
+ return getConnectorData(this.core, this.currentId);
105
+ }
106
+
107
+ _updateControlsFromObject() {
108
+ if (!this.currentId) return;
109
+ const connector = this._getConnector();
110
+ if (!connector) return;
111
+ const style = getConnectorStyle(connector);
112
+ updateConnectorPanelControls(this, style);
113
+ updateLabelRow(this, connector);
114
+
115
+ // Синхронизируем кнопку замка
116
+ if (this._lockBtn) {
117
+ const locked = connector.properties?.locked ?? false;
118
+ this._lockBtn.textContent = locked ? '🔒' : '🔓';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Эмитит StateChanged с частичным style-обновлением.
124
+ * Вызывается из Bindings.
125
+ */
126
+ _emitStyle(partialStyle) {
127
+ if (!this.currentId) return;
128
+ this.eventBus.emit(Events.Object.StateChanged, {
129
+ objectId: this.currentId,
130
+ updates: buildStyleUpdate(partialStyle),
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Эмитит StateChanged с полным объектом label (или null).
136
+ * Вызывается из Bindings при изменении цвета/размера метки.
137
+ */
138
+ _emitLabel(label) {
139
+ if (!this.currentId) return;
140
+ this.eventBus.emit(Events.Object.StateChanged, {
141
+ objectId: this.currentId,
142
+ updates: { properties: { style: { label } } },
143
+ });
144
+ }
145
+
146
+ _closeDropdowns() {
147
+ if (this.strokeColorDropdown) this.strokeColorDropdown.style.display = 'none';
148
+ if (this._labelColorDropdown) this._labelColorDropdown.style.display = 'none';
149
+ }
150
+ }
@@ -13,6 +13,8 @@ export class ContextMenu {
13
13
  // Флаг состояния объекта
14
14
  this.destroyed = false;
15
15
 
16
+ this.enableComments = false;
17
+
16
18
  this.createElement();
17
19
  this.attachEvents();
18
20
  }
@@ -303,6 +305,25 @@ export class ContextMenu {
303
305
  });
304
306
  list.appendChild(itemImg);
305
307
 
308
+ // Добавить комментарий (только если фича включена)
309
+ if (this.enableComments) {
310
+ const commentItem = document.createElement('div');
311
+ commentItem.className = 'moodboard-contextmenu__item';
312
+ const commentIcon = document.createElement('span');
313
+ commentIcon.className = 'moodboard-contextmenu__item-icon';
314
+ commentIcon.textContent = '💬';
315
+ const commentLabel = document.createElement('span');
316
+ commentLabel.className = 'moodboard-contextmenu__label';
317
+ commentLabel.textContent = 'Добавить комментарий';
318
+ commentItem.appendChild(commentIcon);
319
+ commentItem.appendChild(commentLabel);
320
+ commentItem.addEventListener('click', () => {
321
+ this.hide();
322
+ this.eventBus.emit(Events.Comment.OpenDraftAt, { screenX: this.lastX, screenY: this.lastY });
323
+ });
324
+ list.appendChild(commentItem);
325
+ }
326
+
306
327
  // Разделитель
307
328
  const divider = document.createElement('div');
308
329
  divider.className = 'moodboard-contextmenu__divider';
@@ -342,6 +363,10 @@ export class ContextMenu {
342
363
  this.element.innerHTML = '<div style="padding:8px 12px; color:#888;">(пусто)</div>';
343
364
  }
344
365
 
366
+ setEnableComments(val) {
367
+ this.enableComments = !!val;
368
+ }
369
+
345
370
  /**
346
371
  * Уничтожение контекстного меню
347
372
  */