mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
refactor: extract bbcode insertion/wrapping logic and add tests
This commit is contained in:
433
src/utils/bbcode.test.ts
Normal file
433
src/utils/bbcode.test.ts
Normal 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
328
src/utils/bbcode.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user