/** * 마크다운 본문의 줄 시작 오프셋을 반환한다. * @param {string} value - 마크다운 본문 * @param {number} lineIndex - 줄 번호(0-based) * @returns {number} 문자 오프셋 */ export const getMarkdownLineStartOffset = (value, lineIndex) => { const lines = String(value ?? '').split('\n') const safeLineIndex = Math.min(Math.max(0, Number(lineIndex) || 0), Math.max(0, lines.length - 1)) return lines.slice(0, safeLineIndex).join('\n').length + (safeLineIndex > 0 ? 1 : 0) } /** * 문자 오프셋이 속한 마크다운 줄 번호를 반환한다. * @param {string} value - 마크다운 본문 * @param {number} offset - 문자 오프셋 * @returns {number} 줄 번호(0-based) */ export const getMarkdownLineIndexAtOffset = (value, offset) => { const safeValue = String(value ?? '') const safeOffset = Math.min(Math.max(0, Number(offset) || 0), safeValue.length) return Math.max(0, safeValue.slice(0, safeOffset).split('\n').length - 1) } /** * textarea 또는 fallback 선택 위치에서 현재 선택 정보를 만든다. * @param {Object} options - 선택 정보 옵션 * @param {string} options.value - 마크다운 본문 * @param {{ selectionStart?: number, selectionEnd?: number }|null} [options.textarea] - textarea 유사 객체 * @returns {{ start: number, end: number, value: string }} 선택 정보 */ export const getMarkdownSelectionState = ({ value, textarea = null }) => { const safeValue = String(value ?? '') if (!textarea) { return { start: safeValue.length, end: safeValue.length, value: safeValue } } return { start: Math.min(Math.max(0, Number(textarea.selectionStart) || 0), safeValue.length), end: Math.min(Math.max(0, Number(textarea.selectionEnd) || 0), safeValue.length), value: safeValue } } /** * 선택 영역을 지정 문자열로 교체한 결과를 반환한다. * @param {Object} options - 교체 옵션 * @param {string} options.value - 마크다운 본문 * @param {number} options.start - 선택 시작 * @param {number} options.end - 선택 끝 * @param {string} options.replacement - 대체 문자열 * @param {number} [options.cursorOffset] - 대체 문자열 안 커서 위치 * @param {number|null} [options.selectionLength] - 교체 후 선택 길이 * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 교체 결과 */ export const replaceMarkdownSelection = ({ value, start, end, replacement, cursorOffset = String(replacement ?? '').length, selectionLength = null }) => { const safeValue = String(value ?? '') const safeStart = Math.min(Math.max(0, Number(start) || 0), safeValue.length) const safeEnd = Math.min(Math.max(0, Number(end) || 0), safeValue.length) const from = Math.min(safeStart, safeEnd) const to = Math.max(safeStart, safeEnd) const safeReplacement = String(replacement ?? '') const nextValue = `${safeValue.slice(0, from)}${safeReplacement}${safeValue.slice(to)}` const nextStart = from + Math.max(0, Number(cursorOffset) || 0) const nextEnd = nextStart + Math.max(0, Number(selectionLength) || 0) return { value: nextValue, selectionStart: Math.min(nextStart, nextValue.length), selectionEnd: Math.min(nextEnd, nextValue.length) } } /** * 블록형 마크다운 조각 삽입 결과를 반환한다. * @param {Object} options - 삽입 옵션 * @param {string} options.value - 마크다운 본문 * @param {number} options.start - 선택 시작 * @param {number} options.end - 선택 끝 * @param {string} options.snippet - 삽입할 마크다운 * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 삽입 결과 */ export const insertMarkdownBlockSnippet = ({ value, start, end, snippet }) => { const safeValue = String(value ?? '') const safeStart = Math.min(Math.max(0, Number(start) || 0), safeValue.length) const safeEnd = Math.min(Math.max(0, Number(end) || 0), safeValue.length) const needsBeforeLine = safeStart > 0 && safeValue[safeStart - 1] !== '\n' const needsAfterLine = safeEnd < safeValue.length && safeValue[safeEnd] !== '\n' const replacement = `${needsBeforeLine ? '\n\n' : ''}${String(snippet ?? '')}${needsAfterLine ? '\n\n' : ''}` return replaceMarkdownSelection({ value: safeValue, start: safeStart, end: safeEnd, replacement }) } /** * 선택 텍스트를 인라인 마크다운으로 감싼 결과를 반환한다. * @param {Object} options - 감싸기 옵션 * @param {string} options.value - 마크다운 본문 * @param {number} options.start - 선택 시작 * @param {number} options.end - 선택 끝 * @param {string} options.prefix - 앞 표식 * @param {string} options.suffix - 뒤 표식 * @param {string} options.placeholder - 선택이 없을 때 들어갈 텍스트 * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 교체 결과 */ export const wrapMarkdownInlineSelection = ({ value, start, end, prefix, suffix, placeholder }) => { const safeValue = String(value ?? '') const safeStart = Math.min(Math.max(0, Number(start) || 0), safeValue.length) const safeEnd = Math.min(Math.max(0, Number(end) || 0), safeValue.length) const from = Math.min(safeStart, safeEnd) const to = Math.max(safeStart, safeEnd) const selected = safeValue.slice(from, to) const inner = selected || String(placeholder ?? '') const replacement = `${String(prefix ?? '')}${inner}${String(suffix ?? '')}` const result = replaceMarkdownSelection({ value: safeValue, start: from, end: to, replacement }) const selectionStart = from + String(prefix ?? '').length return { value: result.value, selectionStart, selectionEnd: selectionStart + inner.length } } /** * 선택 텍스트를 링크 마크다운으로 바꾼 결과를 반환한다. * @param {Object} options - 링크 삽입 옵션 * @param {string} options.value - 마크다운 본문 * @param {number} options.start - 선택 시작 * @param {number} options.end - 선택 끝 * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 교체 결과 */ export const insertMarkdownInlineLink = ({ value, start, end }) => { const safeValue = String(value ?? '') const safeStart = Math.min(Math.max(0, Number(start) || 0), safeValue.length) const safeEnd = Math.min(Math.max(0, Number(end) || 0), safeValue.length) const from = Math.min(safeStart, safeEnd) const to = Math.max(safeStart, safeEnd) const selected = safeValue.slice(from, to) || '링크 텍스트' const replacement = `[${selected}](https://)` const result = replaceMarkdownSelection({ value: safeValue, start: from, end: to, replacement }) const urlStart = from + selected.length + 3 return { value: result.value, selectionStart: urlStart, selectionEnd: urlStart + 'https://'.length } } /** * 선택 범위가 걸친 줄을 변환한 결과를 반환한다. * @param {Object} options - 줄 변환 옵션 * @param {string} options.value - 마크다운 본문 * @param {number} options.start - 선택 시작 * @param {number} options.end - 선택 끝 * @param {(line: string) => string} options.transformLine - 줄 변환 함수 * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 변환 결과 */ export const transformMarkdownSelectedLines = ({ value, start, end, transformLine }) => { const safeValue = String(value ?? '') const safeStart = Math.min(Math.max(0, Number(start) || 0), safeValue.length) const safeEnd = Math.min(Math.max(0, Number(end) || 0), safeValue.length) const from = Math.min(safeStart, safeEnd) const to = Math.max(safeStart, safeEnd) const lineStart = safeValue.lastIndexOf('\n', Math.max(0, from - 1)) + 1 const nextLineBreak = safeValue.indexOf('\n', to) const lineEnd = nextLineBreak === -1 ? safeValue.length : nextLineBreak const selectedBlock = safeValue.slice(lineStart, lineEnd) const replacement = selectedBlock.split('\n').map(transformLine).join('\n') return { value: `${safeValue.slice(0, lineStart)}${replacement}${safeValue.slice(lineEnd)}`, selectionStart: lineStart, selectionEnd: lineStart + replacement.length } } /** * 선택 범위가 걸친 줄을 삭제한 결과를 반환한다. * @param {Object} options - 삭제 옵션 * @param {string} options.value - 마크다운 본문 * @param {number} options.start - 선택 시작 * @param {number} options.end - 선택 끝 * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 삭제 결과 */ export const deleteMarkdownSelectedLines = ({ value, start, end }) => { const safeValue = String(value ?? '') const safeStart = Math.min(Math.max(0, Number(start) || 0), safeValue.length) const safeEnd = Math.min(Math.max(0, Number(end) || 0), safeValue.length) const from = Math.min(safeStart, safeEnd) const to = Math.max(safeStart, safeEnd) const lineStart = safeValue.lastIndexOf('\n', Math.max(0, from - 1)) + 1 const nextLineBreak = safeValue.indexOf('\n', to) const lineEnd = nextLineBreak === -1 ? safeValue.length : nextLineBreak + 1 const previousLineBreak = safeValue.lastIndexOf('\n', Math.max(0, lineStart - 2)) const nextValue = `${safeValue.slice(0, lineStart)}${safeValue.slice(lineEnd)}` const nextSelection = lineStart > 0 ? previousLineBreak + 1 : 0 return { value: nextValue, selectionStart: Math.min(nextSelection, nextValue.length), selectionEnd: Math.min(nextSelection, nextValue.length) } }