From 35c378c8f5c779daa6e83b373d514461932fb58d Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 14:49:08 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B8=94=EB=A1=9D=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=EB=93=9C=EB=9E=98=EA=B7=B8:=20=ED=96=89?= =?UTF-8?q?=20=EA=B0=84=20margin=EC=97=90=EC=84=9C=EB=8F=84=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EC=A0=81(v1.0.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit elementFromPoint 실패 시 루트 내 행 박스와 clientY 거리로 스냅. 레인 히트 폭 소폭 확대. 문서 반영. --- components/admin/AdminBlockEditor.vue | 76 +++++++++++++++++++++++---- docs/history.md | 6 +++ docs/map.md | 2 +- docs/spec.md | 2 +- docs/update.md | 6 +++ package.json | 2 +- 6 files changed, 81 insertions(+), 13 deletions(-) diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 4b98832..801e00c 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -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({