@sequent-org/moodboard 1.4.49 → 1.4.51

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.
@@ -27,6 +27,7 @@ export class HtmlHandlesLayer {
27
27
  this.handles = {};
28
28
  this._drag = null;
29
29
  this._handlesSuppressed = false; // скрывать ручки во время перетаскивания/трансформаций
30
+ this._cropMode = false; // полное подавление во время crop (ни рамка, ни ручки)
30
31
  this._groupRotationPreview = null;
31
32
 
32
33
  // Ссылки на обработчики, чтобы корректно отписаться при destroy()
@@ -96,6 +97,7 @@ export class HtmlHandlesLayer {
96
97
  }
97
98
 
98
99
  update() {
100
+ if (this._cropMode) return;
99
101
  // Дополнительная защита: если слой или core уже уничтожены,
100
102
  // выходим, чтобы не получить ошибок при resize/смене DPR
101
103
  if (!this.core || !this.core.pixi || !this.core.pixi.app || !this.layer) return;
@@ -2,6 +2,7 @@ import * as PIXI from 'pixi.js';
2
2
  import { Events } from '../core/events/Events.js';
3
3
  import { EditFileNameCommand } from '../core/commands/EditFileNameCommand.js';
4
4
  import { applyRoundedMask } from '../utils/applyRoundedMask.js';
5
+ import { CropController } from '../services/CropController.js';
5
6
 
6
7
  const ICONS = {
7
8
  download: `<svg width="24" height="24" fill="none" viewBox="0 0 24 24" type="download"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 14.75V16.25C4.75 17.9069 6.09315 19.25 7.75 19.25H16.25C17.9069 19.25 19.25 17.9069 19.25 16.25V14.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 14.25L12 4.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 10.75L12 14.25L15.25 10.75"></path></svg>`,
@@ -38,6 +39,9 @@ export class ImagePropertiesPanel {
38
39
  this.currentId = null;
39
40
  this._handlers = null;
40
41
 
42
+ this._cropController = new CropController({ core, eventBus, container });
43
+ this._cropController._onActivate = (active) => this._onCropActivate(active);
44
+
41
45
  this._attachEvents();
42
46
  this._createPanel();
43
47
  }
@@ -125,6 +129,9 @@ export class ImagePropertiesPanel {
125
129
  }
126
130
 
127
131
  showFor(objectId) {
132
+ if (this.currentId !== objectId) {
133
+ this._cropController?.cancel();
134
+ }
128
135
  this.currentId = objectId;
129
136
  if (this.panel) {
130
137
  this.panel.style.display = 'flex';
@@ -148,13 +155,12 @@ export class ImagePropertiesPanel {
148
155
  }
149
156
 
150
157
  _toggleLocked() {
151
- const obj = this._getCurrentObject();
152
- if (!obj) return;
153
- if (!obj.properties) obj.properties = {};
154
- obj.properties.locked = !obj.properties.locked;
155
- if (typeof this.core?.state?.markDirty === 'function') {
156
- this.core.state.markDirty();
157
- }
158
+ if (!this.currentId) return;
159
+ const newLocked = !this._isLocked();
160
+ this.eventBus.emit(Events.Object.StateChanged, {
161
+ objectId: this.currentId,
162
+ updates: { properties: { locked: newLocked } },
163
+ });
158
164
  this._updateLockUI();
159
165
  this.reposition();
160
166
  }
@@ -163,7 +169,7 @@ export class ImagePropertiesPanel {
163
169
  if (!this._btn_lock) return;
164
170
  const locked = this._isLocked();
165
171
 
166
- this._btn_lock.innerHTML = locked ? ICONS.unlock : ICONS.lock;
172
+ this._btn_lock.innerHTML = locked ? ICONS.lock : ICONS.unlock;
167
173
  this._btn_lock.title = locked ? 'Разблокировать' : 'Заблокировать';
168
174
 
169
175
  if (Array.isArray(this._lockableEls)) {
@@ -178,6 +184,7 @@ export class ImagePropertiesPanel {
178
184
  }
179
185
 
180
186
  hide() {
187
+ this._cropController?.cancel();
181
188
  this.currentId = null;
182
189
  if (this.panel) {
183
190
  this.panel.style.display = 'none';
@@ -185,6 +192,36 @@ export class ImagePropertiesPanel {
185
192
  this._closeBorderRadiusPopover();
186
193
  }
187
194
 
195
+ /**
196
+ * Вызывается CropController при входе/выходе из режима crop.
197
+ * Прячет тулбар и ручки выделения на время crop; восстанавливает по завершении.
198
+ */
199
+ _onCropActivate(active) {
200
+ if (!this.panel) return;
201
+
202
+ const handlesLayer = typeof window !== 'undefined' ? window.moodboardHtmlHandlesLayer : null;
203
+ const anchorsLayer = typeof window !== 'undefined' ? window.moodboardConnectionAnchorsLayer : null;
204
+
205
+ if (active) {
206
+ this.panel.style.display = 'none';
207
+ if (handlesLayer) {
208
+ handlesLayer._cropMode = true;
209
+ handlesLayer.hide();
210
+ }
211
+ anchorsLayer?.update();
212
+ } else {
213
+ // Восстанавливаем тулбар только если объект ещё выделен
214
+ if (this.currentId) {
215
+ this.panel.style.display = 'flex';
216
+ }
217
+ if (handlesLayer) {
218
+ handlesLayer._cropMode = false;
219
+ handlesLayer.update();
220
+ }
221
+ anchorsLayer?.update();
222
+ }
223
+ }
224
+
188
225
  _closeBorderRadiusPopover() {
189
226
  if (this._borderRadiusPopover) {
190
227
  this._borderRadiusPopover.classList.remove('is-open');
@@ -316,12 +353,6 @@ export class ImagePropertiesPanel {
316
353
  mainBtn.className = 'ipp-btn-split-main';
317
354
  mainBtn.title = 'Обрезать';
318
355
  mainBtn.innerHTML = ICONS.crop;
319
- mainBtn.addEventListener('click', (e) => {
320
- e.preventDefault();
321
- e.stopPropagation();
322
- // Логика применения текущего кропа
323
- });
324
-
325
356
  const expandBtn = document.createElement('button');
326
357
  expandBtn.className = 'ipp-btn-split-expand';
327
358
  expandBtn.title = 'Выбрать пропорции';
@@ -330,15 +361,36 @@ export class ImagePropertiesPanel {
330
361
  const dropdown = document.createElement('div');
331
362
  dropdown.className = 'ipp-crop-dropdown';
332
363
 
364
+ const toggleCropDropdown = (e) => {
365
+ e.preventDefault();
366
+ e.stopPropagation();
367
+ const isOpen = dropdown.classList.contains('is-open');
368
+
369
+ document.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open, .ipp-border-radius-popover.is-open, .ipp-border-style-popover.is-open').forEach(el => {
370
+ el.classList.remove('is-open');
371
+ });
372
+ document.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active').forEach(el => {
373
+ el.classList.remove('is-expanded', 'is-active');
374
+ });
375
+
376
+ if (!isOpen) {
377
+ dropdown.classList.add('is-open');
378
+ expandBtn.classList.add('is-expanded');
379
+ }
380
+ };
381
+
382
+ mainBtn.addEventListener('click', toggleCropDropdown);
383
+ expandBtn.addEventListener('click', toggleCropDropdown);
384
+
333
385
  const items = [
334
- { id: 'custom', label: 'Custom', icon: ICONS.ddCustom },
335
- { id: 'original', label: 'Original', icon: ICONS.ddOriginal },
386
+ { id: 'custom', label: 'Произвольный', icon: ICONS.ddCustom },
387
+ { id: 'original', label: 'Оригинал', icon: ICONS.ddOriginal },
336
388
  { divider: true },
337
- { id: 'circle', label: 'Circle', icon: ICONS.ddCircle },
338
- { id: 'square', label: 'Square', icon: ICONS.ddSquare },
339
- { id: 'portrait', label: 'Portrait', icon: ICONS.ddPortrait, ratio: '3:4' },
340
- { id: 'landscape', label: 'Landscape', icon: ICONS.ddLandscape, ratio: '4:3' },
341
- { id: 'wide', label: 'Wide', icon: ICONS.ddWide, ratio: '16:9' },
389
+ { id: 'circle', label: 'Круг', icon: ICONS.ddCircle },
390
+ { id: 'square', label: 'Квадрат', icon: ICONS.ddSquare },
391
+ { id: 'portrait', label: 'Портрет', icon: ICONS.ddPortrait, ratio: '3:4' },
392
+ { id: 'landscape', label: 'Пейзаж', icon: ICONS.ddLandscape, ratio: '4:3' },
393
+ { id: 'wide', label: 'Широкий', icon: ICONS.ddWide, ratio: '16:9' },
342
394
  ];
343
395
 
344
396
  items.forEach(item => {
@@ -372,33 +424,16 @@ export class ImagePropertiesPanel {
372
424
  btn.addEventListener('click', (e) => {
373
425
  e.preventDefault();
374
426
  e.stopPropagation();
375
- // Логика выбора пропорции
376
427
  dropdown.classList.remove('is-open');
377
428
  expandBtn.classList.remove('is-expanded');
429
+ if (this.currentId) {
430
+ this._cropController.start(this.currentId, item.id);
431
+ }
378
432
  });
379
433
 
380
434
  dropdown.appendChild(btn);
381
435
  });
382
436
 
383
- expandBtn.addEventListener('click', (e) => {
384
- e.preventDefault();
385
- e.stopPropagation();
386
- const isOpen = dropdown.classList.contains('is-open');
387
-
388
- // Закрываем все другие дропдауны, если есть (на будущее)
389
- document.querySelectorAll('.ipp-crop-dropdown.is-open, .ipp-more-dropdown.is-open').forEach(el => {
390
- el.classList.remove('is-open');
391
- });
392
- document.querySelectorAll('.ipp-btn-split-expand.is-expanded, .ipp-btn.is-active').forEach(el => {
393
- el.classList.remove('is-expanded', 'is-active');
394
- });
395
-
396
- if (!isOpen) {
397
- dropdown.classList.add('is-open');
398
- expandBtn.classList.add('is-expanded');
399
- }
400
- });
401
-
402
437
  wrapper.appendChild(mainBtn);
403
438
  wrapper.appendChild(expandBtn);
404
439
  wrapper.appendChild(dropdown);
@@ -1442,6 +1477,7 @@ export class ImagePropertiesPanel {
1442
1477
  }
1443
1478
 
1444
1479
  destroy() {
1480
+ this._cropController?.destroy();
1445
1481
  if (!this.eventBus || !this._handlers) return;
1446
1482
 
1447
1483
  this.eventBus.off(Events.Tool.SelectionAdd, this._handlers.onSelectionAdd);
@@ -144,6 +144,10 @@ export class ConnectionAnchorsLayer {
144
144
  update() {
145
145
  if (!this.layer) return;
146
146
  if (this._commentPopoverOpen) return;
147
+ if (typeof window !== 'undefined' && window.moodboardHtmlHandlesLayer?._cropMode) {
148
+ this.layer.innerHTML = '';
149
+ return;
150
+ }
147
151
  this.layer.innerHTML = '';
148
152
 
149
153
  const selection = Array.from(this.core?.selectTool?.selectedObjects || []);
@@ -1157,6 +1157,7 @@ export class HandlesDomRenderer {
1157
1157
  let isFrameTarget = false;
1158
1158
  let isMindmapTarget = false;
1159
1159
  let isMindmapOnlyGroupTarget = false;
1160
+ let isLockedTarget = false;
1160
1161
  let isRevitScreenshotTarget = false;
1161
1162
  let revitViewPayload = null;
1162
1163
  let isModel3dScreenshotTarget = false;
@@ -1167,11 +1168,12 @@ export class HandlesDomRenderer {
1167
1168
  const hiddenIncomingSide = { value: null };
1168
1169
  if (id !== '__group__') {
1169
1170
  const req = { objectId: id, pixiObject: null };
1170
- this.host.eventBus.emit(Events.Tool.GetObjectPixi, req);
1171
+ this.host.eventBus.emit('tool:get:object:pixi', req);
1171
1172
  const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
1172
1173
  isFileTarget = mbType === 'file';
1173
1174
  isFrameTarget = mbType === 'frame';
1174
1175
  isMindmapTarget = mbType === 'mindmap';
1176
+ isLockedTarget = !!req.pixiObject?._mb?.properties?.locked;
1175
1177
  isRevitScreenshotTarget = mbType === 'revit-screenshot-img';
1176
1178
  revitViewPayload = req.pixiObject?._mb?.properties?.view || null;
1177
1179
  isModel3dScreenshotTarget = mbType === 'model3d-screenshot-img';
@@ -1199,9 +1201,14 @@ export class HandlesDomRenderer {
1199
1201
  if (selectionIds.length > 0) {
1200
1202
  const byId = new Map((this.host.core?.state?.state?.objects || []).map((obj) => [obj?.id, obj]));
1201
1203
  isMindmapOnlyGroupTarget = selectionIds.every((selectedId) => byId.get(selectedId)?.type === 'mindmap');
1204
+ isLockedTarget = selectionIds.some(selId => {
1205
+ const req = { objectId: selId, pixiObject: null };
1206
+ this.host.eventBus.emit('tool:get:object:pixi', req);
1207
+ return !!req.pixiObject?._mb?.properties?.locked;
1208
+ });
1202
1209
  }
1203
1210
  }
1204
- const isNonResizableTarget = isFileTarget || isMindmapTarget || isMindmapOnlyGroupTarget;
1211
+ const isNonResizableTarget = isFileTarget || isMindmapTarget || isMindmapOnlyGroupTarget || isLockedTarget;
1205
1212
 
1206
1213
  const left = Math.round(cssRect.left);
1207
1214
  const top = Math.round(cssRect.top);
@@ -114,6 +114,7 @@ export class HandlesEventBridge {
114
114
  [Events.UI.ZoomPercent, () => this.host.update()],
115
115
  [Events.Tool.PanUpdate, () => this.host.update()],
116
116
  [Events.Viewport.Changed, () => this.host.update()],
117
+ [Events.Object.TransformUpdated, () => this.host.update()],
117
118
  [Events.History.Changed, (data) => {
118
119
  if (data?.lastUndone || data?.lastRedone) {
119
120
  this.host._endGroupRotationPreview();
@@ -206,49 +206,119 @@
206
206
  height: 36px;
207
207
  }
208
208
 
209
- /* Frame properties panel — mirror text panel look */
209
+ /* Frame properties toolbar — mirror image toolbar look */
210
210
  .frame-properties-panel {
211
211
  position: absolute;
212
212
  pointer-events: auto;
213
- display: inline-flex;
213
+ display: flex;
214
+ flex-direction: row;
214
215
  align-items: center;
215
- gap: 8px;
216
- padding: 12px 32px;
217
- background-color: #ffffff;
218
- border: 1px solid #e0e0e0;
219
- border-radius: 9999px;
220
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
216
+ gap: 2px;
217
+ padding: 4px 8px;
218
+ background: #ffffff;
219
+ border: 1px solid #E5E7EB;
220
+ border-radius: 8px;
221
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06);
222
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
221
223
  font-size: 13px;
222
- font-family: 'Roboto', Arial, sans-serif;
223
- min-width: 320px;
224
+ z-index: 3000;
225
+ user-select: none;
226
+ white-space: nowrap;
227
+ }
228
+
229
+ /* Ratio split-dropdown (двухстрочная кнопка «Ratio / значение») */
230
+ .fpp-ratio {
231
+ position: relative;
232
+ display: inline-flex;
233
+ flex-shrink: 0;
234
+ }
235
+
236
+ .fpp-ratio-btn {
237
+ display: inline-flex;
238
+ align-items: center;
239
+ gap: 6px;
224
240
  height: 36px;
241
+ padding: 0 8px;
242
+ border: none;
243
+ background: none;
244
+ border-radius: 6px;
245
+ cursor: pointer;
246
+ color: #374151;
247
+ font-family: inherit;
248
+ transition: background 0.15s;
225
249
  }
226
250
 
227
- .frame-properties-panel .fpp-label {
228
- font-family: 'Roboto', Arial, sans-serif;
229
- font-size: 12px;
230
- color: #666;
231
- font-weight: 500;
251
+ .fpp-ratio-btn:hover {
252
+ background: #F3F4F6;
232
253
  }
233
254
 
234
- .frame-properties-panel .fpp-select,
235
- .frame-properties-panel .fpp-input {
236
- border: 1px solid #ddd;
237
- border-radius: 4px;
238
- padding: 4px 8px;
255
+ .fpp-ratio-text {
256
+ display: flex;
257
+ flex-direction: column;
258
+ align-items: flex-start;
259
+ line-height: 1.15;
260
+ }
261
+
262
+ .fpp-ratio-caption {
263
+ font-size: 10px;
264
+ color: #9CA3AF;
265
+ }
266
+
267
+ .fpp-ratio-value {
239
268
  font-size: 13px;
240
- background-color: #fff;
269
+ color: #374151;
270
+ font-weight: 500;
241
271
  }
242
272
 
243
- .frame-properties-panel .fpp-select {
244
- height: 28px;
245
- line-height: 28px;
273
+ .fpp-ratio-btn svg {
274
+ color: #6B7280;
275
+ transition: transform 0.2s ease;
276
+ }
277
+
278
+ .fpp-ratio-btn.is-active svg {
279
+ transform: rotate(180deg);
246
280
  }
247
281
 
248
- .frame-properties-panel .fpp-color-button {
282
+ .fpp-ratio-dropdown {
283
+ position: absolute;
284
+ top: calc(100% + 6px);
285
+ left: 0;
286
+ background: #ffffff;
287
+ border: 1px solid #E5E7EB;
288
+ border-radius: 8px;
289
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05);
290
+ padding: 6px;
291
+ display: none;
292
+ flex-direction: column;
293
+ min-width: 180px;
294
+ z-index: 1001;
295
+ }
296
+
297
+ .fpp-ratio-dropdown.is-open {
298
+ display: flex;
299
+ }
300
+
301
+ /* Кружок цвета фона */
302
+ .fpp-color-wrap {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ justify-content: center;
249
306
  width: 28px;
250
307
  height: 28px;
251
- border: 1px solid #ddd;
308
+ border-radius: 6px;
309
+ cursor: pointer;
310
+ flex-shrink: 0;
311
+ transition: background 0.15s;
312
+ }
313
+
314
+ .fpp-color-wrap:hover {
315
+ background: #F3F4F6;
316
+ }
317
+
318
+ .fpp-color-button {
319
+ width: 20px;
320
+ height: 20px;
321
+ border: 1px solid #D1D5DB;
252
322
  border-radius: 50%;
253
323
  background-color: #ffffff;
254
324
  cursor: pointer;
@@ -256,9 +326,274 @@
256
326
  padding: 0;
257
327
  display: block;
258
328
  box-sizing: border-box;
329
+ position: relative;
330
+ }
331
+
332
+ /* transparent indicator — diagonal slash via pseudo */
333
+ .fpp-color-button--none {
334
+ background-color: #ffffff !important;
335
+ border-color: #D1D5DB;
336
+ }
337
+ .fpp-color-button--none::after {
338
+ content: '';
339
+ position: absolute;
340
+ inset: 0;
341
+ background: linear-gradient(to top-right,
342
+ transparent calc(50% - 0.75px),
343
+ #EF4444 calc(50% - 0.75px),
344
+ #EF4444 calc(50% + 0.75px),
345
+ transparent calc(50% + 0.75px)
346
+ );
347
+ border-radius: 50%;
348
+ }
349
+
350
+ /* ═══════════════════════════════════════════════════════════════
351
+ Colour popup dropdown
352
+ ═══════════════════════════════════════════════════════════════ */
353
+
354
+ .fpp-color-popup {
355
+ position: absolute;
356
+ background: #ffffff;
357
+ border: 1px solid #E5E7EB;
358
+ border-radius: 12px;
359
+ box-shadow: 0 10px 25px rgba(0,0,0,0.10), 0 4px 10px rgba(0,0,0,0.05);
360
+ padding: 12px;
361
+ display: none;
362
+ flex-direction: column;
363
+ gap: 14px;
364
+ min-width: 196px;
365
+ z-index: 1001;
366
+ box-sizing: border-box;
367
+ }
368
+ .fpp-color-popup.is-open { display: flex; }
369
+
370
+ .fpp-section { display: flex; flex-direction: column; gap: 8px; }
371
+
372
+ .fpp-section-label {
373
+ font-family: 'Roboto', Arial, sans-serif;
374
+ font-size: 11px;
375
+ font-weight: 500;
376
+ color: #9CA3AF;
377
+ letter-spacing: 0.02em;
378
+ user-select: none;
379
+ }
380
+
381
+ /* Background type row */
382
+ .fpp-bg-type-row { display: flex; gap: 4px; }
383
+
384
+ .fpp-bg-type-btn {
385
+ display: inline-flex;
386
+ align-items: center;
387
+ justify-content: center;
388
+ width: 36px;
389
+ height: 32px;
390
+ border: 1.5px solid transparent;
391
+ border-radius: 6px;
392
+ background: #F9FAFB;
393
+ cursor: pointer;
394
+ color: #374151;
395
+ padding: 0;
396
+ transition: background 0.12s, border-color 0.12s;
397
+ }
398
+ .fpp-bg-type-btn:hover { background: #F3F4F6; border-color: #E5E7EB; }
399
+ .fpp-bg-type-btn.is-active { border-color: #374151; background: #F3F4F6; }
400
+
401
+ /* Color swatch grid */
402
+ .fpp-color-grid {
403
+ display: grid;
404
+ grid-template-columns: repeat(6, 28px);
405
+ gap: 6px;
406
+ }
407
+
408
+ .fpp-color-swatch {
409
+ width: 28px;
410
+ height: 28px;
411
+ border-radius: 50%;
412
+ border: 1.5px solid rgba(0,0,0,0.12);
413
+ background-color: var(--swatch-color, #ffffff);
414
+ cursor: pointer;
415
+ padding: 0;
416
+ margin: 0;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ transition: transform 0.10s, box-shadow 0.10s;
421
+ flex-shrink: 0;
422
+ }
423
+ .fpp-color-swatch:hover { transform: scale(1.12); box-shadow: 0 2px 8px rgba(0,0,0,0.18); }
424
+ .fpp-color-swatch.is-selected { box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #374151; }
425
+
426
+ .fpp-color-swatch--none {
427
+ background-color: #ffffff;
428
+ border-color: #D1D5DB;
429
+ }
430
+ .fpp-color-swatch--none svg { pointer-events: none; }
431
+
432
+ /* Disabled colour section when transparent is selected */
433
+ .fpp-section--color.is-disabled .fpp-color-swatch:not(.fpp-color-swatch--none) {
434
+ opacity: 0.32;
435
+ pointer-events: none;
436
+ cursor: default;
437
+ }
438
+
439
+ /* Custom colours row */
440
+ .fpp-custom-row {
441
+ display: flex;
442
+ flex-wrap: wrap;
443
+ gap: 6px;
444
+ align-items: center;
445
+ }
446
+
447
+ .fpp-add-color-btn {
448
+ width: 28px;
449
+ height: 28px;
450
+ border-radius: 50%;
451
+ border: 1.5px dashed #D1D5DB;
452
+ background: transparent;
453
+ cursor: pointer;
454
+ display: flex;
455
+ align-items: center;
456
+ justify-content: center;
457
+ color: #9CA3AF;
458
+ padding: 0;
459
+ flex-shrink: 0;
460
+ transition: border-color 0.12s, color 0.12s;
461
+ }
462
+ .fpp-add-color-btn:hover { border-color: #9CA3AF; color: #374151; }
463
+
464
+ /* ═══════════════════════════════════════════════════════════════
465
+ HSV colour picker
466
+ ═══════════════════════════════════════════════════════════════ */
467
+
468
+ .fpp-color-picker {
469
+ position: absolute;
470
+ background: #ffffff;
471
+ border: 1px solid #E5E7EB;
472
+ border-radius: 12px;
473
+ box-shadow: 0 10px 25px rgba(0,0,0,0.12);
474
+ padding: 12px;
475
+ display: none;
476
+ flex-direction: column;
477
+ gap: 10px;
478
+ z-index: 1002;
479
+ width: 236px;
480
+ box-sizing: border-box;
259
481
  }
482
+ .fpp-color-picker.is-open { display: flex; }
260
483
 
261
- .frame-properties-panel .fpp-section { display: inline-flex; align-items: center; gap: 6px; }
484
+ .fpp-color-picker__canvas-wrap {
485
+ position: relative;
486
+ width: 212px;
487
+ height: 148px;
488
+ border-radius: 6px;
489
+ overflow: hidden;
490
+ cursor: crosshair;
491
+ flex-shrink: 0;
492
+ }
493
+ .fpp-color-picker__canvas {
494
+ display: block;
495
+ width: 212px;
496
+ height: 148px;
497
+ }
498
+ .fpp-color-picker__cursor {
499
+ position: absolute;
500
+ width: 14px;
501
+ height: 14px;
502
+ border-radius: 50%;
503
+ border: 2px solid #ffffff;
504
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.30);
505
+ transform: translate(-50%, -50%);
506
+ pointer-events: none;
507
+ top: 0;
508
+ left: 0;
509
+ }
510
+
511
+ .fpp-color-picker__hue-wrap { width: 100%; }
512
+
513
+ .fpp-color-picker__hue-slider {
514
+ -webkit-appearance: none;
515
+ appearance: none;
516
+ width: 100%;
517
+ height: 10px;
518
+ border-radius: 5px;
519
+ background: linear-gradient(to right,
520
+ hsl(0,100%,50%), hsl(30,100%,50%), hsl(60,100%,50%), hsl(90,100%,50%),
521
+ hsl(120,100%,50%), hsl(150,100%,50%), hsl(180,100%,50%), hsl(210,100%,50%),
522
+ hsl(240,100%,50%), hsl(270,100%,50%), hsl(300,100%,50%), hsl(330,100%,50%),
523
+ hsl(360,100%,50%));
524
+ outline: none;
525
+ cursor: pointer;
526
+ border: none;
527
+ }
528
+ .fpp-color-picker__hue-slider::-webkit-slider-thumb {
529
+ -webkit-appearance: none;
530
+ width: 16px;
531
+ height: 16px;
532
+ border-radius: 50%;
533
+ background: #ffffff;
534
+ border: 2px solid rgba(0,0,0,0.28);
535
+ box-shadow: 0 1px 3px rgba(0,0,0,0.20);
536
+ cursor: pointer;
537
+ }
538
+ .fpp-color-picker__hue-slider::-moz-range-thumb {
539
+ width: 16px;
540
+ height: 16px;
541
+ border-radius: 50%;
542
+ background: #ffffff;
543
+ border: 2px solid rgba(0,0,0,0.28);
544
+ cursor: pointer;
545
+ }
546
+
547
+ .fpp-color-picker__bottom {
548
+ display: flex;
549
+ align-items: center;
550
+ gap: 8px;
551
+ }
552
+ .fpp-color-picker__eyedropper {
553
+ width: 30px;
554
+ height: 30px;
555
+ border: 1px solid #E5E7EB;
556
+ border-radius: 6px;
557
+ background: transparent;
558
+ display: flex;
559
+ align-items: center;
560
+ justify-content: center;
561
+ cursor: pointer;
562
+ color: #6B7280;
563
+ padding: 0;
564
+ flex-shrink: 0;
565
+ transition: background 0.12s, color 0.12s;
566
+ }
567
+ .fpp-color-picker__eyedropper:hover { background: #F3F4F6; color: #374151; }
568
+
569
+ .fpp-color-picker__hex-input {
570
+ flex: 1;
571
+ height: 30px;
572
+ border: 1px solid #E5E7EB;
573
+ border-radius: 6px;
574
+ padding: 0 8px;
575
+ font-size: 13px;
576
+ font-family: 'Roboto Mono', 'Courier New', monospace;
577
+ color: #374151;
578
+ outline: none;
579
+ background: #ffffff;
580
+ }
581
+ .fpp-color-picker__hex-input:focus { border-color: #9CA3AF; }
582
+
583
+ .fpp-color-picker__apply {
584
+ width: 100%;
585
+ height: 30px;
586
+ border: none;
587
+ border-radius: 6px;
588
+ background: #374151;
589
+ color: #ffffff;
590
+ font-size: 13px;
591
+ font-family: 'Roboto', Arial, sans-serif;
592
+ font-weight: 500;
593
+ cursor: pointer;
594
+ transition: background 0.12s;
595
+ }
596
+ .fpp-color-picker__apply:hover { background: #1F2937; }
262
597
 
263
598
  .text-properties-panel .tpp-label {
264
599
  font-family: 'Roboto', Arial, sans-serif;