v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선
라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기, 사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
63
lib/markdown-callout.js
Normal file
63
lib/markdown-callout.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** @type {string[]} */
|
||||
export const CALLOUT_BACKGROUND_OPTIONS = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
|
||||
|
||||
/** @type {string[]} */
|
||||
export const CALLOUT_EMOJI_OPTIONS = ['💡', '⚠️', '❗', '✅', '📌', '🔥', '💬']
|
||||
|
||||
/**
|
||||
* 콜아웃 선언부 옵션을 파싱한다.
|
||||
* @param {string} line - 콜아웃 선언 라인
|
||||
* @returns {{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string }}
|
||||
*/
|
||||
export const parseCalloutOptions = (line) => {
|
||||
const options = {
|
||||
calloutEmojiEnabled: true,
|
||||
calloutEmoji: '💡',
|
||||
calloutBackground: 'blue'
|
||||
}
|
||||
const tokens = String(line ?? '').trim().split(/\s+/).slice(1)
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const [rawKey, ...rawValueParts] = token.split('=')
|
||||
|
||||
if (!rawKey || !rawValueParts.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = rawKey.toLowerCase()
|
||||
const value = rawValueParts.join('=').trim()
|
||||
|
||||
if (key === 'emoji') {
|
||||
if (!value || value === 'none') {
|
||||
options.calloutEmojiEnabled = false
|
||||
options.calloutEmoji = '💡'
|
||||
} else {
|
||||
options.calloutEmojiEnabled = true
|
||||
options.calloutEmoji = value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(value)) {
|
||||
options.calloutBackground = value
|
||||
}
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜아웃 선언 줄을 만든다.
|
||||
* @param {{ calloutEmojiEnabled?: boolean, calloutEmoji?: string, calloutBackground?: string }} options - 옵션
|
||||
* @returns {string} 선언 줄
|
||||
*/
|
||||
export const buildCalloutOpenerLine = (options = {}) => {
|
||||
const emojiEnabled = options.calloutEmojiEnabled !== false
|
||||
const emoji = emojiEnabled ? (options.calloutEmoji || '💡') : 'none'
|
||||
const background = CALLOUT_BACKGROUND_OPTIONS.includes(options.calloutBackground)
|
||||
? options.calloutBackground
|
||||
: 'blue'
|
||||
|
||||
return `:::callout emoji=${emoji} bg=${background}`
|
||||
}
|
||||
63
lib/markdown-code-block.js
Normal file
63
lib/markdown-code-block.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 코드 펜스 시작 줄을 파싱한다.
|
||||
* @param {string} line - 마크다운 줄
|
||||
* @returns {{ language: string, showLineNumbers: boolean }|null} 파싱 결과
|
||||
*/
|
||||
export const parseCodeFenceLine = (line) => {
|
||||
const trimmed = String(line ?? '').trim()
|
||||
|
||||
if (!trimmed.startsWith('```')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rest = trimmed.slice(3).trim()
|
||||
|
||||
if (!rest) {
|
||||
return { language: '', showLineNumbers: true }
|
||||
}
|
||||
|
||||
const noLineNumberTokens = ['nolinenos', 'no-linenos', 'no-line-numbers']
|
||||
const tokens = rest.split(/\s+/).filter(Boolean)
|
||||
const showLineNumbers = !tokens.some((token) => noLineNumberTokens.includes(token.toLowerCase()))
|
||||
const languageTokens = tokens.filter((token) => !noLineNumberTokens.includes(token.toLowerCase()))
|
||||
const language = languageTokens[0] || ''
|
||||
|
||||
return { language, showLineNumbers }
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 펜스 시작 줄을 만든다.
|
||||
* @param {{ language?: string, showLineNumbers?: boolean }} options - 옵션
|
||||
* @returns {string} 펜스 시작 줄
|
||||
*/
|
||||
export const buildCodeFenceOpener = (options = {}) => {
|
||||
const language = String(options.language ?? '').trim()
|
||||
const showLineNumbers = options.showLineNumbers !== false
|
||||
let opener = '```'
|
||||
|
||||
if (language) {
|
||||
opener += language
|
||||
}
|
||||
|
||||
if (!showLineNumbers) {
|
||||
opener += language ? ' nolinenos' : 'nolinenos'
|
||||
}
|
||||
|
||||
return opener
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록 마크다운 줄 배열을 만든다.
|
||||
* @param {{ language?: string, showLineNumbers?: boolean, body?: string }} options - 옵션
|
||||
* @returns {string[]} 마크다운 줄
|
||||
*/
|
||||
export const buildCodeBlockLines = (options = {}) => {
|
||||
const body = String(options.body ?? '').replace(/\r/g, '')
|
||||
const bodyLines = body.length ? body.split('\n') : ['']
|
||||
|
||||
return [
|
||||
buildCodeFenceOpener(options),
|
||||
...bodyLines,
|
||||
'```'
|
||||
]
|
||||
}
|
||||
@@ -180,6 +180,219 @@ export const getRangeInnerHtml = (range) => {
|
||||
return container.innerHTML
|
||||
}
|
||||
|
||||
/** @type {Set<string>} contenteditable 줄 구분 블록 태그 */
|
||||
const EDITABLE_BLOCK_TAGS = new Set(['div', 'p'])
|
||||
|
||||
/**
|
||||
* 루트 직계 자식이 줄 구분 블록인지 확인한다.
|
||||
* @param {HTMLElement} element - 요소
|
||||
* @param {HTMLElement} root - contenteditable 루트
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isEditableBlockBreak = (element, root) => {
|
||||
if (!element || element === root) {
|
||||
return false
|
||||
}
|
||||
|
||||
return EDITABLE_BLOCK_TAGS.has(element.tagName.toLowerCase())
|
||||
&& element.parentElement === root
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 텍스트 단위를 순회한다.
|
||||
* @param {HTMLElement} root - contenteditable 루트
|
||||
* @yields {{ kind: 'text'|'break'|'block-break', node: Node|null, length: number }}
|
||||
*/
|
||||
function* iterateEditableTextUnits(root) {
|
||||
/**
|
||||
* @param {Node} node - 순회 노드
|
||||
* @param {boolean} parentIsRoot - 루트 직계 여부
|
||||
* @param {number} indexInParent - 형제 인덱스
|
||||
* @returns {Generator<{ kind: string, node: Node|null, length: number }>}
|
||||
*/
|
||||
const visit = function* (node, parentIsRoot, indexInParent) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
yield { kind: 'text', node, length: node.textContent?.length ?? 0 }
|
||||
return
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return
|
||||
}
|
||||
|
||||
const element = /** @type {HTMLElement} */ (node)
|
||||
const tag = element.tagName.toLowerCase()
|
||||
|
||||
if (tag === 'br') {
|
||||
yield { kind: 'break', node, length: 1 }
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableBlockBreak(element, root)) {
|
||||
if (parentIsRoot && indexInParent > 0) {
|
||||
yield { kind: 'block-break', node: null, length: 1 }
|
||||
}
|
||||
|
||||
const children = [...element.childNodes]
|
||||
|
||||
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
|
||||
yield* visit(children[childIndex], false, childIndex)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const children = [...element.childNodes]
|
||||
|
||||
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
|
||||
yield* visit(children[childIndex], parentIsRoot, childIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const children = [...root.childNodes]
|
||||
|
||||
for (let index = 0; index < children.length; index += 1) {
|
||||
yield* visit(children[index], true, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 루트에서 텍스트를 읽는다.
|
||||
* @param {HTMLElement} root - contenteditable 루트
|
||||
* @returns {string} 마크다운 인라인 텍스트
|
||||
*/
|
||||
export const readEditableTextFromElement = (root) => {
|
||||
if (!root) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = []
|
||||
|
||||
for (const unit of iterateEditableTextUnits(root)) {
|
||||
if (unit.kind === 'text') {
|
||||
parts.push(unit.node?.textContent || '')
|
||||
continue
|
||||
}
|
||||
|
||||
parts.push('\n')
|
||||
}
|
||||
|
||||
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 루트에서 커서 오프셋을 계산한다.
|
||||
* @param {HTMLElement} root - contenteditable 루트
|
||||
* @param {Range} range - 선택 범위
|
||||
* @returns {number} 텍스트 오프셋
|
||||
*/
|
||||
export const getEditableCaretOffset = (root, range) => {
|
||||
if (!root || !range) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (range.startContainer === root) {
|
||||
let offset = 0
|
||||
const children = [...root.childNodes]
|
||||
const measureRoot = document.createElement('div')
|
||||
|
||||
for (let index = 0; index < Math.min(range.startOffset, children.length); index += 1) {
|
||||
measureRoot.appendChild(children[index].cloneNode(true))
|
||||
}
|
||||
|
||||
for (const unit of iterateEditableTextUnits(/** @type {HTMLElement} */ (measureRoot))) {
|
||||
offset += unit.length
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
let found = false
|
||||
|
||||
for (const unit of iterateEditableTextUnits(root)) {
|
||||
if (found) {
|
||||
break
|
||||
}
|
||||
|
||||
if (unit.kind === 'text' && unit.node === range.startContainer) {
|
||||
offset += range.startOffset
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
if (unit.node === range.startContainer) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
offset += unit.length
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 루트에 커서를 텍스트 오프셋으로 둔다.
|
||||
* @param {HTMLElement} root - contenteditable 루트
|
||||
* @param {number} targetOffset - 텍스트 오프셋
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setEditableCaretOffset = (root, targetOffset) => {
|
||||
if (!root || !import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const safeOffset = Math.max(0, targetOffset)
|
||||
let walked = 0
|
||||
let placed = false
|
||||
|
||||
for (const unit of iterateEditableTextUnits(root)) {
|
||||
if (placed) {
|
||||
break
|
||||
}
|
||||
|
||||
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
|
||||
if (walked + unit.length >= safeOffset) {
|
||||
const range = document.createRange()
|
||||
const charOffset = Math.min(safeOffset - walked, unit.length)
|
||||
range.setStart(unit.node, charOffset)
|
||||
range.collapse(true)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
|
||||
walked += unit.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (walked + unit.length >= safeOffset) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
|
||||
walked += unit.length
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
|
||||
* @param {string} html - innerHTML
|
||||
@@ -191,22 +404,8 @@ export const convertEditableHtmlToMarkdown = (html) => {
|
||||
}
|
||||
|
||||
const document = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
const parts = []
|
||||
|
||||
document.body.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && /** @type {HTMLElement} */ (node).tagName.toLowerCase() === 'br') {
|
||||
parts.push('\n')
|
||||
return
|
||||
}
|
||||
|
||||
const converted = convertHtmlInlineNodeToMarkdown(node)
|
||||
|
||||
if (converted) {
|
||||
parts.push(converted)
|
||||
}
|
||||
})
|
||||
|
||||
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
|
||||
return readEditableTextFromElement(/** @type {HTMLElement} */ (document.body))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,15 +496,11 @@ export const convertHtmlToMarkdown = (html) => {
|
||||
* @returns {string[]} 마크다운 줄
|
||||
*/
|
||||
export const paragraphTextToSourceLines = (text) => {
|
||||
const parts = String(text || '').split('\n')
|
||||
const parts = String(text || '').split('\n').map((part) => part.trimEnd())
|
||||
|
||||
if (!parts.length) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return [parts[0]]
|
||||
}
|
||||
|
||||
return parts.map((part, index) => (index < parts.length - 1 ? `${part} ` : part))
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -110,6 +110,118 @@ export const isEmptyQuoteMarkerLine = (line) => {
|
||||
return !stripQuoteMarker(line).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 마크다운 줄 끝에 텍스트를 이어 붙인다.
|
||||
* @param {string} line - 이전 줄
|
||||
* @param {string} appendText - 붙일 본문
|
||||
* @returns {string} 병합된 줄
|
||||
*/
|
||||
export const appendTextToMarkdownLine = (line, appendText) => {
|
||||
const prev = String(line ?? '')
|
||||
const add = String(appendText ?? '')
|
||||
|
||||
if (!add) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const ordered = parseOrderedListMarker(prev)
|
||||
|
||||
if (ordered) {
|
||||
return `${ordered.number}. ${ordered.body}${add}`
|
||||
}
|
||||
|
||||
if (hasQuoteMarker(prev)) {
|
||||
const body = stripQuoteMarker(prev)
|
||||
return `> ${body}${add}`
|
||||
}
|
||||
|
||||
if (hasListMarker(prev, false)) {
|
||||
const body = stripListMarker(prev, false)
|
||||
return `- ${body}${add}`
|
||||
}
|
||||
|
||||
return `${prev}${add}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 값에서 이전 줄에 붙일 본문만 추출한다.
|
||||
* @param {string} value - 편집 값
|
||||
* @param {string} previousLine - 이전 마크다운 줄
|
||||
* @param {boolean} raw - 원문 모드 여부
|
||||
* @returns {string} 본문
|
||||
*/
|
||||
export const getAppendTextForMerge = (value, previousLine, raw = false) => {
|
||||
const text = String(value ?? '').trim()
|
||||
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
if (hasQuoteMarker(text)) {
|
||||
return stripQuoteMarker(text)
|
||||
}
|
||||
|
||||
if (hasListMarker(text, true)) {
|
||||
return stripListMarker(text, true)
|
||||
}
|
||||
|
||||
if (hasListMarker(text, false)) {
|
||||
return stripListMarker(text, false)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
if (hasListMarker(previousLine, true)) {
|
||||
return stripListMarker(text, true)
|
||||
}
|
||||
|
||||
if (hasListMarker(previousLine, false)) {
|
||||
return stripListMarker(text, false)
|
||||
}
|
||||
|
||||
if (hasQuoteMarker(previousLine)) {
|
||||
return stripQuoteMarker(text)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄 병합 후 편집 영역에 둘 커서 오프셋(이전 줄 본문 끝)을 반환한다.
|
||||
* @param {string} previousLine - 이전 마크다운 줄
|
||||
* @param {boolean} [raw=false] - 원문 모드 여부
|
||||
* @returns {number} 표시 텍스트 기준 오프셋
|
||||
*/
|
||||
export const getMergeJunctionDisplayOffset = (previousLine, raw = false) => {
|
||||
const prev = String(previousLine ?? '')
|
||||
|
||||
if (raw) {
|
||||
return prev.length
|
||||
}
|
||||
|
||||
const ordered = parseOrderedListMarker(prev)
|
||||
|
||||
if (ordered) {
|
||||
return ordered.body.length
|
||||
}
|
||||
|
||||
if (hasListMarker(prev, true)) {
|
||||
return stripListMarker(prev, true).length
|
||||
}
|
||||
|
||||
if (hasListMarker(prev, false)) {
|
||||
return stripListMarker(prev, false).length
|
||||
}
|
||||
|
||||
if (hasQuoteMarker(prev)) {
|
||||
return stripQuoteMarker(prev).length
|
||||
}
|
||||
|
||||
return prev.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 접두사 제거
|
||||
* @param {string} value - 원본
|
||||
|
||||
195
lib/markdown-slash-commands.js
Normal file
195
lib/markdown-slash-commands.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 라이브/마크다운 에디터 슬래시 명령 정의
|
||||
* @typedef {Object} MarkdownSlashCommand
|
||||
* @property {string} id - 명령 ID
|
||||
* @property {string} label - 표시 이름
|
||||
* @property {string} description - 설명
|
||||
* @property {string[]} keywords - 검색 키워드
|
||||
* @property {'media-image'|'media-gallery'|'lines'} action - 실행 유형
|
||||
* @property {string[]} [lines] - 삽입할 마크다운 줄(action이 lines일 때)
|
||||
* @property {boolean} [showInDefaultMenu=true] - `/`만 입력했을 때 메뉴에 표시할지
|
||||
*/
|
||||
|
||||
/** @type {MarkdownSlashCommand[]} */
|
||||
export const MARKDOWN_SLASH_COMMANDS = [
|
||||
{
|
||||
id: 'image',
|
||||
label: '이미지',
|
||||
description: '단일 이미지 삽입',
|
||||
keywords: ['image', 'img', 'photo', '사진', '이미지'],
|
||||
action: 'media-image'
|
||||
},
|
||||
{
|
||||
id: 'gallery',
|
||||
label: '갤러리',
|
||||
description: '여러 이미지 갤러리',
|
||||
keywords: ['gallery', 'images', '갤러리'],
|
||||
action: 'media-gallery'
|
||||
},
|
||||
{
|
||||
id: 'h1',
|
||||
label: '제목 1',
|
||||
description: '큰 제목(게시물 제목 외에는 비권장)',
|
||||
keywords: ['h1', 'heading1'],
|
||||
action: 'lines',
|
||||
lines: ['# '],
|
||||
showInDefaultMenu: false
|
||||
},
|
||||
{
|
||||
id: 'h2',
|
||||
label: '제목 2',
|
||||
description: '섹션 제목',
|
||||
keywords: ['h2', 'heading', 'subtitle', '제목'],
|
||||
action: 'lines',
|
||||
lines: ['## ']
|
||||
},
|
||||
{
|
||||
id: 'h3',
|
||||
label: '제목 3',
|
||||
description: '작은 섹션 제목',
|
||||
keywords: ['h3', 'heading', '제목'],
|
||||
action: 'lines',
|
||||
lines: ['### ']
|
||||
},
|
||||
{
|
||||
id: 'h4',
|
||||
label: '제목 4',
|
||||
description: '소제목',
|
||||
keywords: ['h4', 'heading', '제목'],
|
||||
action: 'lines',
|
||||
lines: ['#### ']
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
label: '인용',
|
||||
description: '인용문 블록',
|
||||
keywords: ['quote', 'blockquote', '인용'],
|
||||
action: 'lines',
|
||||
lines: ['> ']
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: '목록',
|
||||
description: '불릿 목록',
|
||||
keywords: ['list', 'bullet', 'ul', '목록'],
|
||||
action: 'lines',
|
||||
lines: ['- ']
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
label: '코드',
|
||||
description: '코드 블록',
|
||||
keywords: ['code', 'pre', '코드'],
|
||||
action: 'lines',
|
||||
lines: ['```', '', '```']
|
||||
},
|
||||
{
|
||||
id: 'divider',
|
||||
label: '구분선',
|
||||
description: '가로 구분선',
|
||||
keywords: ['divider', 'hr', 'line', '구분선'],
|
||||
action: 'lines',
|
||||
lines: ['---']
|
||||
},
|
||||
{
|
||||
id: 'callout',
|
||||
label: '콜아웃',
|
||||
description: '강조 안내(첫 줄: :::callout emoji=💡 bg=blue)',
|
||||
keywords: ['callout', 'notice', 'info', '콜아웃'],
|
||||
action: 'lines',
|
||||
lines: [':::callout emoji=💡 bg=blue', '', ':::']
|
||||
},
|
||||
{
|
||||
id: 'toggle',
|
||||
label: '토글',
|
||||
description: '접기/펼치기 블록',
|
||||
keywords: ['toggle', 'details', '토글'],
|
||||
action: 'lines',
|
||||
lines: [':::toggle 제목', '', ':::']
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: '임베드',
|
||||
description: 'YouTube·X 등 외부 링크',
|
||||
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브'],
|
||||
action: 'lines',
|
||||
lines: [':::embed', '', ':::']
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 슬래시 입력 문자열을 파싱한다.
|
||||
* @param {string} value - 편집 값
|
||||
* @returns {{ query: string, raw: string }|null} 파싱 결과
|
||||
*/
|
||||
export const parseSlashInput = (value) => {
|
||||
const raw = String(value ?? '')
|
||||
|
||||
if (!raw.startsWith('/') || raw.includes('\n')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
query: raw.slice(1).trim().toLowerCase(),
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어에 맞는 슬래시 명령을 필터링한다.
|
||||
* @param {string} query - 검색어(/ 제외)
|
||||
* @returns {MarkdownSlashCommand[]} 명령 목록
|
||||
*/
|
||||
export const filterSlashCommands = (query) => {
|
||||
const normalized = String(query ?? '').trim().toLowerCase()
|
||||
|
||||
if (!normalized) {
|
||||
return MARKDOWN_SLASH_COMMANDS.filter((command) => command.showInDefaultMenu !== false)
|
||||
}
|
||||
|
||||
return MARKDOWN_SLASH_COMMANDS.filter((command) => [
|
||||
command.id,
|
||||
command.label,
|
||||
command.description,
|
||||
...command.keywords
|
||||
].some((keyword) => String(keyword).toLowerCase().includes(normalized)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어와 일치하는 최우선 명령을 반환한다.
|
||||
* @param {string} query - 검색어
|
||||
* @returns {MarkdownSlashCommand|null} 명령
|
||||
*/
|
||||
export const resolveSlashCommand = (query) => {
|
||||
const normalized = String(query ?? '').trim().toLowerCase()
|
||||
const matches = filterSlashCommands(normalized)
|
||||
|
||||
if (!matches.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const exact = matches.find((command) => command.id === normalized
|
||||
|| command.keywords.some((keyword) => keyword.toLowerCase() === normalized))
|
||||
|
||||
return exact ?? matches[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 명령 적용 후 포커스할 줄·커서 오프셋을 계산한다.
|
||||
* @param {number} startLine - 교체 시작 줄(0-based)
|
||||
* @param {string[]} lines - 삽입 줄
|
||||
* @returns {{ line: number, offset: number }} 포커스 대상
|
||||
*/
|
||||
export const getSlashCommandFocusTarget = (startLine, lines) => {
|
||||
const safeStart = Number.isFinite(startLine) ? startLine : 0
|
||||
|
||||
if (!Array.isArray(lines) || !lines.length) {
|
||||
return { line: safeStart, offset: 0 }
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
return { line: safeStart, offset: String(lines[0] ?? '').length }
|
||||
}
|
||||
|
||||
return { line: safeStart + 1, offset: 0 }
|
||||
}
|
||||
16
lib/markdown-toggle.js
Normal file
16
lib/markdown-toggle.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 토글 블록 마크다운 줄 배열을 만든다.
|
||||
* @param {{ title?: string, body?: string }} options - 옵션
|
||||
* @returns {string[]} 마크다운 줄
|
||||
*/
|
||||
export const buildToggleBlockLines = (options = {}) => {
|
||||
const title = String(options.title ?? '').trim() || '더 보기'
|
||||
const body = String(options.body ?? '').replace(/\r/g, '')
|
||||
const bodyLines = body.length ? body.split('\n') : ['']
|
||||
|
||||
return [
|
||||
`:::toggle ${title}`,
|
||||
...bodyLines,
|
||||
':::'
|
||||
]
|
||||
}
|
||||
82
lib/textarea-caret-coordinates.js
Normal file
82
lib/textarea-caret-coordinates.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* textarea 커서 위치의 박스 기준 좌표를 계산한다(미러 div 방식).
|
||||
* @param {HTMLTextAreaElement} textarea - 대상 textarea
|
||||
* @param {number} position - 문자 인덱스
|
||||
* @returns {{ top: number, left: number, height: number }} top·left·line height(px)
|
||||
*/
|
||||
export const getTextareaCaretCoordinates = (textarea, position) => {
|
||||
const style = window.getComputedStyle(textarea)
|
||||
const mirror = document.createElement('div')
|
||||
|
||||
mirror.setAttribute('aria-hidden', 'true')
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.whiteSpace = 'pre-wrap'
|
||||
mirror.style.wordWrap = 'break-word'
|
||||
mirror.style.overflow = 'hidden'
|
||||
|
||||
const properties = [
|
||||
'direction',
|
||||
'boxSizing',
|
||||
'width',
|
||||
'height',
|
||||
'overflowX',
|
||||
'overflowY',
|
||||
'borderTopWidth',
|
||||
'borderRightWidth',
|
||||
'borderBottomWidth',
|
||||
'borderLeftWidth',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'fontStyle',
|
||||
'fontVariant',
|
||||
'fontWeight',
|
||||
'fontStretch',
|
||||
'fontSize',
|
||||
'lineHeight',
|
||||
'fontFamily',
|
||||
'textAlign',
|
||||
'textTransform',
|
||||
'textIndent',
|
||||
'textDecoration',
|
||||
'letterSpacing',
|
||||
'wordSpacing',
|
||||
'tabSize'
|
||||
]
|
||||
|
||||
for (const property of properties) {
|
||||
mirror.style[property] = style[property]
|
||||
}
|
||||
|
||||
const isBoxSizingBorderBox = style.boxSizing === 'border-box'
|
||||
const width = isBoxSizingBorderBox
|
||||
? textarea.offsetWidth
|
||||
: textarea.offsetWidth
|
||||
- parseFloat(style.borderLeftWidth)
|
||||
- parseFloat(style.borderRightWidth)
|
||||
- parseFloat(style.paddingLeft)
|
||||
- parseFloat(style.paddingRight)
|
||||
|
||||
mirror.style.width = `${width}px`
|
||||
|
||||
const value = textarea.value
|
||||
const before = value.slice(0, position)
|
||||
const after = value.slice(position) || '.'
|
||||
|
||||
mirror.textContent = before
|
||||
const marker = document.createElement('span')
|
||||
marker.textContent = after
|
||||
mirror.appendChild(marker)
|
||||
|
||||
document.body.appendChild(mirror)
|
||||
|
||||
const top = marker.offsetTop + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop)
|
||||
const left = marker.offsetLeft + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
|
||||
const height = parseFloat(style.lineHeight) || marker.offsetHeight || 0
|
||||
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
return { top, left, height }
|
||||
}
|
||||
Reference in New Issue
Block a user