Files
sori.studio/lib/markdown-source-selection.js

236 lines
9.3 KiB
JavaScript

/**
* 마크다운 본문의 줄 시작 오프셋을 반환한다.
* @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)
}
}