Simple undo/redo
This commit is contained in:
parent
7ca5b70c1e
commit
130ccc0f73
@ -16,9 +16,9 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-holidays": "^3.25.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.0.0",
|
||||
"date-holidays": "^3.25.1",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"vue": "^3.5.18"
|
||||
|
31
src/App.vue
31
src/App.vue
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import CalendarView from './components/CalendarView.vue'
|
||||
import EventDialog from './components/EventDialog.vue'
|
||||
import { useCalendarStore } from './stores/CalendarStore'
|
||||
@ -8,8 +8,37 @@ const eventDialog = ref(null)
|
||||
const calendarStore = useCalendarStore()
|
||||
|
||||
// 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(() => {
|
||||
calendarStore.initializeHolidaysFromConfig()
|
||||
document.addEventListener('keydown', handleGlobalKey, { passive: false })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKey)
|
||||
})
|
||||
|
||||
const handleCreateEvent = (eventData) => {
|
||||
|
@ -9,13 +9,25 @@
|
||||
}
|
||||
|
||||
/* Layout & typography */
|
||||
* { box-sizing: border-box }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
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);
|
||||
color: var(--ink);
|
||||
/* 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 */
|
||||
#app { height: 100%; width: 100%; overflow: hidden; }
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.today-date { cursor: pointer }
|
||||
.today-date::first-line { color: var(--today) }
|
||||
.today-button:hover { opacity: .8 }
|
||||
.today-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
.today-date::first-line {
|
||||
color: var(--today);
|
||||
}
|
||||
.today-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Header row */
|
||||
.calendar-header, #calendar-header {
|
||||
.calendar-header,
|
||||
#calendar-header {
|
||||
display: grid;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.calendar-container, #calendar-container {
|
||||
.calendar-container,
|
||||
#calendar-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@ -63,7 +87,8 @@ header {
|
||||
}
|
||||
|
||||
/* Viewports (support id or class) */
|
||||
.calendar-viewport, #calendar-viewport {
|
||||
.calendar-viewport,
|
||||
#calendar-viewport {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@ -72,11 +97,16 @@ header {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.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;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--overlay-w);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@ -85,10 +115,19 @@ header {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.jogwheel-viewport::-webkit-scrollbar,
|
||||
#jogwheel-viewport::-webkit-scrollbar { display: none }
|
||||
#jogwheel-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jogwheel-content, #jogwheel-content { position: relative; width: 100% }
|
||||
.calendar-content, #calendar-content { position: relative }
|
||||
.jogwheel-content,
|
||||
#jogwheel-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.calendar-content,
|
||||
#calendar-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Week row: label + 7-day grid + jogwheel column */
|
||||
.week-row {
|
||||
@ -102,7 +141,8 @@ header {
|
||||
}
|
||||
|
||||
/* Label cells */
|
||||
.year-label, .week-label {
|
||||
.year-label,
|
||||
.week-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
@ -137,7 +177,8 @@ header {
|
||||
z-index: 15;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: 0; right: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.month-name-label > span {
|
||||
|
@ -588,6 +588,27 @@ window.addEventListener('resize', () => {
|
||||
<h1>Calendar</h1>
|
||||
<div class="header-controls">
|
||||
<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
|
||||
type="button"
|
||||
class="settings-btn"
|
||||
@ -657,18 +678,21 @@ window.addEventListener('resize', () => {
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
@ -686,6 +710,33 @@ header h1 {
|
||||
justify-content: center;
|
||||
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 {
|
||||
color: var(--strong);
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ const title = computed({
|
||||
set(v) {
|
||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||
calendarStore.events.get(editingEventId.value).title = v
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -127,6 +128,7 @@ const selectedColor = computed({
|
||||
const n = parseInt(v)
|
||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||
calendarStore.events.get(editingEventId.value).colorId = n
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
colorId.value = n
|
||||
},
|
||||
@ -144,6 +146,7 @@ const repeatCountBinding = computed({
|
||||
set(v) {
|
||||
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
||||
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
recurrenceOccurrences.value = v
|
||||
},
|
||||
@ -189,6 +192,7 @@ function loadWeekdayPatternFromStore(storePattern) {
|
||||
}
|
||||
|
||||
function openCreateDialog(selectionData = null) {
|
||||
calendarStore.$history?.beginCompound()
|
||||
if (unsavedCreateId.value && !eventSaved.value) {
|
||||
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
||||
calendarStore.deleteEvent(unsavedCreateId.value)
|
||||
@ -249,6 +253,7 @@ function openCreateDialog(selectionData = null) {
|
||||
}
|
||||
|
||||
function openEditDialog(payload) {
|
||||
calendarStore.$history?.beginCompound()
|
||||
if (
|
||||
dialogMode.value === 'create' &&
|
||||
unsavedCreateId.value &&
|
||||
@ -352,6 +357,7 @@ function openEditDialog(payload) {
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
calendarStore.$history?.endCompound()
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
@ -367,6 +373,7 @@ function updateEventInStore() {
|
||||
event.repeatCount =
|
||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
||||
calendarStore.touchEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@ -377,11 +384,13 @@ 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()
|
||||
}
|
||||
|
||||
@ -391,12 +400,14 @@ 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()
|
||||
}
|
||||
|
||||
@ -412,6 +423,8 @@ 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)
|
||||
|
@ -154,8 +154,13 @@ function startLocalDrag(init, evt) {
|
||||
anchorOffset,
|
||||
originSpanDays: spanDays,
|
||||
eventMoved: false,
|
||||
tentativeStart: init.startDate,
|
||||
tentativeEnd: init.endDate,
|
||||
}
|
||||
|
||||
// Begin compound history session (single snapshot after drag completes)
|
||||
store.$history?.beginCompound()
|
||||
|
||||
// Capture pointer events globally
|
||||
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||
try {
|
||||
@ -211,7 +216,18 @@ function onDragPointerMove(e) {
|
||||
|
||||
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||
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) {
|
||||
@ -228,6 +244,8 @@ function onDragPointerUp(e) {
|
||||
}
|
||||
|
||||
const moved = !!st.eventMoved
|
||||
const finalStart = st.tentativeStart
|
||||
const finalEnd = st.tentativeEnd
|
||||
dragState.value = null
|
||||
|
||||
window.removeEventListener('pointermove', onDragPointerMove)
|
||||
@ -235,11 +253,27 @@ function onDragPointerUp(e) {
|
||||
window.removeEventListener('pointercancel', onDragPointerUp)
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
justDragged.value = false
|
||||
}, 120)
|
||||
}
|
||||
// End compound session (snapshot if changed)
|
||||
store.$history?.endCompound()
|
||||
}
|
||||
|
||||
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
||||
|
@ -3,13 +3,16 @@ import './assets/calendar.css'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { calendarHistory } from '@/plugins/calendarHistory'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
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)
|
||||
app.use(pinia)
|
||||
|
||||
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
|
||||
// without tracking deep reactivity on every event object.
|
||||
eventsMutation: 0,
|
||||
// Incremented internally by history plugin to force reactive updates for canUndo/canRedo
|
||||
historyTick: 0,
|
||||
historyCanUndo: false,
|
||||
historyCanRedo: false,
|
||||
weekend: getLocaleWeekendDays(),
|
||||
_holidayConfigSignature: null,
|
||||
_holidaysInitialized: false,
|
||||
|
Loading…
x
Reference in New Issue
Block a user