diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index a9163dc..bb5c6b7 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -22,10 +22,18 @@ import { shallowRef } from 'vue' const eventDialogRef = shallowRef(null) function openCreateEventDialog(eventData) { if (!eventDialogRef.value) return + // Capture baseline before dialog opens (new event creation flow) + try { + calendarStore.$history?._baselineIfNeeded?.(true) + } catch {} const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) } function openEditEventDialog(eventClickPayload) { + // Capture baseline before editing existing event + try { + calendarStore.$history?._baselineIfNeeded?.(true) + } catch {} eventDialogRef.value?.openEditDialog(eventClickPayload) } const viewport = ref(null) diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index 81f0514..ee7be45 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -22,6 +22,8 @@ const props = defineProps({ const emit = defineEmits(['clear-selection']) const calendarStore = useCalendarStore() +// Track baseline signature when dialog opens to decide if we need an undo snapshot on close +let dialogBaselineSig = null const showDialog = ref(false) // Anchoring: element of the DayCell representing the event's start date. @@ -208,7 +210,8 @@ function resolveAnchorFromDate(dateStr) { } function openCreateDialog(selectionData = null) { - calendarStore.$history?.beginCompound() + // Pre-change snapshot (before creating stub event) + calendarStore.$history?.push?.() if (unsavedCreateId.value && !eventSaved.value) { if (calendarStore.events?.has(unsavedCreateId.value)) { calendarStore.deleteEvent(unsavedCreateId.value) @@ -272,6 +275,7 @@ function openCreateDialog(selectionData = null) { // anchor to the starting day cell anchorElement.value = resolveAnchorFromDate(start) showDialog.value = true + // (Pre snapshot already taken before stub creation) nextTick(() => { if (titleInput.value) { @@ -284,7 +288,6 @@ function openCreateDialog(selectionData = null) { } function openEditDialog(payload) { - calendarStore.$history?.beginCompound() if ( dialogMode.value === 'create' && unsavedCreateId.value && @@ -348,6 +351,8 @@ function openEditDialog(payload) { // anchor to base event start date anchorElement.value = resolveAnchorFromDate(event.startDate) showDialog.value = true + // Pre-change snapshot (only once when dialog opens) + calendarStore.$history?.push?.() nextTick(() => { if (titleInput.value) { @@ -360,7 +365,6 @@ function openEditDialog(payload) { } function closeDialog() { - calendarStore.$history?.endCompound() showDialog.value = false } @@ -392,13 +396,11 @@ function saveEvent() { unsavedCreateId.value = null } if (dialogMode.value === 'create') emit('clear-selection') - calendarStore.$history?.endCompound() closeDialog() } function deleteEventAll() { if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value) - calendarStore.$history?.endCompound() closeDialog() } @@ -408,14 +410,12 @@ function deleteEventOne() { } else if (isRepeatingBaseEdit.value && editingEventId.value) { calendarStore.deleteFirstOccurrence(editingEventId.value) } - calendarStore.$history?.endCompound() closeDialog() } function deleteEventFrom() { if (!occurrenceContext.value) return calendarStore.deleteFromOccurrence(occurrenceContext.value) - calendarStore.$history?.endCompound() closeDialog() } @@ -431,8 +431,6 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => { }) watch(showDialog, (val, oldVal) => { if (oldVal && !val) { - // Closed (cancel, escape, outside click) -> end compound session - calendarStore.$history?.endCompound() if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) { if (calendarStore.events?.has(unsavedCreateId.value)) { calendarStore.deleteEvent(unsavedCreateId.value) diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue index 4224f49..8287540 100644 --- a/src/components/EventOverlay.vue +++ b/src/components/EventOverlay.vue @@ -314,7 +314,46 @@ function startLocalDrag(init, evt) { realizedId: null, } - store.$history?.beginCompound() + // If history is empty (no baseline), create a baseline snapshot BEFORE any movement mutations + try { + const isResize = init.mode === 'resize-left' || init.mode === 'resize-right' + // Move: only baseline if history empty. Resize: force baseline (so undo returns to pre-resize) but only once. + store.$history?._baselineIfNeeded?.(isResize) + const evs = [] + if (store.events instanceof Map) { + for (const [id, ev] of store.events) { + evs.push({ + id, + start: ev.startDate, + days: ev.days, + title: ev.title, + color: ev.colorId, + recur: ev.recur + ? { + f: ev.recur.freq, + i: ev.recur.interval, + c: ev.recur.count, + w: Array.isArray(ev.recur.weekdays) ? ev.recur.weekdays.join('') : null, + } + : null, + }) + } + } + console.debug( + isResize ? '[history] pre-resize baseline snapshot' : '[history] pre-drag baseline snapshot', + { + mode: init.mode, + events: evs, + weekend: store.weekend, + firstDay: store.config?.first_day, + }, + ) + } catch {} + + // Enter drag suppression (prevent intermediate pushes) + try { + store.$history?._beginDrag?.() + } catch {} if (evt.currentTarget && evt.pointerId !== undefined) { try { @@ -453,7 +492,10 @@ function onDragPointerUp(e) { justDragged.value = false }, 120) } - store.$history?.endCompound() + // End drag suppression regardless; no post snapshot (pre-only model) + try { + store.$history?._endDrag?.() + } catch {} } const min = (a, b) => (a < b ? a : b) diff --git a/src/components/HeaderControls.vue b/src/components/HeaderControls.vue index 7a4e304..8bfff13 100644 --- a/src/components/HeaderControls.vue +++ b/src/components/HeaderControls.vue @@ -95,6 +95,10 @@ function toggleVisibility() { // Settings dialog integration const settingsDialog = ref(null) function openSettings() { + // Capture baseline before opening settings + try { + calendarStore.$history?._baselineIfNeeded?.(true) + } catch {} settingsDialog.value?.open() } diff --git a/src/main.js b/src/main.js index 82ac165..a8dce10 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import './assets/calendar.css' import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' -import { calendarHistory } from '@/plugins/calendarHistory' +import { history } from '@/plugins/history' import App from './App.vue' @@ -12,7 +12,7 @@ const app = createApp(App) const pinia = createPinia() // Order: persistence first so snapshots recorded by undo reflect already-hydrated state pinia.use(piniaPluginPersistedstate) -pinia.use(calendarHistory) +pinia.use(history) app.use(pinia) app.mount('#app') diff --git a/src/plugins/calendarHistory.js b/src/plugins/calendarHistory.js deleted file mode 100644 index 8219f44..0000000 --- a/src/plugins/calendarHistory.js +++ /dev/null @@ -1,198 +0,0 @@ -// 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 }])) -} - -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 - return `${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 } - }, - } -} diff --git a/src/plugins/calendarUndoNormalize.js b/src/plugins/calendarUndoNormalize.js deleted file mode 100644 index af72c75..0000000 --- a/src/plugins/calendarUndoNormalize.js +++ /dev/null @@ -1,57 +0,0 @@ -// 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() -} diff --git a/src/plugins/history.js b/src/plugins/history.js new file mode 100644 index 0000000..6492f15 --- /dev/null +++ b/src/plugins/history.js @@ -0,0 +1,110 @@ +// Minimal undo/redo with explicit snapshot triggers. +// API: store.$history = { push(), undo(), redo(), clear(), canUndo, canRedo, _baselineIfNeeded(), _beginDrag(), _endDrag() } + +function cloneEvent(ev) { + if (!ev || typeof ev !== 'object') return ev + const c = { ...ev } + if (c.recur && typeof c.recur === 'object') { + c.recur = { + ...c.recur, + weekdays: Array.isArray(c.recur.weekdays) ? [...c.recur.weekdays] : c.recur.weekdays, + } + } + return c +} +function cloneState(store) { + const events = new Map() + for (const [k, ev] of store.events) events.set(k, cloneEvent(ev)) + return { + today: store.today, + weekend: [...store.weekend], + config: JSON.parse(JSON.stringify(store.config)), + events, + } +} +function restoreState(store, snap) { + store.today = snap.today + store.weekend = [...snap.weekend] + store.config = JSON.parse(JSON.stringify(snap.config)) + const events = new Map() + for (const [k, ev] of snap.events) events.set(k, cloneEvent(ev)) + store.events = events +} +function same(a, b) { + if (!a || !b) return false + if (a.today !== b.today) return false + if (JSON.stringify(a.config) !== JSON.stringify(b.config)) return false + if (JSON.stringify(a.weekend) !== JSON.stringify(b.weekend)) return false + if (a.events.size !== b.events.size) return false + for (const [k, ev] of a.events) { + const other = b.events.get(k) + if (!other || JSON.stringify(ev) !== JSON.stringify(other)) return false + } + return true +} + +export function history({ store }) { + if (store.$id !== 'calendar') return + + const undoStack = [] + const redoStack = [] + const maxHistory = 50 + + function push() { + const snap = cloneState(store) + const last = undoStack[undoStack.length - 1] + if (last && same(snap, last)) return + undoStack.push(snap) + redoStack.length = 0 + if (undoStack.length > maxHistory) undoStack.shift() + updateIndicators() + } + + function undo() { + if (!undoStack.length) return + redoStack.push(cloneState(store)) + restoreState(store, undoStack.pop()) + updateIndicators() + } + + function redo() { + if (!redoStack.length) return + undoStack.push(cloneState(store)) + restoreState(store, redoStack.pop()) + updateIndicators() + } + + function clear() { + undoStack.length = redoStack.length = 0 + updateIndicators() + } + + function updateIndicators() { + store.historyCanUndo = undoStack.length > 0 + store.historyCanRedo = redoStack.length > 0 + store.historyTick = (store.historyTick + 1) % 1000000 + } + + // No initial snapshot: caller decides when to baseline. + + store.$history = { + undo, + redo, + clear, + push, + get canUndo() { + return undoStack.length > 0 + }, + get canRedo() { + return redoStack.length > 0 + }, + // Drag lifecycle helpers used by EventOverlay.vue + _baselineIfNeeded(force = false) { + // Force: always push (resize, explicit dialogs). Non-force: rely on duplicate detection. + if (force) return push() + push() + }, + _beginDrag() {}, + _endDrag() {}, + } +} diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index 516923b..4d230f4 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -410,6 +410,8 @@ export const useCalendarStore = defineStore('calendar', { } } this.notifyEventsChanged() + // NOTE: Do NOT push a history snapshot here; wait until drag pointer up. + // Mid-drag snapshots create extra undo points. Final snapshot occurs in EventOverlay on pointerup. return newId },