연속 콜아웃 편집 범위 보정
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.52
|
||||
|
||||
- 연속 콜아웃에서 위 콜아웃을 편집하면 아래 콜아웃 선언 줄이 사라지던 문제를 수정했다.
|
||||
- `:::` fenced 블록의 원본 줄 범위를 닫는 줄까지만 잡도록 보정했다.
|
||||
- 한글 조합 직후 Enter 중복 입력 차단을 더 강하게 적용했다.
|
||||
|
||||
## v1.5.51
|
||||
|
||||
- 라이브 인용·콜아웃에서 한글 입력 후 Enter가 줄을 2개 만들던 문제를 다시 보정했다.
|
||||
|
||||
@@ -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 마이그레이션은 없다.
|
||||
|
||||
@@ -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 | 목록 |
|
||||
|
||||
@@ -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로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.52
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 fenced 블록의 원본 범위가 닫는 `:::` 다음 줄까지 포함되던 문제 수정.
|
||||
- 게시물 글쓰기: 연속 콜아웃에서 위 콜아웃 편집 시 아래 콜아웃 선언 줄이 삭제되던 문제 수정.
|
||||
- 게시물 글쓰기: 한글 조합 종료 직후 Enter 중복 이벤트를 전역 capture 단계에서 1회 차단하도록 보강.
|
||||
|
||||
## v1.5.51
|
||||
|
||||
- 게시물 글쓰기: 라이브 모드 인용·콜아웃에서 한글 조합 Enter가 줄 추가를 중복 실행하지 않도록 보정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.51",
|
||||
"version": "1.5.52",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user