Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b5fc8b81 | |||
| ecd3e69b5a | |||
| 3dba9b0a4d | |||
| 56b0035a45 | |||
| 929ffb2ed6 | |||
| 08ec6f42d1 | |||
| 360ec5ac3d | |||
| 71a13488d9 | |||
| ba9ba8013b | |||
| da35351747 |
@@ -1,5 +1,34 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.101
|
||||||
|
- 실제 문제는 `editorCanvas` 자체보다 그 아래 `workspaceBody--localRail`의 하단 패딩까지 문서가 더 스크롤되는 구간에서 발생했다. 편집 캔버스 자체에도 하단 여백이 있으므로, 데스크톱의 `workspaceBody--localRail` 하단 패딩과 오른쪽 sticky 래퍼의 추가 보정 패딩은 제거하는 편이 더 단순하고 정확하다고 판단했다.
|
||||||
|
- 아이템 풀은 내부 스크롤이 필요하지만, 오른쪽 패널 안에서 스크롤바가 항상 보이면 작업 화면이 좁고 복잡해 보일 수 있으므로 스크롤 동작만 유지하고 스크롤바 시각 요소는 숨기는 편이 낫다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.100
|
||||||
|
- 오른쪽 아이템 패널은 sticky로 동작하지만 부모 컨테이너의 끝에 닿으면 sticky 제한 때문에 마지막 스크롤 위치에서 위쪽 여백이 무너져 보일 수 있다. 그래서 편집 캔버스 자체에 작은 하단 여유 공간을 두어 마지막 위치에서도 패널 주변 여백이 유지되도록 하는 편이 낫다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.99
|
||||||
|
- 단축키는 실제 키 위치 기준으로 기대하는 경우가 많으므로, 한글 입력 상태에서 S 자리 키가 `ㄴ`으로 들어와도 같은 “아이템 검색” 동작을 실행하고, F 자리 키가 `ㄹ`로 들어와도 같은 “전체 화면” 동작을 실행하는 편이 한국어 사용자에게 자연스럽다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.98
|
||||||
|
- 방송/편집처럼 화면을 자주 정리해야 하는 사용 흐름에서는 사이드 패널과 전체 화면 전환을 마우스로만 조작하면 반복 비용이 커진다. 그래서 `[`/`]`/`F`/`S`처럼 한 손으로 누르기 쉬운 단축키를 두되, 입력창에서는 단축키를 무시해 실제 텍스트 입력을 방해하지 않는 편이 맞다고 정리했다.
|
||||||
|
- `S`는 전역 템플릿 검색이 아니라 티어표 편집 화면의 아이템 검색으로 연결해야 하므로, 앱 셸이 편집 화면에 커스텀 이벤트를 보내고 편집 화면이 자신의 아이템 검색창에 포커스를 주는 방식으로 분리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.97
|
||||||
|
- 티어표 편집기의 오른쪽 아이템 패널은 페이지 내부 위치가 헤더, 제목, 스크롤 상태에 따라 달라지므로 `100dvh - 고정값` 방식으로는 왼쪽 레일처럼 하단이 자연스럽게 맞지 않을 수 있다. 실제 패널의 화면 내 시작 위치를 측정해 남은 높이를 계산하는 편이 더 안정적이라고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.96
|
||||||
|
- 템플릿 제목을 버튼화하면 접근성은 좋아지지만, 포커스가 남은 상태의 `Space` 입력이 브라우저 스크롤과 섞이면 작업 화면을 갑자기 밀어낼 수 있다. 따라서 제목 버튼에서는 `Space` 기본 스크롤을 막고 의도한 본문 이동만 실행하는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.95
|
||||||
|
- 티어표 편집 중에는 공통 헤더보다 보드와 아이템 풀이 더 중요한 작업 기준점이므로, 템플릿 제목을 본문 위치로 빠르게 이동하는 가벼운 컨트롤로 활용하는 편이 좋다고 정리했다. 별도 버튼을 추가하기보다 기존 제목 클릭 동작으로 두어 화면 복잡도를 늘리지 않는 쪽을 택했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.94
|
||||||
|
- 아이템 수가 많을 때 오른쪽 풀 때문에 페이지 전체가 길어지면 왼쪽 티어표까지 함께 움직여 방송/녹화 환경에서 기준 화면이 흔들릴 수 있다. 그래서 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하되, 제목과 검색창은 유지하고 아이템 그리드만 스크롤되게 하는 편이 더 적절하다고 정리했다. 모바일에서는 기존처럼 문서 흐름을 유지한다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.93
|
||||||
|
- 티어표 편집기의 커스텀 이미지 추가 영역 아래는 아이템 수가 적을 때 비어 보이기 쉬우므로, 이 공간에는 큰 기능보다 방해되지 않는 작은 작업 팁을 두는 편이 자연스럽다고 정리했다. 특히 우클릭 복제, 미사용 아이템 처리, 브라우저 확대/축소처럼 초반 시행착오를 줄여 주는 내용이 효과적이라고 판단했다.
|
||||||
|
|
||||||
## 2026-04-06 v1.4.92
|
## 2026-04-06 v1.4.92
|
||||||
- 모바일 왼쪽 레일은 사용자 카드, 검색, 메뉴가 세로로 붙는 구조라 기본 `gap`이 빠지면 브라우저별 렌더링 차이에 따라 훨씬 답답하게 보일 수 있으므로, 이 영역 간격은 명시적으로 주는 편이 안전하다고 정리했다.
|
- 모바일 왼쪽 레일은 사용자 카드, 검색, 메뉴가 세로로 붙는 구조라 기본 `gap`이 빠지면 브라우저별 렌더링 차이에 따라 훨씬 답답하게 보일 수 있으므로, 이 영역 간격은 명시적으로 주는 편이 안전하다고 정리했다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.101
|
||||||
|
- `workspaceBody--localRail`의 하단 패딩 구간까지 스크롤했을 때 오른쪽 아이템 카드가 sticky 기준 영역을 벗어나 상단에 붙어 보이던 문제를 보정했다. 데스크톱에서는 `workspaceBody--localRail`의 하단 패딩과 오른쪽 sticky 래퍼 보정 패딩을 제거하고, 편집 캔버스 자체 여백만 사용하도록 정리했다.
|
||||||
|
- 티어표 편집 화면의 아이템 그리드 내부 스크롤은 유지하되, 시각적인 스크롤바는 보이지 않도록 정리했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.100
|
||||||
|
- 티어표 편집 화면을 가장 아래까지 스크롤했을 때 오른쪽 아이템 카드가 부모 컨테이너 끝에 걸리며 상단 여백이 무너져 보일 수 있어, 편집 캔버스 하단에 sticky 여백용 패딩을 추가했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.99
|
||||||
|
- 티어표 편집 화면의 아이템 검색/전체 화면 단축키를 한글 입력 상태에서도 쓸 수 있게 보정했다. `S`뿐 아니라 같은 물리 키에서 들어오는 `ㄴ`도 아이템 검색 포커스로 처리하고, `F` 자리에서 들어오는 `ㄹ`도 전체 화면 토글로 처리한다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.98
|
||||||
|
- 전역 단축키를 추가했다. `[`는 왼쪽 사이드 토글, `]`는 오른쪽 사이드 토글, `F`는 전체 화면 토글, `S`는 티어표 편집 화면의 아이템 검색창 포커스로 동작한다.
|
||||||
|
- 입력창, textarea, select, contenteditable 영역에서는 단축키가 동작하지 않도록 막아 검색이나 이름 입력을 방해하지 않게 했다.
|
||||||
|
- 가이드 보기 마지막 페이지에 단축키 안내를 추가하고, 모달은 `Esc`로 닫을 수 있다는 안내도 함께 정리했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.97
|
||||||
|
- 티어표 편집 화면의 오른쪽 아이템 패널 높이를 고정 숫자 대신 실제 화면 내 시작 위치 기준으로 계산하도록 바꿨다. 공통 헤더/제목 영역/스크롤 위치가 달라져도 아이템 풀의 하단이 viewport 안에 더 자연스럽게 맞도록 보정했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.96
|
||||||
|
- 티어표 편집 화면의 템플릿 제목에 포커스가 남은 상태에서 `Space`를 누르면 브라우저 기본 스크롤이 섞일 수 있어, 제목 버튼의 `Space` 기본 동작을 막고 본문 이동만 실행되도록 보정했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.95
|
||||||
|
- 티어표 편집 화면의 템플릿 제목을 클릭하면 `workspaceBody`가 화면 최상단에 오도록 부드럽게 스크롤되게 했다. 작업 중 공통 헤더를 화면 밖으로 밀어내고 본문 중심으로 볼 수 있다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.94
|
||||||
|
- 티어표 편집 화면에서 아이템이 많을 때 오른쪽 아이템 사이드가 문서 높이를 밀어 왼쪽 티어표까지 함께 움직이던 흐름을 줄였다. 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하고, 아이템 그리드만 내부 스크롤되게 해 검색창은 위에 유지하면서 필요한 아이템을 찾아 가져올 수 있게 했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.93
|
||||||
|
- 티어표 편집 화면의 커스텀 이미지 추가 영역 아래에는 작은 `작업 팁` 안내를 추가했다. 복수 사용, 미사용 아이템 미리보기/저장 제외, 브라우저 확대/축소 활용 같은 자주 묻는 흐름을 바로 확인할 수 있다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
## 2026-04-06 v1.4.92
|
## 2026-04-06 v1.4.92
|
||||||
- 모바일 왼쪽 사이드 메뉴(`leftRail__mobileMenu`)에 `gap`이 빠져 일부 브라우저에서 사용자 카드와 검색창/메뉴가 더 붙어 보일 수 있던 간격을 다시 정리했다.
|
- 모바일 왼쪽 사이드 메뉴(`leftRail__mobileMenu`)에 `gap`이 빠져 일부 브라우저에서 사용자 카드와 검색창/메뉴가 더 붙어 보일 수 있던 간격을 다시 정리했다.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import SvgIcon from './components/SvgIcon.vue'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { toasts, dismissToast } = useToast()
|
const { toasts, dismissToast, error: showErrorToast } = useToast()
|
||||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||||
const currentTopicId = computed(() => route.params.topicId || '')
|
const currentTopicId = computed(() => route.params.topicId || '')
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ const guideStepIndex = ref(0)
|
|||||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||||
const backendState = ref('online')
|
const backendState = ref('online')
|
||||||
const backendMessage = ref('')
|
const backendMessage = ref('')
|
||||||
|
const isFullscreenActive = ref(false)
|
||||||
provide('rightRailOpen', rightRailOpen)
|
provide('rightRailOpen', rightRailOpen)
|
||||||
provide('localRightRailTarget', '#local-right-rail-root')
|
provide('localRightRailTarget', '#local-right-rail-root')
|
||||||
|
|
||||||
@@ -137,6 +138,13 @@ const guideSteps = [
|
|||||||
description:
|
description:
|
||||||
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'keyboard-shortcuts',
|
||||||
|
title: '단축키로 빠른 조작',
|
||||||
|
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
|
||||||
|
description:
|
||||||
|
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F 키는 전체 화면 보기 토글, S 키는 티어표 편집 화면의 아이템 검색창으로 바로 이동할 때 사용할 수 있어요. 한글 입력 상태에서는 F 자리의 ㄹ, S 자리의 ㄴ 키도 같은 단축키로 처리됩니다. 각종 모달은 Esc 키로 닫을 수 있습니다. 단, 검색창이나 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||||
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
||||||
@@ -288,6 +296,11 @@ function handleBackendStatus(event) {
|
|||||||
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncFullscreenState() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
isFullscreenActive.value = !!(document.fullscreenElement || document.webkitFullscreenElement)
|
||||||
|
}
|
||||||
|
|
||||||
function applyTheme(mode) {
|
function applyTheme(mode) {
|
||||||
themeMode.value = mode === 'light' ? 'light' : 'dark'
|
themeMode.value = mode === 'light' ? 'light' : 'dark'
|
||||||
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
|
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
|
||||||
@@ -312,9 +325,12 @@ onMounted(async () => {
|
|||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
syncViewportWidth()
|
syncViewportWidth()
|
||||||
|
syncFullscreenState()
|
||||||
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||||
window.addEventListener('resize', syncViewportWidth)
|
window.addEventListener('resize', syncViewportWidth)
|
||||||
window.addEventListener('keydown', handleGlobalKeydown)
|
window.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
document.addEventListener('webkitfullscreenchange', syncFullscreenState)
|
||||||
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
||||||
if (leftSaved === '1') leftRailCollapsed.value = true
|
if (leftSaved === '1') leftRailCollapsed.value = true
|
||||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||||
@@ -336,6 +352,54 @@ function handleGlobalKeydown(event) {
|
|||||||
}
|
}
|
||||||
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
||||||
closeCollapsedSearch()
|
closeCollapsedSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isGuideModalOpen.value || isCollapsedSearchOpen.value) return
|
||||||
|
if (shouldIgnoreGlobalShortcut(event)) return
|
||||||
|
|
||||||
|
if (event.key === '[') {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleLeftRail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === ']') {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleRightRail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['f', 'ㄹ'].includes(String(event.key || '').toLowerCase())) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['s', 'ㄴ'].includes(String(event.key || '').toLowerCase()) && ['editEditor', 'newEditor'].includes(String(route.name || ''))) {
|
||||||
|
event.preventDefault()
|
||||||
|
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreGlobalShortcut(event) {
|
||||||
|
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return true
|
||||||
|
const target = event.target
|
||||||
|
if (!target || !(target instanceof HTMLElement)) return false
|
||||||
|
const tagName = target.tagName.toLowerCase()
|
||||||
|
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
try {
|
||||||
|
const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement
|
||||||
|
if (fullscreenElement) {
|
||||||
|
const exitFullscreen = document.exitFullscreen || document.webkitExitFullscreen
|
||||||
|
if (exitFullscreen) await exitFullscreen.call(document)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = document.documentElement
|
||||||
|
const requestFullscreen = target.requestFullscreen || target.webkitRequestFullscreen
|
||||||
|
if (requestFullscreen) await requestFullscreen.call(target)
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast('전체 화면 전환을 실행하지 못했어요.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +408,8 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||||
window.removeEventListener('resize', syncViewportWidth)
|
window.removeEventListener('resize', syncViewportWidth)
|
||||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
document.removeEventListener('webkitfullscreenchange', syncFullscreenState)
|
||||||
}
|
}
|
||||||
syncRightRailBodyScrollLock(false)
|
syncRightRailBodyScrollLock(false)
|
||||||
})
|
})
|
||||||
@@ -1396,7 +1462,7 @@ function reloadApp() {
|
|||||||
|
|
||||||
.workspaceBody--localRail {
|
.workspaceBody--localRail {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 18px 18px 32px;
|
padding: 18px 18px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--theme-workspace-bg);
|
background: var(--theme-workspace-bg);
|
||||||
@@ -2145,7 +2211,6 @@ function reloadApp() {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
gap: 10px;
|
|
||||||
transition:
|
transition:
|
||||||
max-height 260ms ease,
|
max-height 260ms ease,
|
||||||
opacity 220ms ease,
|
opacity 220ms ease,
|
||||||
|
|||||||
@@ -90,13 +90,17 @@ let editorLoadToken = 0
|
|||||||
const boardEl = ref(null)
|
const boardEl = ref(null)
|
||||||
const exportBoardEl = ref(null)
|
const exportBoardEl = ref(null)
|
||||||
const groupListEl = ref(null)
|
const groupListEl = ref(null)
|
||||||
|
const sidebarEl = ref(null)
|
||||||
const poolEl = ref(null)
|
const poolEl = ref(null)
|
||||||
|
const poolSearchEl = ref(null)
|
||||||
const groupDropEls = ref({})
|
const groupDropEls = ref({})
|
||||||
const fileEl = ref(null)
|
const fileEl = ref(null)
|
||||||
const thumbnailFileEl = ref(null)
|
const thumbnailFileEl = ref(null)
|
||||||
const groupSortable = ref(null)
|
const groupSortable = ref(null)
|
||||||
const poolSortable = ref(null)
|
const poolSortable = ref(null)
|
||||||
const dropSortables = ref([])
|
const dropSortables = ref([])
|
||||||
|
const editorSidebarMaxHeight = ref('')
|
||||||
|
let editorSidebarMeasureFrame = 0
|
||||||
|
|
||||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||||
@@ -359,6 +363,35 @@ function closeItemContextMenu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollWorkspaceBodyToTop() {
|
||||||
|
const workspaceBody = document.querySelector('.workspaceBody')
|
||||||
|
workspaceBody?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditorSidebarMaxHeight() {
|
||||||
|
if (typeof window === 'undefined' || !sidebarEl.value) return
|
||||||
|
const bottomGap = 14
|
||||||
|
const stickyTop = 14
|
||||||
|
const minHeight = 260
|
||||||
|
const sidebarTop = Math.max(sidebarEl.value.getBoundingClientRect().top, stickyTop)
|
||||||
|
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - sidebarTop - bottomGap))
|
||||||
|
editorSidebarMaxHeight.value = `${nextHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleEditorSidebarMeasure() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (editorSidebarMeasureFrame) return
|
||||||
|
editorSidebarMeasureFrame = window.requestAnimationFrame(() => {
|
||||||
|
editorSidebarMeasureFrame = 0
|
||||||
|
updateEditorSidebarMaxHeight()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusPoolSearch() {
|
||||||
|
poolSearchEl.value?.focus()
|
||||||
|
poolSearchEl.value?.select()
|
||||||
|
}
|
||||||
|
|
||||||
function openItemContextMenu(itemId, event) {
|
function openItemContextMenu(itemId, event) {
|
||||||
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
|
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
|
||||||
selectedItemId.value = itemId
|
selectedItemId.value = itemId
|
||||||
@@ -1350,6 +1383,7 @@ async function loadEditorState() {
|
|||||||
if (loadToken !== editorLoadToken) return
|
if (loadToken !== editorLoadToken) return
|
||||||
|
|
||||||
syncSavedEditorSnapshot()
|
syncSavedEditorSnapshot()
|
||||||
|
scheduleEditorSidebarMeasure()
|
||||||
if (canEdit.value) {
|
if (canEdit.value) {
|
||||||
await initSortables()
|
await initSortables()
|
||||||
}
|
}
|
||||||
@@ -1369,6 +1403,10 @@ onMounted(() => {
|
|||||||
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
window.addEventListener('blur', closeItemContextMenu)
|
window.addEventListener('blur', closeItemContextMenu)
|
||||||
window.addEventListener('scroll', closeItemContextMenu, true)
|
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||||
|
window.addEventListener('resize', scheduleEditorSidebarMeasure)
|
||||||
|
window.addEventListener('scroll', scheduleEditorSidebarMeasure, true)
|
||||||
|
window.addEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
|
||||||
|
nextTick(() => scheduleEditorSidebarMeasure())
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -1377,6 +1415,13 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
window.removeEventListener('blur', closeItemContextMenu)
|
window.removeEventListener('blur', closeItemContextMenu)
|
||||||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||||
|
window.removeEventListener('resize', scheduleEditorSidebarMeasure)
|
||||||
|
window.removeEventListener('scroll', scheduleEditorSidebarMeasure, true)
|
||||||
|
window.removeEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
|
||||||
|
if (editorSidebarMeasureFrame) {
|
||||||
|
window.cancelAnimationFrame(editorSidebarMeasureFrame)
|
||||||
|
editorSidebarMeasureFrame = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
destroySortables()
|
destroySortables()
|
||||||
@@ -1582,7 +1627,15 @@ onUnmounted(() => {
|
|||||||
<div class="editorMain">
|
<div class="editorMain">
|
||||||
<section class="head">
|
<section class="head">
|
||||||
<div class="editorMain__headCopy">
|
<div class="editorMain__headCopy">
|
||||||
<div class="editorMain__title">{{ templateName || templateId }}</div>
|
<button
|
||||||
|
class="editorMain__title editorMain__titleButton"
|
||||||
|
type="button"
|
||||||
|
title="본문을 화면 위로 이동"
|
||||||
|
@click="scrollWorkspaceBodyToTop"
|
||||||
|
@keydown.space.prevent="scrollWorkspaceBodyToTop"
|
||||||
|
>
|
||||||
|
{{ templateName || templateId }}
|
||||||
|
</button>
|
||||||
<div class="editorMain__subtitle">
|
<div class="editorMain__subtitle">
|
||||||
<template v-if="canEdit">
|
<template v-if="canEdit">
|
||||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||||
@@ -1746,60 +1799,71 @@ onUnmounted(() => {
|
|||||||
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
|
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="editorTips">
|
||||||
|
<div class="editorTips__title">작업 팁</div>
|
||||||
|
<ul class="editorTips__list">
|
||||||
|
<li>마우스 오른쪽 클릭으로 아이템을 복수 사용하거나 커스텀 이미지를 빠르게 정리할 수 있어요.</li>
|
||||||
|
<li>미사용 아이템은 미리보기와 이미지 저장 결과에 표시되지 않으니, 필요한 것만 골라 배치해도 괜찮아요.</li>
|
||||||
|
<li>아이템이 많아 한 번에 보기 어렵다면 브라우저 확대/축소(`Ctrl +`, `Ctrl -`)로 화면 밀도를 조절해보세요.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebarStickyFrame">
|
||||||
<div class="sidebar__titleRow">
|
<div ref="sidebarEl" class="sidebar" :style="{ '--editor-sidebar-max-height': editorSidebarMaxHeight || undefined }">
|
||||||
<div class="sidebar__title">아이템</div>
|
<div class="sidebar__titleRow">
|
||||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
<div class="sidebar__title">아이템</div>
|
||||||
</div>
|
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||||
<div class="sidebar__hint">
|
</div>
|
||||||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
<div class="sidebar__hint">
|
||||||
</div>
|
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||||||
<input
|
</div>
|
||||||
v-model="poolSearchQuery"
|
<input
|
||||||
class="sidebar__search"
|
ref="poolSearchEl"
|
||||||
type="text"
|
v-model="poolSearchQuery"
|
||||||
maxlength="60"
|
class="sidebar__search"
|
||||||
placeholder="아이템 이름 검색"
|
type="text"
|
||||||
/>
|
maxlength="60"
|
||||||
<div
|
placeholder="아이템 이름 검색"
|
||||||
ref="poolEl"
|
/>
|
||||||
class="pool"
|
|
||||||
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
|
|
||||||
data-list-type="pool"
|
|
||||||
@click.self="moveSelectedItemToPool"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="id in pool"
|
ref="poolEl"
|
||||||
:key="id"
|
class="pool"
|
||||||
class="poolItem"
|
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
|
||||||
:class="{
|
data-list-type="pool"
|
||||||
'poolItem--readonly': !canEdit,
|
@click.self="moveSelectedItemToPool"
|
||||||
'poolItem--hidden': !isPoolItemVisible(id),
|
|
||||||
'poolItem--selected': selectedItemId === id,
|
|
||||||
}"
|
|
||||||
:data-item-id="id"
|
|
||||||
@click.stop="selectItemByClick(id)"
|
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
:src="resolveItemSrc(itemsById[id])"
|
v-for="id in pool"
|
||||||
class="thumb"
|
:key="id"
|
||||||
:alt="itemsById[id]?.label || id"
|
class="poolItem"
|
||||||
draggable="false"
|
:class="{
|
||||||
/>
|
'poolItem--readonly': !canEdit,
|
||||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
'poolItem--hidden': !isPoolItemVisible(id),
|
||||||
<button
|
'poolItem--selected': selectedItemId === id,
|
||||||
v-if="canRemoveEditorItem(id)"
|
}"
|
||||||
class="poolItem__deleteBtn"
|
:data-item-id="id"
|
||||||
type="button"
|
@click.stop="selectItemByClick(id)"
|
||||||
title="커스텀 이미지 제거"
|
|
||||||
@pointerdown.stop
|
|
||||||
@click.stop="deleteEditorItem(id)"
|
|
||||||
>
|
>
|
||||||
삭제
|
<img
|
||||||
</button>
|
:src="resolveItemSrc(itemsById[id])"
|
||||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
class="thumb"
|
||||||
|
:alt="itemsById[id]?.label || id"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
|
<button
|
||||||
|
v-if="canRemoveEditorItem(id)"
|
||||||
|
class="poolItem__deleteBtn"
|
||||||
|
type="button"
|
||||||
|
title="커스텀 이미지 제거"
|
||||||
|
@pointerdown.stop
|
||||||
|
@click.stop="deleteEditorItem(id)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1960,12 +2024,36 @@ onUnmounted(() => {
|
|||||||
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
|
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
.sidebarStickyFrame {
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.editorMain__title {
|
.editorMain__title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
.editorMain__titleButton {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.editorMain__titleButton:hover {
|
||||||
|
color: var(--theme-text-strong);
|
||||||
|
}
|
||||||
|
.editorMain__titleButton:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
.editorMain__subtitle {
|
.editorMain__subtitle {
|
||||||
color: var(--theme-text-soft);
|
color: var(--theme-text-soft);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -2736,6 +2824,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -2743,6 +2833,9 @@ onUnmounted(() => {
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 14px;
|
top: 14px;
|
||||||
|
max-height: var(--editor-sidebar-max-height, calc(100dvh - 136px));
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone--board {
|
.dropzone--board {
|
||||||
@@ -3057,14 +3150,45 @@ onUnmounted(() => {
|
|||||||
color: var(--theme-text-faint);
|
color: var(--theme-text-faint);
|
||||||
}
|
}
|
||||||
.pool {
|
.pool {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
.pool::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.pool--clickTarget {
|
.pool--clickTarget {
|
||||||
cursor: copy;
|
cursor: copy;
|
||||||
}
|
}
|
||||||
|
.editorTips {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: color-mix(in srgb, var(--theme-card-bg) 82%, transparent);
|
||||||
|
}
|
||||||
|
.editorTips__title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--theme-text-soft);
|
||||||
|
}
|
||||||
|
.editorTips__list {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: var(--theme-text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.editorTips__list li + li {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
.poolItem {
|
.poolItem {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3227,8 +3351,14 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: static;
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sidebarStickyFrame {
|
||||||
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
.pool {
|
.pool {
|
||||||
|
overflow: visible;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user