@sequent-org/moodboard 1.4.49 → 1.4.52
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 +1 -1
- package/src/core/bootstrap/CoreInitializer.js +3 -0
- package/src/core/commands/ApplyCropCommand.js +90 -0
- package/src/core/commands/UpdateFramePropertiesCommand.js +9 -2
- package/src/core/flows/ObjectLifecycleFlow.js +29 -8
- package/src/objects/FrameObject.js +242 -21
- package/src/objects/ImageObject.js +23 -7
- package/src/services/CropController.js +242 -0
- package/src/services/FrameService.js +87 -9
- package/src/services/ImageAttachmentService.js +260 -0
- package/src/tools/manager/ToolManagerLifecycle.js +4 -0
- package/src/tools/object-tools/DrawingTool.js +1 -0
- package/src/tools/object-tools/placement/GhostController.js +2 -2
- package/src/tools/object-tools/placement/PlacementPayloadFactory.js +9 -1
- package/src/tools/object-tools/selection/FrameTitleInlineEditorController.js +13 -4
- package/src/tools/object-tools/selection/InlineEditorDomFactory.js +15 -10
- package/src/tools/object-tools/selection/TransformInteractionController.js +50 -0
- package/src/ui/CropOverlay.js +654 -0
- package/src/ui/FramePropertiesPanel.js +1184 -405
- package/src/ui/HtmlHandlesLayer.js +2 -0
- package/src/ui/ImagePropertiesPanel.js +85 -41
- package/src/ui/connectors/ConnectionAnchorsLayer.js +4 -0
- package/src/ui/handles/HandlesDomRenderer.js +9 -2
- package/src/ui/handles/HandlesEventBridge.js +1 -0
- package/src/ui/styles/panels.css +402 -27
- package/src/ui/styles/workspace.css +20 -0
- package/src/utils/applyCrop.js +92 -0
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { BoardService } from '../../services/BoardService.js';
|
|
|
4
4
|
import { ZoomPanController } from '../../services/ZoomPanController.js';
|
|
5
5
|
import { ZOrderManager } from '../../services/ZOrderManager.js';
|
|
6
6
|
import { FrameService } from '../../services/FrameService.js';
|
|
7
|
+
import { ImageAttachmentService } from '../../services/ImageAttachmentService.js';
|
|
7
8
|
import { Events } from '../events/Events.js';
|
|
8
9
|
|
|
9
10
|
export async function initializeCore(core) {
|
|
@@ -20,6 +21,8 @@ export async function initializeCore(core) {
|
|
|
20
21
|
core.zOrder.attach();
|
|
21
22
|
core.frameService = new FrameService(core.eventBus, core.pixi, core.state);
|
|
22
23
|
core.frameService.attach();
|
|
24
|
+
core.imageAttachmentService = new ImageAttachmentService(core.eventBus, core.pixi, core.state);
|
|
25
|
+
core.imageAttachmentService.attach();
|
|
23
26
|
|
|
24
27
|
setupViewportResize(core);
|
|
25
28
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BaseCommand } from './BaseCommand.js';
|
|
2
|
+
import { Events } from '../events/Events.js';
|
|
3
|
+
import { applyCropToSprite } from '../../utils/applyCrop.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Команда применения / отмены кадрирования изображения.
|
|
7
|
+
*
|
|
8
|
+
* before / after = {
|
|
9
|
+
* cropRect: { x, y, w, h } | null — нормализованные 0..1 от оригинала
|
|
10
|
+
* cropShape: 'circle' | 'rect' | null
|
|
11
|
+
* position: { x, y } — world top-left
|
|
12
|
+
* size: { width, height } — world display size
|
|
13
|
+
* originalPosition: { x, y } | null — до первого кропа
|
|
14
|
+
* originalSize: { width, height } | null
|
|
15
|
+
* borderRadius: number
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
export class ApplyCropCommand extends BaseCommand {
|
|
19
|
+
constructor(core, objectId, before, after) {
|
|
20
|
+
super('apply_crop', 'Обрезать изображение');
|
|
21
|
+
this.core = core;
|
|
22
|
+
this.objectId = objectId;
|
|
23
|
+
this.before = before;
|
|
24
|
+
this.after = after;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute() {
|
|
28
|
+
this._apply(this.after);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async undo() {
|
|
32
|
+
this._apply(this.before);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_apply(state) {
|
|
36
|
+
const objects = this.core.state.getObjects();
|
|
37
|
+
const obj = objects.find(o => o.id === this.objectId);
|
|
38
|
+
if (!obj) return;
|
|
39
|
+
|
|
40
|
+
if (!obj.properties) obj.properties = {};
|
|
41
|
+
|
|
42
|
+
// Кроп-метаданные
|
|
43
|
+
if (state.cropRect) {
|
|
44
|
+
obj.properties.cropRect = { ...state.cropRect };
|
|
45
|
+
} else {
|
|
46
|
+
delete obj.properties.cropRect;
|
|
47
|
+
}
|
|
48
|
+
obj.properties.cropShape = state.cropShape || null;
|
|
49
|
+
|
|
50
|
+
// Хранение оригинальных размеров (для "Original")
|
|
51
|
+
if (state.originalPosition && state.originalSize) {
|
|
52
|
+
obj.properties.originalPosition = { ...state.originalPosition };
|
|
53
|
+
obj.properties.originalSize = { ...state.originalSize };
|
|
54
|
+
} else {
|
|
55
|
+
delete obj.properties.originalPosition;
|
|
56
|
+
delete obj.properties.originalSize;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Позиция и размер объекта
|
|
60
|
+
if (state.position) {
|
|
61
|
+
obj.position = { ...state.position };
|
|
62
|
+
}
|
|
63
|
+
if (state.size) {
|
|
64
|
+
obj.width = state.size.width;
|
|
65
|
+
obj.height = state.size.height;
|
|
66
|
+
obj.properties.width = state.size.width;
|
|
67
|
+
obj.properties.height = state.size.height;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.core.state.markDirty();
|
|
71
|
+
|
|
72
|
+
// PIXI-визуал
|
|
73
|
+
const sprite = this.core?.pixi?.objects?.get(this.objectId);
|
|
74
|
+
if (sprite) {
|
|
75
|
+
applyCropToSprite(
|
|
76
|
+
sprite,
|
|
77
|
+
state.cropRect || null,
|
|
78
|
+
state.cropShape || null,
|
|
79
|
+
state.size,
|
|
80
|
+
state.position,
|
|
81
|
+
state.borderRadius || 0
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Уведомить систему ручек об изменении трансформации
|
|
86
|
+
if (this.core?.eventBus) {
|
|
87
|
+
this.core.eventBus.emit(Events.Object.TransformUpdated, { objectId: this.objectId });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Команда изменения свойств фрейма (название, фон, тип, lockedAspect) для системы Undo/Redo.
|
|
3
|
-
* Поддерживает: title, backgroundColor, type, lockedAspect.
|
|
2
|
+
* Команда изменения свойств фрейма (название, фон, тип, lockedAspect, bgMode) для системы Undo/Redo.
|
|
3
|
+
* Поддерживает: title, backgroundColor, type, lockedAspect, bgMode, hidden.
|
|
4
4
|
*/
|
|
5
5
|
import { BaseCommand } from './BaseCommand.js';
|
|
6
6
|
import { Events } from '../events/Events.js';
|
|
@@ -10,6 +10,7 @@ const FRAME_PROP_LABELS = {
|
|
|
10
10
|
backgroundColor: 'фон',
|
|
11
11
|
type: 'тип',
|
|
12
12
|
lockedAspect: 'фиксация пропорций',
|
|
13
|
+
bgMode: 'режим заливки',
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
export class UpdateFramePropertiesCommand extends BaseCommand {
|
|
@@ -78,6 +79,12 @@ export class UpdateFramePropertiesCommand extends BaseCommand {
|
|
|
78
79
|
if (property === 'backgroundColor' && instance.setBackgroundColor) {
|
|
79
80
|
instance.setBackgroundColor(value);
|
|
80
81
|
}
|
|
82
|
+
if (property === 'hidden' && instance.setHidden) {
|
|
83
|
+
instance.setHidden(value);
|
|
84
|
+
}
|
|
85
|
+
if (property === 'bgMode' && instance.setBgMode) {
|
|
86
|
+
instance.setBgMode(value);
|
|
87
|
+
}
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
let updates;
|
|
@@ -169,12 +169,14 @@ function tryCreateConnectorStyleCommand(core, object, objectId, updates) {
|
|
|
169
169
|
const { style, start, end } = updates.properties;
|
|
170
170
|
|
|
171
171
|
// Проверяем, что в updates.properties только style/start/end (и нет посторонних ключей)
|
|
172
|
-
const allowedKeys = new Set(['style', 'start', 'end', 'locked']);
|
|
172
|
+
const allowedKeys = new Set(['style', 'start', 'end', 'locked', 'lockMode', 'lockedByFrame']);
|
|
173
173
|
const hasOtherKeys = Object.keys(updates.properties).some(k => !allowedKeys.has(k));
|
|
174
174
|
if (hasOtherKeys) return false;
|
|
175
175
|
// Должно быть хотя бы одно из: style, start, end
|
|
176
176
|
if (!style && start === undefined && end === undefined
|
|
177
|
-
&& updates.properties.locked === undefined
|
|
177
|
+
&& updates.properties.locked === undefined
|
|
178
|
+
&& updates.properties.lockMode === undefined
|
|
179
|
+
&& updates.properties.lockedByFrame === undefined) return false;
|
|
178
180
|
|
|
179
181
|
const commandUpdates = {};
|
|
180
182
|
if (style !== undefined) commandUpdates.style = style;
|
|
@@ -198,11 +200,11 @@ function tryCreateConnectorStyleCommand(core, object, objectId, updates) {
|
|
|
198
200
|
return true;
|
|
199
201
|
}
|
|
200
202
|
|
|
201
|
-
const FRAME_PROP_KEYS = ['title'];
|
|
203
|
+
const FRAME_PROP_KEYS = ['title', 'hidden', 'bgMode'];
|
|
202
204
|
|
|
203
205
|
/**
|
|
204
206
|
* Если updates содержит одно свойство фрейма — создаёт UpdateFramePropertiesCommand.
|
|
205
|
-
* Поддерживает: title, backgroundColor, type, lockedAspect.
|
|
207
|
+
* Поддерживает: title, backgroundColor, type, lockedAspect, hidden.
|
|
206
208
|
* @returns {boolean} true, если команда создана и применена
|
|
207
209
|
*/
|
|
208
210
|
function tryCreateFramePropertiesCommand(core, object, objectId, updates) {
|
|
@@ -218,10 +220,16 @@ function tryCreateFramePropertiesCommand(core, object, objectId, updates) {
|
|
|
218
220
|
oldValue = object.backgroundColor ?? 0xFFFFFF;
|
|
219
221
|
} else if (updates.properties) {
|
|
220
222
|
const propKeys = Object.keys(updates.properties);
|
|
221
|
-
if (propKeys.length === 1 && propKeys[0]
|
|
222
|
-
property =
|
|
223
|
-
newValue = updates.properties
|
|
224
|
-
|
|
223
|
+
if (propKeys.length === 1 && FRAME_PROP_KEYS.includes(propKeys[0])) {
|
|
224
|
+
property = propKeys[0];
|
|
225
|
+
newValue = updates.properties[property];
|
|
226
|
+
if (property === 'hidden') {
|
|
227
|
+
oldValue = object.properties?.[property] ?? false;
|
|
228
|
+
} else if (property === 'bgMode') {
|
|
229
|
+
oldValue = object.properties?.[property] ?? 'solid';
|
|
230
|
+
} else {
|
|
231
|
+
oldValue = object.properties?.[property] ?? '';
|
|
232
|
+
}
|
|
225
233
|
}
|
|
226
234
|
// type и lockedAspect — только через UpdateFrameTypeCommand в панели (один шаг в истории)
|
|
227
235
|
}
|
|
@@ -375,6 +383,13 @@ export function setupObjectLifecycleFlow(core) {
|
|
|
375
383
|
Object.assign(object, topLevelUpdates);
|
|
376
384
|
|
|
377
385
|
const pixiObject = core.pixi.objects.get(objectId);
|
|
386
|
+
if (pixiObject && pixiObject._mb) {
|
|
387
|
+
if (updates.properties) {
|
|
388
|
+
if (!pixiObject._mb.properties) pixiObject._mb.properties = {};
|
|
389
|
+
Object.assign(pixiObject._mb.properties, updates.properties);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
378
393
|
if (pixiObject && pixiObject._mb && pixiObject._mb.instance) {
|
|
379
394
|
const instance = pixiObject._mb.instance;
|
|
380
395
|
|
|
@@ -390,6 +405,12 @@ export function setupObjectLifecycleFlow(core) {
|
|
|
390
405
|
}
|
|
391
406
|
}
|
|
392
407
|
|
|
408
|
+
if (object.type === 'frame' && updates.properties?.bgMode !== undefined) {
|
|
409
|
+
if (instance.setBgMode) {
|
|
410
|
+
instance.setBgMode(updates.properties.bgMode);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
393
414
|
if (object.type === 'note' && updates.properties) {
|
|
394
415
|
if (instance.setStyle) {
|
|
395
416
|
const styleUpdates = {};
|
|
@@ -17,12 +17,14 @@ export class FrameObject {
|
|
|
17
17
|
this.height = this.objectData.height || 100;
|
|
18
18
|
// Берем стили рамки из CSS-переменных, с дефолтом
|
|
19
19
|
const rootStyles = (typeof window !== 'undefined') ? getComputedStyle(document.documentElement) : null;
|
|
20
|
-
const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '
|
|
20
|
+
const cssBorderWidth = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-border-width') || '5') : 5;
|
|
21
21
|
const cssCornerRadius = rootStyles ? parseFloat(rootStyles.getPropertyValue('--frame-corner-radius') || '6') : 6;
|
|
22
22
|
const cssBorderColor = rootStyles ? rootStyles.getPropertyValue('--frame-border-color').trim() : '';
|
|
23
|
-
this.borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth :
|
|
23
|
+
this.borderWidth = Number.isFinite(cssBorderWidth) ? cssBorderWidth : 5;
|
|
24
24
|
// Используем backgroundColor из данных объекта, если есть, иначе белый
|
|
25
25
|
this.fillColor = this.objectData.backgroundColor || this.objectData.properties?.backgroundColor || 0xFFFFFF;
|
|
26
|
+
// Режим заливки: solid | solid-bordered | outline
|
|
27
|
+
this.bgMode = this.objectData.properties?.bgMode || this.objectData.bgMode || 'solid';
|
|
26
28
|
// Парсим цвет из CSS переменной, если задан
|
|
27
29
|
if (cssBorderColor && cssBorderColor.startsWith('#')) {
|
|
28
30
|
this.strokeColor = parseInt(cssBorderColor.slice(1), 16);
|
|
@@ -48,6 +50,10 @@ export class FrameObject {
|
|
|
48
50
|
// Под-контейнер: масштаб компенсирует зум, поэтому заголовок всегда одного размера на экране
|
|
49
51
|
this.titleLayer = new PIXI.Container();
|
|
50
52
|
this.titleLayer.eventMode = 'none'; // не перехватывать указатель
|
|
53
|
+
const _frameObjectId = this.objectData.id || '';
|
|
54
|
+
if (_frameObjectId) {
|
|
55
|
+
this.titleLayer.name = `mb-frame-title-${_frameObjectId}`;
|
|
56
|
+
}
|
|
51
57
|
|
|
52
58
|
this.titleBg = new PIXI.Graphics();
|
|
53
59
|
this.titleLayer.addChild(this.titleBg);
|
|
@@ -58,7 +64,7 @@ export class FrameObject {
|
|
|
58
64
|
fill: 0x333333,
|
|
59
65
|
fontWeight: '500'
|
|
60
66
|
});
|
|
61
|
-
this.titleText.anchor.set(0, 0);
|
|
67
|
+
this.titleText.anchor.set(0, 0.5);
|
|
62
68
|
this.titleLayer.addChild(this.titleText);
|
|
63
69
|
|
|
64
70
|
this.container.addChild(this.titleLayer);
|
|
@@ -119,6 +125,17 @@ export class FrameObject {
|
|
|
119
125
|
this._redrawPreserveTransform(this.width, this.height, this.fillColor);
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Установить скрытость фрейма
|
|
130
|
+
* @param {boolean} hidden
|
|
131
|
+
*/
|
|
132
|
+
setHidden(hidden) {
|
|
133
|
+
if (!this.objectData) this.objectData = {};
|
|
134
|
+
if (!this.objectData.properties) this.objectData.properties = {};
|
|
135
|
+
this.objectData.properties.hidden = hidden;
|
|
136
|
+
this._redrawPreserveTransform(this.width, this.height, this.fillColor);
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
/** Скрыть/показать серую рамку (при выделении скрываем, чтобы не накладывалась на синюю) */
|
|
123
140
|
setBorderVisible(visible) {
|
|
124
141
|
if (this._borderVisible === visible) return;
|
|
@@ -128,7 +145,10 @@ export class FrameObject {
|
|
|
128
145
|
|
|
129
146
|
_onSelectionAdd(data) {
|
|
130
147
|
const myId = this.objectData?.id ?? this.container?._mb?.objectId;
|
|
131
|
-
if (data?.object
|
|
148
|
+
if (data?.object !== myId) return;
|
|
149
|
+
// В outline цветная рамка — основной визуал фрейма, не дублирует синюю рамку выделения.
|
|
150
|
+
if (this.bgMode === 'outline') return;
|
|
151
|
+
this.setBorderVisible(false);
|
|
132
152
|
}
|
|
133
153
|
|
|
134
154
|
_onSelectionRemove(data) {
|
|
@@ -182,6 +202,17 @@ export class FrameObject {
|
|
|
182
202
|
}
|
|
183
203
|
}
|
|
184
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Установить режим заливки фрейма
|
|
207
|
+
* @param {'solid'|'solid-bordered'|'outline'} mode
|
|
208
|
+
*/
|
|
209
|
+
setBgMode(mode) {
|
|
210
|
+
this.bgMode = mode || 'solid';
|
|
211
|
+
if (!this.objectData.properties) { this.objectData.properties = {}; }
|
|
212
|
+
this.objectData.properties.bgMode = this.bgMode;
|
|
213
|
+
this._redrawPreserveTransform(this.width, this.height, this.fillColor);
|
|
214
|
+
}
|
|
215
|
+
|
|
185
216
|
/**
|
|
186
217
|
* Обновить размер фрейма
|
|
187
218
|
* @param {{width:number,height:number}} size
|
|
@@ -224,16 +255,66 @@ export class FrameObject {
|
|
|
224
255
|
_draw(width, height, color, showStroke = true) {
|
|
225
256
|
const g = this.graphics;
|
|
226
257
|
g.clear();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
258
|
+
const isHidden = !!(this.objectData?.properties?.hidden);
|
|
259
|
+
|
|
260
|
+
if (isHidden) {
|
|
261
|
+
// Закрашиваем фон фрейма в #f0f6fc при скрытии
|
|
262
|
+
g.beginFill(0xF0F6FC, 1);
|
|
263
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
264
|
+
g.endFill();
|
|
265
|
+
|
|
266
|
+
// Создаём иконку глаза, если её ещё нет
|
|
267
|
+
if (!this.eyeSprite) {
|
|
268
|
+
const eyeSvg = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#666666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M3 3l18 18"/><path d="M10.6 10.7a2 2 0 0 0 2.7 2.8"/><path d="M9.4 5.2A9.6 9.6 0 0 1 12 5c6.5 0 10 7 10 7a13 13 0 0 1-2.2 2.9M6.2 6.2A13 13 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 3.5-.6"/></svg>`;
|
|
269
|
+
const eyeTexture = PIXI.Texture.from(`data:image/svg+xml;utf8,${encodeURIComponent(eyeSvg)}`);
|
|
270
|
+
this.eyeSprite = new PIXI.Sprite(eyeTexture);
|
|
271
|
+
this.eyeSprite.anchor.set(0.5);
|
|
272
|
+
this.container.addChild(this.eyeSprite);
|
|
273
|
+
}
|
|
274
|
+
this.eyeSprite.visible = true;
|
|
275
|
+
this.eyeSprite.x = Math.max(0, width) / 2;
|
|
276
|
+
this.eyeSprite.y = Math.max(0, height) / 2;
|
|
277
|
+
|
|
278
|
+
if (this.titleLayer) this.titleLayer.visible = false;
|
|
279
|
+
} else {
|
|
280
|
+
if (this.eyeSprite) this.eyeSprite.visible = false;
|
|
281
|
+
|
|
282
|
+
const bgMode = this.bgMode || 'solid';
|
|
283
|
+
const fillColor = typeof color === 'number' ? color : 0xFFFFFF;
|
|
284
|
+
|
|
285
|
+
if (bgMode === 'solid') {
|
|
286
|
+
// Сплошная заливка без собственной рамки (рамка выделения — отдельно)
|
|
287
|
+
g.beginFill(fillColor, 1);
|
|
288
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
289
|
+
g.endFill();
|
|
290
|
+
} else if (bgMode === 'solid-bordered') {
|
|
291
|
+
// Фон = выбранный цвет; рамка = S=100,V=50 от того же тона палитры
|
|
292
|
+
const borderColor = this._pickBorderColor(fillColor);
|
|
293
|
+
if (showStroke) {
|
|
294
|
+
try {
|
|
295
|
+
g.lineStyle({ width: this.borderWidth, color: borderColor, alpha: 1, alignment: 0 });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
g.lineStyle(this.borderWidth, borderColor, 1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
g.beginFill(fillColor, 1);
|
|
301
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
302
|
+
g.endFill();
|
|
303
|
+
} else {
|
|
304
|
+
// outline: прозрачный фон, рамка цвета выбранного цвета (всегда видна, в т.ч. при фокусе)
|
|
305
|
+
try {
|
|
306
|
+
g.lineStyle({ width: this.borderWidth, color: fillColor, alpha: 1, alignment: 1 });
|
|
307
|
+
} catch (e) {
|
|
308
|
+
g.lineStyle(this.borderWidth, fillColor, 1);
|
|
309
|
+
}
|
|
310
|
+
g.beginFill(0x000000, 0);
|
|
311
|
+
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
312
|
+
g.endFill();
|
|
232
313
|
}
|
|
314
|
+
|
|
315
|
+
if (this.titleLayer) this.titleLayer.visible = true;
|
|
233
316
|
}
|
|
234
|
-
|
|
235
|
-
g.drawRoundedRect(0, 0, Math.max(0, width), Math.max(0, height), this.cornerRadius);
|
|
236
|
-
g.endFill();
|
|
317
|
+
|
|
237
318
|
// Обновляем hitArea и корректный hit testing
|
|
238
319
|
this.container.hitArea = new PIXI.Rectangle(0, 0, width, height);
|
|
239
320
|
this.container.containsPoint = (point) => {
|
|
@@ -270,7 +351,7 @@ export class FrameObject {
|
|
|
270
351
|
|
|
271
352
|
// Высота подложки в базовых пикселях: baseFontSize + 4px сверху + 4px снизу
|
|
272
353
|
const labelBaseH = this.baseFontSize + 8;
|
|
273
|
-
const gap =
|
|
354
|
+
const gap = 5; // зазор между нижним краем подписи и верхней границей фрейма
|
|
274
355
|
|
|
275
356
|
// Позиционируем над фреймом (y=0 — верхний край фрейма в локальных координатах контейнера)
|
|
276
357
|
this.titleLayer.x = 0;
|
|
@@ -290,6 +371,143 @@ export class FrameObject {
|
|
|
290
371
|
this._redrawTitleBg();
|
|
291
372
|
}
|
|
292
373
|
|
|
374
|
+
/**
|
|
375
|
+
* YIQ-контраст: возвращает 0x000000 или 0xFFFFFF — читаемый цвет на заданном фоне
|
|
376
|
+
* @param {number} hexInt Цвет фона (PIXI hex)
|
|
377
|
+
* @returns {number}
|
|
378
|
+
*/
|
|
379
|
+
_pickContrastColor(hexInt) {
|
|
380
|
+
const r = (hexInt >> 16) & 0xFF;
|
|
381
|
+
const g = (hexInt >> 8) & 0xFF;
|
|
382
|
+
const b = hexInt & 0xFF;
|
|
383
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
384
|
+
const darkness = (1 - yiq / 255) * 100;
|
|
385
|
+
return darkness >= 35 ? 0xFFFFFF : 0x000000;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Цвет рамки в режиме solid-bordered.
|
|
390
|
+
* Крайне правый по горизонтали (S=100) и по середине вертикали (V=50)
|
|
391
|
+
* в палитре того же тона, что и фон.
|
|
392
|
+
* Белый фон: крайне левый по горизонтали (S=0), середина по вертикали (V=50) → нейтральный серый.
|
|
393
|
+
* Прочие ахроматические цвета (серый, чёрный — s=0): чёрный.
|
|
394
|
+
* @param {number} hexInt Цвет фона (PIXI hex)
|
|
395
|
+
* @returns {number}
|
|
396
|
+
*/
|
|
397
|
+
_pickBorderColor(hexInt) {
|
|
398
|
+
const r = (hexInt >> 16) & 0xFF;
|
|
399
|
+
const g = (hexInt >> 8) & 0xFF;
|
|
400
|
+
const b = hexInt & 0xFF;
|
|
401
|
+
if (r === 255 && g === 255 && b === 255) {
|
|
402
|
+
return 0x808080;
|
|
403
|
+
}
|
|
404
|
+
const { h, s } = this._rgbToHsv(r, g, b);
|
|
405
|
+
if (s === 0) {
|
|
406
|
+
return 0x000000;
|
|
407
|
+
}
|
|
408
|
+
return this._hsvToHex(h, 100, 50);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Цвет текста заголовка фрейма.
|
|
413
|
+
* Для почти-белых тонированных фонов (яркость < 5 по шкале 0=белый…100=чёрный,
|
|
414
|
+
* но не чистый белый) берёт тон из той же палитры с макс. насыщенностью (S=100, V=50).
|
|
415
|
+
* Иначе — обычный YIQ-контраст (чёрный/белый).
|
|
416
|
+
* @param {number} hexInt Цвет фона (PIXI hex)
|
|
417
|
+
* @returns {number}
|
|
418
|
+
*/
|
|
419
|
+
_pickTitleTextColor(hexInt) {
|
|
420
|
+
const r = (hexInt >> 16) & 0xFF;
|
|
421
|
+
const g = (hexInt >> 8) & 0xFF;
|
|
422
|
+
const b = hexInt & 0xFF;
|
|
423
|
+
|
|
424
|
+
// Чистый белый фон — исключение: чёрный текст
|
|
425
|
+
if (r === 255 && g === 255 && b === 255) {
|
|
426
|
+
return 0x000000;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Яркость по шкале 0 (белый) → 100 (чёрный) через HSL-lightness
|
|
430
|
+
const darkness = 100 - ((Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255) * 100;
|
|
431
|
+
if (darkness >= 5) {
|
|
432
|
+
return this._pickContrastColor(hexInt);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Почти-белый тонированный фон: та же палитра, правый край по центру (S=100, V=50)
|
|
436
|
+
const { h } = this._rgbToHsv(r, g, b);
|
|
437
|
+
return this._hsvToHex(h, 100, 50);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {number} r 0..255
|
|
442
|
+
* @param {number} g 0..255
|
|
443
|
+
* @param {number} b 0..255
|
|
444
|
+
* @returns {{ h: number, s: number, v: number }} h 0..360, s/v 0..100
|
|
445
|
+
*/
|
|
446
|
+
_rgbToHsv(r, g, b) {
|
|
447
|
+
const rn = r / 255, gn = g / 255, bn = b / 255;
|
|
448
|
+
const max = Math.max(rn, gn, bn);
|
|
449
|
+
const min = Math.min(rn, gn, bn);
|
|
450
|
+
const d = max - min;
|
|
451
|
+
let h = 0;
|
|
452
|
+
if (d !== 0) {
|
|
453
|
+
if (max === rn) {
|
|
454
|
+
h = ((gn - bn) / d) % 6;
|
|
455
|
+
} else if (max === gn) {
|
|
456
|
+
h = (bn - rn) / d + 2;
|
|
457
|
+
} else {
|
|
458
|
+
h = (rn - gn) / d + 4;
|
|
459
|
+
}
|
|
460
|
+
h *= 60;
|
|
461
|
+
if (h < 0) {
|
|
462
|
+
h += 360;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const s = max === 0 ? 0 : (d / max) * 100;
|
|
466
|
+
return { h, s, v: max * 100 };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @param {number} h 0..360
|
|
471
|
+
* @param {number} s 0..100
|
|
472
|
+
* @param {number} v 0..100
|
|
473
|
+
* @returns {number} PIXI hex
|
|
474
|
+
*/
|
|
475
|
+
_hsvToHex(h, s, v) {
|
|
476
|
+
const sn = s / 100, vn = v / 100;
|
|
477
|
+
const c = vn * sn;
|
|
478
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
479
|
+
const m = vn - c;
|
|
480
|
+
let rp = 0, gp = 0, bp = 0;
|
|
481
|
+
if (h < 60) {
|
|
482
|
+
rp = c; gp = x;
|
|
483
|
+
} else if (h < 120) {
|
|
484
|
+
rp = x; gp = c;
|
|
485
|
+
} else if (h < 180) {
|
|
486
|
+
gp = c; bp = x;
|
|
487
|
+
} else if (h < 240) {
|
|
488
|
+
gp = x; bp = c;
|
|
489
|
+
} else if (h < 300) {
|
|
490
|
+
rp = x; bp = c;
|
|
491
|
+
} else {
|
|
492
|
+
rp = c; bp = x;
|
|
493
|
+
}
|
|
494
|
+
const r = Math.round((rp + m) * 255);
|
|
495
|
+
const g = Math.round((gp + m) * 255);
|
|
496
|
+
const b = Math.round((bp + m) * 255);
|
|
497
|
+
return (r << 16) | (g << 8) | b;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Возвращает цвета плашки заголовка в формате CSS-строк для HTML-инпута
|
|
502
|
+
* @returns {{ bgCss: string, textCss: string }}
|
|
503
|
+
*/
|
|
504
|
+
getTitleColors() {
|
|
505
|
+
const bg = this.fillColor;
|
|
506
|
+
const text = this._pickTitleTextColor(bg);
|
|
507
|
+
const toHex = (n) => '#' + n.toString(16).padStart(6, '0');
|
|
508
|
+
return { bgCss: toHex(bg), textCss: toHex(text) };
|
|
509
|
+
}
|
|
510
|
+
|
|
293
511
|
/**
|
|
294
512
|
* Нарисовать скруглённую подложку под текущую ширину текста
|
|
295
513
|
*/
|
|
@@ -299,6 +517,13 @@ export class FrameObject {
|
|
|
299
517
|
const padH = 8; // горизонтальный отступ с каждой стороны
|
|
300
518
|
const padV = 4; // вертикальный отступ с каждой стороны
|
|
301
519
|
|
|
520
|
+
// Фон плашки = цвет фрейма (fill или border в зависимости от bgMode — всегда this.fillColor)
|
|
521
|
+
const bgColor = typeof this.fillColor === 'number' ? this.fillColor : 0xFFFFFF;
|
|
522
|
+
const textColor = this._pickTitleTextColor(bgColor);
|
|
523
|
+
|
|
524
|
+
// Обновляем цвет текста под контраст фона
|
|
525
|
+
this.titleText.style.fill = textColor;
|
|
526
|
+
|
|
302
527
|
// Измеряем текст в базовых единицах
|
|
303
528
|
const style = new PIXI.TextStyle({
|
|
304
529
|
fontFamily: this.titleText.style.fontFamily,
|
|
@@ -312,18 +537,14 @@ export class FrameObject {
|
|
|
312
537
|
|
|
313
538
|
const g = this.titleBg;
|
|
314
539
|
g.clear();
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
} catch (_) {
|
|
318
|
-
g.lineStyle(1, this.strokeColor, 1);
|
|
319
|
-
}
|
|
320
|
-
g.beginFill(0xFFFFFF, 1);
|
|
540
|
+
g.lineStyle(0);
|
|
541
|
+
g.beginFill(bgColor, 1);
|
|
321
542
|
g.drawRoundedRect(0, 0, bgW, bgH, 6);
|
|
322
543
|
g.endFill();
|
|
323
544
|
|
|
324
|
-
// Текст внутри подложки
|
|
545
|
+
// Текст внутри подложки — по вертикали по центру (anchor.y = 0.5)
|
|
325
546
|
this.titleText.x = padH;
|
|
326
|
-
this.titleText.y =
|
|
547
|
+
this.titleText.y = Math.round(bgH / 2);
|
|
327
548
|
}
|
|
328
549
|
|
|
329
550
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js';
|
|
2
2
|
import { applyRoundedMask } from '../utils/applyRoundedMask.js';
|
|
3
|
+
import { applyCropToSprite } from '../utils/applyCrop.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* ImageObject — отображение загруженного изображения как спрайт
|
|
@@ -28,10 +29,7 @@ export class ImageObject {
|
|
|
28
29
|
const fitToSize = () => {
|
|
29
30
|
const texW = this.sprite.texture.width || 1;
|
|
30
31
|
const texH = this.sprite.texture.height || 1;
|
|
31
|
-
|
|
32
|
-
const sy = this.height / texH;
|
|
33
|
-
this.sprite.scale.set(sx, sy);
|
|
34
|
-
// Обновим метаданные базовых размеров
|
|
32
|
+
// Обновим метаданные базовых размеров (до кропа)
|
|
35
33
|
const prevMb = this.sprite._mb || {};
|
|
36
34
|
this.sprite._mb = {
|
|
37
35
|
...prevMb,
|
|
@@ -43,9 +41,27 @@ export class ImageObject {
|
|
|
43
41
|
baseH: texH
|
|
44
42
|
}
|
|
45
43
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
|
|
45
|
+
const cropRect = objectData.properties?.cropRect;
|
|
46
|
+
const cropShape = objectData.properties?.cropShape || null;
|
|
47
|
+
const borderRadius = objectData.properties?.borderRadius || 0;
|
|
48
|
+
|
|
49
|
+
if (cropRect) {
|
|
50
|
+
// Восстановить кроп: позиция и размер уже в objectData (post-crop)
|
|
51
|
+
const pos = objectData.position || { x: 0, y: 0 };
|
|
52
|
+
applyCropToSprite(
|
|
53
|
+
this.sprite,
|
|
54
|
+
cropRect,
|
|
55
|
+
cropShape,
|
|
56
|
+
{ width: this.width, height: this.height },
|
|
57
|
+
pos,
|
|
58
|
+
borderRadius
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
this.sprite.scale.set(this.width / texW, this.height / texH);
|
|
62
|
+
if (borderRadius > 0) {
|
|
63
|
+
applyRoundedMask(this.sprite, borderRadius);
|
|
64
|
+
}
|
|
49
65
|
}
|
|
50
66
|
};
|
|
51
67
|
|