diff --git a/HANDOFF.md b/HANDOFF.md index 4673a55..f3d1b82 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.54` 준비 중 +- 현재 기준 버전: `v0.1.55` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -49,7 +49,8 @@ - `TIME TABLE`은 우클릭 드래그 시 선택된 블록을 지우는 방식으로도 편집할 수 있다. - `TIME TABLE` 숫자 영역은 선택/드래그로 텍스트가 잡히지 않도록 막아두었다. - `TOTAL TIME`은 타임테이블에서 선택된 블록 수를 기준으로 자동 계산된다. -- 오른쪽 패널에는 특정 날짜의 `TIME TABLE`을 다른 날짜로 그대로 복사하는 카드가 추가되었다. +- `TIME TABLE` 라벨은 왼쪽 클릭 시 현재 날짜 타임테이블을 복사하고, 오른쪽 클릭 시 붙여넣기 메뉴를 연다. +- `TIME TABLE` 라벨 오른쪽의 `?` 아이콘으로 복사/붙여넣기 사용법을 바로 볼 수 있다. - 모바일과 태블릿처럼 `TIME TABLE`이 아래로 내려가는 구간에서는 6칸 그리드가 남는 폭을 더 넓게 채우도록 조정했다. - 달력은 연/월 이동이 가능하며, 현재 보이는 월과 선택된 날짜 상태를 분리해서 관리한다. - 달력 상단은 월 좌우 화살표, 클릭형 연도 선택, `TODAY` 버튼 구조로 동작한다. diff --git a/package-lock.json b/package-lock.json index 7700f8b..20a4124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.54", + "version": "0.1.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.54", + "version": "0.1.55", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 2b54083..4499588 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.54", + "version": "0.1.55", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 022a4ce..0da9a2f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -44,6 +44,7 @@ const GUIDE_TOOLTIP_STORAGE_KEY = 'ten-minute-guide-tooltips-hidden' const DDAY_DISABLED_STORAGE_KEY = 'ten-minute-dday-disabled-dates' const CARRYOVER_CHECK_POLICY_STORAGE_KEY = 'ten-minute-carryover-check-policy' const CARRYOVER_CHECK_POLICIES = ['ask', 'all', 'current'] +const TIMETABLE_CLIPBOARD_PREFIX = 'TEN_MINUTE_TIMETABLE:' const screenMode = ref('planner') const viewMode = ref('focus') const printLayout = ref('single') @@ -108,7 +109,6 @@ const passwordMessage = ref('') const accountDeleteBusy = ref(false) const accountDeleteMessage = ref('') const carryoverMessage = ref('') -const timetableCopyMessage = ref('') const carryoverCheckPolicy = ref(readCarryoverCheckPolicy()) const carryoverCheckPrompt = ref(null) const guideTooltipResetMessage = ref('') @@ -132,9 +132,13 @@ const adminUsers = ref([]) const adminRecentLogins = ref([]) const adminSelectedUserId = ref(null) const adminUserDetail = ref(null) -const timetableCopyForm = reactive({ - sourceDate: toKey(new Date()), - targetDate: toKey(new Date()), +const timetableClipboard = ref(null) +const timetableContextMenu = ref({ + open: false, + x: 0, + y: 0, + dateKey: '', + record: null, }) const hours = [ @@ -525,6 +529,7 @@ const secondaryDate = computed(() => { }) const secondaryPlanner = computed(() => getPlannerRecord(secondaryDate.value)) +const secondaryDateKey = computed(() => toKey(secondaryDate.value)) function getDateDisplay(date) { const main = `${date.getFullYear()}. ${`${date.getMonth() + 1}`.padStart(2, '0')}. ${`${date.getDate()}`.padStart(2, '0')}.` @@ -770,7 +775,6 @@ function shiftDate(amount) { selectedDate.value = next calendarViewDate.value = new Date(next) carryoverMessage.value = '' - timetableCopyMessage.value = '' } function shiftCalendarMonth(amount) { @@ -789,7 +793,6 @@ function selectDate(date) { selectedDate.value = new Date(date) calendarViewDate.value = new Date(date) carryoverMessage.value = '' - timetableCopyMessage.value = '' } function updateComment(record, value) { @@ -919,6 +922,11 @@ function handleGlobalKeydown(event) { event.preventDefault() closeCarryoverCheckPrompt() } + + if (event.key === 'Escape' && timetableContextMenu.value.open) { + event.preventDefault() + closeTimetableContextMenu() + } } function clearTasks(record, indexes) { @@ -976,38 +984,113 @@ function carryIncompleteTasksToNextDay() { : `${nextDateLabel} 빈칸 ${copyCount}개까지만 이월했습니다.` } -function useSelectedDateAsTimetableSource() { - timetableCopyForm.sourceDate = selectedDateKey.value - timetableCopyMessage.value = '' +function normalizeTimetableClipboard(candidate) { + if (!candidate || typeof candidate !== 'object') { + return null + } + + const sourceDateKey = typeof candidate.sourceDateKey === 'string' ? candidate.sourceDateKey : '' + const timetable = Array.isArray(candidate.timetable) ? candidate.timetable : [] + + if (!sourceDateKey || timetable.length !== timetableCellCount || timetable.some((value) => typeof value !== 'boolean')) { + return null + } + + return { + sourceDateKey, + timetable: [...timetable], + } } -function useSelectedDateAsTimetableTarget() { - timetableCopyForm.targetDate = selectedDateKey.value - timetableCopyMessage.value = '' -} +async function copyTimetableToClipboard(record, sourceDateKey) { + const clipboardPayload = normalizeTimetableClipboard({ + sourceDateKey, + timetable: record.timetable, + }) -function copyTimetableBetweenDates() { - const sourceKey = timetableCopyForm.sourceDate - const targetKey = timetableCopyForm.targetDate - - if (!sourceKey || !targetKey) { - timetableCopyMessage.value = '복사할 날짜와 붙여넣을 날짜를 모두 선택해 주세요.' + if (!clipboardPayload) { + setSyncFeedback('local', '복사할 타임테이블을 찾지 못했습니다.') return } - const sourceRecord = getPlannerRecord(toDateValue(sourceKey)) - const targetRecord = getPlannerRecord(toDateValue(targetKey)) - const nextTimetable = [...sourceRecord.timetable] + timetableClipboard.value = clipboardPayload - targetRecord.timetable = nextTimetable - schedulePlannerSyncForRecord(targetRecord) + let copiedToSystemClipboard = false - const sourceDateLabel = createDateLabel(sourceKey) - const targetDateLabel = createDateLabel(targetKey) + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText( + `${TIMETABLE_CLIPBOARD_PREFIX}${JSON.stringify(clipboardPayload)}`, + ) + copiedToSystemClipboard = true + } + } catch (error) { + copiedToSystemClipboard = false + } - timetableCopyMessage.value = nextTimetable.some(Boolean) - ? `${sourceDateLabel} 타임테이블을 ${targetDateLabel}에 복사했습니다.` - : `${sourceDateLabel} 타임테이블이 비어 있어 ${targetDateLabel}도 비웠습니다.` + setSyncFeedback( + 'local', + copiedToSystemClipboard + ? `${createDateLabel(sourceDateKey)} 타임테이블을 클립보드에 저장했습니다.` + : `${createDateLabel(sourceDateKey)} 타임테이블을 앱 안에 복사했습니다.`, + ) +} + +function pasteTimetableFromClipboard(record, targetDateKey, clipboardPayload = timetableClipboard.value) { + const normalizedClipboard = normalizeTimetableClipboard(clipboardPayload) + + if (!normalizedClipboard) { + setSyncFeedback('local', '붙여넣을 타임테이블이 없습니다.') + return + } + + record.timetable = [...normalizedClipboard.timetable] + schedulePlannerSyncForRecord(record) + timetableClipboard.value = normalizedClipboard + + setSyncFeedback( + 'local', + normalizedClipboard.timetable.some(Boolean) + ? `${createDateLabel(normalizedClipboard.sourceDateKey)} 타임테이블을 ${createDateLabel(targetDateKey)}에 붙여넣었습니다.` + : `${createDateLabel(normalizedClipboard.sourceDateKey)} 타임테이블이 비어 있어 ${createDateLabel(targetDateKey)}도 비웠습니다.`, + ) +} + +async function handleTimetableHeaderAction(record, dateKey) { + closeTimetableContextMenu() + await copyTimetableToClipboard(record, dateKey) +} + +function openTimetableContextMenu(record, dateKey, event) { + timetableContextMenu.value = { + open: true, + x: event.clientX, + y: event.clientY, + dateKey, + record, + } +} + +function closeTimetableContextMenu() { + timetableContextMenu.value = { + open: false, + x: 0, + y: 0, + dateKey: '', + record: null, + } +} + +function pasteTimetableToContextTarget() { + const { record, dateKey } = timetableContextMenu.value + + if (!record || !dateKey) { + closeTimetableContextMenu() + return + } + + pasteTimetableFromClipboard(record, dateKey) + closeTimetableContextMenu() } function updateMemo(record, { index, value }) { @@ -2905,27 +2988,29 @@ onBeforeUnmount(() => { {{ isCompactMobile ? 'INFO' : 'OPEN SIDE PANEL' }} - +