diff --git a/docs/history.md b/docs/history.md index b5a390e..0a869c3 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-06 v1.4.98 +- 방송/편집처럼 화면을 자주 정리해야 하는 사용 흐름에서는 사이드 패널과 전체 화면 전환을 마우스로만 조작하면 반복 비용이 커진다. 그래서 `[`/`]`/`F`/`S`처럼 한 손으로 누르기 쉬운 단축키를 두되, 입력창에서는 단축키를 무시해 실제 텍스트 입력을 방해하지 않는 편이 맞다고 정리했다. +- `S`는 전역 템플릿 검색이 아니라 티어표 편집 화면의 아이템 검색으로 연결해야 하므로, 앱 셸이 편집 화면에 커스텀 이벤트를 보내고 편집 화면이 자신의 아이템 검색창에 포커스를 주는 방식으로 분리했다. + ## 2026-04-06 v1.4.97 - 티어표 편집기의 오른쪽 아이템 패널은 페이지 내부 위치가 헤더, 제목, 스크롤 상태에 따라 달라지므로 `100dvh - 고정값` 방식으로는 왼쪽 레일처럼 하단이 자연스럽게 맞지 않을 수 있다. 실제 패널의 화면 내 시작 위치를 측정해 남은 높이를 계산하는 편이 더 안정적이라고 정리했다. diff --git a/docs/update.md b/docs/update.md index b3aea31..ad1f1dc 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 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` diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a8bfd38..3def019 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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) }) diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 3de0457..d45f3b1 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -92,6 +92,7 @@ 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) @@ -386,6 +387,11 @@ function scheduleEditorSidebarMeasure() { }) } +function focusPoolSearch() { + poolSearchEl.value?.focus() + poolSearchEl.value?.select() +} + function openItemContextMenu(itemId, event) { if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return selectedItemId.value = itemId @@ -1399,6 +1405,7 @@ onMounted(() => { 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()) }) @@ -1410,6 +1417,7 @@ onUnmounted(() => { 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 @@ -1810,6 +1818,7 @@ onUnmounted(() => { {{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}