v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선

라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기,
사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 16:57:30 +09:00
parent 666bd304fc
commit 3fb8a40031
34 changed files with 3823 additions and 443 deletions

63
lib/markdown-callout.js Normal file
View 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}`
}

View 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,
'```'
]
}

View File

@@ -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
}

View File

@@ -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 - 원본

View 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
View 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,
':::'
]
}

View 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 }
}