feat: add central store with snapshot undo/redo
This commit is contained in:
213
src/composables/useCalendar.js
Normal file
213
src/composables/useCalendar.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
import { parseICS, serializeICS } from '../lib/ical-io.js'
|
||||||
|
import { organize as organizeEvents } from '../lib/organize.js'
|
||||||
|
|
||||||
|
const MAX_HISTORY = 50
|
||||||
|
|
||||||
|
// Singleton reactive state
|
||||||
|
const state = reactive({
|
||||||
|
meta: null,
|
||||||
|
events: [],
|
||||||
|
selectedUid: null,
|
||||||
|
history: [],
|
||||||
|
redoStack: [],
|
||||||
|
fileName: null,
|
||||||
|
dirty: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Snapshot / undo
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
function snapshot() {
|
||||||
|
return {
|
||||||
|
meta: state.meta ? JSON.parse(JSON.stringify(state.meta)) : null,
|
||||||
|
events: JSON.parse(JSON.stringify(state.events)),
|
||||||
|
selectedUid: state.selectedUid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHistory() {
|
||||||
|
state.history.push(snapshot())
|
||||||
|
if (state.history.length > MAX_HISTORY) state.history.shift()
|
||||||
|
state.redoStack = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(snap) {
|
||||||
|
state.meta = snap.meta ? JSON.parse(JSON.stringify(snap.meta)) : null
|
||||||
|
state.events = JSON.parse(JSON.stringify(snap.events))
|
||||||
|
state.selectedUid = snap.selectedUid
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Actions
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
function loadFile(file) {
|
||||||
|
return file.text().then((text) => {
|
||||||
|
const parsed = parseICS(text)
|
||||||
|
state.meta = parsed.meta
|
||||||
|
state.events = parsed.events
|
||||||
|
state.selectedUid = null
|
||||||
|
state.history = []
|
||||||
|
state.redoStack = []
|
||||||
|
state.fileName = file.name
|
||||||
|
state.dirty = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function organize() {
|
||||||
|
if (state.events.length === 0) return
|
||||||
|
pushHistory()
|
||||||
|
const result = organizeEvents(state.events)
|
||||||
|
state.events = result.events
|
||||||
|
state.dirty = true
|
||||||
|
return result.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEvent(uid, patch) {
|
||||||
|
pushHistory()
|
||||||
|
const ev = state.events.find((e) => e.uid === uid)
|
||||||
|
if (ev) {
|
||||||
|
Object.assign(ev, patch)
|
||||||
|
state.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEvent(uid) {
|
||||||
|
pushHistory()
|
||||||
|
state.events = state.events.filter((e) => e.uid !== uid)
|
||||||
|
if (state.selectedUid === uid) state.selectedUid = null
|
||||||
|
state.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent() {
|
||||||
|
pushHistory()
|
||||||
|
const today = new Date()
|
||||||
|
const y = today.getFullYear()
|
||||||
|
const m = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(today.getDate()).padStart(2, '0')
|
||||||
|
const ev = {
|
||||||
|
uid: crypto.randomUUID(),
|
||||||
|
summary: '新日程',
|
||||||
|
location: '',
|
||||||
|
description: '',
|
||||||
|
dtstartDate: `${y}-${m}-${d}`,
|
||||||
|
dtstartTime: '09:00',
|
||||||
|
dtendTime: '10:00',
|
||||||
|
tzid: 'UTC',
|
||||||
|
rrule: null,
|
||||||
|
exdates: [],
|
||||||
|
_raw: { dtstamp: new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' },
|
||||||
|
}
|
||||||
|
state.events.push(ev)
|
||||||
|
state.selectedUid = ev.uid
|
||||||
|
state.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitEvent(uid, splitDate) {
|
||||||
|
const ev = state.events.find((e) => e.uid === uid)
|
||||||
|
if (!ev || !ev.rrule) return
|
||||||
|
|
||||||
|
// Snap splitDate to the next grid point >= splitDate
|
||||||
|
const step = ev.rrule.freq === 'WEEKLY' ? 7 * (ev.rrule.interval || 1) : (ev.rrule.interval || 1)
|
||||||
|
const start = new Date(ev.dtstartDate + 'T00:00:00')
|
||||||
|
let snap = new Date(splitDate + 'T00:00:00')
|
||||||
|
// Advance snap to the next grid point on or after splitDate
|
||||||
|
while (snap < start || ((snap - start) / (24 * 3600 * 1000)) % step !== 0) {
|
||||||
|
if (snap < start) { snap = new Date(start); break }
|
||||||
|
snap.setDate(snap.getDate() + 1)
|
||||||
|
if (snap > new Date(ev.rrule.untilDate + 'T00:00:00')) return // out of range
|
||||||
|
}
|
||||||
|
const snapStr = `${snap.getFullYear()}-${String(snap.getMonth() + 1).padStart(2, '0')}-${String(snap.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
// Part A: rrule.untilDate = day before snap (previous grid point)
|
||||||
|
const prevGrid = new Date(snap)
|
||||||
|
prevGrid.setDate(prevGrid.getDate() - step)
|
||||||
|
const prevStr = `${prevGrid.getFullYear()}-${String(prevGrid.getMonth() + 1).padStart(2, '0')}-${String(prevGrid.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
pushHistory()
|
||||||
|
|
||||||
|
const partA = JSON.parse(JSON.stringify(ev))
|
||||||
|
partA.rrule = { ...ev.rrule, untilDate: prevStr }
|
||||||
|
partA.exdates = ev.exdates.filter((d) => d < splitDate)
|
||||||
|
|
||||||
|
const partB = JSON.parse(JSON.stringify(ev))
|
||||||
|
partB.uid = crypto.randomUUID()
|
||||||
|
partB.dtstartDate = snapStr
|
||||||
|
partB.exdates = ev.exdates.filter((d) => d >= splitDate)
|
||||||
|
|
||||||
|
const idx = state.events.findIndex((e) => e.uid === uid)
|
||||||
|
state.events.splice(idx, 1, partA, partB)
|
||||||
|
state.selectedUid = partB.uid
|
||||||
|
state.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOccurrence(uid, date) {
|
||||||
|
pushHistory()
|
||||||
|
const ev = state.events.find((e) => e.uid === uid)
|
||||||
|
if (!ev) return
|
||||||
|
const i = ev.exdates.indexOf(date)
|
||||||
|
if (i >= 0) {
|
||||||
|
ev.exdates.splice(i, 1)
|
||||||
|
} else {
|
||||||
|
ev.exdates.push(date)
|
||||||
|
ev.exdates.sort()
|
||||||
|
}
|
||||||
|
state.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
if (state.history.length === 0) return
|
||||||
|
state.redoStack.push(snapshot())
|
||||||
|
restore(state.history.pop())
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
if (state.redoStack.length === 0) return
|
||||||
|
state.history.push(snapshot())
|
||||||
|
restore(state.redoStack.pop())
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize() {
|
||||||
|
return serializeICS({ meta: state.meta, events: state.events })
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSaved() {
|
||||||
|
state.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Computed
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
const selectedEvent = computed(() =>
|
||||||
|
state.events.find((e) => e.uid === state.selectedUid) || null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canUndo = computed(() => state.history.length > 0)
|
||||||
|
const canRedo = computed(() => state.redoStack.length > 0)
|
||||||
|
const isLoaded = computed(() => state.events.length > 0)
|
||||||
|
const stats = computed(() => {
|
||||||
|
const nRecur = state.events.filter((e) => e.rrule !== null).length
|
||||||
|
return { total: state.events.length, recurring: nRecur, single: state.events.length - nRecur }
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useCalendar() {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
selectedEvent,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
isLoaded,
|
||||||
|
stats,
|
||||||
|
loadFile,
|
||||||
|
organize,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
addEvent,
|
||||||
|
splitEvent,
|
||||||
|
toggleOccurrence,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
serialize,
|
||||||
|
markSaved,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user