Skip to content

Commit 15037a1

Browse files
feat(apollo-react): add markdown formatting utilities for sticky notes
Add pure utility functions for inline markdown formatting and list management, extracted into a markdown-formatting/ module. - toggleBold/toggleItalic/toggleStrikethrough with smart unwrap - toggleBulletList/toggleNumberedList with prefix-aware toggling - continueListOnEnter with auto-increment and empty-item exit - detectActiveFormats for cursor-position-aware format detection - List-aware inline formatting that protects bullet/number prefixes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91da0d3 commit 15037a1

8 files changed

Lines changed: 800 additions & 0 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,13 @@ export function withAlpha(hex: string, alpha: number = STICKY_NOTE_BG_ALPHA): st
5454
const b = parseInt(normalized.slice(5, 7), 16);
5555
return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`;
5656
}
57+
58+
/** Represents a textarea's value and cursor/selection positions */
59+
export type TextSelection = {
60+
value: string;
61+
selectionStart: number;
62+
selectionEnd: number;
63+
};
64+
65+
/** Available formatting actions for the toolbar and keyboard shortcuts */
66+
export type FormattingAction = 'bold' | 'italic' | 'strikethrough' | 'bulletList' | 'numberedList';
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { detectActiveFormats } from './detectActiveFormats';
3+
4+
describe('detectActiveFormats', () => {
5+
it('detects bold when cursor is inside ** markers', () => {
6+
const result = detectActiveFormats({
7+
value: 'hello **world** end',
8+
selectionStart: 10,
9+
selectionEnd: 10,
10+
});
11+
expect(result.bold).toBe(true);
12+
expect(result.italic).toBe(false);
13+
});
14+
15+
it('detects italic when cursor is inside * markers', () => {
16+
const result = detectActiveFormats({
17+
value: 'hello *world* end',
18+
selectionStart: 9,
19+
selectionEnd: 9,
20+
});
21+
expect(result.italic).toBe(true);
22+
expect(result.bold).toBe(false);
23+
});
24+
25+
it('detects both bold and italic inside *** markers', () => {
26+
const result = detectActiveFormats({
27+
value: 'hello ***world*** end',
28+
selectionStart: 11,
29+
selectionEnd: 11,
30+
});
31+
expect(result.bold).toBe(true);
32+
expect(result.italic).toBe(true);
33+
});
34+
35+
it('detects bold and italic with separate nested markers', () => {
36+
const result = detectActiveFormats({
37+
value: 'hello **say *world* now** end',
38+
selectionStart: 15,
39+
selectionEnd: 15,
40+
});
41+
expect(result.bold).toBe(true);
42+
expect(result.italic).toBe(true);
43+
});
44+
45+
it('detects strikethrough when cursor is inside ~~ markers', () => {
46+
const result = detectActiveFormats({
47+
value: 'hello ~~world~~ end',
48+
selectionStart: 10,
49+
selectionEnd: 10,
50+
});
51+
expect(result.strikethrough).toBe(true);
52+
});
53+
54+
it('does not detect formatting across line boundaries', () => {
55+
expect(
56+
detectActiveFormats({ value: '**hello\nworld**', selectionStart: 10, selectionEnd: 10 }).bold
57+
).toBe(false);
58+
expect(
59+
detectActiveFormats({ value: '*hello\nworld*', selectionStart: 9, selectionEnd: 9 }).italic
60+
).toBe(false);
61+
expect(
62+
detectActiveFormats({ value: '~~hello\nworld~~', selectionStart: 10, selectionEnd: 10 })
63+
.strikethrough
64+
).toBe(false);
65+
});
66+
67+
it('detects bullet list on current line', () => {
68+
const result = detectActiveFormats({
69+
value: '- hello world',
70+
selectionStart: 5,
71+
selectionEnd: 5,
72+
});
73+
expect(result.bulletList).toBe(true);
74+
expect(result.numberedList).toBe(false);
75+
});
76+
77+
it('detects numbered list on current line', () => {
78+
const result = detectActiveFormats({
79+
value: '1. hello world',
80+
selectionStart: 5,
81+
selectionEnd: 5,
82+
});
83+
expect(result.numberedList).toBe(true);
84+
expect(result.bulletList).toBe(false);
85+
});
86+
87+
it("returns all false when there's no formatting", () => {
88+
const result = detectActiveFormats({ value: 'plain text', selectionStart: 5, selectionEnd: 5 });
89+
expect(result.bold).toBe(false);
90+
expect(result.italic).toBe(false);
91+
expect(result.strikethrough).toBe(false);
92+
expect(result.bulletList).toBe(false);
93+
expect(result.numberedList).toBe(false);
94+
});
95+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { TextSelection } from '../StickyNoteNode.types';
2+
import { BULLET_PREFIX, NUMBERED_PREFIX } from './listFormatting';
3+
4+
export type ActiveFormats = {
5+
bold: boolean;
6+
italic: boolean;
7+
strikethrough: boolean;
8+
bulletList: boolean;
9+
numberedList: boolean;
10+
};
11+
12+
export function activeFormatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
13+
return (
14+
a.bold === b.bold &&
15+
a.italic === b.italic &&
16+
a.strikethrough === b.strikethrough &&
17+
a.bulletList === b.bulletList &&
18+
a.numberedList === b.numberedList
19+
);
20+
}
21+
22+
export function detectActiveFormats(input: TextSelection): ActiveFormats {
23+
const { value, selectionStart } = input;
24+
25+
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
26+
let lineEnd = value.indexOf('\n', selectionStart);
27+
if (lineEnd === -1) lineEnd = value.length;
28+
const currentLine = value.slice(lineStart, lineEnd);
29+
30+
// Scope to current line only — inline markdown formatting never spans lines
31+
const cursorInLine = selectionStart - lineStart;
32+
const textBefore = currentLine.slice(0, cursorInLine);
33+
const textAfter = currentLine.slice(cursorInLine);
34+
35+
// Bold: find ** before/after cursor on same line, allowing single * (from italic) in between
36+
const bold =
37+
/\*\*(?:[^*\n]|\*(?!\*))*$/.test(textBefore) && /^(?:[^*\n]|\*(?!\*))*\*\*/.test(textAfter);
38+
const strikethrough = /~~[^~\n]*$/.test(textBefore) && /^[^~\n]*~~/.test(textAfter);
39+
40+
// Italic: find standalone * before/after cursor on same line, allowing ** (from bold) in between
41+
const italicBefore = /(?<!\*)\*(?!\*)(?:[^*\n]|\*\*)*$/.test(textBefore);
42+
const italicAfter = /^(?:[^*\n]|\*\*)*(?<!\*)\*(?!\*)/.test(textAfter);
43+
let italic = italicBefore && italicAfter;
44+
45+
// Handle combined *** (bold+italic) markers where the italic * can't be isolated
46+
if (!italic && bold) {
47+
const beforeStars = /(\*{3,})[^*\n]*$/.exec(textBefore);
48+
const afterStars = /^[^*\n]*(\*{3,})/.exec(textAfter);
49+
if (
50+
beforeStars &&
51+
afterStars &&
52+
beforeStars[1]!.length % 2 === 1 &&
53+
afterStars[1]!.length % 2 === 1
54+
) {
55+
italic = true;
56+
}
57+
}
58+
59+
return {
60+
bold,
61+
italic,
62+
strikethrough,
63+
bulletList: BULLET_PREFIX.test(currentLine),
64+
numberedList: NUMBERED_PREFIX.test(currentLine),
65+
};
66+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export { type ActiveFormats, activeFormatsEqual, detectActiveFormats } from './detectActiveFormats';
2+
export { toggleBold, toggleItalic, toggleStrikethrough } from './inlineFormatting';
3+
export {
4+
BULLET_PREFIX,
5+
continueListOnEnter,
6+
NUMBERED_PREFIX,
7+
NUMBERED_PREFIX_FULL,
8+
toggleBulletList,
9+
toggleNumberedList,
10+
} from './listFormatting';
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { toggleBold, toggleItalic, toggleStrikethrough } from './inlineFormatting';
3+
4+
// All three formats share toggleInlineWrap. Tests use toggleBold as the representative
5+
// unless a test covers behavior unique to a specific marker.
6+
7+
describe('inline formatting (via toggleBold)', () => {
8+
it('wraps selected text', () => {
9+
const result = toggleBold({ value: 'hello world end', selectionStart: 6, selectionEnd: 11 });
10+
expect(result.value).toBe('hello **world** end');
11+
expect(result.selectionStart).toBe(8);
12+
expect(result.selectionEnd).toBe(13);
13+
});
14+
15+
it('unwraps already-wrapped selected text', () => {
16+
const result = toggleBold({
17+
value: 'hello **world** end',
18+
selectionStart: 8,
19+
selectionEnd: 13,
20+
});
21+
expect(result.value).toBe('hello world end');
22+
});
23+
24+
it('inserts empty markers on a plain line with no selection', () => {
25+
const result = toggleBold({ value: 'hello end', selectionStart: 6, selectionEnd: 6 });
26+
expect(result.value).toBe('hello ****end');
27+
expect(result.selectionStart).toBe(8);
28+
});
29+
30+
it('removes markers when cursor is inside formatted region', () => {
31+
const result = toggleBold({
32+
value: 'hello **world** end',
33+
selectionStart: 10,
34+
selectionEnd: 10,
35+
});
36+
expect(result.value).toBe('hello world end');
37+
});
38+
39+
it('wraps list line content with no selection', () => {
40+
const result = toggleBold({ value: '1. hello', selectionStart: 5, selectionEnd: 5 });
41+
expect(result.value).toBe('1. **hello**');
42+
});
43+
44+
it('removes markers on a list line', () => {
45+
const result = toggleBold({ value: '1. **hello**', selectionStart: 7, selectionEnd: 7 });
46+
expect(result.value).toBe('1. hello');
47+
});
48+
49+
it('wraps and unwraps each list line individually', () => {
50+
const wrapped = toggleBold({
51+
value: '1. hello\n2. world',
52+
selectionStart: 0,
53+
selectionEnd: 17,
54+
});
55+
expect(wrapped.value).toBe('1. **hello**\n2. **world**');
56+
57+
const unwrapped = toggleBold({
58+
value: '1. **hello**\n2. **world**',
59+
selectionStart: 0,
60+
selectionEnd: 25,
61+
});
62+
expect(unwrapped.value).toBe('1. hello\n2. world');
63+
});
64+
});
65+
66+
describe('italic / bold boundary', () => {
67+
it('does not unwrap bold markers when toggling italic', () => {
68+
const result = toggleItalic({ value: '**world**', selectionStart: 2, selectionEnd: 7 });
69+
expect(result.value).toBe('***world***');
70+
});
71+
72+
it('unwraps italic from bold+italic list lines (***)', () => {
73+
const result = toggleItalic({
74+
value: '1. ***hello***\n2. ***world***',
75+
selectionStart: 0,
76+
selectionEnd: 29,
77+
});
78+
expect(result.value).toBe('1. **hello**\n2. **world**');
79+
});
80+
81+
it('wraps italic onto bold-only list lines', () => {
82+
const result = toggleItalic({
83+
value: '1. **hello**\n2. **world**',
84+
selectionStart: 0,
85+
selectionEnd: 25,
86+
});
87+
expect(result.value).toBe('1. ***hello***\n2. ***world***');
88+
});
89+
});
90+
91+
describe('strikethrough uses ~~ marker', () => {
92+
it('wraps and unwraps with ~~', () => {
93+
const wrapped = toggleStrikethrough({
94+
value: 'hello world end',
95+
selectionStart: 6,
96+
selectionEnd: 11,
97+
});
98+
expect(wrapped.value).toBe('hello ~~world~~ end');
99+
100+
const unwrapped = toggleStrikethrough({
101+
value: 'hello ~~world~~ end',
102+
selectionStart: 8,
103+
selectionEnd: 13,
104+
});
105+
expect(unwrapped.value).toBe('hello world end');
106+
});
107+
});

0 commit comments

Comments
 (0)