// ==UserScript==
// @name Outlook Calendar Scroll
// @namespace https://github.com/Linho1219
// @version 1.4
// @description Scroll to switch calendar months in Outlook PWA
// @author Linho1219
// @match https://outlook.live.com/*
// @match https://outlook.office.com/*
// @grant none
// @run-at document-end
// @name:zh-CN Outlook 日历滚动增强脚本
// @description:zh-CN 通过滚动切换 Outlook PWA 中的日历月份
// @homepage https://github.com/Linho1219/OutlookCalendarScroll
// @supportURL https://github.com/Linho1219/OutlookCalendarScroll/issues
// @icon https://outlook.live.com/favicon.ico
// @license MIT
// ==/UserScript==
(function() {
"use strict";
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.startsWith("/calendar/view/day"))
return { isCalendar: true, view: "day" };
if (pathname.startsWith("/calendar/view/workweek"))
return { isCalendar: true, view: "workweek" };
if (pathname.startsWith("/calendar/view/week"))
return { isCalendar: true, view: "week" };
if (pathname.startsWith("/calendar/view/month"))
return { isCalendar: true, view: "month" };
if (pathname.startsWith("/calendar/view/"))
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);
}
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 };
}
async function tryGetCalendarDOMs() {
const interval = 300;
return new Promise((resolve) => {
const intervalHandle = setInterval(() => {
try {
const doms = getCalendarDOMs();
clearInterval(intervalHandle);
resolve(doms);
} catch (e) {
console.log("Waiting for calendar DOM elements...");
}
}, interval);
});
}
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 = 0.2;
const TRIGGER_TIMEOUT = 200;
const NORMAL_BG = "var(--neutralTertiary)";
const NORMAL_COLOR = "var(--black)";
const TRIGGERED_BG = "var(--themePrimary)";
const TRIGGERED_COLOR = "var(--white)";
let accumulated = 0;
let timeout;
const indicator = document.createElement("div");
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.06s, background-color 0.1s, color 0.1s",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none"
});
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.style.backgroundColor = TRIGGERED_BG;
indicator.style.color = TRIGGERED_COLOR;
} else {
indicator.style.backgroundColor = NORMAL_BG;
indicator.style.color = NORMAL_COLOR;
}
}
setColor(false);
function setPosition(value, positive = value > 0) {
const translate = -value * DISPLAY_DISTANCE_RATIO;
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();
reset(value);
}
surface.appendChild(indicator);
function onWheel(e) {
if (e.ctrlKey) return;
const delta = dir === "vertical" ? e.deltaY : e.deltaX;
if (!delta) 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();
};
}
const dirMap = {
day: "horizontal",
workweek: "horizontal",
week: "horizontal",
month: "vertical"
};
let lastDir;
let canceler;
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]);
}
} else {
console.log("Quit calendar view");
canceler?.();
lastDir = void 0;
canceler = void 0;
}
}
window.onload = () => watch(handler);
})();