@sequent-org/moodboard 1.4.55 → 1.4.56

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.55",
3
+ "version": "1.4.56",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -356,7 +356,7 @@ export function forceInjectPanelStyles() {
356
356
  }
357
357
 
358
358
  .font-select { min-width: 110px !important; }
359
- .font-size-select { min-width: 56px !important; }
359
+ .font-size-select { min-width: 32px !important; }
360
360
 
361
361
  .current-color-button, .current-bgcolor-button, .fpp-color-button {
362
362
  width: 28px !important;
@@ -108,7 +108,6 @@ export class TextTool extends BaseTool {
108
108
  startEditingNew(textData, x, y) {
109
109
  this.isEditing = true;
110
110
  this.editingObject = textData;
111
-
112
111
  this.createTextInput(x, y, '');
113
112
  this.emit('text:edit:start', { object: textData });
114
113
  }
@@ -166,7 +165,7 @@ export class TextTool extends BaseTool {
166
165
  });
167
166
 
168
167
  // Обработчики событий input
169
- this.textInput.addEventListener('blur', () => this.finishEditing());
168
+ this.textInput.addEventListener('blur', (e) => this.handleBlur(e));
170
169
  this.textInput.addEventListener('keydown', (e) => this.handleInputKeys(e));
171
170
  this.textInput.addEventListener('input', (e) => this.handleTextChange(e));
172
171
 
@@ -183,6 +182,13 @@ export class TextTool extends BaseTool {
183
182
  this.adjustInputSize();
184
183
  }
185
184
 
185
+ /**
186
+ * Обработка blur
187
+ */
188
+ handleBlur(event) {
189
+ this.finishEditing();
190
+ }
191
+
186
192
  /**
187
193
  * Обработка клавиш в input
188
194
  */
@@ -328,7 +334,7 @@ export class TextTool extends BaseTool {
328
334
  // TODO: Реализовать поиск текстового объекта по координатам
329
335
  return null; // Временная заглушка
330
336
  }
331
-
337
+
332
338
  /**
333
339
  * Обработка клавиш во время редактирования (глобальные)
334
340
  */
@@ -164,6 +164,22 @@ export function createTextEditorFinalize(controller, {
164
164
  };
165
165
  }
166
166
 
167
+ // Классы DOM-элементов, клики по которым не должны закрывать пустой текстовый редактор.
168
+ const UI_BLOCK_SELECTOR = [
169
+ '.moodboard-toolbar',
170
+ '.text-properties-layer',
171
+ '.moodboard-topbar',
172
+ '.moodboard-zoom-panel',
173
+ '.moodboard-ui-layer',
174
+ ].join(', ');
175
+
176
+ /**
177
+ * Возвращает true, если элемент находится внутри UI-панелей (тулбар, панель свойств текста и т.п.).
178
+ */
179
+ function _isInsideToolbarUI(target) {
180
+ return !!(target && typeof target.closest === 'function' && target.closest(UI_BLOCK_SELECTOR));
181
+ }
182
+
167
183
  export function bindTextEditorInteractions(controller, {
168
184
  textarea,
169
185
  isNewCreation,
@@ -192,6 +208,46 @@ export function bindTextEditorInteractions(controller, {
192
208
  }, 0);
193
209
  };
194
210
 
211
+ // Перехватываем mousedown на тулбаре/панелях в capture-фазе, чтобы:
212
+ // 1. Предотвратить потерю фокуса textarea (e.preventDefault()).
213
+ // 2. Заблокировать последующий click до ToolbarActionRouter (одноразовый capture-обработчик).
214
+ // Работает только пока поле ввода пусто и объект только что создан — если текст уже есть,
215
+ // клик по другому инструменту отрабатывает штатно (commit + переключение).
216
+ let _pendingClickBlocker = null;
217
+ const mousedownCaptureHandler = (e) => {
218
+ if (!controller?.textEditor?.active) return;
219
+ if (e.target === textarea) return;
220
+ if (!_isInsideToolbarUI(e.target)) return;
221
+
222
+ const value = (textarea.value || '').trim();
223
+ if (!isNewCreation || value.length > 0) return;
224
+
225
+ // Пустое новое поле + клик по UI: не даём сместить фокус с textarea.
226
+ e.preventDefault();
227
+
228
+ // Блокируем следующий click-события, чтобы ToolbarActionRouter не переключил инструмент.
229
+ if (_pendingClickBlocker) {
230
+ document.removeEventListener('click', _pendingClickBlocker, true);
231
+ }
232
+ _pendingClickBlocker = (ce) => {
233
+ if (_isInsideToolbarUI(ce.target)) {
234
+ ce.stopPropagation();
235
+ ce.preventDefault();
236
+ }
237
+ document.removeEventListener('click', _pendingClickBlocker, true);
238
+ _pendingClickBlocker = null;
239
+ };
240
+ document.addEventListener('click', _pendingClickBlocker, true);
241
+
242
+ // Страховочный возврат фокуса на случай, если браузер всё-таки убрал фокус.
243
+ setTimeout(() => {
244
+ if (controller?.textEditor?.active && textarea) {
245
+ try { textarea.focus(); } catch (_) {}
246
+ }
247
+ }, 0);
248
+ };
249
+ document.addEventListener('mousedown', mousedownCaptureHandler, true);
250
+
195
251
  const keydownHandler = (e) => {
196
252
  const isList = listType && listType !== 'none';
197
253
  if (e.key === 'Enter') {
@@ -244,6 +300,11 @@ export function bindTextEditorInteractions(controller, {
244
300
  textarea.removeEventListener('blur', blurHandler);
245
301
  textarea.removeEventListener('keydown', keydownHandler);
246
302
  textarea.removeEventListener('input', inputHandler);
303
+ document.removeEventListener('mousedown', mousedownCaptureHandler, true);
304
+ if (_pendingClickBlocker) {
305
+ document.removeEventListener('click', _pendingClickBlocker, true);
306
+ _pendingClickBlocker = null;
307
+ }
247
308
  };
248
309
 
249
310
  if (controller.textEditor) {
@@ -173,6 +173,12 @@ export class HtmlTextLayer {
173
173
  el.style.overflowWrap = isMarkdown ? 'break-word' : '';
174
174
  if (!isMarkdown) el.style.padding = '0';
175
175
  }
176
+ // После коммита текста высота .mb-text осталась от пустого редактора (схлопнута),
177
+ // а ResizeUpdate при завершении редактирования отрабатывает раньше синхронизации
178
+ // контента. Без пере-подгонки DOM-бокс не оборачивает глифы, и рамка выделения,
179
+ // строящаяся по getBoundingClientRect этого блока, оказывается выше текста.
180
+ this._autoFitTextHeight(objectId);
181
+ this.updateOne(objectId);
176
182
  console.log(`🔍 HtmlTextLayer: содержимое обновлено для ${objectId}:`, content);
177
183
  } else {
178
184
  console.warn(`❌ HtmlTextLayer: не удалось обновить содержимое для ${objectId}:`, { el: !!el, content });
@@ -349,7 +349,11 @@ export class TextPropertiesPanel {
349
349
  const panelY = screenY - this.panel.offsetHeight - 20;
350
350
 
351
351
  const containerRect = this.container.getBoundingClientRect();
352
- const finalX = Math.max(10, Math.min(panelX, containerRect.width - this.panel.offsetWidth - 10));
352
+ const toolbarEl = document.querySelector('.moodboard-toolbar');
353
+ const toolbarRight = toolbarEl
354
+ ? toolbarEl.getBoundingClientRect().right - containerRect.left + 10
355
+ : 10;
356
+ const finalX = Math.max(toolbarRight, Math.min(panelX, containerRect.width - this.panel.offsetWidth - 10));
353
357
  const finalY = Math.max(10, panelY);
354
358
 
355
359
  this.panel.style.left = `${Math.round(finalX)}px`;
@@ -195,7 +195,7 @@
195
195
  flex-direction: row;
196
196
  align-items: center;
197
197
  gap: 8px;
198
- padding: 12px 22px;
198
+ padding: 0 22px;
199
199
  background-color: #ffffff;
200
200
  border: 1px solid #e0e0e0;
201
201
  border-radius: 9999px;
@@ -206,6 +206,10 @@
206
206
  height: 36px;
207
207
  }
208
208
 
209
+ .text-properties-panel * {
210
+ max-height: 36px;
211
+ }
212
+
209
213
  /* Frame properties toolbar — mirror image toolbar look */
210
214
  .frame-properties-panel {
211
215
  position: absolute;
@@ -604,18 +608,151 @@
604
608
 
605
609
  .text-properties-panel .tpp-label--spaced { margin-left: 6px; }
606
610
 
607
- .text-properties-panel .font-select,
608
- .text-properties-panel .font-size-select {
609
- border: 1px solid #ddd;
610
- border-radius: 4px;
611
+ .text-properties-panel .font-select-wrapper {
612
+ position: relative;
613
+ display: inline-block;
614
+ }
615
+
616
+ .text-properties-panel .font-select {
617
+ box-sizing: border-box;
618
+ min-width: 90px;
619
+ width: 90px;
620
+ height: 38px;
611
621
  padding: 4px 8px;
612
622
  font-size: 13px;
613
623
  background-color: #fff;
624
+ border: none;
625
+ border-radius: 4px;
626
+ cursor: pointer;
627
+ display: flex;
628
+ align-items: center;
629
+ user-select: none;
630
+ }
631
+
632
+ .text-properties-panel .font-select:focus { outline: none; box-shadow: none; }
633
+
634
+ .text-properties-panel .font-select__label {
635
+ flex: 1;
636
+ overflow: hidden;
637
+ text-overflow: ellipsis;
638
+ white-space: nowrap;
639
+ }
640
+
641
+ .text-properties-panel .font-dropdown {
642
+ position: absolute;
643
+ top: calc(100% + 4px);
644
+ left: 0;
645
+ min-width: 180px;
646
+ max-height: 280px;
647
+ overflow-y: auto;
648
+ background: #fff;
649
+ border: 1px solid #E5E7EB;
650
+ border-radius: 8px;
651
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05);
652
+ padding: 4px;
653
+ display: none;
654
+ z-index: 10000;
655
+ scrollbar-width: thin;
656
+ scrollbar-color: transparent transparent;
657
+ }
658
+
659
+ .text-properties-panel .font-dropdown.is-open { display: block; }
660
+
661
+ .text-properties-panel .font-dropdown:hover {
662
+ scrollbar-color: rgba(170, 170, 170, 0.7) transparent;
663
+ }
664
+
665
+ .text-properties-panel .font-dropdown::-webkit-scrollbar { width: 9px; }
666
+ .text-properties-panel .font-dropdown::-webkit-scrollbar-button {
667
+ display: none;
668
+ height: 0;
669
+ width: 0;
670
+ }
671
+ .text-properties-panel .font-dropdown::-webkit-scrollbar-track { background: transparent; }
672
+ .text-properties-panel .font-dropdown::-webkit-scrollbar-thumb {
673
+ background: transparent;
674
+ border-radius: 4px;
675
+ }
676
+ .text-properties-panel .font-dropdown:hover::-webkit-scrollbar-thumb {
677
+ background: rgba(170, 170, 170, 0.7);
678
+ }
679
+
680
+ .text-properties-panel .font-dropdown__item {
681
+ display: block;
682
+ width: 100%;
683
+ padding: 6px 10px;
684
+ border: none;
685
+ background: transparent;
686
+ text-align: left;
687
+ font-size: 14px;
688
+ color: #111;
689
+ cursor: pointer;
690
+ border-radius: 4px;
691
+ white-space: nowrap;
692
+ }
693
+
694
+ .text-properties-panel .font-dropdown__item:hover {
695
+ background: #f3f4f6;
696
+ }
697
+
698
+ .text-properties-panel .font-dropdown__item.is-active {
699
+ background: #2563eb;
700
+ color: #fff;
701
+ }
702
+ .text-properties-panel .font-size-wrapper {
703
+ display: flex;
704
+ align-items: center;
705
+ position: relative;
706
+ border: none;
707
+ border-radius: 4px;
708
+ background-color: #fff;
709
+ height: 38px;
710
+ box-sizing: border-box;
711
+ }
712
+
713
+ .text-properties-panel .font-size-select {
714
+ outline: none;
715
+ height: 100%;
716
+ width: 32px;
717
+ border: none;
718
+ background: transparent;
719
+ appearance: none;
720
+ -webkit-appearance: none;
721
+ -moz-appearance: none;
722
+ padding: 0 4px 0 8px;
723
+ font-size: 13px;
614
724
  cursor: pointer;
615
725
  }
616
726
 
617
- .text-properties-panel .font-select { min-width: 110px; }
618
- .text-properties-panel .font-size-select { min-width: 56px; }
727
+ .text-properties-panel .font-size-steppers {
728
+ display: flex;
729
+ flex-direction: column;
730
+ height: 100%;
731
+ width: 20px;
732
+ border-left: none;
733
+ }
734
+
735
+ .text-properties-panel .font-size-stepper {
736
+ flex: 1;
737
+ display: flex;
738
+ align-items: center;
739
+ justify-content: center;
740
+ background: transparent;
741
+ border: none;
742
+ padding: 0;
743
+ cursor: pointer;
744
+ color: #666;
745
+ }
746
+
747
+ .text-properties-panel .font-size-stepper:hover {
748
+ background: #f5f5f5;
749
+ color: #333;
750
+ }
751
+
752
+ .text-properties-panel .font-size-stepper--down {
753
+ border-bottom-right-radius: 4px;
754
+ margin-top: -2px;
755
+ }
619
756
 
620
757
  .text-properties-panel .current-color-button,
621
758
  .text-properties-panel .current-bgcolor-button {
@@ -654,19 +791,101 @@
654
791
  color: #6155F5;
655
792
  }
656
793
 
657
- .text-properties-panel .tpp-align-select,
658
- .text-properties-panel .tpp-list-select {
659
- border: 1px solid #ddd;
794
+ .text-properties-panel .tpp-list-btns {
795
+ display: flex;
796
+ gap: 2px;
797
+ align-items: center;
798
+ position: relative;
799
+ }
800
+ .text-properties-panel .tpp-list-btns svg {
801
+ width: 22px;
802
+ height: 22px;
803
+ }
804
+
805
+ .text-properties-panel .tpp-list-modal {
806
+ max-height: none;
807
+ position: absolute;
808
+ top: calc(100% + 4px);
809
+ left: 0;
810
+ flex-direction: column;
811
+ gap: 2px;
812
+ background: #ffffff;
813
+ border: 1px solid #E5E7EB;
814
+ border-radius: 6px;
815
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06);
816
+ padding: 4px;
817
+ z-index: 4000;
818
+ }
819
+
820
+ .text-properties-panel .tpp-list-modal-option {
821
+ display: flex;
822
+ align-items: center;
823
+ justify-content: center;
824
+ width: 28px;
825
+ height: 28px;
826
+ padding: 0;
827
+ border: none;
828
+ background: transparent;
660
829
  border-radius: 4px;
661
- padding: 4px 6px;
662
- font-size: 12px;
663
- background-color: #fff;
830
+ cursor: pointer;
831
+ color: #374151;
832
+ transition: background 0.1s;
833
+ flex-shrink: 0;
834
+ }
835
+
836
+ .text-properties-panel .tpp-list-modal-option:hover {
837
+ background: #f3f4f6;
838
+ }
839
+
840
+ .text-properties-panel .tpp-align-btns {
841
+ display: flex;
842
+ gap: 2px;
843
+ align-items: center;
844
+ position: relative;
845
+ }
846
+
847
+ .text-properties-panel .tpp-align-btns img,
848
+ .text-properties-panel .tpp-align-btns svg,
849
+ .text-properties-panel .tpp-align-modal img,
850
+ .text-properties-panel .tpp-align-modal svg {
851
+ width: 24px;
852
+ height: 24px;
853
+ }
854
+
855
+ .text-properties-panel .tpp-align-modal {
856
+ max-height: none;
857
+ position: absolute;
858
+ top: calc(100% + 4px);
859
+ left: 0;
860
+ flex-direction: column;
861
+ gap: 2px;
862
+ background: #ffffff;
863
+ border: 1px solid #E5E7EB;
864
+ border-radius: 6px;
865
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06);
866
+ padding: 4px;
867
+ z-index: 4000;
868
+ }
869
+
870
+ .text-properties-panel .tpp-align-modal-option {
871
+ display: flex;
872
+ align-items: center;
873
+ justify-content: center;
874
+ width: 28px;
664
875
  height: 28px;
876
+ padding: 0;
877
+ border: none;
878
+ background: transparent;
879
+ border-radius: 4px;
665
880
  cursor: pointer;
666
- font-family: 'Roboto', Arial, sans-serif;
881
+ color: #374151;
882
+ transition: background 0.1s;
883
+ flex-shrink: 0;
884
+ }
885
+
886
+ .text-properties-panel .tpp-align-modal-option:hover {
887
+ background: #f3f4f6;
667
888
  }
668
- .text-properties-panel .tpp-align-select { min-width: 86px; }
669
- .text-properties-panel .tpp-list-select { min-width: 110px; }
670
889
 
671
890
  .text-properties-panel .tpp-lh-slider {
672
891
  width: 68px;
@@ -684,7 +684,7 @@
684
684
  }
685
685
 
686
686
  .text-properties-panel .font-select { min-width: 110px; }
687
- .text-properties-panel .font-size-select { min-width: 56px; }
687
+ .text-properties-panel .font-size-select { min-width: 32px; }
688
688
 
689
689
  .text-properties-panel .current-color-button,
690
690
  .text-properties-panel .current-bgcolor-button {
@@ -11,6 +11,15 @@ const SVG_ITALIC = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="1
11
11
  const SVG_UNDERLINE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>';
12
12
  const SVG_STRIKE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" y1="12" x2="20" y2="12"/></svg>';
13
13
 
14
+ const SVG_ALIGN_LEFT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4.75 5.73633H19.25"/><path d="M4.75 18.2637H13.25"/><path d="M4.75 14.0879H19.25"/><path d="M4.75 9.91211L13.25 9.91211"/></svg>';
15
+ const SVG_ALIGN_CENTER = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4.75 5.73633H19.25"/><path d="M7.75 18.2637H16.25"/><path d="M4.75 14.0879H19.25"/><path d="M7.75 9.91211L16.25 9.91211"/></svg>';
16
+ const SVG_ALIGN_RIGHT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4.75 5.73633H19.25"/><path d="M10.75 18.2637H19.25"/><path d="M4.75 14.0879H19.25"/><path d="M10.75 9.91211L19.25 9.91211"/></svg>';
17
+ const SVG_ALIGN_JUSTIFY = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4.75 5.73633H19.25"/><path d="M4.75 9.91211H19.25"/><path d="M4.75 14.0879H19.25"/><path d="M4.75 18.2637H19.25"/></svg>';
18
+
19
+ const SVG_LIST_BULLET = '<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8.28516 5.49609H19.2852M8.28516 11H19.2852M8.28516 16.4961H19.2852M4.21484 17.25C4.62906 17.25 4.96484 16.9142 4.96484 16.5C4.96484 16.0858 4.62906 15.75 4.21484 15.75C3.80063 15.75 3.46484 16.0858 3.46484 16.5C3.46484 16.9142 3.80063 17.25 4.21484 17.25ZM4.21484 11.75C4.62906 11.75 4.96484 11.4142 4.96484 11C4.96484 10.5858 4.62906 10.25 4.21484 10.25C3.80063 10.25 3.46484 10.5858 3.46484 11C3.46484 11.4142 3.80063 11.75 4.21484 11.75ZM4.21484 6.24609C4.62906 6.24609 4.96484 5.91031 4.96484 5.49609C4.96484 5.08188 4.62906 4.74609 4.21484 4.74609C3.80063 4.74609 3.46484 5.08188 3.46484 5.49609C3.46484 5.91031 3.80063 6.24609 4.21484 6.24609Z"/></svg>';
20
+ const SVG_LIST_NUMBERED = '<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8.28516 5.49505H19.2852M8.28516 10.999H19.2852M8.28516 16.4951H19.2852M3.30078 5.32812L4.67578 4.64062V9.45312M3.39531 13.2839C3.45867 13.1269 3.55438 12.9853 3.67622 12.8678C3.79808 12.7505 3.9433 12.6602 4.10242 12.6027C4.26156 12.5453 4.43101 12.5221 4.59972 12.5345C4.76844 12.547 4.93262 12.595 5.08156 12.6752C5.23049 12.7555 5.36082 12.8662 5.46407 13.0002C5.56729 13.1343 5.6411 13.2885 5.68067 13.453C5.72022 13.6175 5.72466 13.7885 5.69366 13.9549C5.66266 14.1211 5.59693 14.279 5.50078 14.4182L3.30078 17.3573H5.70703"/></svg>';
21
+ const SVG_LIST_CHECK = '<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8.28516 5.49609H19.2852M8.28516 11H19.2852M8.28516 16.4961H19.2852M2.9 5.2L3.9 6.2L5.7 4.2M2.9 10.7L3.9 11.7L5.7 9.7M2.9 16.2L3.9 17.2L5.7 15.2"/></svg>';
22
+
14
23
  function makeSep() {
15
24
  const d = document.createElement('div');
16
25
  d.style.cssText = 'width:1px;height:18px;background:#e0e0e0;margin:0 6px;flex-shrink:0;';
@@ -27,16 +36,216 @@ function makeToggleBtn(svgStr, title, prop) {
27
36
  return btn;
28
37
  }
29
38
 
30
- function makeSelect(className, options) {
31
- const sel = document.createElement('select');
32
- sel.className = className;
33
- options.forEach(({ value, label }) => {
34
- const opt = document.createElement('option');
35
- opt.value = value;
36
- opt.textContent = label;
37
- sel.appendChild(opt);
39
+ function makeListButtons() {
40
+ const container = document.createElement('div');
41
+ container.className = 'tpp-list-btns';
42
+
43
+ const listOptions = [
44
+ { value: 'bullet', title: 'Маркированный список', svg: SVG_LIST_BULLET },
45
+ { value: 'numbered', title: 'Нумерованный список', svg: SVG_LIST_NUMBERED },
46
+ { value: 'checkbox', title: 'Список с флажками', svg: SVG_LIST_CHECK },
47
+ ];
48
+
49
+ let currentValue = 'none';
50
+ const changeListeners = [];
51
+
52
+ // Кнопка-триггер: всегда показывает иконку bullet-list и открывает модальное окно
53
+ const triggerBtn = document.createElement('button');
54
+ triggerBtn.type = 'button';
55
+ triggerBtn.title = 'Список';
56
+ triggerBtn.className = 'tpp-format-btn tpp-list-trigger';
57
+ triggerBtn.innerHTML = SVG_LIST_BULLET;
58
+
59
+ const modal = document.createElement('div');
60
+ modal.className = 'tpp-list-modal';
61
+ modal.style.display = 'none';
62
+
63
+ listOptions.forEach(({ value, title, svg }) => {
64
+ const optBtn = document.createElement('button');
65
+ optBtn.type = 'button';
66
+ optBtn.title = title;
67
+ optBtn.className = 'tpp-list-modal-option';
68
+ optBtn.dataset.listValue = value;
69
+ optBtn.innerHTML = svg;
70
+ optBtn.addEventListener('click', (e) => {
71
+ e.stopPropagation();
72
+ // Клик по выбранному элементу снимает форматирование (none).
73
+ currentValue = currentValue === value ? 'none' : value;
74
+ closeModal();
75
+ updateActive();
76
+ changeListeners.forEach((cb) => cb({ target: proxy }));
77
+ });
78
+ modal.appendChild(optBtn);
79
+ });
80
+
81
+ container.appendChild(triggerBtn);
82
+ container.appendChild(modal);
83
+
84
+ function openModal() {
85
+ modal.style.display = 'flex';
86
+ updateModalActive();
87
+ setTimeout(() => {
88
+ document.addEventListener('click', closeOnOutside);
89
+ }, 0);
90
+ }
91
+
92
+ function closeModal() {
93
+ modal.style.display = 'none';
94
+ document.removeEventListener('click', closeOnOutside);
95
+ }
96
+
97
+ function closeOnOutside(e) {
98
+ if (!container.contains(e.target)) {
99
+ closeModal();
100
+ }
101
+ }
102
+
103
+ function updateActive() {
104
+ triggerBtn.classList.toggle('is-active', currentValue !== 'none');
105
+ updateModalActive();
106
+ }
107
+
108
+ function updateModalActive() {
109
+ modal.querySelectorAll('.tpp-list-modal-option').forEach((btn) => {
110
+ const isActive = btn.dataset.listValue === currentValue;
111
+ btn.style.color = isActive ? 'rgb(53, 56, 205)' : '';
112
+ });
113
+ }
114
+
115
+ triggerBtn.addEventListener('click', (e) => {
116
+ e.stopPropagation();
117
+ if (modal.style.display === 'none') {
118
+ openModal();
119
+ } else {
120
+ closeModal();
121
+ }
122
+ });
123
+
124
+ const proxy = {
125
+ get value() {
126
+ return currentValue;
127
+ },
128
+ set value(v) {
129
+ currentValue = v || 'none';
130
+ updateActive();
131
+ },
132
+ addEventListener(event, cb) {
133
+ if (event === 'change') changeListeners.push(cb);
134
+ },
135
+ _container: container,
136
+ };
137
+
138
+ updateActive();
139
+ return proxy;
140
+ }
141
+
142
+ function makeAlignButtons() {
143
+ const container = document.createElement('div');
144
+ container.className = 'tpp-align-btns';
145
+
146
+ const alignOptions = [
147
+ { value: 'left', title: 'По левому краю', svg: SVG_ALIGN_LEFT },
148
+ { value: 'center', title: 'По центру', svg: SVG_ALIGN_CENTER },
149
+ { value: 'right', title: 'По правому краю', svg: SVG_ALIGN_RIGHT },
150
+ { value: 'justify', title: 'По ширине', svg: SVG_ALIGN_JUSTIFY },
151
+ ];
152
+
153
+ let currentValue = 'left';
154
+ const changeListeners = [];
155
+
156
+ // Единственная кнопка — отображает текущее выравнивание и открывает модальное окно
157
+ const triggerBtn = document.createElement('button');
158
+ triggerBtn.type = 'button';
159
+ triggerBtn.className = 'tpp-format-btn tpp-align-btn tpp-align-trigger';
160
+ triggerBtn.dataset.alignValue = 'left';
161
+
162
+ // Модальный попап
163
+ const modal = document.createElement('div');
164
+ modal.className = 'tpp-align-modal';
165
+ modal.style.display = 'none';
166
+
167
+ alignOptions.forEach(({ value, title, svg }) => {
168
+ const optBtn = document.createElement('button');
169
+ optBtn.type = 'button';
170
+ optBtn.title = title;
171
+ optBtn.className = 'tpp-align-modal-option';
172
+ optBtn.dataset.alignValue = value;
173
+ optBtn.innerHTML = svg;
174
+ optBtn.addEventListener('click', (e) => {
175
+ e.stopPropagation();
176
+ currentValue = value;
177
+ closeModal();
178
+ updateActive();
179
+ changeListeners.forEach((cb) => cb({ target: proxy }));
180
+ });
181
+ modal.appendChild(optBtn);
182
+ });
183
+
184
+ container.appendChild(triggerBtn);
185
+ container.appendChild(modal);
186
+
187
+ function openModal() {
188
+ modal.style.display = 'flex';
189
+ updateModalActive();
190
+ // Закрытие по клику вне
191
+ setTimeout(() => {
192
+ document.addEventListener('click', closeOnOutside);
193
+ }, 0);
194
+ }
195
+
196
+ function closeModal() {
197
+ modal.style.display = 'none';
198
+ document.removeEventListener('click', closeOnOutside);
199
+ }
200
+
201
+ function closeOnOutside(e) {
202
+ if (!container.contains(e.target)) {
203
+ closeModal();
204
+ }
205
+ }
206
+
207
+ function updateActive() {
208
+ const opt = alignOptions.find((o) => o.value === currentValue);
209
+ if (opt) {
210
+ triggerBtn.innerHTML = opt.svg;
211
+ triggerBtn.title = opt.title;
212
+ triggerBtn.dataset.alignValue = currentValue;
213
+ }
214
+ updateModalActive();
215
+ }
216
+
217
+ function updateModalActive() {
218
+ modal.querySelectorAll('.tpp-align-modal-option').forEach((btn) => {
219
+ const isActive = btn.dataset.alignValue === currentValue;
220
+ btn.style.color = isActive ? 'rgb(53, 56, 205)' : '';
221
+ });
222
+ }
223
+
224
+ triggerBtn.addEventListener('click', (e) => {
225
+ e.stopPropagation();
226
+ if (modal.style.display === 'none') {
227
+ openModal();
228
+ } else {
229
+ closeModal();
230
+ }
38
231
  });
39
- return sel;
232
+
233
+ const proxy = {
234
+ get value() {
235
+ return currentValue;
236
+ },
237
+ set value(v) {
238
+ currentValue = v;
239
+ updateActive();
240
+ },
241
+ addEventListener(event, cb) {
242
+ if (event === 'change') changeListeners.push(cb);
243
+ },
244
+ _container: container,
245
+ };
246
+
247
+ updateActive();
248
+ return proxy;
40
249
  }
41
250
 
42
251
  export function createTextFormatControls(panelInstance, panel) {
@@ -53,21 +262,11 @@ export function createTextFormatControls(panelInstance, panel) {
53
262
 
54
263
  panel.appendChild(makeSep());
55
264
 
56
- panelInstance.alignControl = makeSelect('tpp-align-select', [
57
- { value: 'left', label: '≡ Лево' },
58
- { value: 'center', label: '≡ Центр' },
59
- { value: 'right', label: '≡ Право' },
60
- { value: 'justify', label: '≡ По ширине' },
61
- ]);
62
- panel.appendChild(panelInstance.alignControl);
63
-
64
- panelInstance.listControl = makeSelect('tpp-list-select', [
65
- { value: 'none', label: 'Список: нет' },
66
- { value: 'bullet', label: '• Маркер' },
67
- { value: 'numbered', label: '1. Нумерация' },
68
- { value: 'checkbox', label: '☐ Флажки' },
69
- ]);
70
- panel.appendChild(panelInstance.listControl);
265
+ panelInstance.alignControl = makeAlignButtons();
266
+ panel.appendChild(panelInstance.alignControl._container);
267
+
268
+ panelInstance.listControl = makeListButtons();
269
+ panel.appendChild(panelInstance.listControl._container);
71
270
 
72
271
  panel.appendChild(makeSep());
73
272
 
@@ -7,19 +7,80 @@ function hidePresetTicks(buttons) {
7
7
  });
8
8
  }
9
9
 
10
+ function setFontDropdownOpen(panel, isOpen) {
11
+ if (!panel.fontDropdown || !panel.fontSelect) {
12
+ return;
13
+ }
14
+ panel.fontDropdown.classList.toggle('is-open', isOpen);
15
+ panel.fontSelect.classList.toggle('is-active', isOpen);
16
+ panel.fontSelect.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
17
+ }
18
+
10
19
  export function bindTextPropertiesPanelControls(panel) {
11
20
  if (panel._bindingsAttached) {
12
21
  return;
13
22
  }
14
23
 
15
- panel.fontSelect.addEventListener('change', (event) => {
16
- panel._changeFontFamily(event.target.value);
24
+ panel.fontSelect.addEventListener('click', (event) => {
25
+ event.stopPropagation();
26
+ const willOpen = !panel.fontDropdown.classList.contains('is-open');
27
+ setFontDropdownOpen(panel, willOpen);
17
28
  });
18
29
 
30
+ panel.fontSelect.addEventListener('keydown', (event) => {
31
+ if (event.key === 'Enter' || event.key === ' ') {
32
+ event.preventDefault();
33
+ const willOpen = !panel.fontDropdown.classList.contains('is-open');
34
+ setFontDropdownOpen(panel, willOpen);
35
+ } else if (event.key === 'Escape') {
36
+ setFontDropdownOpen(panel, false);
37
+ }
38
+ });
39
+
40
+ panel.fontDropdown.querySelectorAll('.font-dropdown__item').forEach((item) => {
41
+ item.addEventListener('click', (event) => {
42
+ event.stopPropagation();
43
+ const value = item.dataset.value;
44
+ panel.fontSelect.value = value;
45
+ panel._changeFontFamily(value);
46
+ setFontDropdownOpen(panel, false);
47
+ });
48
+ });
49
+
50
+ panel._onFontDocumentClick = (event) => {
51
+ if (!panel._fontSelectWrapper || !event.target) {
52
+ return;
53
+ }
54
+ if (!panel._fontSelectWrapper.contains(event.target)) {
55
+ setFontDropdownOpen(panel, false);
56
+ }
57
+ };
58
+ document.addEventListener('click', panel._onFontDocumentClick);
59
+
19
60
  panel.fontSizeSelect.addEventListener('change', (event) => {
20
61
  panel._changeFontSize(parseInt(event.target.value, 10));
21
62
  });
22
63
 
64
+ if (panel.fontSizeUpBtn) {
65
+ panel.fontSizeUpBtn.addEventListener('click', () => {
66
+ const select = panel.fontSizeSelect;
67
+ if (select.selectedIndex < select.options.length - 1) {
68
+ select.selectedIndex++;
69
+ select.dispatchEvent(new Event('change'));
70
+ }
71
+ });
72
+ }
73
+
74
+ if (panel.fontSizeDownBtn) {
75
+ panel.fontSizeDownBtn.addEventListener('click', () => {
76
+ const select = panel.fontSizeSelect;
77
+ if (select.selectedIndex > 0) {
78
+ select.selectedIndex--;
79
+ select.dispatchEvent(new Event('change'));
80
+ }
81
+ });
82
+ }
83
+
23
84
  panel.currentColorButton.addEventListener('click', (event) => {
24
85
  event.stopPropagation();
25
86
  panel._toggleColorDropdown();
@@ -115,5 +176,10 @@ export function unbindTextPropertiesPanelControls(panel) {
115
176
  panel._onBgDocumentClick = null;
116
177
  }
117
178
 
179
+ if (panel._onFontDocumentClick) {
180
+ document.removeEventListener('click', panel._onFontDocumentClick);
181
+ panel._onFontDocumentClick = null;
182
+ }
183
+
118
184
  panel._bindingsAttached = false;
119
185
  }
@@ -99,54 +99,109 @@ export function updateCurrentBgColorButton(panelInstance, color) {
99
99
  }
100
100
 
101
101
  function createFontControls(panelInstance, panel) {
102
- const fontLabel = document.createElement('span');
103
- fontLabel.textContent = 'Шрифт:';
104
- fontLabel.className = 'tpp-label';
105
- panel.appendChild(fontLabel);
106
-
107
- panelInstance.fontSelect = document.createElement('select');
108
- panelInstance.fontSelect.className = 'font-select';
109
- panelInstance.fontSelect.className = 'font-select';
102
+ const fontWrapper = document.createElement('div');
103
+ fontWrapper.className = 'font-select-wrapper';
104
+
105
+ const trigger = document.createElement('div');
106
+ trigger.className = 'font-select';
107
+ trigger.setAttribute('role', 'combobox');
108
+ trigger.setAttribute('tabindex', '0');
109
+ trigger.setAttribute('aria-haspopup', 'listbox');
110
+ trigger.setAttribute('aria-expanded', 'false');
111
+
112
+ const triggerLabel = document.createElement('span');
113
+ triggerLabel.className = 'font-select__label';
114
+ trigger.appendChild(triggerLabel);
115
+
116
+ const dropdown = document.createElement('div');
117
+ dropdown.className = 'font-dropdown';
118
+ dropdown.setAttribute('role', 'listbox');
119
+
120
+ const optionElements = [];
121
+ const optionRefs = FONT_OPTIONS.map((font) => {
122
+ const item = document.createElement('button');
123
+ item.type = 'button';
124
+ item.className = 'font-dropdown__item';
125
+ item.setAttribute('role', 'option');
126
+ item.dataset.value = font.value;
127
+ item.textContent = font.name;
128
+ item.style.fontFamily = font.value;
129
+ dropdown.appendChild(item);
130
+ optionElements.push(item);
131
+ return { value: font.value, textContent: font.name, element: item };
132
+ });
110
133
 
111
- FONT_OPTIONS.forEach((font) => {
112
- const option = document.createElement('option');
113
- option.value = font.value;
114
- option.textContent = font.name;
115
- option.style.fontFamily = font.value;
116
- panelInstance.fontSelect.appendChild(option);
134
+ fontWrapper.appendChild(trigger);
135
+ fontWrapper.appendChild(dropdown);
136
+ panel.appendChild(fontWrapper);
137
+
138
+ let currentValue = FONT_OPTIONS[0].value;
139
+ const applyValue = (newValue) => {
140
+ currentValue = newValue;
141
+ const match = optionRefs.find((opt) => opt.value === newValue);
142
+ triggerLabel.textContent = match ? match.textContent : newValue;
143
+ triggerLabel.style.fontFamily = newValue;
144
+ optionRefs.forEach((opt) => {
145
+ opt.element.classList.toggle('is-active', opt.value === newValue);
146
+ });
147
+ };
148
+ applyValue(currentValue);
149
+
150
+ Object.defineProperty(trigger, 'value', {
151
+ configurable: true,
152
+ get() {
153
+ return currentValue;
154
+ },
155
+ set(newValue) {
156
+ applyValue(newValue);
157
+ },
158
+ });
159
+ Object.defineProperty(trigger, 'options', {
160
+ configurable: true,
161
+ get() {
162
+ return optionElements;
163
+ },
117
164
  });
118
165
 
119
- panel.appendChild(panelInstance.fontSelect);
166
+ panelInstance.fontSelect = trigger;
167
+ panelInstance.fontDropdown = dropdown;
168
+ panelInstance._fontSelectWrapper = fontWrapper;
120
169
 
121
- const sizeLabel = document.createElement('span');
122
- sizeLabel.textContent = 'Размер:';
123
- sizeLabel.className = 'tpp-label tpp-label--spaced';
124
- panel.appendChild(sizeLabel);
170
+ const fontSizeWrapper = document.createElement('div');
171
+ fontSizeWrapper.className = 'font-size-wrapper';
125
172
 
126
173
  panelInstance.fontSizeSelect = document.createElement('select');
127
174
  panelInstance.fontSizeSelect.className = 'font-size-select';
128
- panelInstance.fontSizeSelect.className = 'font-size-select';
129
175
 
130
176
  FONT_SIZE_OPTIONS.forEach((size) => {
131
177
  const option = document.createElement('option');
132
178
  option.value = size;
133
- option.textContent = `${size}px`;
179
+ option.textContent = String(size);
134
180
  panelInstance.fontSizeSelect.appendChild(option);
135
181
  });
136
182
 
137
- panel.appendChild(panelInstance.fontSizeSelect);
183
+ const stepperContainer = document.createElement('div');
184
+ stepperContainer.className = 'font-size-steppers';
138
185
 
139
- const colorLabel = document.createElement('span');
140
- colorLabel.textContent = 'Цвет:';
141
- colorLabel.className = 'tpp-label tpp-label--spaced';
142
- panel.appendChild(colorLabel);
186
+ panelInstance.fontSizeUpBtn = document.createElement('button');
187
+ panelInstance.fontSizeUpBtn.type = 'button';
188
+ panelInstance.fontSizeUpBtn.className = 'font-size-stepper font-size-stepper--up';
189
+ panelInstance.fontSizeUpBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg" style="top: 1px;"><path d="M8.25 6.75L5 3.25L1.75 6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
143
190
 
144
- createCompactColorSelector(panelInstance, panel);
191
+ panelInstance.fontSizeDownBtn = document.createElement('button');
192
+ panelInstance.fontSizeDownBtn.type = 'button';
193
+ panelInstance.fontSizeDownBtn.className = 'font-size-stepper font-size-stepper--down';
194
+ panelInstance.fontSizeDownBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="bottom: 1px;"><path d="M8.25 3.25L5 6.75L1.75 3.25"></path></svg>';
195
+
196
+ stepperContainer.appendChild(panelInstance.fontSizeUpBtn);
197
+ stepperContainer.appendChild(panelInstance.fontSizeDownBtn);
198
+
199
+ fontSizeWrapper.appendChild(panelInstance.fontSizeSelect);
200
+ fontSizeWrapper.appendChild(stepperContainer);
145
201
 
146
- const bgColorLabel = document.createElement('span');
147
- bgColorLabel.textContent = 'Фон:';
148
- bgColorLabel.className = 'tpp-label tpp-label--spaced';
149
- panel.appendChild(bgColorLabel);
202
+ panel.appendChild(fontSizeWrapper);
203
+
204
+ createCompactColorSelector(panelInstance, panel);
150
205
 
151
206
  createCompactBackgroundSelector(panelInstance, panel);
152
207
 
@@ -49,7 +49,7 @@ export class StyleLoader {
49
49
  }
50
50
 
51
51
  .font-select { min-width: 110px; }
52
- .font-size-select { min-width: 56px; }
52
+ .font-size-select { min-width: 32px; }
53
53
 
54
54
  .text-properties-panel .tpp-label--spaced { margin-left: 6px; }
55
55