Skip to content

Commit 3bd3187

Browse files
feat(apollo-react): add formatting toolbar to sticky notes
Add formatting toolbar to sticky notes with inline markdown editing. - FormattingToolbar component with bold, italic, strikethrough, bullet list, and numbered list buttons with ApTooltip - Keyboard shortcuts (Cmd+B, Cmd+I, Cmd+Shift+X) - Smart toggle: detects when cursor is inside formatted region and removes markers on toggle (no selection required) - Bold+italic combined marker (***) detection for toolbar state - List-aware formatting: protects bullet/number prefixes when applying inline formatting to list items - Enter key continues bullet and numbered lists with auto-increment; empty item exits the list - Format detection respects line boundaries (no cross-line false positives) - Split markdown formatting into markdown-formatting/ module
1 parent 81f374e commit 3bd3187

13 files changed

Lines changed: 1123 additions & 16 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { FormattingToolbar } from './FormattingToolbar';
4+
import type { ActiveFormats } from './markdown-formatting';
5+
6+
const defaultFormats: ActiveFormats = {
7+
bold: false,
8+
italic: false,
9+
strikethrough: false,
10+
bulletList: false,
11+
numberedList: false,
12+
};
13+
14+
function createMockTextArea(value = '', selectionStart = 0, selectionEnd = 0) {
15+
const textarea = document.createElement('textarea');
16+
textarea.value = value;
17+
textarea.selectionStart = selectionStart;
18+
textarea.selectionEnd = selectionEnd;
19+
document.body.appendChild(textarea);
20+
textarea.focus();
21+
return { current: textarea };
22+
}
23+
24+
describe('FormattingToolbar', () => {
25+
it('renders 5 formatting buttons', () => {
26+
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
27+
render(
28+
<FormattingToolbar
29+
textAreaRef={ref}
30+
borderColor="#42A1FF"
31+
activeFormats={defaultFormats}
32+
onFormat={vi.fn()}
33+
/>
34+
);
35+
36+
expect(screen.getAllByRole('button')).toHaveLength(5);
37+
});
38+
39+
it('calls onFormat when a button is clicked', () => {
40+
const onFormat = vi.fn();
41+
const ref = createMockTextArea('hello world', 6, 11);
42+
43+
render(
44+
<FormattingToolbar
45+
textAreaRef={ref}
46+
borderColor="#42A1FF"
47+
activeFormats={defaultFormats}
48+
onFormat={onFormat}
49+
/>
50+
);
51+
52+
// Click the first button (bold)
53+
fireEvent.click(screen.getAllByRole('button')[0]!);
54+
expect(onFormat).toHaveBeenCalledWith(expect.objectContaining({ value: 'hello **world**' }));
55+
56+
ref.current.remove();
57+
});
58+
59+
it('does not call onFormat when textAreaRef is null', () => {
60+
const onFormat = vi.fn();
61+
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
62+
63+
render(
64+
<FormattingToolbar
65+
textAreaRef={ref}
66+
borderColor="#42A1FF"
67+
activeFormats={defaultFormats}
68+
onFormat={onFormat}
69+
/>
70+
);
71+
72+
fireEvent.click(screen.getAllByRole('button')[0]!);
73+
expect(onFormat).not.toHaveBeenCalled();
74+
});
75+
76+
it('prevents textarea blur on mousedown', () => {
77+
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
78+
const { container } = render(
79+
<FormattingToolbar
80+
textAreaRef={ref}
81+
borderColor="#42A1FF"
82+
activeFormats={defaultFormats}
83+
onFormat={vi.fn()}
84+
/>
85+
);
86+
87+
const toolbarContainer = container.firstChild as HTMLElement;
88+
const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
89+
const prevented = !toolbarContainer.dispatchEvent(mouseDownEvent);
90+
expect(prevented).toBe(true);
91+
});
92+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { NodeIcon } from '@uipath/apollo-react/canvas';
2+
import { ApTooltip } from '@uipath/apollo-react/material/components';
3+
import { memo, type RefObject, useCallback } from 'react';
4+
import type { ActiveFormats } from './markdown-formatting';
5+
import {
6+
toggleBold,
7+
toggleBulletList,
8+
toggleItalic,
9+
toggleNumberedList,
10+
toggleStrikethrough,
11+
} from './markdown-formatting';
12+
import {
13+
FormattingButton,
14+
FormattingToolbarContainer,
15+
ToolbarSeparator,
16+
} from './StickyNoteNode.styles';
17+
import type { TextSelection } from './StickyNoteNode.types';
18+
19+
interface FormattingToolbarProps {
20+
textAreaRef: RefObject<HTMLTextAreaElement | null>;
21+
borderColor: string;
22+
activeFormats: ActiveFormats;
23+
onFormat: (result: TextSelection) => void;
24+
}
25+
26+
const FormattingToolbarComponent = ({
27+
textAreaRef,
28+
borderColor,
29+
activeFormats,
30+
onFormat,
31+
}: FormattingToolbarProps) => {
32+
const applyFormat = useCallback(
33+
(formatFn: (input: TextSelection) => TextSelection) => {
34+
const textarea = textAreaRef.current;
35+
if (!textarea) return;
36+
37+
const input: TextSelection = {
38+
value: textarea.value,
39+
selectionStart: textarea.selectionStart,
40+
selectionEnd: textarea.selectionEnd,
41+
};
42+
43+
onFormat(formatFn(input));
44+
textarea.focus();
45+
},
46+
[textAreaRef, onFormat]
47+
);
48+
49+
const handleBold = useCallback(() => applyFormat(toggleBold), [applyFormat]);
50+
const handleItalic = useCallback(() => applyFormat(toggleItalic), [applyFormat]);
51+
const handleStrikethrough = useCallback(() => applyFormat(toggleStrikethrough), [applyFormat]);
52+
const handleBulletList = useCallback(() => applyFormat(toggleBulletList), [applyFormat]);
53+
const handleNumberedList = useCallback(() => applyFormat(toggleNumberedList), [applyFormat]);
54+
55+
return (
56+
<FormattingToolbarContainer
57+
borderColor={borderColor}
58+
onMouseDown={(e) => e.preventDefault()}
59+
className="nodrag nowheel"
60+
>
61+
<ApTooltip content="Bold (⌘B)" placement="top" delay>
62+
<FormattingButton isActive={activeFormats.bold} onClick={handleBold}>
63+
<NodeIcon icon="bold" size={14} />
64+
</FormattingButton>
65+
</ApTooltip>
66+
<ApTooltip content="Italic (⌘I)" placement="top" delay>
67+
<FormattingButton isActive={activeFormats.italic} onClick={handleItalic}>
68+
<NodeIcon icon="italic" size={14} />
69+
</FormattingButton>
70+
</ApTooltip>
71+
<ApTooltip content="Strikethrough (⌘⇧X)" placement="top" delay>
72+
<FormattingButton isActive={activeFormats.strikethrough} onClick={handleStrikethrough}>
73+
<NodeIcon icon="strikethrough" size={14} />
74+
</FormattingButton>
75+
</ApTooltip>
76+
77+
<ToolbarSeparator />
78+
79+
<ApTooltip content="Bullet list" placement="top" delay>
80+
<FormattingButton isActive={activeFormats.bulletList} onClick={handleBulletList}>
81+
<NodeIcon icon="list" size={14} />
82+
</FormattingButton>
83+
</ApTooltip>
84+
<ApTooltip content="Numbered list" placement="top" delay>
85+
<FormattingButton isActive={activeFormats.numberedList} onClick={handleNumberedList}>
86+
<NodeIcon icon="list-ordered" size={14} />
87+
</FormattingButton>
88+
</ApTooltip>
89+
</FormattingToolbarContainer>
90+
);
91+
};
92+
93+
export const FormattingToolbar = memo(FormattingToolbarComponent);

packages/apollo-react/src/canvas/components/StickyNoteNode/StickyNoteNode.styles.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export const StickyNoteContainer = styled.div<{
6262
background-color: ${(props) => props.backgroundColor};
6363
border-radius: 16px;
6464
border: 2px solid ${(props) => props.borderColor};
65-
padding: 16px;
65+
padding: ${(props) => (props.isEditing ? '8px' : '16px')} 16px 16px 16px;
66+
display: flex;
67+
flex-direction: column;
6668
cursor: ${(props) => (props.isEditing ? 'text' : 'move')};
6769
position: relative;
6870
/* Ensure resize handles are clickable */
@@ -79,6 +81,8 @@ export const StickyNoteContainer = styled.div<{
7981
`;
8082

8183
export const StickyNoteTextArea = styled.textarea<{ isEditing: boolean }>`
84+
flex: 1;
85+
min-height: 0;
8286
${stickyNoteContentStyles}
8387
8488
background: transparent;
@@ -101,6 +105,8 @@ export const StickyNoteTextArea = styled.textarea<{ isEditing: boolean }>`
101105
`;
102106

103107
export const StickyNoteMarkdown = styled.div`
108+
flex: 1;
109+
min-height: 0;
104110
${stickyNoteContentStyles}
105111
106112
word-wrap: break-word;
@@ -339,3 +345,44 @@ export const ColorOption = styled.button<{ color: string; isSelected: boolean }>
339345
outline-offset: 1px;
340346
}
341347
`;
348+
349+
export const FormattingToolbarContainer = styled.div<{ borderColor: string }>`
350+
display: flex;
351+
align-items: center;
352+
gap: 1px;
353+
padding-bottom: 4px;
354+
margin-bottom: 8px;
355+
border-bottom: 1px solid ${(props) => `color-mix(in srgb, ${props.borderColor} 30%, transparent)`};
356+
flex-shrink: 0;
357+
`;
358+
359+
export const FormattingButton = styled.button<{ isActive: boolean }>`
360+
width: 28px;
361+
height: 28px;
362+
display: flex;
363+
align-items: center;
364+
justify-content: center;
365+
border: none;
366+
border-radius: 4px;
367+
cursor: pointer;
368+
padding: 0;
369+
color: var(--uix-canvas-foreground);
370+
background: ${(props) =>
371+
props.isActive
372+
? 'color-mix(in srgb, var(--uix-canvas-primary) 30%, transparent)'
373+
: 'transparent'};
374+
375+
&:hover {
376+
background: ${(props) =>
377+
props.isActive
378+
? 'color-mix(in srgb, var(--uix-canvas-primary) 40%, transparent)'
379+
: 'color-mix(in srgb, var(--uix-canvas-foreground) 10%, transparent)'};
380+
}
381+
`;
382+
383+
export const ToolbarSeparator = styled.div`
384+
width: 1px;
385+
height: 16px;
386+
background: color-mix(in srgb, var(--uix-canvas-foreground) 15%, transparent);
387+
margin: 0 4px;
388+
`;

0 commit comments

Comments
 (0)