블록 범위 레인 드래그: 행 간 margin에서도 인덱스 추적(v1.0.9)

elementFromPoint 실패 시 루트 내 행 박스와 clientY 거리로 스냅.
레인 히트 폭 소폭 확대. 문서 반영.
This commit is contained in:
2026-05-14 14:49:08 +09:00
parent bd0e2ad120
commit 35c378c8f5
6 changed files with 81 additions and 13 deletions

View File

@@ -38,6 +38,7 @@ const calloutEmojiComposingBlockId = ref('')
const editorFlashMessage = ref('')
const blockRangeSelection = ref(null)
const isBlockRangeDragging = ref(false)
const blockEditorRootRef = ref(null)
let blockIdSeed = 0
let editorFlashTimer = null
@@ -1691,6 +1692,68 @@ const isBlockRangeRowSelected = (index) => {
return index >= lo && index <= hi
}
/**
* 포인터 좌표에 해당하는 행 블록 인덱스를 찾는다.
* elementFromPoint가 행 밖 여백(블록 간 margin 등)을 가리키면 세로 거리로 가장 가까운 행을 고른다.
* @param {number} clientX - 뷰포트 X
* @param {number} clientY - 뷰포트 Y
* @returns {number} 인덱스, 없으면 -1
*/
const resolveBlockIndexFromPointer = (clientX, clientY) => {
const root = blockEditorRootRef.value
if (!root || typeof document === 'undefined') {
return -1
}
const topEl = document.elementFromPoint(clientX, clientY)
const directRow = topEl?.closest?.('[data-editor-block-id]')
if (directRow && root.contains(directRow)) {
const id = directRow.getAttribute('data-editor-block-id')
const idx = editorBlocks.value.findIndex((b) => b.id === id)
if (idx >= 0) {
return idx
}
}
const idToIndex = new Map(editorBlocks.value.map((b, i) => [b.id, i]))
let bestIdx = -1
let bestDelta = Infinity
root.querySelectorAll('[data-editor-block-id]').forEach((row) => {
const id = row.getAttribute('data-editor-block-id')
const i = id == null ? -1 : idToIndex.get(id)
if (i === undefined || i < 0) {
return
}
const r = row.getBoundingClientRect()
let delta = 0
if (clientY < r.top) {
delta = r.top - clientY
} else if (clientY > r.bottom) {
delta = clientY - r.bottom
}
if (delta < bestDelta) {
bestDelta = delta
bestIdx = i
}
})
const snapPx = 72
if (bestIdx >= 0 && bestDelta <= snapPx) {
return bestIdx
}
return -1
}
/**
* 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다.
* @param {PointerEvent} event - 포인터 이벤트
@@ -1733,15 +1796,7 @@ const onBlockRangeLanePointerDown = (event, index) => {
return
}
const el = document.elementFromPoint(ev.clientX, ev.clientY)
const row = el?.closest?.('[data-editor-block-id]')
if (!row) {
return
}
const id = row.getAttribute('data-editor-block-id')
const idx = editorBlocks.value.findIndex((b) => b.id === id)
const idx = resolveBlockIndexFromPointer(ev.clientX, ev.clientY)
if (idx < 0) {
return
@@ -2435,6 +2490,7 @@ defineExpose({
<template>
<div
ref="blockEditorRootRef"
class="admin-block-editor bg-transparent py-4 text-ink"
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
@mousemove="handleEditorMouseMove"
@@ -2487,7 +2543,7 @@ defineExpose({
<span
v-if="block.type !== 'divider'"
class="admin-block-editor__range-lane absolute bottom-0 left-[-1.25rem] top-0 z-[8] w-[14px] touch-none select-none"
class="admin-block-editor__range-lane absolute bottom-0 left-[-1.375rem] top-0 z-[8] w-[18px] touch-none select-none"
role="button"
tabindex="-1"
aria-label="블록 범위 선택: 드래그 또는 Shift+클릭"

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v1.0.9
### 블록 범위 드래그와 행 간 여백
범위 드래그 중 `pointermove`마다 `elementFromPoint``[data-editor-block-id]` 조상을 찾았다. 블록 행 사이에는 `margin-top`으로 생기는 **보더 박스 밖 여백**이 있어, 포인터가 그 구간에 있으면 최상단 요소가 행이 아니라 상위 래퍼가 되고 `closest`가 실패한다. 에디터 루트 ref 안의 모든 행 박스와 `clientY`의 거리를 비교하는 보조 경로를 두어 동일 제스처로 다중 블록까지 이어지게 했다.
## 2026-05-13 v1.0.8
### 블록 범위 복사와 부분 텍스트 선택

View File

@@ -64,7 +64,7 @@
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시, 여러 줄·마크다운 붙여넣기 시 블록 분할, 블록 단위 범위 선택(레인 드래그·Shift+클릭·Shift+↑↓·Escape, 레인 aria-label) 및 선택 구간 마크다운 복사(단 contenteditable 비접힘 선택·textarea/input 선택 시 복사는 네이티브)·범위 있을 때 Cmd/Ctrl+A는 구간만 복사, 블록 삭제·이동·분할 붙여넣기 등 배열 변경 시 범위 자동 해제, 범위 없을 때 Cmd/Ctrl+A는 전체 MD 복사 안내 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시, 여러 줄·마크다운 붙여넣기 시 블록 분할, 블록 단위 범위 선택(레인 드래그·행 간 margin에서도 세로 스냅, Shift+클릭·Shift+↑↓·Escape, 레인 aria-label) 및 선택 구간 마크다운 복사(단 contenteditable 비접힘 선택·textarea/input 선택 시 복사는 네이티브)·범위 있을 때 Cmd/Ctrl+A는 구간만 복사, 블록 삭제·이동·분할 붙여넣기 등 배열 변경 시 범위 자동 해제, 범위 없을 때 Cmd/Ctrl+A는 전체 MD 복사 안내 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |

View File

@@ -443,7 +443,7 @@ components/content/
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다. 텍스트 블록마다 별도 `contenteditable`을 쓰므로, 브라우저는 **편집 호스트 경계를 넘는 드래그 선택**을 허용하지 않는다(한 블록 안에서만 연속 선택).
- **Cmd/Ctrl+A**(Mac은 Cmd, Windows/Linux는 Ctrl)는 현재 블록만 전체 선택되는 대신, **저장 형식인 전체 본문 마크다운**을 클립보드에 복사하고 짧은 안내 문구를 표시한다. 다른 편집기·파일로 옮길 때 사용한다.
- **여러 줄**이거나 제목·인용·목록·펜스 코드·콜아웃/갤러리 등 **마크다운으로 인식되는 한 줄**을 텍스트 블록에 붙여넣으면, 기본 한 블록 삽입 대신 `parseMarkdownToBlocks`로 나눈 **여러 블록**을 현재 커서 위치에 끼워 넣는다. 클립보드에 파일이 있으면 기본 붙여넣기(이미지 등)를 유지한다.
- **블록 단위 범위 선택**: 각 행 왼쪽(핸들 오른쪽) **좁은 레인**에서 포인터 드래그로 시작·끝 블록을 지정하거나, **Shift+클릭**으로 끝 블록을 지정한다. 텍스트 블록에서 **Shift+↑/↓**는 경계에 있을 때 범위를 시작하거나, 이미 범위가 있으면 **포커스 쪽 끝 블록 인덱스**를 한 칸씩 늘리거나 줄인다. **Escape**로 범위를 해제한다. 범위가 있을 때 **Cmd/Ctrl+C** 또는 복사(`copy`)는 `text/plain`**선택 구간만** 마크다운으로 넣는다. 다만 **한 블록의 contenteditable 안에서 비접힘 텍스트 선택**이 있거나 **textarea/input에 선택 구간**이 있으면 복사는 브라우저 기본 동작(선택된 문자열 등)을 따른다. 범위가 있을 때 **Cmd/Ctrl+A**는 전체가 아니라 **선택 구간** 마크다운을 클립보드에 복사한다. 블록 삭제·드래그 순서 변경·마크다운 분할 붙여넣기 등으로 `editorBlocks` 순서가 바뀌면 범위 선택은 자동으로 해제된다.
- **블록 단위 범위 선택**: 각 행 왼쪽(핸들 오른쪽) **좁은 레인**에서 포인터 드래그로 시작·끝 블록을 지정한다(드래그 중 포인터가 블록 **사이 margin**에 있어도 세로 위치로 가장 가까운 행에 스냅). **Shift+클릭**으로 끝 블록을 지정한다. 텍스트 블록에서 **Shift+↑/↓**는 경계에 있을 때 범위를 시작하거나, 이미 범위가 있으면 **포커스 쪽 끝 블록 인덱스**를 한 칸씩 늘리거나 줄인다. **Escape**로 범위를 해제한다. 범위가 있을 때 **Cmd/Ctrl+C** 또는 복사(`copy`)는 `text/plain`**선택 구간만** 마크다운으로 넣는다. 다만 **한 블록의 contenteditable 안에서 비접힘 텍스트 선택**이 있거나 **textarea/input에 선택 구간**이 있으면 복사는 브라우저 기본 동작(선택된 문자열 등)을 따른다. 범위가 있을 때 **Cmd/Ctrl+A**는 전체가 아니라 **선택 구간** 마크다운을 클립보드에 복사한다. 블록 삭제·드래그 순서 변경·마크다운 분할 붙여넣기 등으로 `editorBlocks` 순서가 바뀌면 범위 선택은 자동으로 해제된다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
- `/` 입력 시 블록 선택 메뉴를 표시한다.
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.0.9
- 관리자 블록 에디터 범위 레인 드래그 시 `elementFromPoint`만 쓰면 블록 간 margin 구간에서 행을 못 찾아 범위가 늘지 않던 문제를, 에디터 루트 기준 행 `getBoundingClientRect`로 세로 거리 보완해 해결.
- 범위 레인 히트 영역을 약간 넓힘.
- 패키지 버전 `1.0.9`로 갱신.
## v1.0.8
- 관리자 블록 에디터에서 블록 범위 선택이 있어도, contenteditable 안 비접힘 텍스트 선택 또는 textarea/input 선택 구간이 있으면 복사는 브라우저 기본 동작으로 두고 마크다운 가로채기를 하지 않음.

View File

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