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

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
}