From 4c0875446b25cf07f0339a2119c82522ba2ccdaa Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 5 Jun 2026 11:33:48 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=BD=9C?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMarkdownEditor.vue | 25 +++++++++- .../content/ContentMarkdownEditableInline.vue | 50 +++++++++++++++++++ docs/map.md | 4 +- docs/spec.md | 2 +- docs/update.md | 6 +++ package-lock.json | 4 +- package.json | 2 +- 7 files changed, 86 insertions(+), 7 deletions(-) diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 7308ce7..d4456bc 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -1480,6 +1480,29 @@ const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = tr } } +/** + * 현재 문서 기준 fenced 블록의 닫는 줄을 찾는다. + * @param {number} startLine - 시작 줄 + * @param {number} fallbackEndLine - 기본 끝 줄 + * @returns {number} 보정된 끝 줄 + */ +const resolveCurrentFencedBlockEndLine = (startLine, fallbackEndLine) => { + const lines = (markdownValue.value || '').split('\n') + const opener = lines[startLine]?.trim() ?? '' + + if (opener.startsWith(':::')) { + const closingLine = lines.findIndex((line, index) => index > startLine && line.trim() === ':::') + return closingLine >= 0 ? closingLine : fallbackEndLine + } + + if (opener.startsWith('```')) { + const closingLine = lines.findIndex((line, index) => index > startLine && line.trim().startsWith('```')) + return closingLine >= 0 ? closingLine : fallbackEndLine + } + + return fallbackEndLine +} + /** * 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다. * @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록 @@ -1778,7 +1801,7 @@ const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) = return } - replaceLineRange(startLine, endLine, replacementLines, false) + replaceLineRange(startLine, resolveCurrentFencedBlockEndLine(startLine, endLine), replacementLines, false) } /** diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue index 3e57556..9dc14f4 100644 --- a/components/content/ContentMarkdownEditableInline.vue +++ b/components/content/ContentMarkdownEditableInline.vue @@ -391,6 +391,40 @@ const hasNonCollapsedSelection = () => { return !range.collapsed } +/** + * 현재 선택 영역에 순수 텍스트를 삽입한다. + * @param {string} text - 삽입할 텍스트 + * @returns {boolean} 삽입 여부 + */ +const insertTextAtSelection = (text) => { + if (!import.meta.client || !rootRef.value) { + return false + } + + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0) { + return false + } + + const range = selection.getRangeAt(0) + + if (!rootRef.value.contains(range.commonAncestorContainer)) { + return false + } + + range.deleteContents() + + const textNode = document.createTextNode(text) + range.insertNode(textNode) + range.setStartAfter(textNode) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return true +} + /** * 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함). * @returns {boolean} @@ -1000,6 +1034,22 @@ const onKeydown = (event) => { return } + if (event.key === 'Enter' && enterMode === 'multiline') { + event.preventDefault() + event.stopPropagation() + + if (event.isComposing || event.keyCode === 229) { + return + } + + if (!insertTextAtSelection('\n')) { + return + } + + nextTick(onEditorInput) + return + } + if (event.key === 'Enter' && enterMode === 'focus-next') { event.preventDefault() event.stopPropagation() diff --git a/docs/map.md b/docs/map.md index a9fe69f..35d5786 100644 --- a/docs/map.md +++ b/docs/map.md @@ -94,7 +94,7 @@ | components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 | -| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 | +| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 | | components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | @@ -113,7 +113,7 @@ |------|-----------| | components/content/ContentRenderer.vue | 게시물/페이지 본문 | | components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 | -| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운 렌더링과 plain text 멀티라인 본문 보존, 첫 줄 빈 줄 포함 줄바꿈 유지 | +| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운 렌더링과 plain text 멀티라인 본문 보존, 멀티라인 Enter 텍스트 줄바꿈 삽입, 첫 줄 빈 줄 포함 줄바꿈 유지 | | 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 d1eda3e..4e88989 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -628,7 +628,7 @@ components/content/ - `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다. - 인용(`>`) 블록은 첫 인용 줄에 `> [!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=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다. -- 라이브 모드 인용·콜아웃 내부 Enter는 한글 IME 조합 확정 뒤에도 한 번만 줄을 추가한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 첫 줄 빈 줄을 포함한 선행·후행 줄바꿈을 보존해 소스·라이브 모드 전환 시 본문이 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들 수 있지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다. +- 라이브 모드 인용·콜아웃 내부 Enter는 한글 IME 조합 확정 뒤에도 한 번만 줄을 추가한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 줄바꿈을 삽입하며, 첫 줄 빈 줄을 포함한 선행·후행 줄바꿈을 보존해 소스·라이브 모드 전환 시 본문이 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들 수 있지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다. - 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다. - 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다. - 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다. diff --git a/docs/update.md b/docs/update.md index 302d376..120ba9f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.5.60 + +- 게시물 글쓰기: 라이브 콜아웃·코드·인용 등 멀티라인 편집 영역 Enter를 브라우저 기본 DOM 줄 생성 대신 텍스트 줄바꿈 삽입으로 처리하도록 수정. +- 게시물 글쓰기: 라이브 fenced 블록 편집 반영 시 현재 문서의 닫는 fence 줄을 다시 찾아 오래된 줄 범위로 본문이 유실될 수 있던 문제 수정. +- 게시물 글쓰기: 콜아웃 첫 줄 앞에 빈 줄을 만든 뒤 소스·라이브 모드를 오갈 때 본문 보존을 보강. + ## v1.5.59 - 게시물 글쓰기: 라이브 멀티라인 블록 편집 텍스트를 `
` HTML 대신 원문 텍스트와 `pre-wrap`으로 복원하도록 수정. diff --git a/package-lock.json b/package-lock.json index 9593f3d..19dd9a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.59", + "version": "1.5.60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.59", + "version": "1.5.60", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 387d6ba..814da40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.59", + "version": "1.5.60", "private": true, "type": "module", "imports": {