Compare commits

...

3 Commits

Author SHA1 Message Date
929ffb2ed6 ui: add keyboard shortcuts 2026-04-06 13:47:50 +09:00
08ec6f42d1 ui: measure editor sidebar height 2026-04-06 13:44:16 +09:00
360ec5ac3d ui: prevent title space scroll 2026-04-06 13:33:56 +09:00
4 changed files with 135 additions and 4 deletions

View File

@@ -1,5 +1,15 @@
# 의사결정 이력
## 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
- 티어표 편집 중에는 공통 헤더보다 보드와 아이템 풀이 더 중요한 작업 기준점이므로, 템플릿 제목을 본문 위치로 빠르게 이동하는 가벼운 컨트롤로 활용하는 편이 좋다고 정리했다. 별도 버튼을 추가하기보다 기존 제목 클릭 동작으로 두어 화면 복잡도를 늘리지 않는 쪽을 택했다.

View File

@@ -1,5 +1,19 @@
# 업데이트 로그
## 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`

View File

@@ -22,7 +22,7 @@ import SvgIcon from './components/SvgIcon.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const { toasts, dismissToast, error: showErrorToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
const currentTopicId = computed(() => route.params.topicId || '')
@@ -38,6 +38,7 @@ const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
const backendState = ref('online')
const backendMessage = ref('')
const isFullscreenActive = ref(false)
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
@@ -137,6 +138,13 @@ const guideSteps = [
description:
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
},
{
id: 'keyboard-shortcuts',
title: '단축키로 빠른 조작',
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
description:
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F 키는 전체 화면 보기 토글, S 키는 티어표 편집 화면의 아이템 검색창으로 바로 이동할 때 사용할 수 있어요. 각종 모달은 Esc 키로 닫을 수 있습니다. 단, 검색창이나 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
},
]
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
@@ -288,6 +296,11 @@ function handleBackendStatus(event) {
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) {
themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
@@ -312,9 +325,12 @@ onMounted(async () => {
await auth.refresh()
if (typeof window !== 'undefined') {
syncViewportWidth()
syncFullscreenState()
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
window.addEventListener('resize', syncViewportWidth)
window.addEventListener('keydown', handleGlobalKeydown)
document.addEventListener('fullscreenchange', syncFullscreenState)
document.addEventListener('webkitfullscreenchange', syncFullscreenState)
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
if (leftSaved === '1') leftRailCollapsed.value = true
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
@@ -336,6 +352,54 @@ function handleGlobalKeydown(event) {
}
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
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 (String(event.key || '').toLowerCase() === 'f') {
event.preventDefault()
toggleFullscreen()
return
}
if (String(event.key || '').toLowerCase() === 's' && ['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('resize', syncViewportWidth)
window.removeEventListener('keydown', handleGlobalKeydown)
document.removeEventListener('fullscreenchange', syncFullscreenState)
document.removeEventListener('webkitfullscreenchange', syncFullscreenState)
}
syncRightRailBodyScrollLock(false)
})

View File

@@ -90,13 +90,17 @@ let editorLoadToken = 0
const boardEl = ref(null)
const exportBoardEl = ref(null)
const groupListEl = ref(null)
const sidebarEl = ref(null)
const poolEl = ref(null)
const poolSearchEl = ref(null)
const groupDropEls = ref({})
const fileEl = ref(null)
const thumbnailFileEl = ref(null)
const groupSortable = ref(null)
const poolSortable = ref(null)
const dropSortables = ref([])
const editorSidebarMaxHeight = ref('')
let editorSidebarMeasureFrame = 0
const isNewTierList = computed(() => tierListId.value === 'new')
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
@@ -364,6 +368,30 @@ function scrollWorkspaceBodyToTop() {
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) {
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
selectedItemId.value = itemId
@@ -1355,6 +1383,7 @@ async function loadEditorState() {
if (loadToken !== editorLoadToken) return
syncSavedEditorSnapshot()
scheduleEditorSidebarMeasure()
if (canEdit.value) {
await initSortables()
}
@@ -1374,6 +1403,10 @@ onMounted(() => {
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
window.addEventListener('blur', closeItemContextMenu)
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(() => {
@@ -1382,6 +1415,13 @@ onUnmounted(() => {
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
window.removeEventListener('blur', closeItemContextMenu)
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)
destroySortables()
@@ -1592,6 +1632,7 @@ onUnmounted(() => {
type="button"
title="본문을 화면 위로 이동"
@click="scrollWorkspaceBodyToTop"
@keydown.space.prevent="scrollWorkspaceBodyToTop"
>
{{ templateName || templateId }}
</button>
@@ -1768,7 +1809,7 @@ onUnmounted(() => {
</div>
</div>
<div class="sidebar">
<div ref="sidebarEl" class="sidebar" :style="{ '--editor-sidebar-max-height': editorSidebarMaxHeight || undefined }">
<div class="sidebar__titleRow">
<div class="sidebar__title">아이템</div>
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
@@ -1777,6 +1818,7 @@ onUnmounted(() => {
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
ref="poolSearchEl"
v-model="poolSearchQuery"
class="sidebar__search"
type="text"
@@ -2773,7 +2815,6 @@ onUnmounted(() => {
object-fit: cover;
}
.sidebar {
--editor-sidebar-visible-offset: 136px;
min-width: 0;
display: flex;
flex-direction: column;
@@ -2784,7 +2825,7 @@ onUnmounted(() => {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
position: sticky;
top: 14px;
max-height: calc(100dvh - var(--editor-sidebar-visible-offset));
max-height: var(--editor-sidebar-max-height, calc(100dvh - 136px));
overflow: hidden;
overscroll-behavior: contain;
}