[snolab] Google 日历键盘操作增强

【功能测试中, bug反馈:[email protected]】Google日历键盘增强,雪星自用,功能:双击复制日程视图里的文本内容, Alt+hjkl 移动日程

As of 27. 01. 2022. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         [snolab] Google 日历键盘操作增强
// @name:zh      [雪星实验室] Google Calendar with Keyboard Enhanced
// @namespace    https://userscript.snomiao.com/
// @version      0.0.6
// @description  【功能测试中, bug反馈:[email protected]】Google日历键盘增强,雪星自用,功能:双击复制日程视图里的文本内容, Alt+hjkl 移动日程
// @author       [email protected]
// @match        *://calendar.google.com/*
// @grant        none
// ==/UserScript==

/* 
    1. event move enhance
        - date time input change
        - event drag
    2. journal view text copy for the day-summary
*/
console.clear();
const debug = false;
const qsa = (sel, ele = document) => [...ele.querySelectorAll(sel)];
const eleVis = (ele) => (ele.getClientRects().length && ele) || null;
const eleSelVis = (sel, ele = document) =>
    (typeof sel === 'string' && qsa(sel, ele).filter(eleVis)[0]) || null;
// const nestList = (e, fn)=>e.reduce
const parentList = (ele) =>
    [
        ele?.parentElement,
        ...((ele?.parentElement && parentList(ele?.parentElement)) || []),
    ].filter((e) => e);
const eleSearchVis = (pattern, ele = document) =>
    ((list) =>
        list?.find((e) => e.textContent?.match(pattern)) ||
        list?.find((e) => e.innerHTML?.match(pattern)))(
        qsa('*', ele).filter(eleVis).reverse()
    ) || null;
const eleSearch = (sel, ele = document) =>
    ((list) =>
        list?.find((e) => e.textContent?.match(sel)) ||
        list?.find((e) => e.innerHTML?.match(sel)))(qsa('*', ele).reverse()) ||
    null;
const hotkeyNameParse = (event) => {
    const { altKey, metaKey, ctrlKey, shiftKey, key, type } = event;
    const hkName =
        ((altKey && '!') || '') +
        ((ctrlKey && '^') || '') +
        ((metaKey && '#') || '') +
        ((shiftKey && '+') || '') +
        key?.toLowerCase() +
        ({ keydown: '', keypress: ' Press', keyup: ' Up' }[type] || '');
    return hkName;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const inputValueSet = async (ele, value) => {
    // console.log('inputValueSet', ele, value);
    if (!ele) throw new Error('no element');
    if (undefined === value) throw new Error('no value');
    ele.value = value;
    ele.dispatchEvent(new InputEvent('input', { bubbles: true }));
    ele.dispatchEvent(new Event('change', { bubbles: true }));
    ele.dispatchEvent(
        new KeyboardEvent('keydown', {
            bubbles: true,
            keyCode: 13 /* enter */,
        })
    );
    await sleep(16);
};
const waitFor = async (fn) => {
    let re = null;
    while (!(re = fn())) await sleep(8);
    return re;
};

const mouseEventOpt = ([x, y]) => ({
    isTrusted: true,
    bubbles: true,
    button: 0,
    buttons: 1,
    cancelBubble: false,
    cancelable: true,
    clientX: x,
    clientY: y,
    movementX: 0,
    movementY: 0,
    x: x,
    y: y,
});
const centerGet = (元素) => {
    const { x, y, width: w, height: h } = 元素.getBoundingClientRect();
    return [x + w / 2, y + h / 2];
};
const bottomGet = (元素) => {
    const { x, y, width: w, height: h } = 元素.getBoundingClientRect();
    return [x + w / 2, y + h - 2];
};
const vec2add = ([x, y], [z, w]) => [x + z, y + w];
const vec2mul = ([x, y], [z, w]) => [x * z, y * w];
const eventDragMouseMove = (dx, dy) => {
    // a unit size is 15 min
    const container = document.querySelector(
        '[role="row"][data-dragsource-type="4"]'
    );
    const gridcells = [...container.querySelectorAll('[role="gridcell"]')];
    const containerSize = container.getBoundingClientRect();
    const [w, h] = [
        containerSize.width / gridcells.length,
        containerSize.height / 24 / 4,
    ];
    const [rdx, rdy] = [dx * w, dy * h];
    globalThis.gckDraggingPos = vec2add(globalThis.gckDraggingPos, [rdx, rdy]);
    document.dispatchEvent(
        new MouseEvent('mousemove', mouseEventOpt(globalThis.gckDraggingPos))
    );
};
const eventDragStart = async (
    [dx = 0, dy = 0] = [],
    { expand = false, immediatelyRelease = false } = {}
) => {
    console.log('eventDrag', [dx, dy], expand, immediatelyRelease);
    if (!globalThis.gckDraggingPos) {
        // console.log(eventDrag, dx, dy);
        const floatingBtn = qsa('div[role="button"]').find(
            (e) => getComputedStyle(e).zIndex === '5004'
        );
        if (!floatingBtn) throw new Error('no event selected');
        const dragTarget = expand
            ? floatingBtn.querySelector('*[data-dragsource-type="3"]')
            : floatingBtn;
        // debugger;
        const cPos = centerGet(dragTarget); // !expand ?  : bottomGet(floatingBtn);
        console.log('cpos', cPos);
        // mousedown
        globalThis.gckDraggingPos = cPos;
        dragTarget.dispatchEvent(
            new MouseEvent(
                'mousedown',
                mouseEventOpt(globalThis.gckDraggingPos)
            )
        );
        dragTarget.dispatchEvent(
            new MouseEvent(
                'mousemove',
                mouseEventOpt(globalThis.gckDraggingPos)
            )
        );
    }
    // mousemove
    if (globalThis.gckDraggingPos) {
        eventDragMouseMove(dx, dy);
    }
    // mouseup
    const mouseup = () => {
        globalThis.gckDraggingPos = null;
        document.dispatchEvent(
            new MouseEvent('mouseup', { bubbles: true, cancelable: true })
        );
    };
    const release = (event) => {
        const hkn = hotkeyNameParse(event);
        console.log('hkn', hkn);
        // ;
        if (hkn === '!j Up') eventDragMouseMove(0, +1);
        if (hkn === '!k Up') eventDragMouseMove(0, -1);
        if (hkn === '!h Up') eventDragMouseMove(-1, 0);
        if (hkn === '!l Up') eventDragMouseMove(+1, 0);
        if (hkn === '!+j Up') eventDragMouseMove(0, +1);
        if (hkn === '!+k Up') eventDragMouseMove(0, -1);
        if (hkn === '!+h Up') eventDragMouseMove(-1, 0);
        if (hkn === '!+l Up') eventDragMouseMove(+1, 0);
        if (hkn === 'alt Up') mouseup();
        if (hkn === '+alt Up') mouseup();
        if (hkn === 'alt Up') document.removeEventListener('keyup', release);
        if (hkn === '+alt Up') document.removeEventListener('keyup', release);
    };
    if (immediatelyRelease) {
        mouseup();
        document.removeEventListener('keyup', release);
    } else {
        document.addEventListener('keyup', release);
    }
};
const movHandle = async (e) => {
    const hktb = {
        '!j': async () => {
            let pos = bottomGet(floatingBtn);
            document.addEventListener('keyup');
        },
    };
    const f = hktb[hkName];
    if (f) f();
};
// useHotkey('!j', () => {});
// document.onkeydown = movHandle;
// document.addEventListener('keydown', globalThis.movHandle , false)

const inputDateTimeChange = async (startDT = 0, endDT = 0) => {
    const isoDateInputParse = async (dateEle, timeEle) => {
        // const dateEle = eleSelVis('[aria-label="Start date"]');
        const dataDate = dateEle.getAttribute('data-date');
        const dataIcal = parentList(dateEle)
            .find((e) => e.getAttribute('data-ical'))
            .getAttribute('data-ical');
        const todayDate = new Date().toISOString().slice(0, 10);
        const dateString = (dataDate || dataIcal).replace(
            /(\d{4})(\d{2})(\d{2})/,
            (_, a, b, c) => [a, b, c].join('-')
        );
        const timeString = timeEle?.value || '00:00';
        return new Date(`${dateString} ${timeString} Z`);
    };
    const dateObjParse = (dateObj) => {
        const [date, time] = dateObj
            .toISOString()
            .match(/(\d\d\d\d-\d\d-\d\d)T(\d\d:\d\d):\d\d\.\d\d\dZ/)
            .slice(1);
        return [date, time];
    };
    // All day: both dates, no time
    // Date time: start date + start time + end date
    const startDateEleTry = eleSelVis('[aria-label="Start date"]');
    if (!startDateEleTry) {
        const tz = eleSearchVis(/^Time zone$/);
        const editBtn =
            tz &&
            parentList(tz)
                ?.find((e) => e.querySelector('[role="button"]'))
                ?.querySelector('[role="button"]');
        if (!editBtn) {
            throw new Error('No editable input');
            // return 'No editable input';
        }
        editBtn.click();
        await sleep(16);
    }
    const startDateEle =
        startDateEleTry &&
        (await waitFor(() => eleSelVis('[aria-label="Start date"]')));
    const startTimeEle = eleSelVis('[aria-label="Start time"]');
    const endDateEle = eleSelVis('[aria-label="End date"]');
    const endTimeEle = eleSelVis('[aria-label="End time"]');
    const startDateObj = await isoDateInputParse(startDateEle, startTimeEle);
    const endDateObj = await isoDateInputParse(
        endDateEle || startDateEle,
        endTimeEle
    );
    const shiftedStartDateObj = new Date(+startDateObj + startDT);
    const shiftedEndDateObj = new Date(+endDateObj + endDT);
    const [
        originStartDate,
        originStartTime,
        originEndDate,
        originEndTime,
        shiftedStartDate,
        shiftedStartTime,
        shiftedEndDate,
        shiftedEndTime,
    ] = [
        ...dateObjParse(startDateObj),
        ...dateObjParse(endDateObj),
        ...dateObjParse(shiftedStartDateObj),
        ...dateObjParse(shiftedEndDateObj),
    ];
    debug &&
        console.table({
            startDateObj: startDateObj.toISOString(),
            endDateObj: endDateObj.toISOString(),
            shiftedStartDateObj: shiftedStartDateObj.toISOString(),
            shiftedEndDateObj: shiftedEndDateObj.toISOString(),
        });
    debug &&
        console.table({
            startDateEle: !!startDateEle,
            startTimeEle: !!startTimeEle,
            endDateEle: !!endDateEle,
            endTimeEle: !!endTimeEle,
            originStartDate,
            originStartTime,
            originEndDate,
            originEndTime,
            shiftedStartDate,
            shiftedStartTime,
            shiftedEndDate,
            shiftedEndTime,
        });
    startDateEle &&
        shiftedStartDate !== originStartDate &&
        (await inputValueSet(startDateEle, shiftedStartDate));
    endDateEle &&
        shiftedEndDate !== originEndDate &&
        (await inputValueSet(endDateEle, shiftedEndDate));
    startTimeEle &&
        shiftedStartTime !== originStartTime &&
        (await inputValueSet(startTimeEle, shiftedStartTime));
    endTimeEle &&
        shiftedEndTime !== originEndTime &&
        (await inputValueSet(endTimeEle, shiftedEndTime));
};
const timeAdd = async () => {
    parentList(eleSearchVis(/^Add time$/))
        ?.find((e) => e.querySelector('[role="button"]'))
        ?.querySelector('[role="button"]')
        .click();
    await sleep(16);
    return;
};
const gcksHotkeyHandler = (e) => {
    const isInput = ['INPUT', 'BUTTON'].includes(e.target.tagName);
    const hkName = hotkeyNameParse(e);
    console.log(hkName);
    const okay = () => {
        e.preventDefault();
        e.stopPropagation();
    };
    const hkft = {
        '!k': async () => {
            await timeAdd();
            return await inputDateTimeChange(-15 * 60e3).catch(
                async () => await eventDragStart([0, 0], { expand: false })
            );
        },
        '!j': async () => {
            await timeAdd();
            return await inputDateTimeChange(+15 * 60e3).catch(
                async () => await eventDragStart([0, 0], { expand: false })
            );
        },
        '!h': async () =>
            await inputDateTimeChange(-1 * 86400e3).catch(
                async () => await eventDragStart([0, 0], { expand: false })
            ),
        '!l': async () =>
            await inputDateTimeChange(+1 * 86400e3).catch(
                async () => await eventDragStart([0, 0], { expand: false })
            ),
        '!+k': async () => {
            await timeAdd();
            return await inputDateTimeChange(0, -15 * 60e3).catch(
                async () => await eventDragStart([0, 0], { expand: true })
            );
        },
        '!+j': async () => {
            await timeAdd();
            return await inputDateTimeChange(0, +15 * 60e3).catch(
                async () => await eventDragStart([0, 0], { expand: true })
            );
        },
        '!+h': async () =>
            await inputDateTimeChange(0, -1 * 86400e3).catch(
                async () => await eventDragStart([0, 0], { expand: true })
            ),
        '!+l': async () =>
            await inputDateTimeChange(0, +1 * 86400e3).catch(
                async () => await eventDragStart([0, 0], { expand: true })
            ),
    };
    const f = hkft[hkName];
    if (f) {
        okay();
        f();
        // .then(okay());
        // .catch((e) => console.error(e));
    } else {
        debug && console.log(hkName + ' pressed on ', e.target.tagName, e);
    }
    console.log('rd');
};
// await inputDateTimeChange(-15 * 60e3);

globalThis.gcksHotkeyHandler &&
    document.removeEventListener(
        'keydown',
        globalThis.gcksHotkeyHandler,
        false
    );
globalThis.gcksHotkeyHandler = gcksHotkeyHandler;
document.addEventListener('keydown', globalThis.gcksHotkeyHandler, false);
console.log('done');

// 复制日程内容
var cpy = (ele) => {
    ele.style.background = 'lightblue';
    setTimeout(() => (ele.style.background = 'none'), 200);
    return navigator.clipboard.writeText(
        ele.innerText
            // 把时间和summary拼到一起
            .replace(
                /.*\n(.*) – (.*)\n(.*)\n.*/gim,
                (_, a, b, c) => a + '-' + b + ' ' + c
            )
            // 删掉前2行
            .replace(/^.*\n.*\n/, '')
    );
};
const mdHandler = () => {
    const dblClickCopyHooker = (e) => {
        if (!e.flag_cpy_eventlistener) {
            e.addEventListener('dblclick', () => cpy(e), false);
        }
        e.flag_cpy_eventlistener = 1;
    };
    [...document.querySelectorAll('div.L1Ysrb')]?.map(dblClickCopyHooker);
};
document.body.addEventListener('mousedown', mdHandler, true);