v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선
라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기, 사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user