From 6919669330097753da0422caa4d751f15974d720 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 21 May 2026 17:07:52 +0900 Subject: [PATCH] =?UTF-8?q?v1.4.2:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=C2=B7=EA=B0=A4=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20UX=EC=99=80=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=83=89=EC=83=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브 모드 이미지·갤러리 드래그 병합·분리, 갤러리 개별 편집, 블록 패널 유지, 다크모드 인용·사이드바·리스트 마커 색상을 보정한다. Co-authored-by: Cursor --- assets/css/main.css | 2 +- components/admin/AdminEditorBlockPanel.vue | 8 + components/admin/AdminMarkdownEditor.vue | 596 ++++++++++++- .../content/ContentMarkdownRenderer.vue | 809 ++++++++++++++++-- components/content/ProseBlockquote.vue | 6 +- components/content/ProseImage.vue | 93 +- docs/changelog.md | 18 + docs/map.md | 13 +- docs/spec.md | 26 +- docs/update.md | 20 + lib/markdown-block-context.js | 24 +- lib/markdown-image.js | 21 + package-lock.json | 4 +- package.json | 2 +- 14 files changed, 1551 insertions(+), 91 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 07c6e62..cd7f8b5 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -176,7 +176,7 @@ .site-sidebar { min-height: 0; - background: var(--site-panel); + background: var(--site-bg); color: var(--site-text); } diff --git a/components/admin/AdminEditorBlockPanel.vue b/components/admin/AdminEditorBlockPanel.vue index 231576f..98a5d10 100644 --- a/components/admin/AdminEditorBlockPanel.vue +++ b/components/admin/AdminEditorBlockPanel.vue @@ -133,6 +133,7 @@ const onPanelFocusOut = (event) => { v-for="(image, imageIndex) in panel.images" :key="`block-panel-image-${imageIndex}`" class="admin-editor-block-panel__media-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3" + :class="panel.selectedImageIndex === imageIndex ? 'admin-editor-block-panel__media-row--selected' : ''" > { + + diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 809739d..c287583 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -1,7 +1,12 @@ + + diff --git a/docs/changelog.md b/docs/changelog.md index 210a660..5ce42c6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,23 @@ # 업데이트 요약 +## v1.4.2 + +- 글쓰기 소스 모드에서 긴 줄이 자동 줄바꿈될 때 라인 번호가 실제 줄 높이와 어긋나던 문제를 수정했다. +- 소스 모드에서 라이브 모드로 전환한 직후에도 현재 줄에 포커스가 유지되도록 보정했다. +- 라이브 모드에서 소스 모드로 돌아올 때 현재 작성 위치와 가까운 줄로 커서·스크롤을 복원하도록 보정했다. +- 이미지 파일 URL 한 줄을 입력했을 때 임베드가 아니라 이미지로 표시되도록 수정했다. +- 라이브 모드 이미지 블록에 편집·삭제 버튼을 추가하고, 편집 버튼을 기존 이미지 설정 패널과 연결했다. +- 잘못된 이미지 URL·로드 실패 시에도 최소 높이와 오류 안내 placeholder를 표시한다. +- 라이브 모드에서 이미지 블록끼리 드래그해 갤러리로 합치고, 갤러리 이미지를 블록 사이에 드롭해 단일 이미지로 분리할 수 있다. +- 단독 이미지 URL 줄도 드래그 갤러리 병합·추가 대상으로 처리한다. +- 라이브 모드에서 단일 이미지를 기존 갤러리에 드래그로 추가할 수 있다. +- 갤러리는 이미지 수와 실제 비율에 따라 행 너비를 자동 조정한다. +- 라이브 모드 갤러리 블록도 키보드 이동과 편집·삭제 버튼 접근을 지원한다. +- 라이브 모드 갤러리에서는 개별 이미지별 편집·삭제 버튼을 제공한다. +- 갤러리 이미지 추가 모달을 열어도 오른쪽 블록 패널 상태가 유지되며, 패널 바깥 클릭 시 닫힌다. +- 다크모드 기본 인용 블록과 공개 본문 리스트 마커 색상을 글쓰기 화면 기준으로 정리했다. +- 다크모드에서 좌우 사이드바 배경이 본문 배경과 다르게 튀어 보이지 않도록 통일했다. + ## v1.4.1 - 관리자에서 비디오 등 대용량 미디어 업로드 시 적용되던 10MB 공통 한도를 종류별로 분리했다(비디오 기본 200MB). diff --git a/docs/map.md b/docs/map.md index 9fd571e..8804686 100644 --- a/docs/map.md +++ b/docs/map.md @@ -28,6 +28,7 @@ | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | | lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드 블록 설정 패널 대상 판별 | +| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 | | lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | | lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) | @@ -77,8 +78,8 @@ | components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | -| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit | -| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드) | +| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 | +| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | | components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) | @@ -95,11 +96,11 @@ | 파일 | 화면 위치 | |------|-----------| | components/content/ContentRenderer.vue | 게시물/페이지 본문 | -| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱 | +| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 | | components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 | -| components/content/ProseImage.vue | 본문 내 이미지 | +| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder | | components/content/ProseList.vue | 목록 | -| components/content/ProseBlockquote.vue | 인용구 | +| components/content/ProseBlockquote.vue | 인용구, 다크모드 기본 인용 텍스트 가독성 보정 | | components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) | | components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) | | components/content/ProseButton.vue | 버튼 | @@ -289,7 +290,7 @@ | package.json | Nuxt 실행 스크립트와 의존성 | | nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 | | tailwind.config.js | Tailwind 테마 설정 | -| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 | +| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 | | composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) | | composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 | | middleware/admin-auth.global.js | 관리자 페이지 접근 인증 | diff --git a/docs/spec.md b/docs/spec.md index c9add1e..f3137dd 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -46,7 +46,7 @@ - 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리 - 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응 - 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다. -- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현 +- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 라인으로 구분한다. 사이드바 자체 배경은 라이트/다크 모두 기본 화면 배경(`--site-bg`)과 통일하고, 내부 카드형 요소만 패널 배경을 사용한다. ### 홈 Featured (인덱스) @@ -170,11 +170,11 @@ components/content/ - 리스트 - Unordered: `- 항목` - Ordered: `1. 항목` - - 렌더링: `ProseList.vue` (마커 컬러, 간격, 줄높이 통일) + - 렌더링: `ProseList.vue` (마커 컬러는 글쓰기 화면과 같은 파란 계열, 간격, 줄높이 통일) - 인용구 - 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인) - 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록 - - 렌더링: `ProseBlockquote.vue` (`variant=default|alt`) + - 렌더링: `ProseBlockquote.vue` (`variant=default|alt`, 기본 인용은 다크모드에서도 밝은 배경 위 어두운 텍스트 유지) - 이미지 - 기본: `![](url)` — 이미지 아래 캡션 없음 - 캡션(표시용): `![](url "캡션")` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시 @@ -183,7 +183,8 @@ components/content/ - 렌더링: `ProseImage.vue` (라운드/보더/패널 배경) - 이미지 갤러리 - `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성 - - 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스, Esc 닫기·←/→ 이전·다음) + - 렌더링: `ContentMarkdownRenderer.vue` (최대 3개 단위 행 + 라이트박스, Esc 닫기·←/→ 이전·다음) + - 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다. - 비디오·오디오·파일 카드 - 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄) - 오디오: `:::audio` ~ `:::` (`url`, `title`, `description`) @@ -520,12 +521,17 @@ components/content/ - `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다. - 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다. - 관리자 미리보기에서 `ContentMarkdownRenderer`에 `interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다. +- 라이브 모드 단일 이미지 블록은 드래그 가능하다. `![](...)` 이미지 줄과 단독 이미지 URL 줄 모두 같은 이미지 블록으로 다룬다. 다른 이미지 블록 위에 드롭하면 두 줄을 `:::gallery` fenced block 한 개로 병합하며(`merge-images-to-gallery`), 문서 순서를 유지해 위쪽 이미지가 먼저 들어간다. 자동 인접 병합은 하지 않는다. +- 라이브 모드 갤러리 블록은 이미지 블록과 같은 선택형 카드로 취급한다. Tab/클릭으로 포커스할 수 있고, 포커스 상태에서 방향키 위/아래 이동을 지원한다. 갤러리 이미지 hover/focus 시 개별 편집/삭제 버튼을 제공하며, 편집 버튼은 해당 이미지 줄 기준으로 갤러리 블록 설정 패널을 연다. +- 라이브 모드 단일 이미지 블록을 기존 갤러리 이미지 셀에 드롭하면 해당 셀 뒤에 이미지를 추가하고 원래 단일 이미지 줄은 제거한다(`insert-image-to-gallery`). +- 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(`extract-gallery-image`). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다. +- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다. - 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). 콜아웃 옵션은 첫 줄 `:::callout emoji=💡 bg=blue`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple|pink)로 지정하며, 라이브 모드에서는 아이콘 클릭으로 모달에서 편집한다(이모지 7종 프리셋·배경색 스와치, 직접 입력 없음). 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다. - 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다. - 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다. - 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다. - 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다. -- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄 또는 기존 `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문 포커스가 완전히 이탈하면 슬라이드 아웃한다. +- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄 또는 기존 `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다. - 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다. - 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다. - 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다. @@ -571,8 +577,9 @@ components/content/ - 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다. - 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다. - 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다. -- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![](url)` 또는 파일명 캡션 토글 시 `![](url "파일명")` 형식으로 저장한다. +- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![](url)` 또는 파일명 캡션 토글 시 `![](url "파일명")` 형식으로 저장한다. 단독 이미지 파일 URL(`jpg`, `png`, `webp`, `gif`, `avif`, `svg`) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다. - 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 **파일명을 캡션으로 사용** 토글로 이미지 아래에 URL 파일명을 표시한다. +- 라이브 모드 이미지 블록은 hover/focus 시 우측 상단에 `편집`·`삭제` 버튼을 표시한다. `편집`은 기존 오른쪽 이미지 설정 패널을 열어 이미지 URL·캡션·파일명 캡션 사용 여부를 수정한다. - 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다. - 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다. - 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다. @@ -586,11 +593,14 @@ components/content/ - `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다. - 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다. - 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다. -- 임베드 블록은 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다. +- 임베드 블록은 이미지 파일 URL을 제외한 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다. - 기존 `:::embed` fenced block은 이전 콘텐츠 호환을 위해 계속 파싱·렌더링한다. - 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 임베드 블록은 URL 입력 카드 없이 즉시 실제 임베드 프리뷰로 표시된다. 임베드·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다. - 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다. -- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다. 라이브 모드 전환 시 미리보기 스크롤은 맨 위에서 시작한다. +- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다. +- 소스 모드 라인 번호는 논리 줄 수를 표시하되, 긴 문장 자동 줄바꿈으로 textarea의 한 줄 높이가 늘어나면 라인 번호 칸도 같은 높이로 맞춘다. +- 소스 모드에서 라이브 모드로 전환하면 현재 textarea 커서 줄과 줄 안 오프셋을 기준으로 대응하는 라이브 편집 블록에 포커스를 둔다. 이때 현재 화면 위치를 불필요하게 맨 위나 맨 아래로 이동하지 않는다. +- 라이브 모드에서 소스 모드로 전환하면 현재 포커스된 블록 또는 화면 상단에 가까운 원본 줄을 기준으로 textarea 커서와 스크롤 위치를 복원한다. - YouTube 임베드 URL은 공개 화면에서 본문 폭 기준 16:9 iframe으로 렌더링한다. - Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다. X 공식 iframe의 내부 최대 폭 때문에 공개 화면에서는 카드 폭을 좁혀 중앙 정렬한다. - Mastodon 공개 게시물 URL(`/@user/id`, `/users/user/statuses/id`)은 `{원본 URL}/embed` iframe으로 렌더링한다. iframe 로드 후 Mastodon 공식 embed 방식과 같은 `postMessage` 높이 요청을 보내 응답 높이를 반영한다. 인스턴스가 embed를 차단하거나 지원하지 않으면 브라우저 iframe 정책에 따라 표시되지 않을 수 있다. diff --git a/docs/update.md b/docs/update.md index 9c03652..b87c642 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,25 @@ # 업데이트 이력 +## v1.4.2 + +- 관리자 글쓰기: 소스 모드 긴 문장 자동 줄바꿈 시 라인 번호 높이를 실제 wrap 높이에 맞춰 보정. +- 관리자 글쓰기: 라이브 모드에서 소스 모드로 전환할 때 현재 포커스·화면 위치에 가까운 원본 줄로 커서와 스크롤 복원. +- 관리자 글쓰기: 소스 모드에서 라이브 모드로 전환할 때 현재 textarea 커서 줄을 라이브 편집 블록에 포커스하도록 보정. +- 콘텐츠 렌더러: 단독 이미지 파일 URL은 임베드가 아니라 이미지 블록으로 렌더링하도록 보정. +- 관리자 글쓰기: 라이브 모드 이미지 블록 hover/focus 시 편집·삭제 버튼 표시, 편집 버튼으로 기존 오른쪽 이미지 설정 패널 연결. +- `ProseImage`: URL 비어 있음·로드 실패 시 최소 높이 placeholder와 오류 안내 표시. +- 관리자 라이브 미리보기: 이미지 블록을 다른 이미지 블록에 드롭하면 `:::gallery`로 병합(`merge-images-to-gallery`). +- 관리자 라이브 미리보기: 갤러리 이미지를 블록 사이 삽입선에 드롭하면 단일 이미지 블록으로 분리(`extract-gallery-image`), 갤러리 1장 남으면 단일 이미지로 정리·0장이면 제거. +- 관리자 라이브 미리보기: 단독 이미지 URL 줄도 이미지 드래그 병합·갤러리 추가 대상으로 인식하도록 보정. +- 관리자 라이브 미리보기: 단일 이미지 블록을 기존 갤러리 셀에 드롭해 갤러리에 추가(`insert-image-to-gallery`). +- 콘텐츠 렌더러: 갤러리를 최대 3개 단위 행으로 나누고 이미지 자연 비율에 따라 셀 너비를 조정. +- 관리자 라이브 미리보기: 갤러리 블록도 포커스·방향키 이동·편집/삭제 버튼 접근이 가능하도록 보정. +- 관리자 라이브 미리보기: 갤러리 전체 편집/삭제 버튼을 개별 이미지 편집/삭제 버튼으로 변경. +- 관리자 글쓰기: 블록 설정 패널 바깥 클릭 시 닫힘, 갤러리 이미지 추가 미디어 모달 중 패널 유지. +- 콘텐츠 렌더러: 다크모드 기본 인용 블록 텍스트를 어두운 색으로 고정해 가독성 보정. +- 콘텐츠 렌더러: 공개 본문 리스트 번호·점 색상을 글쓰기 화면과 같은 파란 계열로 통일. +- 공개 레이아웃: 다크모드에서 좌우 사이드바 배경을 홈페이지 기본 배경(`--site-bg`)과 동일하게 통일. + ## v1.4.1 - 관리자 미디어 업로드: 이미지·비디오·오디오·문서별 최대 크기 한도 분리(`MAX_VIDEO_FILE_SIZE` 등). 기본 비디오 200MB. diff --git a/lib/markdown-block-context.js b/lib/markdown-block-context.js index 112233f..46b9bb6 100644 --- a/lib/markdown-block-context.js +++ b/lib/markdown-block-context.js @@ -1,4 +1,4 @@ -import { parseImageMarkdownLine } from './markdown-image.js' +import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js' /** * fenced 블록 시작 줄 인덱스를 찾는다. @@ -44,6 +44,13 @@ const findFencedBlockEnd = (lines, startLine) => { */ const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim()) +/** + * 단독 이미지 URL 줄인지 확인한다. + * @param {string} line - 마크다운 줄 + * @returns {boolean} 이미지 URL 여부 + */ +const isStandaloneImageUrlLine = (line) => isStandaloneUrlLine(line) && isImageUrl(line) + /** * 갤러리 fenced 블록을 파싱한다. * @param {string[]} lines - 본문 줄 목록 @@ -67,6 +74,9 @@ const resolveGalleryBlock = (lines, currentLine) => { kind: 'gallery', startLine: galleryStart, endLine: galleryEnd, + selectedImageIndex: currentLine > galleryStart && currentLine < galleryEnd + ? currentLine - galleryStart - 1 + : null, images: lines .slice(galleryStart + 1, galleryEnd) .map(parseImageMarkdownLine) @@ -83,7 +93,7 @@ const resolveGalleryBlock = (lines, currentLine) => { const resolveEmbedBlock = (lines, currentLine) => { const standaloneUrl = String(lines[currentLine] || '').trim() - if (isStandaloneUrlLine(standaloneUrl)) { + if (isStandaloneUrlLine(standaloneUrl) && !isStandaloneImageUrlLine(standaloneUrl)) { return { kind: 'embed', startLine: currentLine, @@ -122,6 +132,7 @@ export const resolveActiveBlockContext = (markdown, lineIndex) => { const lines = String(markdown || '').split('\n') const currentLine = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1)) const activeImage = parseImageMarkdownLine(lines[currentLine] || '') + const activeImageUrl = String(lines[currentLine] || '').trim() if (activeImage) { return { @@ -132,6 +143,15 @@ export const resolveActiveBlockContext = (markdown, lineIndex) => { } } + if (isStandaloneImageUrlLine(activeImageUrl)) { + return { + kind: 'image', + startLine: currentLine, + endLine: currentLine, + images: [{ url: activeImageUrl, width: 'regular', caption: '', useAlt: false }] + } + } + const gallery = resolveGalleryBlock(lines, currentLine) if (gallery) { diff --git a/lib/markdown-image.js b/lib/markdown-image.js index 1d61d0b..870c2f6 100644 --- a/lib/markdown-image.js +++ b/lib/markdown-image.js @@ -1,5 +1,7 @@ /** @type {RegExp} 이미지 마크다운 한 줄 */ const IMAGE_MARKDOWN_LINE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)(?:\{width=(regular|wide|full)\})?$/ +/** @type {RegExp} 이미지 파일 확장자 */ +const IMAGE_URL_EXTENSION_RE = /\.(?:jpe?g|png|webp|gif|avif|svg)(?:$|[?#])/i /** * 캡션 문자열 이스케이프 해제 @@ -55,6 +57,25 @@ export const getImageDefaultAltLabel = (url) => { } } +/** + * 이미지 파일 URL인지 확인한다. + * @param {string} url - 검사할 URL + * @returns {boolean} 이미지 URL 여부 + */ +export const isImageUrl = (url) => { + const raw = String(url || '').trim() + + if (!raw) { + return false + } + + try { + return IMAGE_URL_EXTENSION_RE.test(new URL(raw, 'https://sori.studio').pathname) + } catch { + return IMAGE_URL_EXTENSION_RE.test(raw) + } +} + /** * 이미지 마크다운 한 줄 파싱 * @param {string} line - 마크다운 줄 diff --git a/package-lock.json b/package-lock.json index b45e0c4..506e381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.4.0", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.4.0", + "version": "1.4.2", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index e441868..891c702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "imports": {