연속 콜아웃 편집 범위 보정

This commit is contained in:
2026-06-04 15:18:57 +09:00
parent 648ce5fbab
commit f048eaac2b
9 changed files with 87 additions and 37 deletions

View File

@@ -108,9 +108,8 @@ const suppressBlurCommit = ref(false)
const splitLock = ref(false)
/** 조합 중 Enter 후 compositionend에서 분리할지 */
const pendingSplitAfterComposition = ref(false)
/** 조합 종료 직후 중복 Enter를 1회 무시할지 */
const suppressNextEnterAfterComposition = ref(false)
const showingRaw = ref(false)
let cleanupComposedEnterSuppressor = null
/** @returns {string} Enter 동작 모드 */
const resolvedEnterMode = computed(() => {
@@ -186,6 +185,10 @@ onMounted(() => {
syncEditorHtml()
})
onBeforeUnmount(() => {
cleanupComposedEnterSuppressor?.()
})
/**
* 포커스 시 편집 상태를 표시한다.
* @returns {void}
@@ -680,6 +683,46 @@ const scheduleEnterAction = (action) => {
})
}
/**
* 조합 종료 직후 브라우저가 다시 전달하는 Enter를 한 번 차단한다.
* @returns {void}
*/
const suppressNextComposedEnterGlobally = () => {
if (!import.meta.client) {
return
}
cleanupComposedEnterSuppressor?.()
const deadline = Date.now() + 500
const handler = (event) => {
if (
event.key === 'Enter'
&& !event.shiftKey
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& Date.now() <= deadline
) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
cleanupComposedEnterSuppressor?.()
}
}
cleanupComposedEnterSuppressor = () => {
window.removeEventListener('keydown', handler, true)
cleanupComposedEnterSuppressor = null
}
window.addEventListener('keydown', handler, true)
window.setTimeout(() => {
cleanupComposedEnterSuppressor?.()
}, 520)
}
/**
* 원문 모드 상태를 부모에 알린다.
* @param {boolean} active - 활성 여부
@@ -878,17 +921,6 @@ const onKeydown = (event) => {
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (
event.key === 'Enter'
&& !event.shiftKey
&& suppressNextEnterAfterComposition.value
) {
event.preventDefault()
event.stopPropagation()
suppressNextEnterAfterComposition.value = false
return
}
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
event.preventDefault()
event.stopPropagation()
@@ -950,11 +982,8 @@ const onCompositionEnd = () => {
}
nextTick(() => {
suppressNextEnterAfterComposition.value = true
suppressNextComposedEnterGlobally()
scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below')
window.setTimeout(() => {
suppressNextEnterAfterComposition.value = false
}, 1200)
})
}

View File

@@ -272,7 +272,7 @@ const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
* 닫힘 표식까지의 행 목록을 반환
* @param {Array<string>} lines - 전체 마크다운 행
* @param {number} startIndex - 본문 시작 인덱스
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
* @returns {{contentLines: Array<string>, endLine: number, nextIndex: number}} 블록 본문과 다음 인덱스
*/
const collectFencedLines = (lines, startIndex) => {
const contentLines = []
@@ -285,7 +285,8 @@ const collectFencedLines = (lines, startIndex) => {
return {
contentLines,
nextIndex: index + 1
endLine: index < lines.length ? index : Math.max(startIndex - 1, 0),
nextIndex: index < lines.length ? index + 1 : lines.length
}
}
@@ -489,14 +490,14 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine === ':::bookmark') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
if (bookmarkMeta.url) {
blocks.push(attachSourceRange(
createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }),
startLine,
nextIndex
endLine
))
}
@@ -506,12 +507,12 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine === ':::signup') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const signupMeta = parseSignupMeta(contentLines.join('\n'))
blocks.push(attachSourceRange(
createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }),
startLine,
nextIndex
endLine
))
index = nextIndex
continue
@@ -519,7 +520,7 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine === ':::gallery') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const images = []
contentLines.forEach((contentLine) => {
@@ -531,7 +532,7 @@ const parseMarkdownBlocks = (markdown) => {
blocks.push(attachSourceRange(createBlock('gallery', '', null, `block-${blocks.length}`, {
images
}), startLine, nextIndex))
}), startLine, endLine))
index = nextIndex
continue
}
@@ -539,13 +540,13 @@ const parseMarkdownBlocks = (markdown) => {
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
const startLine = index
const blockType = trimmedLine.replace(':::', '')
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
blocks.push(attachSourceRange(
createBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
startLine,
nextIndex
endLine
))
index = nextIndex
@@ -554,11 +555,11 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine.startsWith(':::callout')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(attachSourceRange(
createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)),
startLine,
nextIndex
endLine
))
index = nextIndex
continue
@@ -566,12 +567,12 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine.startsWith(':::toggle')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
const toggleOptions = parseToggleOpenerLine(trimmedLine)
blocks.push(attachSourceRange(
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, toggleOptions),
startLine,
nextIndex
endLine
))
index = nextIndex
continue
@@ -579,11 +580,11 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine === ':::embed') {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(attachSourceRange(
createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
startLine,
nextIndex
endLine
))
index = nextIndex
continue

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.52
- 연속 콜아웃에서 위 콜아웃을 편집하면 아래 콜아웃 선언 줄이 사라지던 문제를 수정했다.
- `:::` fenced 블록의 원본 줄 범위를 닫는 줄까지만 잡도록 보정했다.
- 한글 조합 직후 Enter 중복 입력 차단을 더 강하게 적용했다.
## v1.5.51
- 라이브 인용·콜아웃에서 한글 입력 후 Enter가 줄을 2개 만들던 문제를 다시 보정했다.

View File

@@ -68,6 +68,13 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
### v1.5.52 참고
- 추가 DB 마이그레이션은 없다.
- 연속 콜아웃을 만들고 위 콜아웃에서 한글 입력 후 Enter 시 아래 콜아웃 선언 줄이 유지되는지 확인한다.
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
- 갤러리·토글·임베드 등 다른 `:::` fenced 블록 편집 시 다음 블록 첫 줄이 교체 범위에 포함되지 않는지 확인한다.
### v1.5.51 참고
- 추가 DB 마이그레이션은 없다.

View File

@@ -111,7 +111,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 콜아웃 아래 방향키 새 줄 생성 차단, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 콜아웃 아래 방향키 새 줄 생성 차단, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
| components/content/ProseList.vue | 목록 |

View File

@@ -629,6 +629,7 @@ components/content/
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다.
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 현재 줄을 삭제하며 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=💡 bg=blue`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
- 라이브 모드 인용·콜아웃 내부 Enter는 한글 IME 조합 확정 뒤에도 한 번만 줄을 추가한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들 수 있지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.5.52
- 게시물 글쓰기: 라이브 모드 fenced 블록의 원본 범위가 닫는 `:::` 다음 줄까지 포함되던 문제 수정.
- 게시물 글쓰기: 연속 콜아웃에서 위 콜아웃 편집 시 아래 콜아웃 선언 줄이 삭제되던 문제 수정.
- 게시물 글쓰기: 한글 조합 종료 직후 Enter 중복 이벤트를 전역 capture 단계에서 1회 차단하도록 보강.
## v1.5.51
- 게시물 글쓰기: 라이브 모드 인용·콜아웃에서 한글 조합 Enter가 줄 추가를 중복 실행하지 않도록 보정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.51",
"version": "1.5.52",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.51",
"version": "1.5.52",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.51",
"version": "1.5.52",
"private": true,
"type": "module",
"imports": {