refactor: extract bbcode insertion/wrapping logic and add tests

This commit is contained in:
2026-01-06 00:53:44 +02:00
parent 1ff6349337
commit 370eed1286
2 changed files with 761 additions and 0 deletions

433
src/utils/bbcode.test.ts Normal file
View File

@@ -0,0 +1,433 @@
import { describe, it, expect } from 'vitest'
import {
getSelectedText,
applyBBCodeTemplate,
insertTextAtSelection,
wrapSelection,
getCursorPositionBetweenTags,
isSelectionWrapped,
unwrapSelection,
toggleBBCodeTags,
type TextSelection,
} from './bbcode'
describe('bbcode utilities', () => {
describe('getSelectedText', () => {
it('returns empty string when start equals end', () => {
const selection: TextSelection = { text: 'Hello world', start: 5, end: 5 }
expect(getSelectedText(selection)).toBe('')
})
it('returns selected text from middle of string', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
expect(getSelectedText(selection)).toBe('world')
})
it('returns selected text from start of string', () => {
const selection: TextSelection = { text: 'Hello world', start: 0, end: 5 }
expect(getSelectedText(selection)).toBe('Hello')
})
it('returns entire string when fully selected', () => {
const selection: TextSelection = { text: 'Hello', start: 0, end: 5 }
expect(getSelectedText(selection)).toBe('Hello')
})
it('handles empty text', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
expect(getSelectedText(selection)).toBe('')
})
})
describe('applyBBCodeTemplate', () => {
it('wraps selected text with simple template', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
const result = applyBBCodeTemplate(selection, { template: '[b]{text}[/b]' })
expect(result.text).toBe('Hello [b]world[/b]')
expect(result.cursorPosition).toBe(18)
})
it('inserts template at cursor when no selection', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 6 }
const result = applyBBCodeTemplate(selection, { template: '[b]{text}[/b]' })
expect(result.text).toBe('Hello [b][/b]world')
expect(result.cursorPosition).toBe(13) // cursor after [/b]
})
it('uses fallback text when no selection', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 6 }
const result = applyBBCodeTemplate(selection, {
template: '[b]{text}[/b]',
fallbackText: 'bold text',
})
expect(result.text).toBe('Hello [b]bold text[/b]world')
expect(result.cursorPosition).toBe(22) // cursor after [/b]
})
it('handles template with {value} placeholder', () => {
const selection: TextSelection = { text: 'Click here', start: 6, end: 10 }
const result = applyBBCodeTemplate(selection, {
template: '[url={value}]{text}[/url]',
value: 'http://example.com',
})
expect(result.text).toBe('Click [url=http://example.com]here[/url]')
expect(result.cursorPosition).toBe(40)
})
it('handles template with both {value} and {text}', () => {
const selection: TextSelection = { text: 'Red text', start: 4, end: 8 }
const result = applyBBCodeTemplate(selection, {
template: '[color={value}]{text}[/color]',
value: 'red',
})
expect(result.text).toBe('Red [color=red]text[/color]')
expect(result.cursorPosition).toBe(27)
})
it('replaces selected text at start of string', () => {
const selection: TextSelection = { text: 'Hello world', start: 0, end: 5 }
const result = applyBBCodeTemplate(selection, { template: '[i]{text}[/i]' })
expect(result.text).toBe('[i]Hello[/i] world')
expect(result.cursorPosition).toBe(12)
})
it('replaces selected text at end of string', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
const result = applyBBCodeTemplate(selection, { template: '[u]{text}[/u]' })
expect(result.text).toBe('Hello [u]world[/u]')
expect(result.cursorPosition).toBe(18)
})
it('handles complex template with newlines', () => {
const selection: TextSelection = { text: 'item', start: 0, end: 4 }
const result = applyBBCodeTemplate(selection, {
template: '[list]\n[*]{text}\n[/list]',
})
expect(result.text).toBe('[list]\n[*]item\n[/list]')
expect(result.cursorPosition).toBe(22)
})
it('handles empty value', () => {
const selection: TextSelection = { text: 'text', start: 0, end: 4 }
const result = applyBBCodeTemplate(selection, {
template: '[size={value}]{text}[/size]',
value: '',
})
expect(result.text).toBe('[size=]text[/size]')
})
})
describe('insertTextAtSelection', () => {
it('inserts text at cursor position (no selection)', () => {
const selection: TextSelection = { text: 'Hello world', start: 5, end: 5 }
const result = insertTextAtSelection(selection, ' beautiful')
expect(result.text).toBe('Hello beautiful world')
expect(result.cursorPosition).toBe(15)
})
it('replaces selected text', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
const result = insertTextAtSelection(selection, 'universe')
expect(result.text).toBe('Hello universe')
expect(result.cursorPosition).toBe(14)
})
it('inserts at start of string', () => {
const selection: TextSelection = { text: 'world', start: 0, end: 0 }
const result = insertTextAtSelection(selection, 'Hello ')
expect(result.text).toBe('Hello world')
expect(result.cursorPosition).toBe(6)
})
it('inserts at end of string', () => {
const selection: TextSelection = { text: 'Hello', start: 5, end: 5 }
const result = insertTextAtSelection(selection, ' world')
expect(result.text).toBe('Hello world')
expect(result.cursorPosition).toBe(11)
})
it('inserts emoji', () => {
const selection: TextSelection = { text: 'Hello ', start: 6, end: 6 }
const result = insertTextAtSelection(selection, '😀')
expect(result.text).toBe('Hello 😀')
expect(result.cursorPosition).toBe(8) // emoji is 2 UTF-16 code units
})
})
describe('wrapSelection', () => {
it('wraps selected text with tags', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
const result = wrapSelection(selection, '[b]', '[/b]')
expect(result.text).toBe('Hello [b]world[/b]')
expect(result.cursorPosition).toBe(18)
})
it('inserts empty tags when no selection', () => {
const selection: TextSelection = { text: 'Hello', start: 5, end: 5 }
const result = wrapSelection(selection, '[i]', '[/i]')
expect(result.text).toBe('Hello[i][/i]')
expect(result.cursorPosition).toBe(12)
})
it('uses fallback text when no selection', () => {
const selection: TextSelection = { text: 'Hello ', start: 6, end: 6 }
const result = wrapSelection(selection, '[code]', '[/code]', 'your code here')
expect(result.text).toBe('Hello [code]your code here[/code]')
expect(result.cursorPosition).toBe(33)
})
it('handles asymmetric tags', () => {
const selection: TextSelection = { text: 'text', start: 0, end: 4 }
const result = wrapSelection(selection, '**', '**')
expect(result.text).toBe('**text**')
expect(result.cursorPosition).toBe(8)
})
})
describe('getCursorPositionBetweenTags', () => {
it('returns position after opening tag', () => {
const selection: TextSelection = { text: 'Hello ', start: 6, end: 6 }
const position = getCursorPositionBetweenTags(selection, '[b]', '[/b]')
expect(position).toBe(9) // 6 + 3 (length of '[b]')
})
it('works with longer tags', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const position = getCursorPositionBetweenTags(selection, '[quote]', '[/quote]')
expect(position).toBe(7)
})
})
describe('isSelectionWrapped', () => {
it('returns true when selection is wrapped', () => {
const selection: TextSelection = {
text: 'Hello [b]world[/b] there',
start: 9,
end: 14,
}
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(true)
})
it('returns false when selection is not wrapped', () => {
const selection: TextSelection = {
text: 'Hello world there',
start: 6,
end: 11,
}
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(false)
})
it('returns false when only opening tag exists', () => {
const selection: TextSelection = {
text: 'Hello [b]world there',
start: 9,
end: 14,
}
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(false)
})
it('returns false when only closing tag exists', () => {
const selection: TextSelection = {
text: 'Hello world[/b] there',
start: 6,
end: 11,
}
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(false)
})
it('returns false when not enough text before selection', () => {
const selection: TextSelection = {
text: '[b]test[/b]',
start: 0,
end: 3,
}
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(false)
})
it('returns false when not enough text after selection', () => {
const selection: TextSelection = {
text: '[b]test[/b]',
start: 7,
end: 11,
}
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(false)
})
it('handles nested tags correctly', () => {
const selection: TextSelection = {
text: '[b][i]text[/i][/b]',
start: 6,
end: 10,
}
expect(isSelectionWrapped(selection, '[i]', '[/i]')).toBe(true)
expect(isSelectionWrapped(selection, '[b]', '[/b]')).toBe(false)
})
})
describe('unwrapSelection', () => {
it('removes wrapping tags', () => {
const selection: TextSelection = {
text: 'Hello [b]world[/b] there',
start: 9,
end: 14,
}
const result = unwrapSelection(selection, '[b]', '[/b]')
expect(result.text).toBe('Hello world there')
expect(result.cursorPosition).toBe(11)
})
it('returns unchanged when not wrapped', () => {
const selection: TextSelection = {
text: 'Hello world there',
start: 6,
end: 11,
}
const result = unwrapSelection(selection, '[b]', '[/b]')
expect(result.text).toBe('Hello world there')
expect(result.cursorPosition).toBe(11)
})
it('unwraps at start of string', () => {
const selection: TextSelection = {
text: '[i]Hello[/i] world',
start: 3,
end: 8,
}
const result = unwrapSelection(selection, '[i]', '[/i]')
expect(result.text).toBe('Hello world')
expect(result.cursorPosition).toBe(5)
})
it('unwraps at end of string', () => {
const selection: TextSelection = {
text: 'Hello [u]world[/u]',
start: 9,
end: 14,
}
const result = unwrapSelection(selection, '[u]', '[/u]')
expect(result.text).toBe('Hello world')
expect(result.cursorPosition).toBe(11)
})
})
describe('toggleBBCodeTags', () => {
it('wraps unwrapped selection', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
const result = toggleBBCodeTags(selection, '[b]', '[/b]')
expect(result.text).toBe('Hello [b]world[/b]')
expect(result.cursorPosition).toBe(18)
})
it('unwraps wrapped selection', () => {
const selection: TextSelection = {
text: 'Hello [b]world[/b] there',
start: 9,
end: 14,
}
const result = toggleBBCodeTags(selection, '[b]', '[/b]')
expect(result.text).toBe('Hello world there')
expect(result.cursorPosition).toBe(11)
})
it('uses fallback text when wrapping with no selection', () => {
const selection: TextSelection = { text: 'Hello ', start: 6, end: 6 }
const result = toggleBBCodeTags(selection, '[code]', '[/code]', 'code')
expect(result.text).toBe('Hello [code]code[/code]')
expect(result.cursorPosition).toBe(23)
})
it('can toggle multiple times', () => {
// Start with unwrapped
let selection: TextSelection = { text: 'Hello world', start: 6, end: 11 }
let result = toggleBBCodeTags(selection, '[s]', '[/s]')
expect(result.text).toBe('Hello [s]world[/s]')
// Now toggle off
selection = { text: result.text, start: 9, end: 14 }
result = toggleBBCodeTags(selection, '[s]', '[/s]')
expect(result.text).toBe('Hello world')
// Toggle on again
selection = { text: result.text, start: 6, end: 11 }
result = toggleBBCodeTags(selection, '[s]', '[/s]')
expect(result.text).toBe('Hello [s]world[/s]')
})
})
describe('edge cases', () => {
it('handles unicode characters', () => {
const selection: TextSelection = { text: '你好世界', start: 2, end: 4 }
const result = wrapSelection(selection, '[b]', '[/b]')
expect(result.text).toBe('你好[b]世界[/b]')
})
it('handles special characters in text', () => {
const selection: TextSelection = { text: 'a < b && c > d', start: 0, end: 14 }
const result = wrapSelection(selection, '[code]', '[/code]')
expect(result.text).toBe('[code]a < b && c > d[/code]')
})
it('handles BBCode-like content in selection', () => {
const selection: TextSelection = { text: 'Use [b] for bold', start: 4, end: 7 }
const result = wrapSelection(selection, '[code]', '[/code]')
expect(result.text).toBe('Use [code][b][/code] for bold')
})
it('handles empty string', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = wrapSelection(selection, '[b]', '[/b]')
expect(result.text).toBe('[b][/b]')
expect(result.cursorPosition).toBe(7)
})
it('handles very long text', () => {
const longText = 'a'.repeat(10000)
const selection: TextSelection = { text: longText, start: 5000, end: 5010 }
const result = wrapSelection(selection, '[b]', '[/b]')
expect(result.text.length).toBe(10000 + 7) // original + [b][/b]
expect(result.text.substring(5000, 5017)).toBe('[b]aaaaaaaaaa[/b]')
})
})
})

328
src/utils/bbcode.ts Normal file
View File

@@ -0,0 +1,328 @@
/**
* BBCode text manipulation utilities.
*
* These functions handle pure string/selection operations for BBCode insertion,
* independent of DOM or Vue component logic.
*/
/**
* Selection state in a text editor
*/
export interface TextSelection {
/** Full text content */
text: string
/** Start position of selection (0-indexed) */
start: number
/** End position of selection (0-indexed) */
end: number
}
/**
* Result of a BBCode insertion operation
*/
export interface InsertionResult {
/** The new full text after insertion */
text: string
/** The cursor position after insertion */
cursorPosition: number
}
/**
* BBCode template configuration
*/
export interface BBCodeTemplate {
/** The BBCode template string with {text} and optional {value} placeholders */
template: string
/** Optional value to substitute for {value} placeholder */
value?: string
/** Fallback text if no text is selected */
fallbackText?: string
}
/**
* Get the selected text from a selection state.
*
* @param selection - The text selection state
* @returns The selected text (empty string if no selection)
*/
export function getSelectedText(selection: TextSelection): string {
if (selection.start === selection.end) {
return ''
}
return selection.text.substring(selection.start, selection.end)
}
/**
* Apply a BBCode template to a text selection.
*
* This function:
* 1. Takes the current text and selection
* 2. Replaces the selected text with the BBCode-wrapped version
* 3. Returns the new text and cursor position
*
* Template placeholders:
* - {text}: Replaced with selected text (or fallbackText if nothing selected)
* - {value}: Replaced with the provided value (for tags like [url=...], [color=...])
*
* @param selection - Current text selection state
* @param template - BBCode template configuration
* @returns The insertion result with new text and cursor position
*
* @example
* // Simple wrap with [b] tags
* applyBBCodeTemplate(
* { text: 'Hello world', start: 6, end: 11 },
* { template: '[b]{text}[/b]' }
* )
* // Returns: { text: 'Hello [b]world[/b]', cursorPosition: 18 }
*
* @example
* // URL with value
* applyBBCodeTemplate(
* { text: 'Check this', start: 6, end: 10 },
* { template: '[url={value}]{text}[/url]', value: 'http://example.com' }
* )
* // Returns: { text: 'Check [url=http://example.com]this[/url]', cursorPosition: 40 }
*/
export function applyBBCodeTemplate(
selection: TextSelection,
template: BBCodeTemplate,
): InsertionResult {
const { text, start, end } = selection
const selectedText = getSelectedText(selection)
const beforeText = text.substring(0, start)
const afterText = text.substring(end)
// Build the BBCode string from template
const contentText = selectedText || template.fallbackText || ''
const insertText = template.template
.replace('{value}', template.value || '')
.replace('{text}', contentText)
const newText = beforeText + insertText + afterText
const cursorPosition = beforeText.length + insertText.length
return {
text: newText,
cursorPosition,
}
}
/**
* Insert text at the current selection position.
*
* This replaces any selected text with the new text.
*
* @param selection - Current text selection state
* @param insertText - Text to insert
* @returns The insertion result with new text and cursor position
*
* @example
* insertTextAtSelection(
* { text: 'Hello world', start: 5, end: 5 },
* ' beautiful'
* )
* // Returns: { text: 'Hello beautiful world', cursorPosition: 15 }
*/
export function insertTextAtSelection(
selection: TextSelection,
insertText: string,
): InsertionResult {
const { text, start, end } = selection
const beforeText = text.substring(0, start)
const afterText = text.substring(end)
const newText = beforeText + insertText + afterText
const cursorPosition = beforeText.length + insertText.length
return {
text: newText,
cursorPosition,
}
}
/**
* Wrap selected text with opening and closing strings.
*
* This is a convenience function for simple wrapping operations.
*
* @param selection - Current text selection state
* @param openTag - Opening string (e.g., '[b]')
* @param closeTag - Closing string (e.g., '[/b]')
* @param fallbackText - Text to use if nothing is selected
* @returns The insertion result with new text and cursor position
*
* @example
* wrapSelection(
* { text: 'Hello world', start: 6, end: 11 },
* '[b]',
* '[/b]'
* )
* // Returns: { text: 'Hello [b]world[/b]', cursorPosition: 18 }
*/
export function wrapSelection(
selection: TextSelection,
openTag: string,
closeTag: string,
fallbackText = '',
): InsertionResult {
return applyBBCodeTemplate(selection, {
template: `${openTag}{text}${closeTag}`,
fallbackText,
})
}
/**
* Calculate cursor position after inserting BBCode.
*
* When inserting BBCode without selected text, the cursor should be placed
* between the opening and closing tags so the user can type content.
*
* @param selection - Current text selection state
* @param openTag - Opening BBCode tag
* @param closeTag - Closing BBCode tag
* @returns Cursor position between the tags
*
* @example
* getCursorPositionBetweenTags(
* { text: 'Hello ', start: 6, end: 6 },
* '[b]',
* '[/b]'
* )
* // Returns: 9 (position right after '[b]')
*/
export function getCursorPositionBetweenTags(
selection: TextSelection,
openTag: string,
_closeTag: string,
): number {
return selection.start + openTag.length
}
/**
* Check if a BBCode tag is already applied around the selection.
*
* This checks if the text immediately before and after the selection
* contains the specified opening and closing tags.
*
* @param selection - Current text selection state
* @param openTag - Opening BBCode tag (e.g., '[b]')
* @param closeTag - Closing BBCode tag (e.g., '[/b]')
* @returns True if the selection is wrapped with the tags
*
* @example
* isSelectionWrapped(
* { text: 'Hello [b]world[/b] there', start: 9, end: 14 },
* '[b]',
* '[/b]'
* )
* // Returns: true
*/
export function isSelectionWrapped(
selection: TextSelection,
openTag: string,
closeTag: string,
): boolean {
const { text, start, end } = selection
// Check if there's enough text before and after for the tags
if (start < openTag.length || end + closeTag.length > text.length) {
return false
}
const beforeSelection = text.substring(start - openTag.length, start)
const afterSelection = text.substring(end, end + closeTag.length)
return beforeSelection === openTag && afterSelection === closeTag
}
/**
* Remove BBCode tags from around the selection.
*
* This is the inverse of wrapSelection - it removes the tags if they exist.
*
* @param selection - Current text selection state
* @param openTag - Opening BBCode tag to remove
* @param closeTag - Closing BBCode tag to remove
* @returns The result with tags removed, or unchanged if tags weren't present
*
* @example
* unwrapSelection(
* { text: 'Hello [b]world[/b] there', start: 9, end: 14 },
* '[b]',
* '[/b]'
* )
* // Returns: { text: 'Hello world there', cursorPosition: 11 }
*/
export function unwrapSelection(
selection: TextSelection,
openTag: string,
closeTag: string,
): InsertionResult {
if (!isSelectionWrapped(selection, openTag, closeTag)) {
// Not wrapped, return unchanged
return {
text: selection.text,
cursorPosition: selection.end,
}
}
const { text, start, end } = selection
const selectedText = getSelectedText(selection)
// Remove the tags
const beforeText = text.substring(0, start - openTag.length)
const afterText = text.substring(end + closeTag.length)
const newText = beforeText + selectedText + afterText
const cursorPosition = beforeText.length + selectedText.length
return {
text: newText,
cursorPosition,
}
}
/**
* Toggle BBCode tags around the selection.
*
* If the selection is already wrapped, unwrap it.
* If not, wrap it with the tags.
*
* @param selection - Current text selection state
* @param openTag - Opening BBCode tag
* @param closeTag - Closing BBCode tag
* @param fallbackText - Text to use if nothing is selected (when wrapping)
* @returns The result with tags toggled
*
* @example
* // Wrap unformatted text
* toggleBBCodeTags(
* { text: 'Hello world', start: 6, end: 11 },
* '[b]',
* '[/b]'
* )
* // Returns: { text: 'Hello [b]world[/b]', cursorPosition: 18 }
*
* @example
* // Unwrap already formatted text
* toggleBBCodeTags(
* { text: 'Hello [b]world[/b]', start: 9, end: 14 },
* '[b]',
* '[/b]'
* )
* // Returns: { text: 'Hello world', cursorPosition: 11 }
*/
export function toggleBBCodeTags(
selection: TextSelection,
openTag: string,
closeTag: string,
fallbackText = '',
): InsertionResult {
if (isSelectionWrapped(selection, openTag, closeTag)) {
return unwrapSelection(selection, openTag, closeTag)
}
return wrapSelection(selection, openTag, closeTag, fallbackText)
}