• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kommitters / camunda-modeler-plugin-enhanced-color-picker / 20249372351

15 Dec 2025 10:19PM UTC coverage: 90.438%. First build
20249372351

push

github

web-flow
Merge pull request #1 from kommitters/v1.0

feat: add tests

66 of 81 branches covered (81.48%)

Branch coverage included in aggregate %.

58 of 64 new or added lines in 1 file covered. (90.63%)

161 of 170 relevant lines covered (94.71%)

54.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.44
/client/EnhancedColorPickerPlugin.js
1
'use strict';
2

3
const domify = require('min-dom/lib/domify');
1✔
4
const { getDi } = require('bpmn-js/lib/util/ModelUtil');
1✔
5
const Pickr = require('@simonwep/pickr');
1✔
6

7
const COLORS = [
1✔
8
    // Row 1
9
    { label: 'Black', fill: '#000000' },
10
    { label: 'Dark Grey 4', fill: '#434343' },
11
    { label: 'Dark Grey 3', fill: '#666666' },
12
    { label: 'Dark Grey 2', fill: '#999999' },
13
    { label: 'Dark Grey 1', fill: '#B7B7B7' },
14
    { label: 'Grey', fill: '#CCCCCC' },
15
    { label: 'Light Grey 1', fill: '#D9D9D9' },
16
    { label: 'Light Grey 2', fill: '#EFEFEF' },
17
    { label: 'Light Grey 3', fill: '#F3F3F3' },
18
    { label: 'White', fill: '#FFFFFF' },
19

20
    // Row 2
21
    { label: 'Red Berry', fill: '#980000' },
22
    { label: 'Red', fill: '#FF0000' },
23
    { label: 'Orange', fill: '#FF9900' },
24
    { label: 'Yellow', fill: '#FFFF00' },
25
    { label: 'Green', fill: '#00FF00' },
26
    { label: 'Cyan', fill: '#00FFFF' },
27
    { label: 'Cornflower Blue', fill: '#4A86E8' },
28
    { label: 'Blue', fill: '#0000FF' },
29
    { label: 'Purple', fill: '#9900FF' },
30
    { label: 'Magenta', fill: '#FF00FF' },
31

32
    // Row 3
33
    { label: 'Light Red Berry 3', fill: '#E6B8AF' },
34
    { label: 'Light Red 3', fill: '#F4CCCC' },
35
    { label: 'Light Orange 3', fill: '#FCE5CD' },
36
    { label: 'Light Yellow 3', fill: '#FFF2CC' },
37
    { label: 'Light Green 3', fill: '#D9EAD3' },
38
    { label: 'Light Cyan 3', fill: '#D0E0E3' },
39
    { label: 'Light Cornflower Blue 3', fill: '#C9DAF8' },
40
    { label: 'Light Blue 3', fill: '#CFE2F3' },
41
    { label: 'Light Purple 3', fill: '#D9D2E9' },
42
    { label: 'Light Magenta 3', fill: '#EAD1DC' },
43

44
    // Row 4
45
    { label: 'Light Red Berry 2', fill: '#DD7E6B' },
46
    { label: 'Light Red 2', fill: '#EA9999' },
47
    { label: 'Light Orange 2', fill: '#F9CB9C' },
48
    { label: 'Light Yellow 2', fill: '#FFE599' },
49
    { label: 'Light Green 2', fill: '#B6D7A8' },
50
    { label: 'Light Cyan 2', fill: '#A2C4C9' },
51
    { label: 'Light Cornflower Blue 2', fill: '#A4C2F4' },
52
    { label: 'Light Blue 2', fill: '#9FC5E8' },
53
    { label: 'Light Purple 2', fill: '#B4A7D6' },
54
    { label: 'Light Magenta 2', fill: '#D5A6BD' },
55

56
    // Row 5
57
    { label: 'Light Red Berry 1', fill: '#CC4125' },
58
    { label: 'Light Red 1', fill: '#E06666' },
59
    { label: 'Light Orange 1', fill: '#F6B26B' },
60
    { label: 'Light Yellow 1', fill: '#FFD966' },
61
    { label: 'Light Green 1', fill: '#93C47D' },
62
    { label: 'Light Cyan 1', fill: '#76A5AF' },
63
    { label: 'Light Cornflower Blue 1', fill: '#6D9EEB' },
64
    { label: 'Light Blue 1', fill: '#6FA8DC' },
65
    { label: 'Light Purple 1', fill: '#8E7CC3' },
66
    { label: 'Light Magenta 1', fill: '#C27BA0' },
67

68
    // Row 6
69
    { label: 'Dark Red Berry 1', fill: '#A61C00' },
70
    { label: 'Dark Red 1', fill: '#CC0000' },
71
    { label: 'Dark Orange 1', fill: '#E69138' },
72
    { label: 'Dark Yellow 1', fill: '#F1C232' },
73
    { label: 'Dark Green 1', fill: '#6AA84F' },
74
    { label: 'Dark Cyan 1', fill: '#45818E' },
75
    { label: 'Dark Cornflower Blue 1', fill: '#3C78D8' },
76
    { label: 'Dark Blue 1', fill: '#3D85C6' },
77
    { label: 'Dark Purple 1', fill: '#674EA7' },
78
    { label: 'Dark Magenta 1', fill: '#A64D79' },
79

80
    // Row 7
81
    { label: 'Dark Red Berry 2', fill: '#85200C' },
82
    { label: 'Dark Red 2', fill: '#990000' },
83
    { label: 'Dark Orange 2', fill: '#B45F06' },
84
    { label: 'Dark Yellow 2', fill: '#BF9000' },
85
    { label: 'Dark Green 2', fill: '#38761D' },
86
    { label: 'Dark Cyan 2', fill: '#134F5C' },
87
    { label: 'Dark Cornflower Blue 2', fill: '#1155CC' },
88
    { label: 'Dark Blue 2', fill: '#0B5394' },
89
    { label: 'Dark Purple 2', fill: '#351C75' },
90
    { label: 'Dark Magenta 2', fill: '#741B47' },
91

92
    // Row 8
93
    { label: 'Dark Red Berry 3', fill: '#5B0F00' },
94
    { label: 'Dark Red 3', fill: '#660000' },
95
    { label: 'Dark Orange 3', fill: '#783F04' },
96
    { label: 'Dark Yellow 3', fill: '#7F6000' },
97
    { label: 'Dark Green 3', fill: '#274E13' },
98
    { label: 'Dark Cyan 3', fill: '#0C343D' },
99
    { label: 'Dark Cornflower Blue 3', fill: '#1C4587' },
100
    { label: 'Dark Blue 3', fill: '#073763' },
101
    { label: 'Dark Purple 3', fill: '#20124D' },
102
    { label: 'Dark Magenta 3', fill: '#4C1130' }
103
];
104

105
class EnhancedColorPickerPlugin {
106
    constructor(eventBus, bpmnRules, editorActions, canvas, commandStack, bpmnFactory) {
107
        this.commandStack = commandStack;
35✔
108
        this.bpmnFactory = bpmnFactory;
35✔
109
        this.activeModes = new Set(['fill']); // 'fill', 'stroke', 'text'
35✔
110
        this.isActive = false;
35✔
111
        this.pickerContainer = null;
35✔
112
        this.selectedElement = null;
35✔
113
        this.customColors = this.loadCustomColors();
35✔
114

115
        editorActions.register({
35✔
116
            toggleColorPicker: () => {
117
                this.toggle(canvas);
1✔
118
            }
119
        });
120

121
        eventBus.on('selection.changed', (e) => {
35✔
122
            this.selectedElement = e.newSelection[0];
1✔
123
        });
124
    }
125

126
    loadCustomColors() {
127
        const storageKey = 'camunda-modeler-plugin-enhanced-color-picker';
35✔
128

129
        try {
35✔
130
            const stored = localStorage.getItem(storageKey);
35✔
131

132
            if (!stored) {
35✔
133
                return [];
33✔
134
            }
135

136
            const parsed = JSON.parse(stored);
2✔
137

138
            // We expect an array of hex strings; anything else is treated as invalid.
139
            if (!Array.isArray(parsed)) {
1!
NEW
140
                localStorage.removeItem(storageKey);
×
NEW
141
                return [];
×
142
            }
143

144
            return parsed
1✔
145
                .filter(c => typeof c === 'string')
1✔
146
                .map(c => (c.startsWith('#') ? c : ('#' + c)))
1!
147
                .map(c => c.toLowerCase());
1✔
148

149
        } catch (e) {
150
            // Clean up invalid persisted state to avoid repeated parse errors.
151
            try {
1✔
152
                localStorage.removeItem(storageKey);
1✔
153
            } catch (_) {
154
                // ignore
155
            }
156

157
            // Avoid printing the raw exception (and stack) to keep console noise down.
158
            console.error('Failed to load custom colors');
1✔
159
            return [];
1✔
160
        }
161
    }
162

163
    saveCustomColors() {
164
        const storageKey = 'camunda-modeler-plugin-enhanced-color-picker';
3✔
165

166
        try {
3✔
167
            // Unify case to avoid duplicates like #ffffff vs #FFFFFF
168
            const uniqueColors = [...new Set(this.customColors.map(c => c.toLowerCase()))];
3✔
169
            localStorage.setItem(storageKey, JSON.stringify(uniqueColors));
3✔
170

171
            // Reload into memory to ensure consistency
172
            this.customColors = uniqueColors;
2✔
173
        } catch (e) {
174
            // Avoid printing the raw exception (and stack) to keep console noise down.
175
            console.error('Failed to save custom colors');
1✔
176
        }
177
    }
178

179
    toggle(canvas) {
180
        if (this.isActive) {
17✔
181
            this.close();
1✔
182
        } else {
183
            this.isActive = true;
16✔
184
            this.addPicker(canvas.getContainer().parentNode);
16✔
185
        }
186
    }
187

188
    close() {
189
        if (this.pickerContainer) {
1!
190
            this.pickerContainer.remove();
1✔
191
            this.pickerContainer = null;
1✔
192
        }
193
        this.isActive = false;
1✔
194
    }
195

196
    createColorButton(fill, label, isCustom = false) {
1,281✔
197
        // Ensure hex includes #
198
        if (fill && !fill.startsWith('#')) {
1,286✔
199
            fill = '#' + fill;
1✔
200
        }
201

202
        const button = domify(`<div class="color-button ${isCustom ? 'custom-color-item' : ''}" style="background-color: ${fill}" title="${label || fill}"></div>`);
1,286!
203

204
        if (isCustom) {
1,286✔
205
            const deleteIcon = domify(`
5✔
206
                <div class="delete-color-action" title="Remove Color">
207
                    <svg viewBox="0 0 24 24" width="10" height="10"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
208
                </div>
209
            `);
210

211
            deleteIcon.addEventListener('click', (e) => {
5✔
212
                e.stopPropagation();
1✔
213
                e.preventDefault();
1✔
214

215
                // Remove from state
216
                this.customColors = this.customColors.filter(c => c.toLowerCase() !== fill.toLowerCase());
1✔
217
                this.saveCustomColors();
1✔
218

219
                // Remove from DOM
220
                button.remove();
1✔
221
            });
222

223
            button.appendChild(deleteIcon);
5✔
224
        }
225

226
        button.addEventListener('click', () => {
1,286✔
227
            if (!this.selectedElement) return;
2✔
228
            this.applyColor(fill);
1✔
229
        });
230

231
        return button;
1,286✔
232
    }
233

234
    addPicker(container) {
235
        const markup = `
16✔
236
      <div class="enhanced-color-picker-container">
237
        <div class="picker-header">Enhanced Color Picker</div>
238
        <div class="picker-toolbar">
239
          <button class="mode-button ${this.activeModes.has('fill') ? 'active' : ''}" data-mode="fill" title="Background Color">
16!
240
            <div class="icon-fill"></div>
241
          </button>
242
          <button class="mode-button ${this.activeModes.has('stroke') ? 'active' : ''}" data-mode="stroke" title="Border Color">
16!
243
            <div class="icon-stroke"></div>
244
          </button>
245
          <button class="mode-button ${this.activeModes.has('text') ? 'active' : ''}" data-mode="text" title="Text Color">
16!
246
            <div class="icon-text">T</div>
247
          </button>
248
        </div>
249
        <div class="picker-grid"></div>
250
      </div>
251
    `;
252

253
        this.pickerContainer = domify(markup);
16✔
254
        const grid = this.pickerContainer.querySelector('.picker-grid');
16✔
255
        const toolbar = this.pickerContainer.querySelector('.picker-toolbar');
16✔
256

257
        // Toolbar logic
258
        const buttons = toolbar.querySelectorAll('.mode-button');
16✔
259
        buttons.forEach((btn) => {
16✔
260
            btn.addEventListener('click', () => {
48✔
261
                const mode = btn.dataset.mode;
3✔
262
                if (this.activeModes.has(mode)) {
3✔
263
                    this.activeModes.delete(mode);
2✔
264
                    btn.classList.remove('active');
2✔
265
                } else {
266
                    this.activeModes.add(mode);
1✔
267
                    btn.classList.add('active');
1✔
268
                }
269
            });
270
        });
271

272
        // Standard Colors
273
        COLORS.forEach((color) => {
16✔
274
            grid.appendChild(this.createColorButton(color.fill, color.label));
1,280✔
275
        });
276

277
        const separator = domify('<div class="separator"></div>');
16✔
278
        grid.appendChild(separator);
16✔
279

280
        // Custom Colors
281
        this.customColors.forEach((colorFill) => {
16✔
282
            grid.appendChild(this.createColorButton(colorFill, 'Custom Color', true));
5✔
283
        });
284

285
        // Custom Color Picker Button
286
        const customButton = domify(`
16✔
287
            <div class="color-button custom-color-button" title="Custom Color">
288
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
289
            </div>
290
        `);
291
        grid.appendChild(customButton);
16✔
292

293
        // Reset Button
294
        const resetButton = domify(`
16✔
295
            <div class="color-button reset-button" title="Reset Custom Colors">
296
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
297
            </div>
298
        `);
299

300
        resetButton.onclick = this.handleResetCustomColors.bind(this, grid);
16✔
301

302
        grid.appendChild(resetButton);
16✔
303

304
        // @simonwep/pickr plugin
305
        // Allow mocking for tests
306
        const PickrCtor = this.Pickr || Pickr;
16!
307

308
        const pickr = PickrCtor.create({
16✔
309
            el: customButton,
310
            theme: 'monolith',
311
            useAsButton: true,
312
            swatches: [],
313
            position: 'bottom-middle',
314
            components: {
315
                preview: false,
316
                opacity: false,
317
                hue: true,
318
                interaction: {
319
                    hex: true,
320
                    rgba: true,
321
                    hsla: false,
322
                    input: true,
323
                    save: true
324
                }
325
            }
326
        });
327

328
        pickr.on('save', (color, instance) => this.handlePickrSave(color, instance, grid, customButton));
16✔
329

330
        container.appendChild(this.pickerContainer);
16✔
331
        this.makeDraggable(this.pickerContainer);
16✔
332
    }
333

334
    handleResetCustomColors(grid, e) {
335
        e.stopPropagation();
3✔
336
        e.preventDefault();
3✔
337

338
        setTimeout(() => {
3✔
339
            if (confirm('Remove all custom colors?')) {
3✔
340
                localStorage.removeItem('camunda-modeler-plugin-enhanced-color-picker');
2✔
341
                this.customColors = [];
2✔
342

343
                let separatorFound = false;
2✔
344
                const children = Array.from(grid.children);
2✔
345
                children.forEach(child => {
2✔
346
                    if (child.classList.contains('separator')) {
170✔
347
                        separatorFound = true;
2✔
348
                        return; // keep separator
2✔
349
                    }
350
                    if (separatorFound) {
168✔
351
                        if (!child.classList.contains('custom-color-button') && !child.classList.contains('reset-button')) {
7✔
352
                            grid.removeChild(child);
3✔
353
                        }
354
                    }
355
                });
356
            }
357
        }, 10);
358
    }
359

360
    handlePickrSave(color, instance, grid, customButton) {
361
        const hexColor = color.toHEXA().toString();
1✔
362

363
        // Apply immediately
364
        this.applyColor(hexColor);
1✔
365

366
        // Save if not exists
367
        if (!this.customColors.includes(hexColor.toLowerCase())) {
1!
NEW
368
            this.customColors.push(hexColor);
×
NEW
369
            this.saveCustomColors();
×
370

371
            // Append new button BEFORE the custom picker button
NEW
372
            const newBtn = this.createColorButton(hexColor, 'Custom Color', true);
×
NEW
373
            grid.insertBefore(newBtn, customButton);
×
374
        }
375

376
        instance.hide();
1✔
377
    }
378

379
    applyColor(color) {
380
        if (!this.selectedElement) return;
10✔
381

382
        // If we are changing stroke but NOT text, we want to pin the current text color
383
        // so it doesn't implicitly change to the new stroke color.
384
        if (this.activeModes.has('stroke') && !this.activeModes.has('text')) {
8✔
385
            this.preserveTextColorIfNeeded();
2✔
386
        }
387

388
        const colorData = {};
8✔
389

390
        // 1. Collect Fill/Stroke for main element
391
        if (this.activeModes.has('fill')) {
8✔
392
            colorData.fill = color;
3✔
393
        }
394
        if (this.activeModes.has('stroke')) {
8✔
395
            colorData.stroke = color;
2✔
396
        }
397

398
        if (Object.keys(colorData).length > 0) {
8✔
399
            this.commandStack.execute('element.setColor', {
4✔
400
                elements: [this.selectedElement],
401
                colors: colorData
402
            });
403
        }
404

405
        if (this.activeModes.has('text')) {
8✔
406
            this.handleTextColoring({ fill: color });
3✔
407
        }
408
    }
409

410
    preserveTextColorIfNeeded() {
411
        const element = this.selectedElement;
5✔
412
        const di = getDi(element);
5✔
413

414
        if (!di) return;
5✔
415

416
        if (element.labelTarget) {
2!
417
            return;
×
418
        }
419

420
        if (di.label) {
2✔
421
            if (di.label.color) return;
1!
422

423
            let currentStroke = di.stroke || 'black';
×
424

425
            this.commandStack.execute('element.updateModdleProperties', {
×
426
                element: element,
427
                moddleElement: di.label,
428
                properties: { color: currentStroke }
429
            });
430

431
        } else {
432
            const currentStroke = di.stroke || 'black';
1!
433

434
            const newLabel = this.bpmnFactory.create('bpmndi:BPMNLabel', {
1✔
435
                bounds: this.bpmnFactory.create('dc:Bounds')
436
            });
437
            newLabel.color = currentStroke;
1✔
438

439
            this.commandStack.execute('element.updateProperties', {
1✔
440
                element: element,
441
                properties: {
442
                    di: { label: newLabel }
443
                }
444
            });
445
        }
446
    }
447

448
    handleTextColoring(color) {
449
        const element = this.selectedElement;
4✔
450

451
        // 1. External Label (e.g. Sequence Flow, some Events)
452
        if (element.label) {
4✔
453
            this.commandStack.execute('element.setColor', {
1✔
454
                elements: [element.label],
455
                colors: { stroke: color.fill }
456
            });
457
            return;
1✔
458
        }
459

460
        // 2. Internal Label (e.g. Task, SubProcess)
461
        const di = getDi(element);
3✔
462
        if (!di) return;
3✔
463

464
        if (di.label) {
2✔
465
            // If DI label exists, update its color property
466
            this.commandStack.execute('element.updateModdleProperties', {
1✔
467
                element: element,
468
                moddleElement: di.label,
469
                properties: { color: color.fill }
470
            });
471
        } else {
472
            // If no DI label exists, create one
473
            const newLabel = this.bpmnFactory.create('bpmndi:BPMNLabel', {
1✔
474
                bounds: this.bpmnFactory.create('dc:Bounds')
475
            });
476
            newLabel.color = color.fill;
1✔
477

478
            this.commandStack.execute('element.updateProperties', {
1✔
479
                element: element,
480
                properties: {
481
                    di: { label: newLabel }
482
                }
483
            });
484
        }
485
    }
486

487
    makeDraggable(element) {
488
        const header = element.querySelector('.picker-header');
16✔
489
        header.onmousedown = this.handleDragMouseDown.bind(this, element);
16✔
490
    }
491

492
    handleDragMouseDown(element, e) {
493
        e = e || window.event;
4✔
494
        e.preventDefault();
4✔
495
        this.pos3 = e.clientX;
4✔
496
        this.pos4 = e.clientY;
4✔
497
        document.onmouseup = this.handleCloseDragElement.bind(this);
4✔
498
        document.onmousemove = this.handleElementDrag.bind(this, element);
4✔
499
    }
500

501
    handleElementDrag(element, e) {
502
        e = e || window.event;
2!
503
        e.preventDefault();
2✔
504
        this.pos1 = this.pos3 - e.clientX;
2✔
505
        this.pos2 = this.pos4 - e.clientY;
2✔
506
        this.pos3 = e.clientX;
2✔
507
        this.pos4 = e.clientY;
2✔
508
        element.style.top = (element.offsetTop - this.pos2) + "px";
2✔
509
        element.style.left = (element.offsetLeft - this.pos1) + "px";
2✔
510
    }
511

512
    handleCloseDragElement() {
513
        document.onmouseup = null;
3✔
514
        document.onmousemove = null;
3✔
515
    }
516
}
517

518
EnhancedColorPickerPlugin.$inject = ['eventBus', 'bpmnRules', 'editorActions', 'canvas', 'commandStack', 'bpmnFactory'];
1✔
519

520
module.exports = {
1✔
521
    __init__: ['enhancedColorPickerPlugin'],
522
    enhancedColorPickerPlugin: ['type', EnhancedColorPickerPlugin]
523
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc