@sequent-org/moodboard 1.4.56 → 1.4.58

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/core/commands/PasteObjectCommand.js +7 -1
  3. package/src/core/commands/UpdateContentCommand.js +95 -0
  4. package/src/core/flows/ObjectLifecycleFlow.js +10 -1
  5. package/src/initNoBundler.js +28 -4
  6. package/src/services/text/TextBoxMetrics.js +133 -0
  7. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +98 -7
  8. package/src/tools/object-tools/selection/SelectInputRouter.js +56 -3
  9. package/src/tools/object-tools/selection/TextEditorCaretService.js +113 -0
  10. package/src/tools/object-tools/selection/TextEditorDomFactory.js +37 -21
  11. package/src/tools/object-tools/selection/TextEditorInteractionController.js +111 -12
  12. package/src/tools/object-tools/selection/TextEditorPositioningService.js +19 -1
  13. package/src/tools/object-tools/selection/TextEditorSyncService.js +2 -1
  14. package/src/tools/object-tools/selection/TextInlineEditorController.js +70 -45
  15. package/src/tools/object-tools/selection/TransformInteractionController.js +7 -1
  16. package/src/ui/FramePropertiesPanel.js +7 -0
  17. package/src/ui/HtmlTextLayer.js +174 -64
  18. package/src/ui/ImagePropertiesPanel.js +7 -0
  19. package/src/ui/TextPropertiesPanel.js +305 -2
  20. package/src/ui/handles/HandlesDomRenderer.js +2 -0
  21. package/src/ui/handles/HandlesInteractionController.js +40 -0
  22. package/src/ui/styles/panels.css +240 -19
  23. package/src/ui/styles/workspace.css +94 -7
  24. package/src/ui/text-properties/TextFormatControls.js +132 -21
  25. package/src/ui/text-properties/TextLinkControl.js +255 -0
  26. package/src/ui/text-properties/TextLockMoreControls.js +173 -0
  27. package/src/ui/text-properties/TextPropertiesPanelBindings.js +45 -0
  28. package/src/ui/text-properties/TextPropertiesPanelMapper.js +52 -0
  29. package/src/ui/text-properties/TextPropertiesPanelRenderer.js +263 -26
  30. package/src/ui/text-properties/TextPropertiesPanelState.js +6 -0
@@ -6,10 +6,11 @@ import {
6
6
  } from './TextPropertiesPanelMapper.js';
7
7
 
8
8
  // Статичные SVG-строки (lucide-style). innerHTML допустим — это доверенные константы, не пользовательский ввод.
9
- const SVG_BOLD = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>';
10
- const SVG_ITALIC = '<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"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>';
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
- 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>';
9
+ const SVG_TEXT_FORMAT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" type="text-format" size="24"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.54459 15.3341C7.52012 15.4823 7.52819 15.634 7.56823 15.7787C7.60828 15.9234 7.67935 16.0576 7.77651 16.1721C7.87366 16.2866 7.99458 16.3786 8.13085 16.4416C8.26712 16.5047 8.41548 16.5373 8.56563 16.5372H11.9587C12.8918 16.5372 13.6811 16.3966 14.3277 16.1141C14.9742 15.8286 15.4822 15.4417 15.8505 14.9566C16.2229 14.4662 16.4608 13.9138 16.5674 13.2993C16.6656 12.6755 16.6242 12.1469 16.4432 11.7124C16.2632 11.2779 16.0005 10.9417 15.657 10.7069C15.3354 10.4746 14.9536 10.3399 14.5574 10.319L14.5801 10.201C14.9567 10.1079 15.3146 9.9569 15.656 9.74897C15.9974 9.54207 16.287 9.2669 16.526 8.92552C16.7691 8.58414 16.9315 8.16414 17.0142 7.66241C17.1156 7.06655 17.0691 6.53172 16.8725 6.05793C16.676 5.58 16.3201 5.20138 15.8029 4.9231C15.2898 4.64069 14.607 4.5 13.7515 4.5H10.2208C9.97593 4.50011 9.73904 4.58708 9.55227 4.74544C9.3655 4.9038 9.24096 5.12328 9.2008 5.36483L7.54563 15.3341H7.54459ZM12.0415 14.9803H9.42011L10.0667 11.1238H12.7522C13.2384 11.1238 13.6418 11.2179 13.9625 11.4062C14.2884 11.5945 14.5201 11.8469 14.6567 12.1645C14.7974 12.4821 14.8387 12.8307 14.7808 13.2114C14.6898 13.7162 14.428 14.1372 13.9925 14.4745C13.5622 14.8117 12.9115 14.9803 12.0415 14.9803ZM12.7346 9.70759H10.3077L10.9077 6.04552H13.3874C14.1042 6.04552 14.608 6.21414 14.8977 6.55138C15.1915 6.88448 15.296 7.30138 15.2091 7.8031C15.1511 8.1869 15.0022 8.52207 14.7632 8.80862C14.5274 9.09414 14.2315 9.31552 13.8756 9.47276C13.5229 9.62897 13.1422 9.70759 12.7346 9.70759ZM6.56908 17.9483C6.3633 17.9483 6.16596 18.03 6.02046 18.1755C5.87496 18.321 5.79321 18.5184 5.79321 18.7241C5.79321 18.9299 5.87496 19.1273 6.02046 19.2728C6.16596 19.4183 6.3633 19.5 6.56908 19.5H17.4311C17.6369 19.5 17.8343 19.4183 17.9798 19.2728C18.1253 19.1273 18.207 18.9299 18.207 18.7241C18.207 18.5184 18.1253 18.321 17.9798 18.1755C17.8343 18.03 17.6369 17.9483 17.4311 17.9483H6.56908Z"></path></svg>';
10
+ const SVG_BOLD = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.92188 12H13.1719C14.8978 12 16.2969 10.6009 16.2969 8.875C16.2969 7.14911 14.8978 5.75 13.1719 5.75H6.92188V12ZM6.92188 12H13.9531C15.679 12 17.0781 13.3991 17.0781 15.125C17.0781 16.8509 15.679 18.25 13.9531 18.25H6.92188V12Z"></path></svg>';
11
+ const SVG_ITALIC = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M17.4688 5.75H10.4375M13.5625 18.25H6.53125M14.3438 5.75L9.65625 18.25"></path></svg>';
12
+ const SVG_UNDERLINE = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.6" 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>';
13
+ const SVG_STRIKE = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M7.3125 15.125C7.3125 16.8509 8.71161 18.25 10.4375 18.25H13.5625C15.2884 18.25 16.6875 16.8509 16.6875 15.125C16.6875 13.3991 15.2884 12 13.5625 12M16.6875 8.875C16.6875 7.14911 15.2884 5.75 13.5625 5.75H10.4375C8.71161 5.75 7.3125 7.14911 7.3125 8.875M4.96875 12H19.0312"></path></svg>';
13
14
 
14
15
  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
16
  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>';
@@ -19,6 +20,7 @@ const SVG_ALIGN_JUSTIFY = '<svg width="24" height="24" viewBox="0 0 24 24" fill=
19
20
  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
21
  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
22
  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>';
23
+ const SVG_LINE_HEIGHT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" type="line-height-2" size="24"><path d="M6.02009 5.35419V18.6459M6.02009 5.35419L8.77156 8.10419M6.02009 5.35419L3.27156 8.10419M6.02009 18.6459L3.26862 15.8959M6.02009 18.6459L8.76862 15.8959" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13.6112 13.2205H18.9513M11.8311 17.481L15.633 7.47121C15.839 6.92904 15.9419 6.65795 16.0846 6.57385C16.2086 6.50077 16.3539 6.50077 16.4779 6.57385C16.6206 6.65795 16.7235 6.92904 16.9295 7.47121L20.7314 17.481" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
22
24
 
23
25
  function makeSep() {
24
26
  const d = document.createElement('div');
@@ -57,6 +59,7 @@ function makeListButtons() {
57
59
  triggerBtn.innerHTML = SVG_LIST_BULLET;
58
60
 
59
61
  const modal = document.createElement('div');
62
+ modal.id = `tpp-list-modal-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
60
63
  modal.className = 'tpp-list-modal';
61
64
  modal.style.display = 'none';
62
65
 
@@ -161,6 +164,7 @@ function makeAlignButtons() {
161
164
 
162
165
  // Модальный попап
163
166
  const modal = document.createElement('div');
167
+ modal.id = `tpp-align-modal-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
164
168
  modal.className = 'tpp-align-modal';
165
169
  modal.style.display = 'none';
166
170
 
@@ -248,33 +252,86 @@ function makeAlignButtons() {
248
252
  return proxy;
249
253
  }
250
254
 
251
- export function createTextFormatControls(panelInstance, panel) {
252
- panel.appendChild(makeSep());
255
+ function makeFormatButtons(panelInstance) {
256
+ const container = document.createElement('div');
257
+ container.className = 'tpp-format-btns-wrapper';
258
+ container.style.position = 'relative';
259
+ container.style.display = 'flex';
260
+
261
+ const triggerBtn = document.createElement('button');
262
+ triggerBtn.type = 'button';
263
+ triggerBtn.title = 'Форматирование текста';
264
+ triggerBtn.className = 'tpp-format-btn tpp-format-trigger';
265
+ triggerBtn.innerHTML = SVG_TEXT_FORMAT;
266
+
267
+ const modal = document.createElement('div');
268
+ modal.id = `tpp-format-modal-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
269
+ modal.className = 'tpp-format-modal';
270
+ modal.style.display = 'none';
253
271
 
254
272
  panelInstance.boldBtn = makeToggleBtn(SVG_BOLD, 'Жирный', 'bold');
255
273
  panelInstance.italicBtn = makeToggleBtn(SVG_ITALIC, 'Курсив', 'italic');
256
274
  panelInstance.underlineBtn = makeToggleBtn(SVG_UNDERLINE, 'Подчёркнутый', 'underline');
257
275
  panelInstance.strikethroughBtn = makeToggleBtn(SVG_STRIKE, 'Зачёркнутый', 'strikethrough');
258
276
 
259
- [panelInstance.boldBtn, panelInstance.italicBtn, panelInstance.underlineBtn, panelInstance.strikethroughBtn].forEach(
260
- (btn) => panel.appendChild(btn),
261
- );
277
+ const btns = [
278
+ panelInstance.boldBtn,
279
+ panelInstance.italicBtn,
280
+ panelInstance.underlineBtn,
281
+ panelInstance.strikethroughBtn
282
+ ];
262
283
 
263
- panel.appendChild(makeSep());
284
+ btns.forEach((btn) => {
285
+ btn.classList.add('tpp-format-modal-option');
286
+ modal.appendChild(btn);
287
+ });
264
288
 
265
- panelInstance.alignControl = makeAlignButtons();
266
- panel.appendChild(panelInstance.alignControl._container);
289
+ container.appendChild(triggerBtn);
290
+ container.appendChild(modal);
267
291
 
268
- panelInstance.listControl = makeListButtons();
269
- panel.appendChild(panelInstance.listControl._container);
292
+ function openModal() {
293
+ modal.style.display = 'flex';
294
+ setTimeout(() => {
295
+ document.addEventListener('click', closeOnOutside);
296
+ }, 0);
297
+ }
270
298
 
271
- panel.appendChild(makeSep());
299
+ function closeModal() {
300
+ modal.style.display = 'none';
301
+ document.removeEventListener('click', closeOnOutside);
302
+ }
303
+
304
+ function closeOnOutside(e) {
305
+ if (!container.contains(e.target)) {
306
+ closeModal();
307
+ }
308
+ }
309
+
310
+ triggerBtn.addEventListener('click', (e) => {
311
+ e.stopPropagation();
312
+ if (modal.style.display === 'none') {
313
+ openModal();
314
+ } else {
315
+ closeModal();
316
+ }
317
+ });
318
+
319
+ return container;
320
+ }
321
+
322
+ function makeLineHeightControl(panelInstance) {
323
+ const container = document.createElement('div');
324
+ container.className = 'tpp-lh-control';
325
+
326
+ const triggerBtn = document.createElement('button');
327
+ triggerBtn.type = 'button';
328
+ triggerBtn.title = 'Межстрочный интервал';
329
+ triggerBtn.className = 'tpp-format-btn';
330
+ triggerBtn.innerHTML = SVG_LINE_HEIGHT;
272
331
 
273
- const lhLabel = document.createElement('span');
274
- lhLabel.className = 'tpp-label';
275
- lhLabel.title = 'Межстрочный интервал';
276
- lhLabel.textContent = '↕';
277
- panel.appendChild(lhLabel);
332
+ const modal = document.createElement('div');
333
+ modal.className = 'tpp-lh-modal';
334
+ modal.style.display = 'none';
278
335
 
279
336
  panelInstance.lineHeightSlider = document.createElement('input');
280
337
  panelInstance.lineHeightSlider.type = 'range';
@@ -283,5 +340,59 @@ export function createTextFormatControls(panelInstance, panel) {
283
340
  panelInstance.lineHeightSlider.max = String(LINE_HEIGHT_MAX);
284
341
  panelInstance.lineHeightSlider.step = String(LINE_HEIGHT_STEP);
285
342
  panelInstance.lineHeightSlider.value = String(LINE_HEIGHT_DEFAULT);
286
- panel.appendChild(panelInstance.lineHeightSlider);
343
+
344
+ modal.appendChild(panelInstance.lineHeightSlider);
345
+ container.appendChild(triggerBtn);
346
+ container.appendChild(modal);
347
+
348
+ function openModal() {
349
+ modal.style.display = 'flex';
350
+ triggerBtn.classList.add('is-active');
351
+ setTimeout(() => {
352
+ document.addEventListener('click', closeOnOutside);
353
+ }, 0);
354
+ }
355
+
356
+ function closeModal() {
357
+ modal.style.display = 'none';
358
+ triggerBtn.classList.remove('is-active');
359
+ document.removeEventListener('click', closeOnOutside);
360
+ }
361
+
362
+ function closeOnOutside(e) {
363
+ if (!container.contains(e.target)) {
364
+ closeModal();
365
+ }
366
+ }
367
+
368
+ triggerBtn.addEventListener('click', (e) => {
369
+ e.stopPropagation();
370
+ if (modal.style.display === 'none') {
371
+ openModal();
372
+ } else {
373
+ closeModal();
374
+ }
375
+ });
376
+
377
+ return container;
378
+ }
379
+
380
+ export function createTextFormatControls(panelInstance, panel) {
381
+ panel.appendChild(makeSep());
382
+
383
+ const formatButtonsWrapper = makeFormatButtons(panelInstance);
384
+ panel.appendChild(formatButtonsWrapper);
385
+
386
+ panelInstance.alignControl = makeAlignButtons();
387
+ panel.appendChild(panelInstance.alignControl._container);
388
+
389
+ panelInstance.listControl = makeListButtons();
390
+ panel.appendChild(panelInstance.listControl._container);
391
+
392
+ panel.appendChild(makeSep());
393
+
394
+ const lhControl = makeLineHeightControl(panelInstance);
395
+ panel.appendChild(lhControl);
396
+
397
+ panel.appendChild(makeSep());
287
398
  }
@@ -0,0 +1,255 @@
1
+ import { getObjectProperties } from './TextPropertiesPanelMapper.js';
2
+
3
+ const ICON_LINK = `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><path d="M10.5893 15.3024L9.4108 16.4809C7.78361 18.1081 5.14542 18.1081 3.51824 16.4809C1.89106 14.8537 1.89106 12.2155 3.51824 10.5883L4.69675 9.40982M15.3034 10.5883L16.4819 9.40982C18.109 7.78264 18.109 5.14445 16.4819 3.51726C14.8547 1.89008 12.2165 1.89008 10.5893 3.51726L9.4108 4.69577M7.08339 12.9157L12.9167 7.08239"></path></svg>`;
4
+
5
+ /**
6
+ * Создаёт кнопку «Ссылка» с всплывающей формой ввода URL.
7
+ *
8
+ * Поведение:
9
+ * - Кнопка disabled пока у объекта выключен MD-режим.
10
+ * - Клик сохраняет selection в textarea, показывает форму рядом с textarea.
11
+ * - Сабмит: если было выделение — [selected](url), иначе [весь текст](url).
12
+ * - Автоматически добавляет https:// если схема не указана.
13
+ * - Enter = OK, Esc = отмена, клик вне = отмена.
14
+ *
15
+ * @param {object} panelInstance - экземпляр TextPropertiesPanel
16
+ * @returns {HTMLButtonElement} кнопка для вставки в панель
17
+ */
18
+ export function createLinkButton(panelInstance) {
19
+ const btn = document.createElement('button');
20
+ btn.className = 'ipp-btn tpp-link-btn';
21
+ btn.title = 'Добавить ссылку';
22
+ btn.id = 'tpp-btn-link';
23
+ btn.dataset.id = 'tpp-btn-link';
24
+ btn.disabled = false;
25
+ btn.innerHTML = ICON_LINK;
26
+
27
+ btn.addEventListener('click', (e) => {
28
+ e.preventDefault();
29
+ e.stopPropagation();
30
+ _openLinkForm(panelInstance, btn);
31
+ });
32
+
33
+ panelInstance._tppBtnLink = btn;
34
+ return btn;
35
+ }
36
+
37
+ /**
38
+ * Обновляет disabled-состояние кнопки в зависимости от MD-флага объекта.
39
+ * При включённом MD-режиме кнопка недоступна — ссылки добавляются markdown-синтаксисом вручную.
40
+ * Вызывается из TextPropertiesPanelBindings при смене markdownToggle.
41
+ */
42
+ export function updateLinkButtonState(panelInstance, isMarkdown) {
43
+ if (!panelInstance._tppBtnLink) return;
44
+ const btn = panelInstance._tppBtnLink;
45
+ btn.disabled = isMarkdown;
46
+ btn.title = isMarkdown
47
+ ? 'Ссылки в MD-режиме добавляются через синтаксис [текст](url)'
48
+ : 'Добавить ссылку к тексту';
49
+ btn.classList.toggle('tpp-link-btn--disabled', isMarkdown);
50
+ }
51
+
52
+ function _getActiveTextarea(panelInstance) {
53
+ return panelInstance.core?.selectTool?.textEditor?.textarea ?? null;
54
+ }
55
+
56
+ function _normalizeUrl(raw) {
57
+ const trimmed = raw.trim();
58
+ if (!trimmed) return '';
59
+ if (/^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed)) return trimmed;
60
+ return 'https://' + trimmed;
61
+ }
62
+
63
+ function _openLinkForm(panelInstance, triggerBtn) {
64
+ // Закрываем предыдущую форму, если она уже открыта
65
+ _closeExistingForm();
66
+
67
+ const textarea = _getActiveTextarea(panelInstance);
68
+
69
+ // Запоминаем id выделенного объекта ДО показа формы — фокус ухода в URL-input
70
+ // или клик мимо canvas может сбросить выделение и panelInstance.currentId станет null
71
+ const savedObjectId = panelInstance.currentId;
72
+
73
+ // Запоминаем выделение ДО показа формы (фокус уйдёт на input)
74
+ let savedStart = 0;
75
+ let savedEnd = 0;
76
+ let savedText = '';
77
+ if (textarea) {
78
+ savedStart = textarea.selectionStart ?? 0;
79
+ savedEnd = textarea.selectionEnd ?? 0;
80
+ savedText = textarea.value ?? '';
81
+ } else {
82
+ // Объект только выделен (без активного редактора) — применяем ссылку ко всему содержимому
83
+ const props = getObjectProperties(panelInstance.eventBus, savedObjectId);
84
+ savedText = props?.content ?? '';
85
+ }
86
+ const hasSelection = savedEnd > savedStart;
87
+
88
+ const form = _buildForm(panelInstance, textarea, {
89
+ savedStart,
90
+ savedEnd,
91
+ savedText,
92
+ hasSelection,
93
+ triggerBtn,
94
+ savedObjectId,
95
+ });
96
+
97
+ document.body.appendChild(form);
98
+
99
+ // Позиционируем форму рядом с textarea или рядом с кнопкой
100
+ _positionForm(form, textarea, triggerBtn);
101
+
102
+ const urlInput = form.querySelector('.tpp-link-input');
103
+ urlInput.focus();
104
+
105
+ // Закрытие по клику вне формы
106
+ const closeOnOutside = (ev) => {
107
+ if (!form.contains(ev.target) && ev.target !== triggerBtn) {
108
+ _closeForm(form, closeOnOutside);
109
+ }
110
+ };
111
+ setTimeout(() => document.addEventListener('mousedown', closeOnOutside), 0);
112
+ form._closeOnOutside = closeOnOutside;
113
+ }
114
+
115
+ function _buildForm(panelInstance, textarea, ctx) {
116
+ const { savedStart, savedEnd, savedText, hasSelection, triggerBtn, savedObjectId } = ctx;
117
+
118
+ const form = document.createElement('div');
119
+ form.className = 'tpp-link-form';
120
+ form.id = 'tpp-link-form-active';
121
+
122
+ const label = document.createElement('span');
123
+ label.className = 'tpp-link-form-label';
124
+ label.textContent = hasSelection ? `Ссылка для «${_truncate(savedText.slice(savedStart, savedEnd), 24)}»` : 'URL ссылки';
125
+ form.appendChild(label);
126
+
127
+ const row = document.createElement('div');
128
+ row.className = 'tpp-link-form-row';
129
+
130
+ const urlInput = document.createElement('input');
131
+ urlInput.type = 'url';
132
+ urlInput.placeholder = 'https://example.com';
133
+ urlInput.className = 'tpp-link-input';
134
+ urlInput.autocomplete = 'url';
135
+
136
+ const okBtn = document.createElement('button');
137
+ okBtn.type = 'button';
138
+ okBtn.className = 'tpp-link-form-ok';
139
+ okBtn.textContent = 'OK';
140
+
141
+ row.appendChild(urlInput);
142
+ row.appendChild(okBtn);
143
+ form.appendChild(row);
144
+
145
+ const submit = () => {
146
+ const url = _normalizeUrl(urlInput.value);
147
+ if (!url) {
148
+ urlInput.classList.add('tpp-link-input--error');
149
+ urlInput.focus();
150
+ return;
151
+ }
152
+ urlInput.classList.remove('tpp-link-input--error');
153
+
154
+ // Запись ссылки как диапазона в properties.links (не markdown-синтаксис).
155
+ // objectId передаём явно — currentId на панели мог сброситься, пока пользователь
156
+ // печатал URL и фокус был в input.
157
+ if (typeof panelInstance._addLink === 'function') {
158
+ const contentLen = (savedText || '').length;
159
+ const start = hasSelection ? savedStart : 0;
160
+ const end = hasSelection ? savedEnd : contentLen;
161
+ panelInstance._addLink(url, start, end, savedObjectId);
162
+ }
163
+
164
+ _closeForm(form, form._closeOnOutside);
165
+ // Возвращаем фокус в textarea
166
+ if (textarea) {
167
+ try { textarea.focus(); } catch (_) {}
168
+ }
169
+ };
170
+
171
+ urlInput.addEventListener('keydown', (e) => {
172
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
173
+ if (e.key === 'Escape') { e.preventDefault(); _closeForm(form, form._closeOnOutside); }
174
+ urlInput.classList.remove('tpp-link-input--error');
175
+ });
176
+
177
+ okBtn.addEventListener('click', (e) => {
178
+ e.preventDefault();
179
+ e.stopPropagation();
180
+ submit();
181
+ });
182
+
183
+ return form;
184
+ }
185
+
186
+ function _insertLink(textarea, { savedStart, savedEnd, savedText, hasSelection, url }) {
187
+ let newValue;
188
+ let newCursorPos;
189
+
190
+ if (hasSelection) {
191
+ const selected = savedText.slice(savedStart, savedEnd);
192
+ const mdLink = `[${selected}](${url})`;
193
+ newValue = savedText.slice(0, savedStart) + mdLink + savedText.slice(savedEnd);
194
+ newCursorPos = savedStart + mdLink.length;
195
+ } else {
196
+ const allText = savedText || url;
197
+ const mdLink = `[${allText}](${url})`;
198
+ newValue = mdLink;
199
+ newCursorPos = mdLink.length;
200
+ }
201
+
202
+ textarea.value = newValue;
203
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
204
+
205
+ // Сигналим autoSize и commit-пайплайну
206
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
207
+ }
208
+
209
+ function _positionForm(form, textarea, triggerBtn) {
210
+ // Временно делаем видимым для измерения
211
+ form.style.visibility = 'hidden';
212
+ form.style.display = 'flex';
213
+
214
+ const anchor = textarea ?? triggerBtn;
215
+ const rect = anchor.getBoundingClientRect();
216
+ const formH = form.offsetHeight || 80;
217
+ const winH = window.innerHeight;
218
+
219
+ let top;
220
+ if (rect.bottom + formH + 8 < winH) {
221
+ top = rect.bottom + 8;
222
+ } else {
223
+ top = rect.top - formH - 8;
224
+ }
225
+
226
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - 280));
227
+
228
+ form.style.top = `${Math.round(top)}px`;
229
+ form.style.left = `${Math.round(left)}px`;
230
+ form.style.visibility = '';
231
+ }
232
+
233
+ function _closeForm(form, outsideHandler) {
234
+ if (outsideHandler) {
235
+ document.removeEventListener('mousedown', outsideHandler);
236
+ }
237
+ if (form && form.parentNode) {
238
+ form.parentNode.removeChild(form);
239
+ }
240
+ }
241
+
242
+ function _closeExistingForm() {
243
+ const existing = document.getElementById('tpp-link-form-active');
244
+ if (existing) {
245
+ if (existing._closeOnOutside) {
246
+ document.removeEventListener('mousedown', existing._closeOnOutside);
247
+ }
248
+ existing.parentNode?.removeChild(existing);
249
+ }
250
+ }
251
+
252
+ function _truncate(str, max) {
253
+ if (str.length <= max) return str;
254
+ return str.slice(0, max) + '…';
255
+ }
@@ -0,0 +1,173 @@
1
+ import { Events } from '../../core/events/Events.js';
2
+ import { createLinkButton } from './TextLinkControl.js';
3
+
4
+ const ICON_LOCK = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5.75 11.75C5.75 11.1977 6.19772 10.75 6.75 10.75H17.25C17.8023 10.75 18.25 11.1977 18.25 11.75V17.25C18.25 18.3546 17.3546 19.25 16.25 19.25H7.75C6.64543 19.25 5.75 18.3546 5.75 17.25V11.75Z"></path><path d="M7.75008 10.5V10.3427C7.75008 8.78147 7.65615 7.04125 8.74654 5.9239C9.36837 5.2867 10.3746 4.75 12.0001 4.75C13.6256 4.75 14.6318 5.2867 15.2536 5.9239C16.344 7.04125 16.2501 8.78147 16.2501 10.3427V10.5"></path><path d="M12 14.25L12 15.75"></path></svg>`;
5
+ const ICON_UNLOCK = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5.75 11.75C5.75 11.1977 6.19772 10.75 6.75 10.75H17.25C17.8023 10.75 18.25 11.1977 18.25 11.75V17.25C18.25 18.3546 17.3546 19.25 16.25 19.25H7.75C6.64543 19.25 5.75 18.3546 5.75 17.25V11.75Z"></path><path d="M7.74972 10.5V9.84343C7.74972 8.61493 7.70065 7.29883 8.42388 6.30578C8.99834 5.51699 10.0565 4.75 11.9997 4.75C13.9997 4.75 15.2497 6.25 15.2497 6.25"></path><path d="M12 14.25L12 15.75"></path></svg>`;
6
+ const ICON_MORE = `<svg width="24" height="24" viewBox="0 0 22 22" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M16.2246 12.375C16.984 12.375 17.5996 11.7594 17.5996 11C17.5996 10.2406 16.984 9.625 16.2246 9.625C15.4652 9.625 14.8496 10.2406 14.8496 11C14.8496 11.7594 15.4652 12.375 16.2246 12.375Z"></path><path d="M11 12.375C11.7594 12.375 12.375 11.7594 12.375 11C12.375 10.2406 11.7594 9.625 11 9.625C10.2406 9.625 9.625 10.2406 9.625 11C9.625 11.7594 10.2406 12.375 11 12.375Z"></path><path d="M5.77539 12.375C6.53478 12.375 7.15039 11.7594 7.15039 11C7.15039 10.2406 6.53478 9.625 5.77539 9.625C5.016 9.625 4.40039 10.2406 4.40039 11C4.40039 11.7594 5.016 12.375 5.77539 12.375Z"></path></svg>`;
7
+ const ICON_COMMENT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`;
8
+
9
+ /**
10
+ * Добавляет к text-properties-panel кнопку lock + кнопку more с дропдауном.
11
+ * Поведение идентично lock / more в ImagePropertiesPanel; идентификаторы с префиксом tpp-.
12
+ */
13
+ export function createTextLockMoreControls(panelInstance, panel) {
14
+ // Все уже добавленные дочерние элементы скрываются при locked
15
+ panelInstance._lockableEls = Array.from(panel.children);
16
+ panelInstance._tppLockIcon = ICON_LOCK;
17
+ panelInstance._tppUnlockIcon = ICON_UNLOCK;
18
+
19
+ const linkBtn = createLinkButton(panelInstance);
20
+ panel.appendChild(linkBtn);
21
+
22
+ const divider1 = document.createElement('div');
23
+ divider1.className = 'ipp-divider';
24
+ panel.appendChild(divider1);
25
+
26
+ const lockBtn = document.createElement('button');
27
+ lockBtn.className = 'ipp-btn';
28
+ lockBtn.title = 'Заблокировать';
29
+ lockBtn.id = 'tpp-btn-lock';
30
+ lockBtn.dataset.id = 'tpp-btn-lock';
31
+ lockBtn.innerHTML = ICON_LOCK;
32
+ lockBtn.addEventListener('click', (e) => {
33
+ e.preventDefault();
34
+ e.stopPropagation();
35
+ panelInstance._toggleLocked();
36
+ });
37
+ panel.appendChild(lockBtn);
38
+ panelInstance._tppBtnLock = lockBtn;
39
+
40
+ const divider = document.createElement('div');
41
+ divider.className = 'ipp-divider';
42
+ panel.appendChild(divider);
43
+
44
+ const moreWrapper = _makeMoreWrapper(panelInstance);
45
+ panel.appendChild(moreWrapper);
46
+ panelInstance._tppMoreGroup = moreWrapper;
47
+ }
48
+
49
+ function _makeMoreWrapper(panelInstance) {
50
+ const wrapper = document.createElement('div');
51
+ wrapper.className = 'tpp-btn-wrapper';
52
+ wrapper.dataset.id = 'tpp-btn-wrapper';
53
+ wrapper.style.cssText = 'position:relative;display:inline-flex;';
54
+
55
+ const mainBtn = document.createElement('button');
56
+ mainBtn.className = 'ipp-btn';
57
+ mainBtn.title = 'Ещё';
58
+ mainBtn.id = 'tpp-btn-more';
59
+ mainBtn.dataset.id = 'tpp-btn-more';
60
+ mainBtn.innerHTML = ICON_MORE;
61
+
62
+ const dropdown = document.createElement('div');
63
+ dropdown.className = 'tpp-more-dropdown';
64
+
65
+ const items = [
66
+ { id: 'copy', label: 'Копировать', shortcut: 'Ctrl+C' },
67
+ { id: 'copy-link', label: 'Копировать ссылку на объект' },
68
+ { divider: true },
69
+ { id: 'bring-front', label: 'На передний план', shortcut: ']' },
70
+ { id: 'bring-forward', label: 'Переместить вперёд', shortcut: 'Ctrl+]' },
71
+ { id: 'send-backward', label: 'Переместить назад', shortcut: 'Ctrl+[' },
72
+ { id: 'send-back', label: 'На задний план', shortcut: '[' },
73
+ { divider: true },
74
+ { id: 'lock', label: 'Заблокировать', shortcut: 'Ctrl+Shift+L' },
75
+ { id: 'duplicate', label: 'Дублировать', shortcut: 'Ctrl+D' },
76
+ { divider: true },
77
+ { id: 'add-comment', label: 'Добавить комментарий', icon: ICON_COMMENT },
78
+ { divider: true },
79
+ { id: 'delete', label: 'Удалить', shortcut: 'Delete' },
80
+ ];
81
+
82
+ items.forEach((item) => {
83
+ if (item.divider) {
84
+ const div = document.createElement('div');
85
+ div.className = 'tpp-dropdown-divider';
86
+ dropdown.appendChild(div);
87
+ return;
88
+ }
89
+
90
+ const btn = document.createElement('button');
91
+ btn.className = 'tpp-dropdown-item';
92
+ if (item.id) btn.dataset.id = `tpp-more-${item.id}`;
93
+
94
+ if (item.icon) {
95
+ const iconSpan = document.createElement('span');
96
+ iconSpan.className = 'tpp-dropdown-icon';
97
+ iconSpan.innerHTML = item.icon;
98
+ btn.appendChild(iconSpan);
99
+ }
100
+
101
+ const labelSpan = document.createElement('span');
102
+ labelSpan.textContent = item.label;
103
+ btn.appendChild(labelSpan);
104
+
105
+ if (item.shortcut) {
106
+ const shortcutSpan = document.createElement('span');
107
+ shortcutSpan.className = 'tpp-dropdown-item-shortcut';
108
+ shortcutSpan.textContent = item.shortcut;
109
+ btn.appendChild(shortcutSpan);
110
+ }
111
+
112
+ btn.addEventListener('click', (e) => {
113
+ e.preventDefault();
114
+ e.stopPropagation();
115
+
116
+ const { eventBus, currentId } = panelInstance;
117
+
118
+ if (item.id === 'copy') {
119
+ eventBus.emit(Events.Keyboard.Copy);
120
+ } else if (item.id === 'bring-front') {
121
+ if (currentId) eventBus.emit(Events.UI.LayerBringToFront, { objectId: currentId });
122
+ } else if (item.id === 'bring-forward') {
123
+ if (currentId) eventBus.emit(Events.UI.LayerBringForward, { objectId: currentId });
124
+ } else if (item.id === 'send-backward') {
125
+ if (currentId) eventBus.emit(Events.UI.LayerSendBackward, { objectId: currentId });
126
+ } else if (item.id === 'send-back') {
127
+ if (currentId) eventBus.emit(Events.UI.LayerSendToBack, { objectId: currentId });
128
+ } else if (item.id === 'lock') {
129
+ panelInstance._toggleLocked();
130
+ } else if (item.id === 'duplicate') {
131
+ panelInstance._duplicateText();
132
+ } else if (item.id === 'add-comment') {
133
+ if (currentId) eventBus.emit(Events.Comment.OpenImageDraft, { objectId: currentId });
134
+ } else if (item.id === 'delete') {
135
+ if (currentId) eventBus.emit(Events.Tool.ObjectsDelete, { objects: [currentId] });
136
+ }
137
+
138
+ dropdown.classList.remove('is-open');
139
+ mainBtn.classList.remove('is-active');
140
+ });
141
+
142
+ if (item.id === 'lock') {
143
+ panelInstance._moreLockLabel = labelSpan;
144
+ }
145
+
146
+ dropdown.appendChild(btn);
147
+ });
148
+
149
+ mainBtn.addEventListener('click', (e) => {
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+ const isOpen = dropdown.classList.contains('is-open');
153
+
154
+ document.querySelectorAll('.tpp-more-dropdown.is-open').forEach((el) => el.classList.remove('is-open'));
155
+ document.querySelectorAll('.ipp-btn.is-active').forEach((el) => el.classList.remove('is-active'));
156
+
157
+ if (!isOpen) {
158
+ const rect = mainBtn.getBoundingClientRect();
159
+ dropdown.style.top = (rect.bottom + 6) + 'px';
160
+ dropdown.style.left = (rect.right - 220) + 'px';
161
+ dropdown.classList.add('is-open');
162
+ requestAnimationFrame(() => {
163
+ const left = Math.max(4, rect.right - dropdown.offsetWidth);
164
+ dropdown.style.left = left + 'px';
165
+ });
166
+ mainBtn.classList.add('is-active');
167
+ }
168
+ });
169
+
170
+ wrapper.appendChild(mainBtn);
171
+ wrapper.appendChild(dropdown);
172
+ return wrapper;
173
+ }