Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ export function withAlpha(hex: string, alpha: number = STICKY_NOTE_BG_ALPHA): st
const b = parseInt(normalized.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`;
}

/** Represents a textarea's value and cursor/selection positions */
export type TextSelection = {
value: string;
selectionStart: number;
selectionEnd: number;
};

/** Available formatting actions for the toolbar and keyboard shortcuts */
export type FormattingAction = 'bold' | 'italic' | 'strikethrough' | 'bulletList' | 'numberedList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { detectActiveFormats } from './detectActiveFormats';

describe('detectActiveFormats', () => {
it('detects bold when cursor is inside ** markers', () => {
const result = detectActiveFormats({
value: 'hello **world** end',
selectionStart: 10,
selectionEnd: 10,
});
expect(result.bold).toBe(true);
expect(result.italic).toBe(false);
});

it('detects italic when cursor is inside * markers', () => {
const result = detectActiveFormats({
value: 'hello *world* end',
selectionStart: 9,
selectionEnd: 9,
});
expect(result.italic).toBe(true);
expect(result.bold).toBe(false);
});

it('detects both bold and italic inside *** markers', () => {
const result = detectActiveFormats({
value: 'hello ***world*** end',
selectionStart: 11,
selectionEnd: 11,
});
expect(result.bold).toBe(true);
expect(result.italic).toBe(true);
});

it('detects bold and italic with separate nested markers', () => {
const result = detectActiveFormats({
value: 'hello **say *world* now** end',
selectionStart: 15,
selectionEnd: 15,
});
expect(result.bold).toBe(true);
expect(result.italic).toBe(true);
});

it('detects strikethrough when cursor is inside ~~ markers', () => {
const result = detectActiveFormats({
value: 'hello ~~world~~ end',
selectionStart: 10,
selectionEnd: 10,
});
expect(result.strikethrough).toBe(true);
});

it('does not detect formatting across line boundaries', () => {
expect(
detectActiveFormats({ value: '**hello\nworld**', selectionStart: 10, selectionEnd: 10 }).bold
).toBe(false);
expect(
detectActiveFormats({ value: '*hello\nworld*', selectionStart: 9, selectionEnd: 9 }).italic
).toBe(false);
expect(
detectActiveFormats({ value: '~~hello\nworld~~', selectionStart: 10, selectionEnd: 10 })
.strikethrough
).toBe(false);
});

it('detects bullet list on current line', () => {
const result = detectActiveFormats({
value: '- hello world',
selectionStart: 5,
selectionEnd: 5,
});
expect(result.bulletList).toBe(true);
expect(result.numberedList).toBe(false);
});

it('detects numbered list on current line', () => {
const result = detectActiveFormats({
value: '1. hello world',
selectionStart: 5,
selectionEnd: 5,
});
expect(result.numberedList).toBe(true);
expect(result.bulletList).toBe(false);
});

it("returns all false when there's no formatting", () => {
const result = detectActiveFormats({ value: 'plain text', selectionStart: 5, selectionEnd: 5 });
expect(result.bold).toBe(false);
expect(result.italic).toBe(false);
expect(result.strikethrough).toBe(false);
expect(result.bulletList).toBe(false);
expect(result.numberedList).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { TextSelection } from '../StickyNoteNode.types';
import { BULLET_PREFIX, NUMBERED_PREFIX } from './listFormatting';

export type ActiveFormats = {
bold: boolean;
italic: boolean;
strikethrough: boolean;
bulletList: boolean;
numberedList: boolean;
};

export function activeFormatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
return (
a.bold === b.bold &&
a.italic === b.italic &&
a.strikethrough === b.strikethrough &&
a.bulletList === b.bulletList &&
a.numberedList === b.numberedList
);
}

export function detectActiveFormats(input: TextSelection): ActiveFormats {
const { value, selectionStart } = input;

const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
let lineEnd = value.indexOf('\n', selectionStart);
if (lineEnd === -1) lineEnd = value.length;
const currentLine = value.slice(lineStart, lineEnd);

// Scope to current line only — inline markdown formatting never spans lines
const cursorInLine = selectionStart - lineStart;
const textBefore = currentLine.slice(0, cursorInLine);
const textAfter = currentLine.slice(cursorInLine);

// Bold: find ** before/after cursor on same line, allowing single * (from italic) in between
const bold =
/\*\*(?:[^*\n]|\*(?!\*))*$/.test(textBefore) && /^(?:[^*\n]|\*(?!\*))*\*\*/.test(textAfter);
const strikethrough = /~~[^~\n]*$/.test(textBefore) && /^[^~\n]*~~/.test(textAfter);

// Italic: find standalone * before/after cursor on same line, allowing ** (from bold) in between
const italicBefore = /(?<!\*)\*(?!\*)(?:[^*\n]|\*\*)*$/.test(textBefore);
const italicAfter = /^(?:[^*\n]|\*\*)*(?<!\*)\*(?!\*)/.test(textAfter);
let italic = italicBefore && italicAfter;

// Handle combined *** (bold+italic) markers where the italic * can't be isolated
if (!italic && bold) {
const beforeStars = /(\*{3,})[^*\n]*$/.exec(textBefore);
const afterStars = /^[^*\n]*(\*{3,})/.exec(textAfter);
if (
beforeStars &&
afterStars &&
beforeStars[1]!.length % 2 === 1 &&
afterStars[1]!.length % 2 === 1
) {
italic = true;
}
}

return {
bold,
italic,
strikethrough,
bulletList: BULLET_PREFIX.test(currentLine),
numberedList: NUMBERED_PREFIX.test(currentLine),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { type ActiveFormats, activeFormatsEqual, detectActiveFormats } from './detectActiveFormats';
export { toggleBold, toggleItalic, toggleStrikethrough } from './inlineFormatting';
export {
BULLET_PREFIX,
continueListOnEnter,
NUMBERED_PREFIX,
NUMBERED_PREFIX_FULL,
toggleBulletList,
toggleNumberedList,
} from './listFormatting';
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { toggleBold, toggleItalic, toggleStrikethrough } from './inlineFormatting';

// All three formats share toggleInlineWrap. Tests use toggleBold as the representative
// unless a test covers behavior unique to a specific marker.

describe('inline formatting (via toggleBold)', () => {
it('wraps selected text', () => {
const result = toggleBold({ value: 'hello world end', selectionStart: 6, selectionEnd: 11 });
expect(result.value).toBe('hello **world** end');
expect(result.selectionStart).toBe(8);
expect(result.selectionEnd).toBe(13);
});

it('unwraps already-wrapped selected text', () => {
const result = toggleBold({
value: 'hello **world** end',
selectionStart: 8,
selectionEnd: 13,
});
expect(result.value).toBe('hello world end');
});

it('inserts empty markers on a plain line with no selection', () => {
const result = toggleBold({ value: 'hello end', selectionStart: 6, selectionEnd: 6 });
expect(result.value).toBe('hello ****end');
expect(result.selectionStart).toBe(8);
});

it('removes markers when cursor is inside formatted region', () => {
const result = toggleBold({
value: 'hello **world** end',
selectionStart: 10,
selectionEnd: 10,
});
expect(result.value).toBe('hello world end');
});

it('wraps list line content with no selection', () => {
const result = toggleBold({ value: '1. hello', selectionStart: 5, selectionEnd: 5 });
expect(result.value).toBe('1. **hello**');
});

it('removes markers on a list line', () => {
const result = toggleBold({ value: '1. **hello**', selectionStart: 7, selectionEnd: 7 });
expect(result.value).toBe('1. hello');
});

it('wraps and unwraps each list line individually', () => {
const wrapped = toggleBold({
value: '1. hello\n2. world',
selectionStart: 0,
selectionEnd: 17,
});
expect(wrapped.value).toBe('1. **hello**\n2. **world**');

const unwrapped = toggleBold({
value: '1. **hello**\n2. **world**',
selectionStart: 0,
selectionEnd: 25,
});
expect(unwrapped.value).toBe('1. hello\n2. world');
});
});

describe('italic / bold boundary', () => {
it('does not unwrap bold markers when toggling italic', () => {
const result = toggleItalic({ value: '**world**', selectionStart: 2, selectionEnd: 7 });
expect(result.value).toBe('***world***');
});

it('unwraps italic from bold+italic list lines (***)', () => {
const result = toggleItalic({
value: '1. ***hello***\n2. ***world***',
selectionStart: 0,
selectionEnd: 29,
});
expect(result.value).toBe('1. **hello**\n2. **world**');
});

it('wraps italic onto bold-only list lines', () => {
const result = toggleItalic({
value: '1. **hello**\n2. **world**',
selectionStart: 0,
selectionEnd: 25,
});
expect(result.value).toBe('1. ***hello***\n2. ***world***');
});
});

describe('strikethrough uses ~~ marker', () => {
it('wraps and unwraps with ~~', () => {
const wrapped = toggleStrikethrough({
value: 'hello world end',
selectionStart: 6,
selectionEnd: 11,
});
expect(wrapped.value).toBe('hello ~~world~~ end');

const unwrapped = toggleStrikethrough({
value: 'hello ~~world~~ end',
selectionStart: 8,
selectionEnd: 13,
});
expect(unwrapped.value).toBe('hello world end');
});
});
Loading
Loading