From f048eaac2baef3a495db0bdae847dd88cdddc059 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 4 Jun 2026 15:18:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=B0=EC=86=8D=20=EC=BD=9C=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=ED=8E=B8=EC=A7=91=20=EB=B2=94=EC=9C=84=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownEditableInline.vue | 63 ++++++++++++++----- .../content/ContentMarkdownRenderer.vue | 33 +++++----- docs/changelog.md | 6 ++ docs/deploy.md | 7 +++ docs/map.md | 2 +- docs/spec.md | 1 + docs/update.md | 6 ++ package-lock.json | 4 +- package.json | 2 +- 9 files changed, 87 insertions(+), 37 deletions(-) diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue index ffac635..193d72a 100644 --- a/components/content/ContentMarkdownEditableInline.vue +++ b/components/content/ContentMarkdownEditableInline.vue @@ -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) }) } diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 1a170a6..f31dd39 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -272,7 +272,7 @@ const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8' * 닫힘 표식까지의 행 목록을 반환 * @param {Array} lines - 전체 마크다운 행 * @param {number} startIndex - 본문 시작 인덱스 - * @returns {{contentLines: Array, nextIndex: number}} 블록 본문과 다음 인덱스 + * @returns {{contentLines: Array, 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 diff --git a/docs/changelog.md b/docs/changelog.md index 2987d1d..0ea6476 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # 업데이트 요약 +## v1.5.52 + +- 연속 콜아웃에서 위 콜아웃을 편집하면 아래 콜아웃 선언 줄이 사라지던 문제를 수정했다. +- `:::` fenced 블록의 원본 줄 범위를 닫는 줄까지만 잡도록 보정했다. +- 한글 조합 직후 Enter 중복 입력 차단을 더 강하게 적용했다. + ## v1.5.51 - 라이브 인용·콜아웃에서 한글 입력 후 Enter가 줄을 2개 만들던 문제를 다시 보정했다. diff --git a/docs/deploy.md b/docs/deploy.md index 5ac1d7a..316e577 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 마이그레이션은 없다. diff --git a/docs/map.md b/docs/map.md index b3c1aab..41c947b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -111,7 +111,7 @@ | 파일 | 화면 위치 | |------|-----------| | components/content/ContentRenderer.vue | 게시물/페이지 본문 | -| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 콜아웃 아래 방향키 새 줄 생성 차단, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 | +| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 콜아웃 아래 방향키 새 줄 생성 차단, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 | | components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 | | components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder | | components/content/ProseList.vue | 목록 | diff --git a/docs/spec.md b/docs/spec.md index 6ff7d23..6c4f839 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -629,6 +629,7 @@ components/content/ - 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다. - 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 ``·`` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. **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)` 형식으로 삽입한다. diff --git a/docs/update.md b/docs/update.md index 149e737..9da523b 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.5.52 + +- 게시물 글쓰기: 라이브 모드 fenced 블록의 원본 범위가 닫는 `:::` 다음 줄까지 포함되던 문제 수정. +- 게시물 글쓰기: 연속 콜아웃에서 위 콜아웃 편집 시 아래 콜아웃 선언 줄이 삭제되던 문제 수정. +- 게시물 글쓰기: 한글 조합 종료 직후 Enter 중복 이벤트를 전역 capture 단계에서 1회 차단하도록 보강. + ## v1.5.51 - 게시물 글쓰기: 라이브 모드 인용·콜아웃에서 한글 조합 Enter가 줄 추가를 중복 실행하지 않도록 보정. diff --git a/package-lock.json b/package-lock.json index ab560d2..3161ecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 69455d8..bd26cad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.51", + "version": "1.5.52", "private": true, "type": "module", "imports": {