슬래시 메뉴 위치 계산 분리

This commit is contained in:
2026-07-27 10:28:23 +09:00
parent ce64ab7a9f
commit 63c657d7a3
7 changed files with 163 additions and 57 deletions

View File

@@ -52,6 +52,11 @@ import {
resolveSlashCommand,
shouldIgnorePendingMediaSlashCommit
} from '../../lib/markdown-slash-commands.js'
import {
calculateSlashMenuPosition,
DEFAULT_SLASH_MENU_HEIGHT,
DEFAULT_SLASH_MENU_WIDTH
} from '../../lib/markdown-slash-menu-position.js'
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
@@ -145,8 +150,6 @@ const liveSlashMenuPosition = ref({ top: 0, left: 0 })
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
const liveSlashMenuRef = ref(null)
/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */
const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
const liveSlashInsertLine = ref(null)
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */
@@ -2324,44 +2327,6 @@ const handlePreviewKeydownCapture = (event) => {
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
* @returns {void}
*/
/**
* 슬래시 메뉴 좌표를 뷰포트 안으로 맞춘다.
* @param {number} top - 후보 top(px)
* @param {number} left - 후보 left(px)
* @param {number} menuHeight - 메뉴 높이
* @param {number} menuWidth - 메뉴 너비
* @param {number} anchorTop - 기준 요소 top(위로 넘칠 때)
* @returns {{ top: number, left: number }}
*/
const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) => {
const gap = 4
const padding = 8
const viewportBottom = window.innerHeight - padding
const viewportRight = window.innerWidth - padding
let nextTop = top
if (nextTop + menuHeight > viewportBottom) {
const aboveTop = anchorTop - menuHeight - gap
if (aboveTop >= padding) {
nextTop = aboveTop
} else {
nextTop = Math.max(padding, viewportBottom - menuHeight)
}
}
let nextLeft = left
if (nextLeft + menuWidth > viewportRight) {
nextLeft = viewportRight - menuWidth
}
return {
top: nextTop,
left: Math.max(padding, nextLeft)
}
}
/**
* 슬래시 메뉴 컴포넌트가 노출한 실제 DOM 엘리먼트를 반환한다.
* @returns {HTMLElement|null} 메뉴 엘리먼트
@@ -2393,17 +2358,19 @@ const updatePreviewSlashMenuPosition = () => {
const rect = element.getBoundingClientRect()
const menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
const gap = 4
liveSlashMenuPosition.value = clampSlashMenuPosition(
rect.bottom + gap,
rect.left,
liveSlashMenuPosition.value = calculateSlashMenuPosition({
top: rect.bottom + gap,
left: rect.left,
menuHeight,
menuWidth,
rect.top
)
anchorTop: rect.top,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth
})
}
/**
@@ -2423,18 +2390,20 @@ const updateWriteSlashMenuPosition = () => {
const coords = getTextareaCaretCoordinates(textarea, lineStart)
const rect = textarea.getBoundingClientRect()
const menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
const gap = 4
const anchorTop = rect.top + coords.top - textarea.scrollTop
liveSlashMenuPosition.value = clampSlashMenuPosition(
anchorTop + coords.height + gap,
rect.left + coords.left - textarea.scrollLeft,
liveSlashMenuPosition.value = calculateSlashMenuPosition({
top: anchorTop + coords.height + gap,
left: rect.left + coords.left - textarea.scrollLeft,
menuHeight,
menuWidth,
anchorTop
)
anchorTop,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth
})
}
/**

View File

@@ -61,6 +61,7 @@
| lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 |
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의, 미디어 명령 action 판별, 미디어 명령 지연 commit 무시·슬래시 원문 줄 교체 삽입 helper |
| lib/markdown-slash-menu-position.js | 관리자 Markdown-first 에디터 슬래시 메뉴 뷰포트 위치 보정 helper |
| lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `<br>` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 |
| lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 |
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.83
- 관리자 Markdown 에디터의 슬래시 메뉴 뷰포트 위치 보정 계산을 `markdown-slash-menu-position` helper로 분리.
- 슬래시 메뉴 위치 보정 단위 테스트를 추가해 아래·위·좌우 경계 동작을 고정.
## v1.11.82
- 관리자 Markdown 에디터의 라이브 미리보기 표면을 `AdminMarkdownPreviewSurface` 컴포넌트로 분리.

View File

@@ -0,0 +1,55 @@
export const DEFAULT_SLASH_MENU_HEIGHT = 280
export const DEFAULT_SLASH_MENU_WIDTH = 288
export const DEFAULT_SLASH_MENU_GAP = 4
export const DEFAULT_SLASH_MENU_PADDING = 8
/**
* 슬래시 메뉴 좌표를 뷰포트 안으로 보정한다.
* @param {Object} options - 위치 계산 옵션
* @param {number} options.top - 후보 top(px)
* @param {number} options.left - 후보 left(px)
* @param {number} options.anchorTop - 기준 요소 top(px)
* @param {number} options.menuHeight - 메뉴 높이(px)
* @param {number} options.menuWidth - 메뉴 너비(px)
* @param {number} options.viewportHeight - 뷰포트 높이(px)
* @param {number} options.viewportWidth - 뷰포트 너비(px)
* @param {number} [options.gap] - 기준 요소와 메뉴 간격(px)
* @param {number} [options.padding] - 뷰포트 안쪽 여백(px)
* @returns {{ top: number, left: number }} 보정된 좌표
*/
export const calculateSlashMenuPosition = ({
top,
left,
anchorTop,
menuHeight,
menuWidth,
viewportHeight,
viewportWidth,
gap = DEFAULT_SLASH_MENU_GAP,
padding = DEFAULT_SLASH_MENU_PADDING
}) => {
const viewportBottom = viewportHeight - padding
const viewportRight = viewportWidth - padding
let nextTop = top
if (nextTop + menuHeight > viewportBottom) {
const aboveTop = anchorTop - menuHeight - gap
if (aboveTop >= padding) {
nextTop = aboveTop
} else {
nextTop = Math.max(padding, viewportBottom - menuHeight)
}
}
let nextLeft = left
if (nextLeft + menuWidth > viewportRight) {
nextLeft = viewportRight - menuWidth
}
return {
top: nextTop,
left: Math.max(padding, nextLeft)
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.82",
"version": "1.11.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.82",
"version": "1.11.83",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { calculateSlashMenuPosition } from '../lib/markdown-slash-menu-position.js'
test('슬래시 메뉴가 뷰포트 안에 있으면 후보 좌표를 유지한다', () => {
assert.deepEqual(calculateSlashMenuPosition({
top: 120,
left: 40,
anchorTop: 90,
menuHeight: 200,
menuWidth: 240,
viewportHeight: 800,
viewportWidth: 1000
}), {
top: 120,
left: 40
})
})
test('슬래시 메뉴가 아래로 넘치면 기준 요소 위로 올린다', () => {
assert.deepEqual(calculateSlashMenuPosition({
top: 700,
left: 40,
anchorTop: 660,
menuHeight: 200,
menuWidth: 240,
viewportHeight: 800,
viewportWidth: 1000
}), {
top: 456,
left: 40
})
})
test('위쪽 공간도 부족하면 뷰포트 하단 기준으로 보정한다', () => {
assert.deepEqual(calculateSlashMenuPosition({
top: 240,
left: 40,
anchorTop: 120,
menuHeight: 700,
menuWidth: 240,
viewportHeight: 800,
viewportWidth: 1000
}), {
top: 92,
left: 40
})
})
test('슬래시 메뉴가 좌우로 넘치면 안쪽 여백 안으로 보정한다', () => {
assert.deepEqual(calculateSlashMenuPosition({
top: 120,
left: 900,
anchorTop: 90,
menuHeight: 200,
menuWidth: 240,
viewportHeight: 800,
viewportWidth: 1000
}), {
top: 120,
left: 752
})
assert.deepEqual(calculateSlashMenuPosition({
top: 120,
left: -20,
anchorTop: 90,
menuHeight: 200,
menuWidth: 240,
viewportHeight: 800,
viewportWidth: 1000
}), {
top: 120,
left: 8
})
})