init
This commit is contained in:
519
calendar/Toolbar/ToolbarPopout.js
Normal file
519
calendar/Toolbar/ToolbarPopout.js
Normal 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)
|
||||
Reference in New Issue
Block a user