Scroll to switch calendar months in Outlook PWA
// ==UserScript==
// @name Outlook Calendar Scroll
// @namespace https://github.com/Linho1219
// @version 1.8.1
// @description Scroll to switch calendar months in Outlook PWA
// @author Linho1219
// @match https://outlook.live.com/*
// @match https://outlook.office.com/*
// @match https://outlook.office365.com/*
// @match https://outlook.cloud.microsoft/*
// @grant none
// @run-at document-end
// @name:zh-CN Outlook 日历滚动增强脚本
// @description:zh-CN 通过滚动切换 Outlook PWA 中的日历月份
// @homepage https://github.com/Linho1219/outlook-calendar-scroll
// @supportURL https://github.com/Linho1219/outlook-calendar-scroll/issues
// @icon https://outlook.live.com/favicon.ico
// @license MIT
// ==/UserScript==
(function() {
//#region src/listen.ts
function hookHistoryMethod(type) {
const orig = history[type];
history[type] = function(...args) {
const result = orig.apply(this, args);
window.dispatchEvent(new Event(type));
return result;
};
}
function compareStates(a, b) {
if (!a.isCalendar && !b.isCalendar) return true;
if (a.isCalendar && b.isCalendar) return a.view === b.view;
return false;
}
function watch(callback) {
hookHistoryMethod("pushState");
hookHistoryMethod("replaceState");
function getState() {
const pathname = location.pathname;
if (!pathname.startsWith("/calendar")) return { isCalendar: false };
if (pathname.includes("/view/")) {
if (pathname.includes("view/day")) return {
isCalendar: true,
view: "day"
};
if (pathname.includes("view/workweek")) return {
isCalendar: true,
view: "workweek"
};
if (pathname.includes("view/week")) return {
isCalendar: true,
view: "week"
};
if (pathname.includes("view/month")) return {
isCalendar: true,
view: "month"
};
console.warn("Unknown calendar view:", pathname);
} else console.log("Not view path:", pathname);
return null;
}
let currentState = getState() || { isCalendar: false };
function listener() {
const newState = getState();
if (newState && !compareStates(currentState, newState)) {
currentState = newState;
callback(currentState);
}
}
window.addEventListener("popstate", listener);
window.addEventListener("pushState", listener);
window.addEventListener("replaceState", listener);
callback(currentState);
}
//#endregion
//#region src/utils.ts
async function tryFunc(callback, maxAttempts = 100, interval = 300) {
return new Promise((resolve) => {
const intervalHandle = setInterval(() => {
try {
const value = callback();
clearInterval(intervalHandle);
resolve(value);
} catch (e) {
console.log("Waiting for function to succeed...");
if (--maxAttempts <= 0) {
clearInterval(intervalHandle);
throw new Error("Function failed after maximum attempts");
}
}
}, interval);
});
}
function getCalendarDOMs() {
const surface = document.querySelector("[data-app-section=\"CalendarModuleSurface\"]");
const [_, prevBtn, nextBtn] = document.querySelectorAll("[role=\"toolbar\"] button");
if (!surface || !prevBtn || !nextBtn) throw new Error("Calendar DOM elements not found");
const prev = () => prevBtn.click();
const next = () => nextBtn.click();
return {
surface,
prevBtn,
nextBtn,
prev,
next
};
}
function getIconSet() {
const ribbonIconEls = document.querySelectorAll("#innerRibbonContainer .ms-RibbonButton-icon .fui-Icon-font");
if (ribbonIconEls.length === 0) throw new Error("Ribbon icons not found");
const day = ribbonIconEls[1].innerText.trim();
const workweek = ribbonIconEls[2].innerText.trim();
const week = ribbonIconEls[3].innerText.trim();
const month = ribbonIconEls[4].innerText.trim();
if (!day || !workweek || !week || !month) throw new Error("Failed to extract calendar view icons");
return {
day,
workweek,
week,
month
};
}
var tryGetCalendarDOMs = () => tryFunc(getCalendarDOMs);
var tryGetIconSet = () => tryFunc(getIconSet, 5);
function getWeekDayScrollerEl() {
return document.querySelector(".inDayScrollContainer");
}
//#endregion
//#region src/mount.ts
async function mount(dir) {
const { surface, prev, next } = await tryGetCalendarDOMs();
return mountScrollIndicator(surface, dir, {
next,
prev
});
}
function interpretAccumulated(accumulated, TRIGGER_DISTANCE) {
const positive = accumulated > 0;
const abs = Math.abs(accumulated);
if (abs < TRIGGER_DISTANCE) return {
value: accumulated,
triggered: false
};
const value = (2 - TRIGGER_DISTANCE / abs) * TRIGGER_DISTANCE;
return {
value: positive ? value : -value,
triggered: true
};
}
function mountScrollIndicator(surface, dir, { next, prev }) {
const INDICATOR_SIZE = 50;
const TRIGGER_DISTANCE = 400;
const DISPLAY_DISTANCE_RATIO = dir === "vertical" ? .25 : .35;
const DISPLAY_SHADOW_RATIO = .035;
const TRIGGER_TIMEOUT = 200;
const NORMAL_BG = "var(--oobeWhite)";
const NORMAL_COLOR = "var(--oobePrimary)";
const TRIGGERED_BG = "var(--oobeDarkAlt)";
const TRIGGERED_COLOR = "var(--oobeWhite)";
const BOX_SHADOW = "0 2px 4px rgba(0,0,0,0.2)";
const EMISSION_COLOR = "color-mix(in srgb, var(--oobePrimary) 25%, transparent)";
let accumulated = 0;
let timeout;
const indicator = document.createElement("div");
indicator.className = "ocs-scroll-indicator";
Object.assign(indicator.style, {
position: "absolute",
width: `${INDICATOR_SIZE}px`,
height: `${INDICATOR_SIZE}px`,
borderRadius: "50%",
fontSize: "20px",
backgroundColor: NORMAL_BG,
color: NORMAL_COLOR,
fontFamily: "FluentSystemIcons",
zIndex: "9999",
transition: [
"transform 0.1s",
"background-color 0.1s",
"color 0.1s",
"box-shadow 0.1s linear",
"opacity 0.3s"
].join(","),
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
opacity: "0"
});
indicator.innerText = "";
if (dir === "vertical") {
indicator.style.left = "50%";
surface.style.overflowY = "hidden";
} else {
indicator.style.top = "50%";
surface.style.overflowX = "hidden";
}
surface.style.position = "relative";
function setColor(triggered) {
if (triggered) {
indicator.classList.add("triggered");
indicator.style.backgroundColor = TRIGGERED_BG;
indicator.style.color = TRIGGERED_COLOR;
} else {
indicator.classList.remove("triggered");
indicator.style.backgroundColor = NORMAL_BG;
indicator.style.color = NORMAL_COLOR;
}
}
setColor(false);
function setPosition(value, positive = value > 0) {
indicator.style.opacity = value ? "1" : "0";
const translate = -value * DISPLAY_DISTANCE_RATIO;
const shadowSize = Math.abs(value) * DISPLAY_SHADOW_RATIO;
indicator.style.boxShadow = `${BOX_SHADOW}, 0 0 0 ${shadowSize}px ${EMISSION_COLOR}`;
if (dir === "vertical") {
if (positive) {
indicator.style.bottom = `-${INDICATOR_SIZE}px`;
indicator.style.top = "auto";
} else {
indicator.style.top = `-${INDICATOR_SIZE}px`;
indicator.style.bottom = "auto";
}
indicator.style.transform = `translateX(-50%) translateY(${translate}px)`;
} else {
if (positive) {
indicator.style.right = `-${INDICATOR_SIZE}px`;
indicator.style.left = "auto";
} else {
indicator.style.left = `-${INDICATOR_SIZE}px`;
indicator.style.right = "auto";
}
indicator.style.transform = `translateY(-50%) translateX(${translate}px)`;
}
}
setPosition(0);
function reset(value) {
accumulated = 0;
setPosition(0, value > 0);
setColor(false);
}
function trigger(value) {
if (value < 0) prev();
else next();
if (dir === "horizontal") {
const scrollEl = getWeekDayScrollerEl();
if (scrollEl) {
const { scrollLeft, scrollWidth, clientWidth } = scrollEl;
if (value < 0) scrollEl.scrollLeft = Math.max(0, scrollWidth - clientWidth);
else scrollEl.scrollLeft = 0;
}
}
reset(value);
}
surface.appendChild(indicator);
function onWheel(e) {
if (e.ctrlKey) return;
let delta;
if (dir === "vertical") {
if (e.shiftKey) return;
delta = e.deltaY;
} else delta = e.deltaX !== 0 ? e.deltaX : e.shiftKey ? e.deltaY : 0;
if (!delta) return;
if (dir === "horizontal") {
const originalScrollEl = getWeekDayScrollerEl();
if (originalScrollEl) {
const { scrollLeft, scrollWidth, clientWidth } = originalScrollEl;
console.log(scrollLeft, delta);
if (delta < 0 && scrollLeft > 0) return;
if (delta > 0 && Math.ceil(scrollLeft) + clientWidth < scrollWidth) return;
}
}
accumulated += delta;
const { triggered, value } = interpretAccumulated(accumulated, TRIGGER_DISTANCE);
setColor(triggered);
setPosition(value);
clearTimeout(timeout);
timeout = window.setTimeout(() => {
if (Math.abs(accumulated) >= TRIGGER_DISTANCE) trigger(value);
else reset(value);
}, TRIGGER_TIMEOUT);
}
surface.addEventListener("wheel", onWheel, { passive: true });
return () => {
surface.removeEventListener("wheel", onWheel);
indicator.remove();
};
}
//#endregion
//#region src/main.ts
var dirMap = {
day: "horizontal",
workweek: "horizontal",
week: "horizontal",
month: "vertical"
};
var lastDir;
var canceler;
var iconSet = null;
async function handler(state) {
if (state.isCalendar) {
console.log(`Calendar view: ${state.view}`);
if (lastDir !== dirMap[state.view]) {
canceler?.();
lastDir = dirMap[state.view];
canceler = await mount(dirMap[state.view]);
}
if (!iconSet) iconSet = await tryGetIconSet();
const indicatorEl = document.querySelector(".ocs-scroll-indicator");
if (indicatorEl) indicatorEl.innerText = iconSet[state.view];
} else {
console.log("Quit calendar view");
canceler?.();
lastDir = void 0;
canceler = void 0;
}
}
window.onload = () => watch(handler);
//#endregion
})();