Simple undo/redo

This commit is contained in:
Leo Vasanko 2025-08-24 21:59:56 -06:00
parent 7ca5b70c1e
commit 130ccc0f73
10 changed files with 459 additions and 27 deletions

View File

@ -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"

View File

@ -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) => {

View File

@ -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 {

View File

@ -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);
}

View File

@ -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)

View File

@ -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) {

View File

@ -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')

View 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 }
},
}
}

View 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()
}

View File

@ -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,