슬래시 메뉴 위치 계산 분리
This commit is contained in:
@@ -52,6 +52,11 @@ import {
|
|||||||
resolveSlashCommand,
|
resolveSlashCommand,
|
||||||
shouldIgnorePendingMediaSlashCommit
|
shouldIgnorePendingMediaSlashCommit
|
||||||
} from '../../lib/markdown-slash-commands.js'
|
} 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 { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
|
||||||
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
||||||
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.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 */
|
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
|
||||||
const liveSlashMenuRef = ref(null)
|
const liveSlashMenuRef = ref(null)
|
||||||
|
|
||||||
/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */
|
|
||||||
const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280
|
|
||||||
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
|
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
|
||||||
const liveSlashInsertLine = ref(null)
|
const liveSlashInsertLine = ref(null)
|
||||||
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */
|
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */
|
||||||
@@ -2324,44 +2327,6 @@ const handlePreviewKeydownCapture = (event) => {
|
|||||||
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
|
* @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
|
||||||
* @returns {void}
|
* @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 엘리먼트를 반환한다.
|
* 슬래시 메뉴 컴포넌트가 노출한 실제 DOM 엘리먼트를 반환한다.
|
||||||
* @returns {HTMLElement|null} 메뉴 엘리먼트
|
* @returns {HTMLElement|null} 메뉴 엘리먼트
|
||||||
@@ -2393,17 +2358,19 @@ const updatePreviewSlashMenuPosition = () => {
|
|||||||
|
|
||||||
const rect = element.getBoundingClientRect()
|
const rect = element.getBoundingClientRect()
|
||||||
const menuEl = getLiveSlashMenuElement()
|
const menuEl = getLiveSlashMenuElement()
|
||||||
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
|
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
|
||||||
const menuWidth = menuEl?.offsetWidth || 288
|
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
|
||||||
const gap = 4
|
const gap = 4
|
||||||
|
|
||||||
liveSlashMenuPosition.value = clampSlashMenuPosition(
|
liveSlashMenuPosition.value = calculateSlashMenuPosition({
|
||||||
rect.bottom + gap,
|
top: rect.bottom + gap,
|
||||||
rect.left,
|
left: rect.left,
|
||||||
menuHeight,
|
menuHeight,
|
||||||
menuWidth,
|
menuWidth,
|
||||||
rect.top
|
anchorTop: rect.top,
|
||||||
)
|
viewportHeight: window.innerHeight,
|
||||||
|
viewportWidth: window.innerWidth
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2423,18 +2390,20 @@ const updateWriteSlashMenuPosition = () => {
|
|||||||
const coords = getTextareaCaretCoordinates(textarea, lineStart)
|
const coords = getTextareaCaretCoordinates(textarea, lineStart)
|
||||||
const rect = textarea.getBoundingClientRect()
|
const rect = textarea.getBoundingClientRect()
|
||||||
const menuEl = getLiveSlashMenuElement()
|
const menuEl = getLiveSlashMenuElement()
|
||||||
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
|
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
|
||||||
const menuWidth = menuEl?.offsetWidth || 288
|
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
|
||||||
const gap = 4
|
const gap = 4
|
||||||
const anchorTop = rect.top + coords.top - textarea.scrollTop
|
const anchorTop = rect.top + coords.top - textarea.scrollTop
|
||||||
|
|
||||||
liveSlashMenuPosition.value = clampSlashMenuPosition(
|
liveSlashMenuPosition.value = calculateSlashMenuPosition({
|
||||||
anchorTop + coords.height + gap,
|
top: anchorTop + coords.height + gap,
|
||||||
rect.left + coords.left - textarea.scrollLeft,
|
left: rect.left + coords.left - textarea.scrollLeft,
|
||||||
menuHeight,
|
menuHeight,
|
||||||
menuWidth,
|
menuWidth,
|
||||||
anchorTop
|
anchorTop,
|
||||||
)
|
viewportHeight: window.innerHeight,
|
||||||
|
viewportWidth: window.innerWidth
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
| lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 |
|
| lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 |
|
||||||
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
|
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
|
||||||
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의, 미디어 명령 action 판별, 미디어 명령 지연 commit 무시·슬래시 원문 줄 교체 삽입 helper |
|
| 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/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `<br>` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 |
|
||||||
| lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 |
|
| lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 |
|
||||||
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.83
|
||||||
|
|
||||||
|
- 관리자 Markdown 에디터의 슬래시 메뉴 뷰포트 위치 보정 계산을 `markdown-slash-menu-position` helper로 분리.
|
||||||
|
- 슬래시 메뉴 위치 보정 단위 테스트를 추가해 아래·위·좌우 경계 동작을 고정.
|
||||||
|
|
||||||
## v1.11.82
|
## v1.11.82
|
||||||
|
|
||||||
- 관리자 Markdown 에디터의 라이브 미리보기 표면을 `AdminMarkdownPreviewSurface` 컴포넌트로 분리.
|
- 관리자 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",
|
"name": "sori.studio",
|
||||||
"version": "1.11.82",
|
"version": "1.11.83",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.82",
|
"version": "1.11.83",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.82",
|
"version": "1.11.83",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"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