diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 06b1a59..bdaf34e 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -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} 슬래시 메뉴 DOM */ const liveSlashMenuRef = ref(null) -/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */ -const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280 /** @type {import('vue').Ref} 슬래시 후 미디어 삽입 줄 */ const liveSlashInsertLine = ref(null) /** @type {import('vue').Ref>} 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 + }) } /** diff --git a/docs/map.md b/docs/map.md index 8b928f2..85664de 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 | | lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | diff --git a/docs/update.md b/docs/update.md index 772a2fa..7ae28ef 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.83 + +- 관리자 Markdown 에디터의 슬래시 메뉴 뷰포트 위치 보정 계산을 `markdown-slash-menu-position` helper로 분리. +- 슬래시 메뉴 위치 보정 단위 테스트를 추가해 아래·위·좌우 경계 동작을 고정. + ## v1.11.82 - 관리자 Markdown 에디터의 라이브 미리보기 표면을 `AdminMarkdownPreviewSurface` 컴포넌트로 분리. diff --git a/lib/markdown-slash-menu-position.js b/lib/markdown-slash-menu-position.js new file mode 100644 index 0000000..2e423ae --- /dev/null +++ b/lib/markdown-slash-menu-position.js @@ -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) + } +} diff --git a/package-lock.json b/package-lock.json index e18edd6..9f27624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c9ca914..628cf94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.82", + "version": "1.11.83", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-slash-menu-position.test.js b/tests/markdown-slash-menu-position.test.js new file mode 100644 index 0000000..2f5237e --- /dev/null +++ b/tests/markdown-slash-menu-position.test.js @@ -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 + }) +})