Simple undo/redo
This commit is contained in:
parent
7ca5b70c1e
commit
130ccc0f73
@ -16,9 +16,9 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-holidays": "^3.25.1",
|
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"date-fns-tz": "^3.0.0",
|
"date-fns-tz": "^3.0.0",
|
||||||
|
"date-holidays": "^3.25.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.5.0",
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
"vue": "^3.5.18"
|
"vue": "^3.5.18"
|
||||||
|
31
src/App.vue
31
src/App.vue
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import CalendarView from './components/CalendarView.vue'
|
import CalendarView from './components/CalendarView.vue'
|
||||||
import EventDialog from './components/EventDialog.vue'
|
import EventDialog from './components/EventDialog.vue'
|
||||||
import { useCalendarStore } from './stores/CalendarStore'
|
import { useCalendarStore } from './stores/CalendarStore'
|
||||||
@ -8,8 +8,37 @@ const eventDialog = ref(null)
|
|||||||
const calendarStore = useCalendarStore()
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
// Initialize holidays when app starts
|
// Initialize holidays when app starts
|
||||||
|
function isEditableElement(el) {
|
||||||
|
if (!el) return false
|
||||||
|
const tag = el.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return true
|
||||||
|
if (el.isContentEditable) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKey(e) {
|
||||||
|
// Only consider Ctrl/Meta+Z combos
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return
|
||||||
|
if (e.key !== 'z' && e.key !== 'Z') return
|
||||||
|
// Don't interfere with native undo/redo inside editable fields
|
||||||
|
const target = e.target
|
||||||
|
if (isEditableElement(target)) return
|
||||||
|
// Decide undo vs redo (Shift = redo)
|
||||||
|
if (e.shiftKey) {
|
||||||
|
calendarStore.$history?.redo()
|
||||||
|
} else {
|
||||||
|
calendarStore.$history?.undo()
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
calendarStore.initializeHolidaysFromConfig()
|
calendarStore.initializeHolidaysFromConfig()
|
||||||
|
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('keydown', handleGlobalKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreateEvent = (eventData) => {
|
const handleCreateEvent = (eventData) => {
|
||||||
|
@ -9,13 +9,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Layout & typography */
|
/* Layout & typography */
|
||||||
* { box-sizing: border-box }
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
html, body { height: 100%; }
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial;
|
font:
|
||||||
|
500 14px/1.2 ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
Segoe UI,
|
||||||
|
Roboto,
|
||||||
|
Inter,
|
||||||
|
Arial;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
/* Prevent body scrolling / unwanted scrollbars due to mobile browser UI chrome affecting vh */
|
/* Prevent body scrolling / unwanted scrollbars due to mobile browser UI chrome affecting vh */
|
||||||
@ -23,38 +35,50 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure root app container doesn't introduce its own scrollbars */
|
/* Ensure root app container doesn't introduce its own scrollbars */
|
||||||
#app { height: 100%; width: 100%; overflow: hidden; }
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: .75rem;
|
margin-bottom: 0.75rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-date { cursor: pointer }
|
.today-date {
|
||||||
.today-date::first-line { color: var(--today) }
|
cursor: pointer;
|
||||||
.today-button:hover { opacity: .8 }
|
}
|
||||||
|
.today-date::first-line {
|
||||||
|
color: var(--today);
|
||||||
|
}
|
||||||
|
.today-button:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header row */
|
/* Header row */
|
||||||
.calendar-header, #calendar-header {
|
.calendar-header,
|
||||||
|
#calendar-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w);
|
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w);
|
||||||
border-bottom: .2em solid var(--muted);
|
border-bottom: 0.2em solid var(--muted);
|
||||||
align-items: last baseline;
|
align-items: last baseline;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main container */
|
/* Main container */
|
||||||
.calendar-container, #calendar-container {
|
.calendar-container,
|
||||||
|
#calendar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -63,7 +87,8 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Viewports (support id or class) */
|
/* Viewports (support id or class) */
|
||||||
.calendar-viewport, #calendar-viewport {
|
.calendar-viewport,
|
||||||
|
#calendar-viewport {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -72,11 +97,16 @@ header {
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.calendar-viewport::-webkit-scrollbar,
|
.calendar-viewport::-webkit-scrollbar,
|
||||||
#calendar-viewport::-webkit-scrollbar { display: none }
|
#calendar-viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.jogwheel-viewport, #jogwheel-viewport {
|
.jogwheel-viewport,
|
||||||
|
#jogwheel-viewport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; right: 0; bottom: 0;
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
width: var(--overlay-w);
|
width: var(--overlay-w);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -85,10 +115,19 @@ header {
|
|||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
.jogwheel-viewport::-webkit-scrollbar,
|
.jogwheel-viewport::-webkit-scrollbar,
|
||||||
#jogwheel-viewport::-webkit-scrollbar { display: none }
|
#jogwheel-viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.jogwheel-content, #jogwheel-content { position: relative; width: 100% }
|
.jogwheel-content,
|
||||||
.calendar-content, #calendar-content { position: relative }
|
#jogwheel-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.calendar-content,
|
||||||
|
#calendar-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
/* Week row: label + 7-day grid + jogwheel column */
|
/* Week row: label + 7-day grid + jogwheel column */
|
||||||
.week-row {
|
.week-row {
|
||||||
@ -102,7 +141,8 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Label cells */
|
/* Label cells */
|
||||||
.year-label, .week-label {
|
.year-label,
|
||||||
|
.week-label {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -137,7 +177,8 @@ header {
|
|||||||
z-index: 15;
|
z-index: 15;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; right: 0;
|
top: 0;
|
||||||
|
right: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.month-name-label > span {
|
.month-name-label > span {
|
||||||
|
@ -588,6 +588,27 @@ window.addEventListener('resize', () => {
|
|||||||
<h1>Calendar</h1>
|
<h1>Calendar</h1>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
|
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hist-btn"
|
||||||
|
:disabled="!calendarStore.historyCanUndo"
|
||||||
|
@click="calendarStore.$history?.undo()"
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
aria-label="Undo"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hist-btn"
|
||||||
|
:disabled="!calendarStore.historyCanRedo"
|
||||||
|
@click="calendarStore.$history?.redo()"
|
||||||
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
|
aria-label="Redo"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="settings-btn"
|
class="settings-btn"
|
||||||
@ -657,18 +678,21 @@ window.addEventListener('resize', () => {
|
|||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.6rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
@ -686,6 +710,33 @@ header h1 {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
.hist-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
outline: none;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
}
|
||||||
|
.hist-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.hist-btn:not(:disabled):hover,
|
||||||
|
.hist-btn:not(:disabled):focus-visible {
|
||||||
|
color: var(--strong);
|
||||||
|
}
|
||||||
|
.hist-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
.settings-btn:hover {
|
.settings-btn:hover {
|
||||||
color: var(--strong);
|
color: var(--strong);
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,7 @@ const title = computed({
|
|||||||
set(v) {
|
set(v) {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
calendarStore.events.get(editingEventId.value).title = v
|
calendarStore.events.get(editingEventId.value).title = v
|
||||||
|
calendarStore.touchEvents()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -127,6 +128,7 @@ const selectedColor = computed({
|
|||||||
const n = parseInt(v)
|
const n = parseInt(v)
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
calendarStore.events.get(editingEventId.value).colorId = n
|
calendarStore.events.get(editingEventId.value).colorId = n
|
||||||
|
calendarStore.touchEvents()
|
||||||
}
|
}
|
||||||
colorId.value = n
|
colorId.value = n
|
||||||
},
|
},
|
||||||
@ -144,6 +146,7 @@ const repeatCountBinding = computed({
|
|||||||
set(v) {
|
set(v) {
|
||||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||||
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
||||||
|
calendarStore.touchEvents()
|
||||||
}
|
}
|
||||||
recurrenceOccurrences.value = v
|
recurrenceOccurrences.value = v
|
||||||
},
|
},
|
||||||
@ -189,6 +192,7 @@ function loadWeekdayPatternFromStore(storePattern) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateDialog(selectionData = null) {
|
function openCreateDialog(selectionData = null) {
|
||||||
|
calendarStore.$history?.beginCompound()
|
||||||
if (unsavedCreateId.value && !eventSaved.value) {
|
if (unsavedCreateId.value && !eventSaved.value) {
|
||||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||||
@ -249,6 +253,7 @@ function openCreateDialog(selectionData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(payload) {
|
function openEditDialog(payload) {
|
||||||
|
calendarStore.$history?.beginCompound()
|
||||||
if (
|
if (
|
||||||
dialogMode.value === 'create' &&
|
dialogMode.value === 'create' &&
|
||||||
unsavedCreateId.value &&
|
unsavedCreateId.value &&
|
||||||
@ -352,6 +357,7 @@ function openEditDialog(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
|
calendarStore.$history?.endCompound()
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,6 +373,7 @@ function updateEventInStore() {
|
|||||||
event.repeatCount =
|
event.repeatCount =
|
||||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||||
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||||
|
calendarStore.touchEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,11 +384,13 @@ function saveEvent() {
|
|||||||
unsavedCreateId.value = null
|
unsavedCreateId.value = null
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') emit('clear-selection')
|
if (dialogMode.value === 'create') emit('clear-selection')
|
||||||
|
calendarStore.$history?.endCompound()
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEventAll() {
|
function deleteEventAll() {
|
||||||
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
||||||
|
calendarStore.$history?.endCompound()
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,12 +400,14 @@ function deleteEventOne() {
|
|||||||
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||||
}
|
}
|
||||||
|
calendarStore.$history?.endCompound()
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEventFrom() {
|
function deleteEventFrom() {
|
||||||
if (!occurrenceContext.value) return
|
if (!occurrenceContext.value) return
|
||||||
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
||||||
|
calendarStore.$history?.endCompound()
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,6 +423,8 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
|||||||
})
|
})
|
||||||
watch(showDialog, (val, oldVal) => {
|
watch(showDialog, (val, oldVal) => {
|
||||||
if (oldVal && !val) {
|
if (oldVal && !val) {
|
||||||
|
// Closed (cancel, escape, outside click) -> end compound session
|
||||||
|
calendarStore.$history?.endCompound()
|
||||||
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
|
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
|
||||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||||
|
@ -154,8 +154,13 @@ function startLocalDrag(init, evt) {
|
|||||||
anchorOffset,
|
anchorOffset,
|
||||||
originSpanDays: spanDays,
|
originSpanDays: spanDays,
|
||||||
eventMoved: false,
|
eventMoved: false,
|
||||||
|
tentativeStart: init.startDate,
|
||||||
|
tentativeEnd: init.endDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Begin compound history session (single snapshot after drag completes)
|
||||||
|
store.$history?.beginCompound()
|
||||||
|
|
||||||
// Capture pointer events globally
|
// Capture pointer events globally
|
||||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||||
try {
|
try {
|
||||||
@ -211,7 +216,18 @@ function onDragPointerMove(e) {
|
|||||||
|
|
||||||
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||||
if (!ns || !ne) return
|
if (!ns || !ne) return
|
||||||
applyRangeDuringDrag(st, ns, ne)
|
// Only proceed if changed
|
||||||
|
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
|
||||||
|
st.tentativeStart = ns
|
||||||
|
st.tentativeEnd = ne
|
||||||
|
// Real-time update only for non-virtual events (avoid repeated split operations)
|
||||||
|
if (!st.isVirtual) {
|
||||||
|
applyRangeDuringDrag(
|
||||||
|
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
|
||||||
|
ns,
|
||||||
|
ne,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragPointerUp(e) {
|
function onDragPointerUp(e) {
|
||||||
@ -228,6 +244,8 @@ function onDragPointerUp(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moved = !!st.eventMoved
|
const moved = !!st.eventMoved
|
||||||
|
const finalStart = st.tentativeStart
|
||||||
|
const finalEnd = st.tentativeEnd
|
||||||
dragState.value = null
|
dragState.value = null
|
||||||
|
|
||||||
window.removeEventListener('pointermove', onDragPointerMove)
|
window.removeEventListener('pointermove', onDragPointerMove)
|
||||||
@ -235,11 +253,27 @@ function onDragPointerUp(e) {
|
|||||||
window.removeEventListener('pointercancel', onDragPointerUp)
|
window.removeEventListener('pointercancel', onDragPointerUp)
|
||||||
|
|
||||||
if (moved) {
|
if (moved) {
|
||||||
|
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare)
|
||||||
|
if (st.isVirtual) {
|
||||||
|
applyRangeDuringDrag(
|
||||||
|
{
|
||||||
|
id: st.id,
|
||||||
|
isVirtual: st.isVirtual,
|
||||||
|
mode: st.mode,
|
||||||
|
startDate: finalStart,
|
||||||
|
endDate: finalEnd,
|
||||||
|
},
|
||||||
|
finalStart,
|
||||||
|
finalEnd,
|
||||||
|
)
|
||||||
|
}
|
||||||
justDragged.value = true
|
justDragged.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
justDragged.value = false
|
justDragged.value = false
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
|
// End compound session (snapshot if changed)
|
||||||
|
store.$history?.endCompound()
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
||||||
|
@ -3,13 +3,16 @@ import './assets/calendar.css'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
|
import { calendarHistory } from '@/plugins/calendarHistory'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
|
||||||
pinia.use(piniaPluginPersistedstate)
|
pinia.use(piniaPluginPersistedstate)
|
||||||
|
pinia.use(calendarHistory)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
200
src/plugins/calendarHistory.js
Normal file
200
src/plugins/calendarHistory.js
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
// Custom lightweight undo/redo specifically for calendar store with Map support
|
||||||
|
// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() }
|
||||||
|
// Wraps action calls to create history entries only for meaningful mutations.
|
||||||
|
|
||||||
|
function deepCloneCalendarState(raw) {
|
||||||
|
// We only need to snapshot keys we care about; omit volatile fields
|
||||||
|
const { today, events, config, weekend } = raw
|
||||||
|
return {
|
||||||
|
today,
|
||||||
|
weekend: Array.isArray(weekend) ? [...weekend] : weekend,
|
||||||
|
config: JSON.parse(JSON.stringify(config)),
|
||||||
|
events: new Map([...events].map(([k, v]) => [k, { ...v }])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCalendarState(store, snap) {
|
||||||
|
store.today = snap.today
|
||||||
|
store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend
|
||||||
|
store.config = JSON.parse(JSON.stringify(snap.config))
|
||||||
|
store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }]))
|
||||||
|
store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarHistory({ store }) {
|
||||||
|
if (store.$id !== 'calendar') return
|
||||||
|
|
||||||
|
const max = 100 // history depth limit
|
||||||
|
const history = [] // past states
|
||||||
|
let pointer = -1 // index of current state in history
|
||||||
|
let isRestoring = false
|
||||||
|
let lastSerialized = null
|
||||||
|
// Compound editing session (e.g. event dialog) flags
|
||||||
|
let compoundActive = false
|
||||||
|
let compoundBaseSig = null
|
||||||
|
let compoundChanged = false
|
||||||
|
|
||||||
|
function serializeForComparison() {
|
||||||
|
const evCount = store.events instanceof Map ? store.events.size : 0
|
||||||
|
const em = store.eventsMutation || 0
|
||||||
|
return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSnapshot() {
|
||||||
|
if (isRestoring) return
|
||||||
|
const sig = serializeForComparison()
|
||||||
|
if (sig === lastSerialized) return
|
||||||
|
// Drop any redo branch
|
||||||
|
if (pointer < history.length - 1) history.splice(pointer + 1)
|
||||||
|
history.push(deepCloneCalendarState(store))
|
||||||
|
if (history.length > max) history.shift()
|
||||||
|
pointer = history.length - 1
|
||||||
|
lastSerialized = sig
|
||||||
|
bumpIndicators()
|
||||||
|
// console.log('[history] pushed', pointer, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpIndicators() {
|
||||||
|
if (typeof store.historyTick === 'number') {
|
||||||
|
store.historyTick = (store.historyTick + 1) % 1_000_000_000
|
||||||
|
}
|
||||||
|
if (typeof store.historyCanUndo === 'boolean') {
|
||||||
|
store.historyCanUndo = pointer > 0
|
||||||
|
}
|
||||||
|
if (typeof store.historyCanRedo === 'boolean') {
|
||||||
|
store.historyCanRedo = pointer >= 0 && pointer < history.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPotentialChange() {
|
||||||
|
if (isRestoring) return
|
||||||
|
if (compoundActive) {
|
||||||
|
const sig = serializeForComparison()
|
||||||
|
if (sig !== compoundBaseSig) compoundChanged = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginCompound() {
|
||||||
|
if (compoundActive) return
|
||||||
|
compoundActive = true
|
||||||
|
compoundBaseSig = serializeForComparison()
|
||||||
|
compoundChanged = false
|
||||||
|
}
|
||||||
|
function endCompound() {
|
||||||
|
if (!compoundActive) return
|
||||||
|
const finalSig = serializeForComparison()
|
||||||
|
const changed = compoundChanged || finalSig !== compoundBaseSig
|
||||||
|
compoundActive = false
|
||||||
|
compoundBaseSig = null
|
||||||
|
if (changed) pushSnapshot()
|
||||||
|
else bumpIndicators() // session ended without change – still refresh flags
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
// Ensure any active compound changes are finalized before moving back
|
||||||
|
if (compoundActive) endCompound()
|
||||||
|
else {
|
||||||
|
// If current state differs from last snapshot, push it so redo can restore it
|
||||||
|
const curSig = serializeForComparison()
|
||||||
|
if (curSig !== lastSerialized) pushSnapshot()
|
||||||
|
}
|
||||||
|
if (pointer <= 0) return
|
||||||
|
pointer--
|
||||||
|
isRestoring = true
|
||||||
|
try {
|
||||||
|
restoreCalendarState(store, history[pointer])
|
||||||
|
lastSerialized = serializeForComparison()
|
||||||
|
} finally {
|
||||||
|
isRestoring = false
|
||||||
|
}
|
||||||
|
bumpIndicators()
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
if (compoundActive) endCompound()
|
||||||
|
else {
|
||||||
|
const curSig = serializeForComparison()
|
||||||
|
if (curSig !== lastSerialized) pushSnapshot()
|
||||||
|
}
|
||||||
|
if (pointer >= history.length - 1) return
|
||||||
|
pointer++
|
||||||
|
isRestoring = true
|
||||||
|
try {
|
||||||
|
restoreCalendarState(store, history[pointer])
|
||||||
|
lastSerialized = serializeForComparison()
|
||||||
|
} finally {
|
||||||
|
isRestoring = false
|
||||||
|
}
|
||||||
|
bumpIndicators()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
history.length = 0
|
||||||
|
pointer = -1
|
||||||
|
lastSerialized = null
|
||||||
|
bumpIndicators()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap selected mutating actions to push snapshot AFTER they run if state changed.
|
||||||
|
const actionNames = [
|
||||||
|
'createEvent',
|
||||||
|
'deleteEvent',
|
||||||
|
'deleteFirstOccurrence',
|
||||||
|
'deleteSingleOccurrence',
|
||||||
|
'deleteFromOccurrence',
|
||||||
|
'setEventRange',
|
||||||
|
'splitMoveVirtualOccurrence',
|
||||||
|
'splitRepeatSeries',
|
||||||
|
'_terminateRepeatSeriesAtIndex',
|
||||||
|
'toggleHolidays',
|
||||||
|
'initializeHolidays',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const name of actionNames) {
|
||||||
|
if (typeof store[name] === 'function') {
|
||||||
|
const original = store[name].bind(store)
|
||||||
|
store[name] = (...args) => {
|
||||||
|
const beforeSig = serializeForComparison()
|
||||||
|
const result = original(...args)
|
||||||
|
const afterSig = serializeForComparison()
|
||||||
|
if (afterSig !== beforeSig) markPotentialChange()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture direct property edits (e.g., deep field edits signaled via touchEvents())
|
||||||
|
store.$subscribe((mutation, _state) => {
|
||||||
|
if (mutation.storeId !== 'calendar') return
|
||||||
|
markPotentialChange()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initial snapshot after hydration (next microtask to let persistence load)
|
||||||
|
Promise.resolve().then(() => pushSnapshot())
|
||||||
|
|
||||||
|
store.$history = {
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
clear,
|
||||||
|
pushManual: pushSnapshot,
|
||||||
|
beginCompound,
|
||||||
|
endCompound,
|
||||||
|
flush() {
|
||||||
|
pushSnapshot()
|
||||||
|
},
|
||||||
|
get canUndo() {
|
||||||
|
return pointer > 0
|
||||||
|
},
|
||||||
|
get canRedo() {
|
||||||
|
return pointer >= 0 && pointer < history.length - 1
|
||||||
|
},
|
||||||
|
get compoundActive() {
|
||||||
|
return compoundActive
|
||||||
|
},
|
||||||
|
_debug() {
|
||||||
|
return { pointer, length: history.length }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
57
src/plugins/calendarUndoNormalize.js
Normal file
57
src/plugins/calendarUndoNormalize.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots
|
||||||
|
export function calendarUndoNormalize({ store }) {
|
||||||
|
if (store.$id !== 'calendar') return
|
||||||
|
|
||||||
|
function fixEvents() {
|
||||||
|
const evs = store.events
|
||||||
|
if (evs instanceof Map) return
|
||||||
|
// If serialized form { __map: true, data: [...] }
|
||||||
|
if (evs && evs.__map && Array.isArray(evs.data)) {
|
||||||
|
store.events = new Map(evs.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If an array of [k,v]
|
||||||
|
if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) {
|
||||||
|
store.events = new Map(evs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If plain object, convert own enumerable props
|
||||||
|
if (evs && typeof evs === 'object') {
|
||||||
|
store.events = new Map(Object.entries(evs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch undo/redo if present (after pinia-undo is installed)
|
||||||
|
const patchFns = ['undo', 'redo']
|
||||||
|
for (const fn of patchFns) {
|
||||||
|
if (typeof store[fn] === 'function') {
|
||||||
|
const original = store[fn].bind(store)
|
||||||
|
store[fn] = (...args) => {
|
||||||
|
console.log(`[calendar history] ${fn} invoked`)
|
||||||
|
const beforeType = store.events && store.events.constructor && store.events.constructor.name
|
||||||
|
const out = original(...args)
|
||||||
|
const afterRawType =
|
||||||
|
store.events && store.events.constructor && store.events.constructor.name
|
||||||
|
fixEvents()
|
||||||
|
const finalType = store.events && store.events.constructor && store.events.constructor.name
|
||||||
|
let size = null
|
||||||
|
try {
|
||||||
|
if (store.events instanceof Map) size = store.events.size
|
||||||
|
else if (Array.isArray(store.events)) size = store.events.length
|
||||||
|
} catch {}
|
||||||
|
console.log(
|
||||||
|
`[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also watch all mutations (includes direct assigns and action commits)
|
||||||
|
store.$subscribe(() => {
|
||||||
|
fixEvents()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initial sanity
|
||||||
|
fixEvents()
|
||||||
|
}
|
@ -18,6 +18,10 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
// Lightweight mutation counter so views can rebuild in a throttled / idle way
|
// Lightweight mutation counter so views can rebuild in a throttled / idle way
|
||||||
// without tracking deep reactivity on every event object.
|
// without tracking deep reactivity on every event object.
|
||||||
eventsMutation: 0,
|
eventsMutation: 0,
|
||||||
|
// Incremented internally by history plugin to force reactive updates for canUndo/canRedo
|
||||||
|
historyTick: 0,
|
||||||
|
historyCanUndo: false,
|
||||||
|
historyCanRedo: false,
|
||||||
weekend: getLocaleWeekendDays(),
|
weekend: getLocaleWeekendDays(),
|
||||||
_holidayConfigSignature: null,
|
_holidayConfigSignature: null,
|
||||||
_holidaysInitialized: false,
|
_holidaysInitialized: false,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user