슬래시 메뉴 위치 계산 분리
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.83
|
||||
|
||||
- 관리자 Markdown 에디터의 슬래시 메뉴 뷰포트 위치 보정 계산을 `markdown-slash-menu-position` helper로 분리.
|
||||
- 슬래시 메뉴 위치 보정 단위 테스트를 추가해 아래·위·좌우 경계 동작을 고정.
|
||||
|
||||
## v1.11.82
|
||||
|
||||
- 관리자 Markdown 에디터의 라이브 미리보기 표면을 `AdminMarkdownPreviewSurface` 컴포넌트로 분리.
|
||||
|
||||
55
lib/markdown-slash-menu-position.js
Normal file
55
lib/markdown-slash-menu-position.js
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.82",
|
||||
"version": "1.11.83",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
76
tests/markdown-slash-menu-position.test.js
Normal file
76
tests/markdown-slash-menu-position.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user