Skip to content

Commit 1fd3a16

Browse files
committed
feat(editor): Add custom color picker for sticky notes
Allow users to select any custom color for sticky notes in addition to the 7 preset colors. Adds an 8th color button with a rainbow gradient that opens a color picker modal with hex input and recent colors history. Custom colors feature theme-aware borders that automatically adjust for optimal visibility in both light and dark modes. Key changes: - Add custom color picker modal using N8nColorPicker component - Extend sticky note color type from number to number | string - Add theme-aware border adjustment (20% darker in light mode, 80% lighter in dark mode) - Add hex color validation and lightness adjustment utilities with tests - Store up to 8 recent custom colors in localStorage - Add 20 unit tests for color utilities - Add 13 unit tests for sticky note component - Full backward compatibility with existing preset colors (1-7) 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Related Community forum posts - https://community.n8n.io/t/expand-color-palette-or-add-custom-color-picker-for-sticky-notes/166547 - https://community.n8n.io/t/more-colors-custom-colors-for-sticky-notes/52508 - https://community.n8n.io/t/sticky-note-colors-created/15343
1 parent ea889be commit 1fd3a16

File tree

11 files changed

+790
-11
lines changed

11 files changed

+790
-11
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { render } from '@testing-library/vue';
2+
import { describe, it, expect } from 'vitest';
3+
4+
import N8nSticky from './Sticky.vue';
5+
6+
describe('N8nSticky', () => {
7+
describe('color handling', () => {
8+
describe('with preset colors (numbers 1-7)', () => {
9+
it.each([
10+
[1, 'color-1'],
11+
[2, 'color-2'],
12+
[3, 'color-3'],
13+
[4, 'color-4'],
14+
[5, 'color-5'],
15+
[6, 'color-6'],
16+
[7, 'color-7'],
17+
])('applies CSS class for preset color %i', (color, expectedClass) => {
18+
const wrapper = render(N8nSticky, {
19+
props: {
20+
backgroundColor: color,
21+
modelValue: 'Test content',
22+
},
23+
});
24+
25+
const stickyElement = wrapper.container.querySelector('.n8n-sticky');
26+
expect(stickyElement?.classList.contains(expectedClass)).toBe(true);
27+
});
28+
29+
it('does not apply inline color styles for preset colors', () => {
30+
const wrapper = render(N8nSticky, {
31+
props: {
32+
backgroundColor: 3,
33+
modelValue: 'Test content',
34+
},
35+
});
36+
37+
const stickyElement = wrapper.container.querySelector('.n8n-sticky') as HTMLElement;
38+
expect(stickyElement?.style.getPropertyValue('--sticky--color--background')).toBe('');
39+
expect(stickyElement?.style.getPropertyValue('--sticky--border-color--custom-light')).toBe(
40+
'',
41+
);
42+
expect(stickyElement?.style.getPropertyValue('--sticky--border-color--custom-dark')).toBe(
43+
'',
44+
);
45+
});
46+
});
47+
48+
describe('with custom hex colors (strings)', () => {
49+
it('applies inline CSS variables for valid hex color', () => {
50+
const hexColor = '#FF5733';
51+
const wrapper = render(N8nSticky, {
52+
props: {
53+
backgroundColor: hexColor,
54+
modelValue: 'Test content',
55+
},
56+
});
57+
58+
const stickyElement = wrapper.container.querySelector('.n8n-sticky') as HTMLElement;
59+
expect(stickyElement?.style.getPropertyValue('--sticky--color--background')).toBe(hexColor);
60+
// Check that theme-aware border colors are set (not exact hex)
61+
expect(
62+
stickyElement?.style.getPropertyValue('--sticky--border-color--custom-light'),
63+
).toBeTruthy();
64+
expect(
65+
stickyElement?.style.getPropertyValue('--sticky--border-color--custom-dark'),
66+
).toBeTruthy();
67+
});
68+
69+
it('does not apply color CSS class for hex colors', () => {
70+
const wrapper = render(N8nSticky, {
71+
props: {
72+
backgroundColor: '#FF5733',
73+
modelValue: 'Test content',
74+
},
75+
});
76+
77+
const stickyElement = wrapper.container.querySelector('.n8n-sticky');
78+
expect(stickyElement?.classList.contains('color-1')).toBe(false);
79+
expect(stickyElement?.classList.contains('color-2')).toBe(false);
80+
expect(stickyElement?.classList.contains('color-3')).toBe(false);
81+
});
82+
83+
it('handles invalid hex color gracefully by not applying styles', () => {
84+
const wrapper = render(N8nSticky, {
85+
props: {
86+
backgroundColor: 'invalid-color',
87+
modelValue: 'Test content',
88+
},
89+
});
90+
91+
const stickyElement = wrapper.container.querySelector('.n8n-sticky') as HTMLElement;
92+
expect(stickyElement?.style.getPropertyValue('--sticky--color--background')).toBe('');
93+
expect(stickyElement?.style.getPropertyValue('--sticky--border-color--custom-light')).toBe(
94+
'',
95+
);
96+
expect(stickyElement?.style.getPropertyValue('--sticky--border-color--custom-dark')).toBe(
97+
'',
98+
);
99+
});
100+
101+
it('applies correct inline styles for multiple valid hex colors', () => {
102+
const testColors = ['#000000', '#FFFFFF', '#123ABC'];
103+
104+
testColors.forEach((hexColor) => {
105+
const wrapper = render(N8nSticky, {
106+
props: {
107+
backgroundColor: hexColor,
108+
modelValue: 'Test content',
109+
},
110+
});
111+
112+
const stickyElement = wrapper.container.querySelector('.n8n-sticky') as HTMLElement;
113+
expect(stickyElement?.style.getPropertyValue('--sticky--color--background')).toBe(
114+
hexColor,
115+
);
116+
});
117+
});
118+
});
119+
120+
describe('default behavior', () => {
121+
it('renders without backgroundColor prop', () => {
122+
const wrapper = render(N8nSticky, {
123+
props: {
124+
modelValue: 'Test content',
125+
},
126+
});
127+
128+
const stickyElement = wrapper.container.querySelector('.n8n-sticky');
129+
expect(stickyElement).toBeTruthy();
130+
});
131+
});
132+
});
133+
});

packages/frontend/@n8n/design-system/src/components/N8nSticky/Sticky.vue

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue';
44
import { defaultStickyProps } from './constants';
55
import type { StickyProps } from './types';
66
import { useI18n } from '../../composables/useI18n';
7+
import { isValidHexColor, adjustColorLightness } from '../../utils/colorUtils';
78
import N8nInput from '../N8nInput';
89
import N8nMarkdown from '../N8nMarkdown';
910
import N8nText from '../N8nText';
@@ -37,6 +38,30 @@ const styles = computed((): { height: string; width: string } => ({
3738
3839
const shouldShowFooter = computed((): boolean => resHeight.value > 100 && resWidth.value > 155);
3940
41+
const getCustomColorStyles = (hexColor: string) => {
42+
if (!isValidHexColor(hexColor)) {
43+
return {};
44+
}
45+
46+
// Create lighter border for dark mode (80% lighter)
47+
const lighterBorder = adjustColorLightness(hexColor, 80);
48+
// Create darker border for light mode (20% darker)
49+
const darkerBorder = adjustColorLightness(hexColor, -20);
50+
51+
return {
52+
'--sticky--color--background': hexColor,
53+
'--sticky--border-color--custom-light': darkerBorder,
54+
'--sticky--border-color--custom-dark': lighterBorder,
55+
};
56+
};
57+
58+
const customColorStyles = computed(() => {
59+
if (typeof props.backgroundColor === 'string') {
60+
return getCustomColorStyles(props.backgroundColor);
61+
}
62+
return {};
63+
});
64+
4065
watch(
4166
() => props.editMode,
4267
(newMode, prevMode) => {
@@ -81,9 +106,10 @@ const onInputScroll = (event: WheelEvent) => {
81106
'n8n-sticky': true,
82107
[$style.sticky]: true,
83108
[$style.clickable]: !isResizing,
84-
[$style[`color-${backgroundColor}`]]: true,
109+
[$style[`color-${backgroundColor}`]]: typeof backgroundColor === 'number',
110+
[$style.customColor]: typeof backgroundColor === 'string',
85111
}"
86-
:style="styles"
112+
:style="{ ...styles, ...customColorStyles }"
87113
@keydown.prevent
88114
>
89115
<div v-show="!editMode" :class="$style.wrapper" @dblclick.stop="onDoubleClick">
@@ -132,6 +158,24 @@ const onInputScroll = (event: WheelEvent) => {
132158
border: 1px solid var(--sticky--border-color);
133159
}
134160
161+
// Custom color borders - only apply to custom colors
162+
.customColor {
163+
// Default to darker border (for light mode)
164+
--sticky--border-color: var(--sticky--border-color--custom-light);
165+
}
166+
167+
// Dark mode: use lighter borders for custom colors
168+
:global(body[data-theme='dark']) .customColor {
169+
--sticky--border-color: var(--sticky--border-color--custom-dark);
170+
}
171+
172+
// System dark mode (when theme is 'system')
173+
@media (prefers-color-scheme: dark) {
174+
:global(body:not([data-theme='light'])) .customColor {
175+
--sticky--border-color: var(--sticky--border-color--custom-dark);
176+
}
177+
}
178+
135179
.clickable {
136180
cursor: pointer;
137181
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { isValidHexColor } from './colorUtils';
4+
5+
describe('colorUtils.isValidHexColor', () => {
6+
it.each([
7+
// Valid hex colors
8+
['#000000', true],
9+
['#FFFFFF', true],
10+
['#123456', true],
11+
['#abcdef', true],
12+
['#ABCDEF', true],
13+
['#FF5733', true],
14+
['#fF5733', true],
15+
16+
// Invalid hex colors
17+
['', false],
18+
['#', false],
19+
['#FFF', false], // Too short
20+
['#FFFFFFF', false], // Too long
21+
['#GGGGGG', false], // Invalid characters
22+
['#12345Z', false], // Invalid character
23+
['FFFFFF', false], // Missing #
24+
['#12 45 67', false], // Contains spaces
25+
['#xyz123', false], // Invalid characters
26+
['rgb(255, 255, 255)', false], // Wrong format
27+
['red', false], // Named color
28+
[null, false], // Null
29+
[undefined, false], // Undefined
30+
])('validates "%s" as %s', (input, expected) => {
31+
expect(isValidHexColor(input as string)).toBe(expected);
32+
});
33+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Validates if a string is a valid 6-digit hex color code
3+
* @param color - The color string to validate
4+
* @returns True if the color is a valid hex color (#RRGGBB format)
5+
*/
6+
export function isValidHexColor(color: string): boolean {
7+
if (!color || typeof color !== 'string') {
8+
return false;
9+
}
10+
return /^#[0-9A-Fa-f]{6}$/.test(color);
11+
}
12+
13+
/**
14+
* Adjusts the lightness of a hex color
15+
* @param hexColor - Hex color code (#RRGGBB)
16+
* @param percent - Percentage to adjust (-100 to 100, positive = lighter, negative = darker)
17+
* @returns Adjusted hex color
18+
*/
19+
export function adjustColorLightness(hexColor: string, percent: number): string {
20+
if (!isValidHexColor(hexColor)) {
21+
return hexColor;
22+
}
23+
24+
// Remove # and convert to RGB
25+
const hex = hexColor.replace('#', '');
26+
const r = parseInt(hex.substring(0, 2), 16);
27+
const g = parseInt(hex.substring(2, 4), 16);
28+
const b = parseInt(hex.substring(4, 6), 16);
29+
30+
// Adjust each component
31+
const adjust = (component: number) => {
32+
const adjusted = component + (component * percent) / 100;
33+
return Math.min(255, Math.max(0, Math.round(adjusted)));
34+
};
35+
36+
const newR = adjust(r);
37+
const newG = adjust(g);
38+
const newB = adjust(b);
39+
40+
// Convert back to hex
41+
const toHex = (n: number) => n.toString(16).padStart(2, '0');
42+
return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`.toUpperCase();
43+
}

packages/frontend/@n8n/design-system/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './colorUtils';
12
export * from './form-event-bus';
23
export * from './markdown';
34
export * from './typeguards';

packages/frontend/@n8n/i18n/src/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,6 +1491,7 @@
14911491
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
14921492
"node.activateDeactivateNode": "Activate/Deactivate Node",
14931493
"node.changeColor": "Change color",
1494+
"node.customColor": "Custom color",
14941495
"node.disabled": "Deactivated",
14951496
"node.testStep": "Execute step",
14961497
"node.disable": "Deactivate",
@@ -1516,6 +1517,11 @@
15161517
"node.settings.retriesOnFailure": "This node will automatically retry if it fails",
15171518
"node.settings.executeOnce": "This node executes only once, no matter how many input items there are",
15181519
"node.settings.alwaysOutputData": "This node will output an empty item if nothing would normally be returned",
1520+
"sticky.customColor.title": "Custom Color",
1521+
"sticky.customColor.recentColors": "Recent Colors",
1522+
"sticky.customColor.apply": "Apply",
1523+
"sticky.customColor.cancel": "Cancel",
1524+
"sticky.markdownHint": "<a target=\"_blank\" href=\"https://docs.n8n.io/workflows/sticky-notes/\">Markdown</a> supported",
15191525
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node \n or drag to connect",
15201526
"nodeCreator.actionsPlaceholderNode.scheduleTrigger": "On a Schedule",
15211527
"nodeCreator.actionsPlaceholderNode.webhook": "On a Webhook call",

packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export type CanvasNodeStickyNoteRender = {
9393
options: Partial<{
9494
width: number;
9595
height: number;
96-
color: number;
96+
color: number | string; // 1-7 for presets, hex string for custom colors
9797
content: string;
9898
}>;
9999
};

packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeToolbar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function onDeleteNode() {
8787
emit('delete');
8888
}
8989
90-
function onChangeStickyColor(color: number) {
90+
function onChangeStickyColor(color: number | string) {
9191
emit('update', {
9292
color,
9393
});

0 commit comments

Comments
 (0)