519 lines
20 KiB
JavaScript
519 lines
20 KiB
JavaScript
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) |