This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
css(`
bottombar- {
pointer-events: none;
}
bottombar- select {
-webkit-appearance: none;
text-align-last: center;
-webkit-text-align-last: center;
}
`)
class BottomBar extends Shadow {
baseUrl = `${config.UI}/apps/calendar/icons`
constructor(callbacks = {}) {
super()
this.onAddEvent = callbacks.onAddEvent;
this.onCalendarOptions = callbacks.onCalendarOptions;
this.onChangeView = callbacks.onChangeView;
this.viewMode = callbacks.viewMode;
this.hideViewSelect = callbacks.hideViewSelect ?? false;
}
getImageURL(iconName) {
let imgUrl = `${this.baseUrl}/${iconName}`
if(util.darkMode()) {
imgUrl += "light"
}
imgUrl += ".svg"
imgUrl = imgUrl.toLowerCase()
return imgUrl
}
render() {
HStack(() => {
// Left: view mode select
if (!this.hideViewSelect) select(() => {
["day", "week", "month"].forEach(mode => {
const o = option(mode.charAt(0).toUpperCase() + mode.slice(1))
.attr({ value: mode })
if (mode === this.viewMode) o.attr({ selected: true })
})
})
.fontSize(1, em)
.paddingVertical(0.6, em)
.paddingHorizontal(0.75, em)
.background("var(--button-color)")
.backdropFilter("blur(10px)")
.borderRadius(100, px)
.pointerEvents("auto")
.border(`.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
.color(util.darkMode() ? "var(--text)" : "white")
.onTouch(function (start) {
if(start) {
this.background("var(--main)")
} else {
this.background("var(--button-color)")
}
})
.fontFamily("Arial")
.outline("none")
.appearance("none")
.textAlign("center")
.onChange(e => this.onChangeView(e.target.value))
// Right: add event + calendar options
HStack(() => {
const addEventImg = img(this.getImageURL("addevent"), "1em", "1em")
.paddingVertical(0.75, em).paddingHorizontal(1, em)
.onTap(async () => {
this.onAddEvent()
addEventImg.src = `${this.baseUrl}/addeventlightselected.svg`
const sheet = $("bottomsheet-")
const revert = () => {
if (!sheet.isOpen) {
addEventImg.src = this.getImageURL("addevent")
sheet.sheetEl.removeEventListener("transitionend", revert)
}
}
sheet.sheetEl.addEventListener("transitionend", revert)
})
const calendarSheetImg = img(this.getImageURL("calbutton"), "1.1em")
.paddingVertical(0.75, em).paddingHorizontal(1, em)
.onTap(async () => {
this.onCalendarOptions()
calendarSheetImg.src = `${this.baseUrl}/calbuttonlightselected.svg`
const sheet = $("bottomsheet-")
const revert = () => {
if (!sheet.isOpen) {
calendarSheetImg.src = this.getImageURL("calbutton")
sheet.sheetEl.removeEventListener("transitionend", revert)
}
}
sheet.sheetEl.addEventListener("transitionend", revert)
})
})
.background("var(--button-color)")
.pointerEvents("auto")
.onTouch(function (start) {
if(start) {
this.background("var(--main)")
} else {
this.background("var(--button-color)")
}
})
.backdropFilter("blur(10px)")
.alignItems("center")
.flexShrink(0)
.borderRadius(100, px)
.border(`0.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
.overflow("hidden")
})
.position("absolute")
.left(4, vw)
.bottom(0.75, em)
.zIndex(2)
.justifyContent(this.hideViewSelect ? "flex-end" : "space-between")
.width(92, vw)
.onEvent("themechange", () => {
this.rerender()
})
}
}
register(BottomBar)

View File

@@ -0,0 +1,256 @@
import "../../components/BackButton.js"
import "../CalendarForm.js"
class CalendarOptions extends Shadow {
baseUrl = `${config.UI}/apps/calendar/icons`
getImageURL(iconName) {
let imgUrl = `${this.baseUrl}/${iconName}`
if (util.darkMode()) imgUrl += "light"
imgUrl += ".svg"
return imgUrl.toLowerCase()
}
constructor(calendars = [], selectedCalendars = [], actions) {
super()
this.calendars = calendars;
this.selectedCalendars = selectedCalendars;
this.onSelection = actions.onSelection;
this.onCalendarAdded = actions.onCalendarAdded;
this.onCalendarUpdated = actions.onCalendarUpdated;
this.onCalendarDeleted = actions.onCalendarDeleted;
}
isSelected(calendar) {
return this.selectedCalendars.some(c => c.id === calendar.id);
}
toggleCalendar(calendar) {
const isSelected = this.isSelected(calendar);
if (isSelected && this.selectedCalendars.length === 1) return;
let newSelectedIds;
if (isSelected) {
newSelectedIds = this.selectedCalendars
.filter(c => c.id !== calendar.id)
.map(c => c.id);
} else {
newSelectedIds = [...this.selectedCalendars.map(c => c.id), calendar.id];
}
this.selectedCalendars = this.calendars.filter(c => newSelectedIds.includes(c.id));
this.rerender();
}
render() {
this.calFormSheet = BottomSheet(200)
const doClose = () => {
$("bottomsheet-")._closeOverride = null
$("bottomsheet-").setSheet(false)
setTimeout(() => this.onSelection(this.selectedCalendars), 300)
}
$("bottomsheet-")._closeOverride = doClose
VStack(() => {
HStack(() => {
BackButton(false, true, doClose)
.zIndex(3)
h2("Calendars")
.position("absolute")
.zIndex(2)
.color("var(--headertext)")
.paddingVertical(0.5, em)
.margin(0)
.top(0)
.left(0)
.width(100, pct)
})
.position("relative")
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
.border("1px solid var(--divider)")
.textAlign("center")
if (this.calendars.length > 0) {
h3("Visibility")
.marginBottom(0)
.marginLeft(1.25, em)
VStack(() => {
this.calendars.forEach(cal => {
const selected = this.isSelected(cal);
HStack(() => {
HStack(() => {
p("")
.width(2, em)
.height(2, em)
.background(selected ? cal.color : "transparent")
.borderRadius(100, pct)
.border(`3.5px solid ${cal.color}`)
.boxSizing("border-box")
p(cal.name)
.fontSize(1.1, em)
.opacity(selected ? 1 : 0.5)
})
.gap(1, em)
.alignItems("center")
.flex(1)
.cursor("pointer")
.onTap(async () => {
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
this.toggleCalendar(cal)
})
p("i")
.width(1.5, em)
.height(1.5, em)
.borderRadius(100, pct)
.border("2px solid var(--text)")
.boxSizing("border-box")
.display(cal.owner_id === global.profile.id ? "" : "none")
.textAlign("center")
.lineHeight("1.4em")
.fontSize(1.3, em)
.fontWeight("bold")
.color("var(--text)")
.opacity(0.5)
.flexShrink(0)
.cursor("pointer")
.onTap(async () => {
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
let calFormEl
const closeCalForm = () => {
this.calFormSheet._closeOverride = null
this.calFormSheet.setSheet(false)
}
const onSaveErrorEdit = () => {
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
}
this.calFormSheet.show(() => {
calFormEl = CalendarForm(
closeCalForm,
(updated) => {
closeCalForm()
setTimeout(() => {
this.calendars = this.calendars.map(c => c.id === updated.id ? updated : c);
this.selectedCalendars = this.selectedCalendars.map(c => c.id === updated.id ? updated : c);
this.onCalendarUpdated(updated);
this.rerender();
}, 300);
},
cal,
(deletedId) => {
closeCalForm()
setTimeout(() => {
this.calendars = this.calendars.filter(c => c.id !== deletedId);
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId);
this.onCalendarDeleted(deletedId);
this.rerender();
}, 300);
},
onSaveErrorEdit
).height(100, pct)
})
this.calFormSheet._closeOverride = async () => {
const saved = await calFormEl?.trySave()
if (saved === null) {
this.calFormSheet.setSheet(true)
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
return
}
closeCalForm()
if (saved !== cal) {
setTimeout(() => {
this.calendars = this.calendars.map(c => c.id === saved.id ? saved : c)
this.selectedCalendars = this.selectedCalendars.map(c => c.id === saved.id ? saved : c)
this.onCalendarUpdated(saved)
this.rerender()
}, 300)
}
}
})
})
.padding(0.75, em)
.gap(0.75, em)
.alignItems("center")
})
})
.background("var(--darkaccent)")
.margin(1, em)
.marginTop(0.5, em)
.border("1px solid var(--divider)")
.borderRadius(12, px)
}
HStack(() => {
img(this.getImageURL("calendar"), "1.5em", "1.5em")
p("Add Calendar")
.fontSize(1.1, em)
.paddingLeft(0.5, em)
})
.background("var(--darkaccent)")
.color("var(--text)")
.gap(0.75, em)
.alignItems("center")
.padding(1, em)
.paddingVertical(0.75, em)
.marginHorizontal(1, em)
.border("1px solid var(--divider)")
.borderRadius(12, px)
.cursor("pointer")
.onTap(async () => {
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
let calFormEl
const closeDirect = () => {
this.calFormSheet._closeOverride = null
this.calFormSheet.setSheet(false)
}
const onSaveErrorAdd = () => {
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
}
this.calFormSheet.show(() => {
calFormEl = CalendarForm(
closeDirect,
(calendar) => {
closeDirect()
setTimeout(() => {
this.calendars = [...this.calendars, calendar]
this.selectedCalendars = [...this.selectedCalendars, calendar]
this.onCalendarAdded(calendar)
this.rerender()
}, 300)
},
null,
null,
onSaveErrorAdd
).height(100, pct)
})
this.calFormSheet._closeOverride = async () => {
const saved = await calFormEl?.trySave()
if (saved === null) {
this.calFormSheet.setSheet(true)
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
return
}
closeDirect()
if (saved) {
setTimeout(() => {
this.calendars = [...this.calendars, saved]
this.selectedCalendars = [...this.selectedCalendars, saved]
this.onCalendarAdded(saved)
this.rerender()
}, 300)
}
}
})
})
.width(100, pct)
.boxSizing("border-box")
}
}
register(CalendarOptions)

View File

@@ -0,0 +1,123 @@
import calendarUtil from "../calendarUtil.js"
import "./ToolbarPopout.js";
class CalendarToolbar extends Shadow {
constructor(currentDate, weekStartsOn, actions, calendars, events, showPopout, options = {}) {
super()
this.currentDate = currentDate
this.weekStartsOn = weekStartsOn
this.goToPrevious = actions.goToPrevious;
this.goToCurrent = actions.goToCurrent;
this.goToNext = actions.goToNext;
this.goToDate = actions.goToDate;
this.calendars = calendars;
this.events = events;
this.showPopout = showPopout;
this.onBack = options.onBack ?? null;
this.viewModeOverride = options.viewModeOverride ?? null;
}
render() {
let popout;
VStack(() => {
HStack(() => {
if (this.onBack) {
BackButton(true, false, this.onBack)
.position("absolute")
.left(0.75, em)
.bottom(0.2, em)
.transform("scale(0.75)")
.transformOrigin("left center")
}
h2(this.getToolbarLabel(this.currentDate))
.margin(0)
.fontWeight("600")
.color("var(--headertext)")
.paddingLeft(this.onBack ? 2 : 0, em)
.state("label", function (label) {
if (label) {
this.innerText = label;
}
})
HStack(() => {
if (!window.isMobile()) {
this.navButton("", this.goToPrevious)
this.navButton("Today", this.goToCurrent)
this.navButton("", this.goToNext)
}
this.navButton("⊞", () => this.togglePopout(popout))
})
.gap(0.5, em)
.alignItems("center")
})
.boxSizing("border-box")
.width(100, pct)
.alignItems("flex-end")
.position("relative")
.overflow("visible")
.paddingBottom(0.9, em)
.paddingHorizontal(1, em)
.justifyContent("space-between")
popout = ToolbarPopout(this.currentDate, this.weekStartsOn, this.calendars, this.events, this.showPopout, (date) => this.goToDate(date), (date) => {
this.$("h2").attr({ "label": this.getToolbarLabel(date)});
});
})
.overflow("visible")
.borderBottom("1px solid var(--main)")
.width(100, pct)
}
togglePopout(popout) {
this.showPopout = !this.showPopout;
$("calendar-").showPopout = this.showPopout;
if (popout) {
popout.showPopout = this.showPopout;
popout.style.maxHeight = this.showPopout ? `${popout.layout.openHeight}em` : "0em";
}
if (!this.showPopout) {
const newLabel = this.getToolbarLabel(this.currentDate)
const oldLabel = this.$("h2").innerText;
this.$("h2").attr({ "label": newLabel });
if (newLabel !== oldLabel) {
setTimeout(() => {
this.rerender();
}, 300);
}
}
}
navButton(label, handler) {
const isWide = label === "Today";
return button(label)
.onTap(handler)
.paddingVertical(0.45, em)
.fontSize(1, rem)
.paddingHorizontal(isWide ? 0.9 : 0.8, em)
.border("1px solid var(--desktop-item-border)")
.borderRadius(0.6, em)
.background("var(--desktop-item-background)")
.color("var(--text)")
.cursor("pointer");
}
getToolbarLabel(date) {
const viewMode = this.viewModeOverride ?? $("calendar-").viewMode;
if (viewMode === "week") {
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
if (viewMode === "day") {
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
}
if (viewMode === "month") {
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
return "";
}
}
register(CalendarToolbar)

View File

@@ -0,0 +1,519 @@
import calendarUtil from "../calendarUtil.js";
class ToolbarPopout extends Shadow {
swipeTranslate = 0;
isSwiping = false;
swipeDragStartX = null;
swipeDragStartY = null;
swipeDragStartTime = null;
swipeAxisLocked = false;
swipeIsHorizontal = false;
SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.25;
SWIPE_VELOCITY_THRESHOLD = 0.4;
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
constructor(currentDate, weekStartsOn, calendars, events, showPopout, goToDate, onMonthChange) {
super()
this.currentDate = currentDate;
this.weekStartsOn = weekStartsOn;
this.calendars = calendars;
this.events = events;
this.selectedDate = this.currentDate;
this.showPopout = showPopout;
this.goToDate = goToDate;
this.onMonthChange = onMonthChange;
this.swipeDidMove = false;
if (this.miniCurrentDate === undefined) {
this.miniCurrentDate = this.currentDate;
}
this.layout = this.buildLayout();
// Caches
this.monthCache = new Map();
this._miniPanels = null;
this._monthEventsVersion = 0;
this.buildDerivedData();
}
buildLayout() {
const fs = 0.75, hp = 0.4, dch = 1.55, dh = 0.28, dmt = 0.15, cp = 0.4;
return {
fontSize: fs,
headerPadding: hp,
headerRowHeight: fs + hp * 2,
dateCircleHeight: dch,
dotHeight: dh,
dotMarginTop: dmt,
cellPadding: cp,
rowHeight: cp + dch + dh + dmt,
get lastRowHeight() { return cp + dch; },
get gridHeight() { return this.rowHeight * 5 + this.lastRowHeight; },
get openHeight() { return this.headerRowHeight * fs + this.gridHeight; },
};
}
buildDerivedData() {
this.colorByCalendarId = new Map(
(this.calendars || []).map(calendar => [calendar.id, calendar.color])
);
// Version string for invalidating month cache when events update
this.eventsVersion = JSON.stringify(
(this.events || []).map(ev => [
ev.id,
ev.calendars,
ev.recurrence_id,
+new Date(ev.time_start),
+new Date(ev.time_end)
])
);
}
getMiniPanels() {
if (!this._miniPanels) {
this._miniPanels = this.$$("[data-mini-panel]");
}
return this._miniPanels;
}
getMonthKey(date) {
return [
date.getFullYear(),
date.getMonth(),
this.weekStartsOn,
this.eventsVersion
].join("|");
}
render() {
const l = this.layout;
const ordered = Array.from(
{ length: 7 },
(_, i) => ToolbarPopout.DAY_NAMES[(this.weekStartsOn + i) % 7]
);
const today = new Date();
// Remove stale cache
this._miniPanels = null;
// Only render the current month panel
// Previous/Next injected programmatically whenever user begins
// swiping - touch event never interrupted by rerenders
const panelOffsets = [0];
VStack(() => {
HStack(() => {
ordered.forEach(name => {
p(name)
.margin(0)
.fontSize(l.fontSize, em)
.fontWeight("600")
.color("var(--headertext)")
.opacity(0.45)
.flex(1)
.textAlign("center")
.paddingVertical(l.headerPadding, em)
.boxSizing("border-box")
})
})
.width(100, pct)
ZStack(() => {
panelOffsets.forEach(offset => {
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
const { weeks } = this.getMonthData(viewDate);
VStack(() => {
weeks.forEach((week, wi) => {
const isLast = wi === weeks.length - 1;
HStack(() => {
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
const isToday = calendarUtil.isSameDay(day, today);
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
VStack(() => {
p(day.getDate())
.margin(0)
.fontSize(l.fontSize, em)
.fontWeight(isSelected ? "700" : "400")
.color(isSelected || isToday ? "white" : "var(--headertext)")
.background(
isToday
? "var(--quillred)"
: isSelected
? "var(--lightaccent)"
: "transparent"
)
.width(l.dateCircleHeight, em)
.height(l.dateCircleHeight, em)
.borderRadius(25, pct)
.textAlign("center")
.lineHeight(`${l.dateCircleHeight}em`)
.opacity(isCurrentMonth ? 1 : 0.28)
.boxSizing("border-box")
// Only show event dots if they exist
if (calendarColors.length > 0) {
HStack(() => {
calendarColors.slice(0, 3).forEach(color => {
VStack(() => {})
.width(l.dotHeight, em)
.height(l.dotHeight, em)
.borderRadius(50, pct)
.background(color)
.flexShrink(0)
})
})
.gap(0.18, em)
.justifyContent("center")
.alignItems("center")
.marginTop(l.dotMarginTop, em)
.height(l.dotHeight, em)
} else {
// Spacer for dot-less cells
VStack(() => {})
.marginTop(l.dotMarginTop, em)
.height(l.dotHeight, em)
}
})
.flex(1)
.alignItems("center")
.paddingTop(l.cellPadding, em)
.paddingBottom(isLast ? 0 : l.cellPadding, em)
.boxSizing("border-box")
.onTap(() => {
if (!this.swipeDidMove) {
this.selectedDate = day;
if (!this.goToDate(day)) {
this.rerender();
}
}
})
})
})
.width(100, pct)
.background("var(--minicalendarbackground)")
})
})
.position("absolute")
.width(100, pct)
.height(100, pct)
.transform(`translateX(${offset * 100}%)`)
.willChange("transform")
.attr({ "data-mini-panel": offset })
})
})
.position("relative")
.overflow("hidden")
.width(100, pct)
.height(l.gridHeight, em)
.onTouch((start, e) => this.handleSwipeTouch(start, e))
})
.width(100, pct)
.boxSizing("border-box")
.overflow("hidden")
.maxHeight(this.showPopout ? l.openHeight : 0, em)
.transition("max-height 0.3s ease")
}
// Build previous/next calendars and insert them without triggering a rerender
injectSidePanels() {
const currentPanel = this.$("[data-mini-panel='0']");
if (!currentPanel) return;
const container = currentPanel.parentElement;
if (!container) return;
// Already injected (shouldn't happen, but guard anyway).
if (this.$("[data-mini-panel='-1']")) return;
const l = this.layout;
const today = new Date();
[-1, 1].forEach(offset => {
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
const { weeks } = this.getMonthData(viewDate);
// Outer panel div — mirrors the VStack styling applied in render().
const panel = document.createElement("div");
panel.classList.add("VStack");
panel.setAttribute("data-mini-panel", offset);
panel.style.cssText = `
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
display: flex; flex-direction: column;
transform: translateX(${offset * 100}%);
will-change: transform;
box-sizing: border-box;
`;
weeks.forEach((week, wi) => {
const isLast = wi === weeks.length - 1;
const row = document.createElement("div");
row.classList.add("HStack");
row.style.cssText = `
display: flex; flex-direction: row;
width: 100%;
background: var(--minicalendarbackground);
`;
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
const isToday = calendarUtil.isSameDay(day, today);
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
const cell = document.createElement("div");
cell.classList.add("VStack");
cell.style.cssText = `
flex: 1; display: flex; flex-direction: column;
align-items: center;
padding-top: ${l.cellPadding}em;
padding-bottom: ${isLast ? 0 : l.cellPadding}em;
box-sizing: border-box;
`;
const circle = document.createElement("p");
circle.textContent = day.getDate();
circle.style.cssText = `
margin: 0;
font-size: ${l.fontSize}em;
font-weight: ${isSelected ? "700" : "400"};
color: ${isSelected || isToday ? "white" : "var(--headertext)"};
background: ${isToday ? "var(--quillred)" : isSelected ? "var(--lightaccent)" : "transparent"};
width: ${l.dateCircleHeight}em;
height: ${l.dateCircleHeight}em;
border-radius: 25%;
text-align: center;
line-height: ${l.dateCircleHeight}em;
opacity: ${isCurrentMonth ? 1 : 0.28};
box-sizing: border-box;
`;
cell.appendChild(circle);
const spacer = document.createElement("div");
spacer.classList.add("HStack");
spacer.style.cssText = `
margin-top: ${l.dotMarginTop}em;
height: ${l.dotHeight}em;
display: flex; flex-direction: row;
justify-content: center; align-items: center;
gap: 0.18em;
`;
if (calendarColors.length > 0) {
calendarColors.slice(0, 3).forEach(color => {
const dot = document.createElement("div");
dot.classList.add("VStack");
dot.style.cssText = `
width: ${l.dotHeight}em; height: ${l.dotHeight}em;
border-radius: 50%; background: ${color}; flex-shrink: 0;
`;
spacer.appendChild(dot);
});
}
cell.appendChild(spacer);
row.appendChild(cell);
});
panel.appendChild(row);
});
container.appendChild(panel);
});
// Invalidate the panel cache so getMiniPanels() picks up the new nodes.
this._miniPanels = null;
}
handleSwipeTouch(start, e) {
if (start) {
this.swipeDragStartX = e.touches[0].clientX;
this.swipeDragStartY = e.touches[0].clientY;
this.swipeDragStartTime = Date.now();
this.isSwiping = true;
this.swipeDidMove = false;
this.swipeAxisLocked = false;
this.swipeIsHorizontal = false;
// Inject previous/next calendar
this.injectSidePanels();
document.addEventListener("touchmove", this.onSwipeMove, { passive: true });
} else {
if (!this.isSwiping) return;
document.removeEventListener("touchmove", this.onSwipeMove);
if (!this.swipeIsHorizontal) {
this.isSwiping = false;
this.swipeDragStartX = null;
this.swipeDragStartY = null;
return;
}
const endX = e.changedTouches[0].clientX;
const delta = endX - this.swipeDragStartX;
const elapsed = Date.now() - this.swipeDragStartTime;
const velocity = Math.abs(delta) / elapsed;
const shouldCommit =
Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE ||
velocity > this.SWIPE_VELOCITY_THRESHOLD;
if (shouldCommit && delta < 0) this.commitSwipe("next");
else if (shouldCommit && delta > 0) this.commitSwipe("previous");
else this.snapBack();
this.isSwiping = false;
this.swipeDragStartX = null;
this.swipeDragStartY = null;
}
}
onSwipeMove = (e) => {
if (!this.isSwiping) return;
const dx = e.touches[0].clientX - this.swipeDragStartX;
const dy = e.touches[0].clientY - this.swipeDragStartY;
if (!this.swipeAxisLocked) {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
this.swipeAxisLocked = true;
this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy);
}
if (!this.swipeIsHorizontal) return;
this.swipeDidMove = true;
const delta = e.touches[0].clientX - this.swipeDragStartX;
this.swipeTranslate = delta;
this.applySwipeTransform(delta, false);
}
applySwipeTransform(delta, animated) {
const panels = this.getMiniPanels();
panels.forEach(panel => {
const offset = parseInt(panel.getAttribute("data-mini-panel"));
panel.style.transition = animated ? "transform 300ms ease" : "";
panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`;
});
}
commitSwipe(direction) {
const sign = direction === "next" ? 1 : -1;
const panels = this.getMiniPanels();
const container = panels[0]?.parentElement;
// Snap panels to their current drag position
panels.forEach(panel => {
const offset = parseInt(panel.getAttribute("data-mini-panel"));
panel.style.transition = "none";
panel.style.transform = `translateX(calc(${offset * 100}% + ${this.swipeTranslate}px))`;
});
// Force reflow so the browser registers the snap as a committed state
panels[0]?.getBoundingClientRect();
panels.forEach(panel => {
const offset = parseInt(panel.getAttribute("data-mini-panel"));
panel.style.transition = "transform 300ms ease";
panel.style.transform = `translateX(calc(${(offset - sign) * 100}%))`;
});
setTimeout(() => {
this.miniCurrentDate = direction === "next"
? calendarUtil.addMonths(this.miniCurrentDate, 1)
: calendarUtil.addMonths(this.miniCurrentDate, -1);
this.onMonthChange(this.miniCurrentDate)
this.swipeTranslate = 0;
this.rerender();
}, 300);
}
snapBack() {
const panels = this.getMiniPanels();
panels.forEach(panel => {
const offset = parseInt(panel.getAttribute("data-mini-panel"));
panel.style.transition = "transform 300ms ease";
panel.style.transform = `translateX(${offset * 100}%)`;
});
this.swipeTranslate = 0;
}
getMonthData(date) {
const monthKey = this.getMonthKey(date);
const cached = this.monthCache.get(monthKey);
if (cached) return cached;
const year = date.getFullYear();
const month = date.getMonth();
const firstOfMonth = new Date(year, month, 1);
const gridStart = calendarUtil.startOfWeek(firstOfMonth, this.weekStartsOn);
const allDays = Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i));
const weeks = [];
for (let w = 0; w < 6; w++) {
weeks.push(allDays.slice(w * 7, w * 7 + 7));
}
const gridEnd = calendarUtil.endOfDay(allDays[41]);
const colorsByDay = new Map();
allDays.forEach(day => {
colorsByDay.set(calendarUtil.toDateInput(day), []);
});
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
const relevantEvents = expanded.filter(ev => {
const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd);
});
relevantEvents.forEach(ev => {
const colors = (ev.calendars || [])
.map(id => this.colorByCalendarId.get(id))
.filter(Boolean);
if (colors.length === 0) return;
const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
let cursor = calendarUtil.startOfDay(
ev.time_start > gridStart ? ev.time_start : gridStart
);
const eventEnd = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
while (cursor < eventEnd) {
const key = calendarUtil.toDateInput(cursor);
const arr = colorsByDay.get(key);
if (arr) {
colors.forEach(color => {
if (!arr.includes(color) && arr.length < 3) arr.push(color);
});
}
cursor = calendarUtil.addDays(cursor, 1);
}
});
const result = {
weeks: weeks.map(week =>
week.map(day => ({
day,
isCurrentMonth: day.getMonth() === month,
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
}))
)
};
this.monthCache.set(monthKey, result);
return result;
}
}
register(ToolbarPopout)