script.jsimport { addMilliseconds, lightFormat, isBefore } from '../date-fns.min.js/script.js';
export class Pomodoro {
constructor(title, {icon, workPeriod, breakPeriod, record, start, end} = {}) {
// 内部変数の初期化:
this._title = title ?? 'Pomodoro Timer';
this._icon = icon ?? 'https://gyazo.com/978797f03cb0112a0a4cafdf02dcdde8/raw';
this._record = record ?? false;
this._workPeriod = workPeriod ?? 25 * 60 * 1000;
this._breakPeriod = breakPeriod ?? 5 * 60 * 1000;
this._getStartNotificationOption = start ?? undefined;
this._getEndNotificationOption = end ?? ((title, icon, isWork) => {
return {title: (isWork ? 'ポモドーロが完了しました。' : '休憩時間が終了しました。'), body: `With 🍅 by ${title}`, icon,};
});
// page menuを追加する
scrapbox.PageMenu.addMenu({
title: this._title,
image: this._icon,
onClick: () => Pomodoro.waitForPermission(),
});
this._onStateChange({}, this._state);
this._onStorage();
}
// 定数の設定
get _startWorkButton() { return { title: '\uf04b︎ Start Work', onClick: () => this.startWork() };}
get _stopWorkButton() { return { title: '\uf04d Stop Work', onClick: () => this.stopWork() };}
get _startBreakButton() { return { title: '\uf0f4 Start Break ', onClick: () => this.startBreak() };}
get _stopBreakButton() { return { title: '\uf04d Stop Break', onClick: () => this.stopBreak() };}
get _timerDisplay() { return { title: '--:--', onClick: () => { } };}
get _menu() {return scrapbox.PageMenu(this._title);}
startWork() { this._changeStateTo('work'); }
startBreak() { this._changeStateTo('break'); }
stopWork() { this._changeStateTo('beforebreak'); }
stopBreak() { this._changeStateTo('ready'); }
// 通知機能の使用許可が下りるまで待つ
static waitForPermission() {
return new Promise((resolve, reject) => {
if (Notification.permission === 'granted') {resolve();return;}
if (Notification.permission === 'denied') {reject(Error('Permission denied.'));return;}
// UI操作を通じて許可申請を出す
const a = document.createElement('a');
a.onclick = async () => {
const state = await Notification.requestPermission();
if (state !== 'granted') reject(Error('Permission denied.'));
resolve();
};
document.body.appendChild(a);
a.click();
a.remove();
});
}
// 内部methods
get _key() {
return `${this._title}-timer`;
}
get _state() {
return toObject(localStorage.getItem(this._key));
}
set _state({state, until}) {
const old = this._state;
localStorage.setItem(this._key, toString({state, until}));
this._onStateChange(old, {state, until});
}
_onStorage() {
// 他のタブでのstorage更新を監視する
window.addEventListener('storage', ({key, newValue, oldValue}) => {
if (key !== this._key) return;
this._onStateChange(toObject(oldValue), toObject(newValue));
});
}
// 全てのタブで行うやつ
_onStateChange(oldObject, newObject) {
switch (newObject.state) {
// 残り作業時間を表示する
case 'work':
this._setItems(this._timerDisplay, this._stopWorkButton);
this._setUpdateTimerLoop();
return;
// 休憩に入るまでの待機状態
case 'beforebreak':
this._setItems(this._startBreakButton);
return;
// 残り休憩時間を表示する
case 'break':
this._setItems(this._timerDisplay, this._stopBreakButton);
this._setUpdateTimerLoop();
return;
// 初期状態に戻す
case 'ready':
default:
this._setItems(this._startWorkButton);
return;
}
}
// page menu itemsの更新
_setItems(...items) {
this._menu.removeAllItems();
items.forEach(i => this._menu.addItem(i));
}
// 状態を切り替える
// 状態を切り替えたタブでのみ行う処理も含まれている
_changeStateTo(state) {
switch (state) {
case 'ready':
{
this._state = {state};
// 通知を出す
const {title, ...rest} = this._getEndNotificationOption(this._title, this._image, false);
new Notification(title, rest);
}
break;
case 'beforebreak':
{
this._state = {state};
// 通知を出す
const {title, ...rest} = this._getEndNotificationOption(this._title, this._image, true);
new Notification(title, rest);
// log を記録する
if (!this._record) return;
const endTime = new Date();
const page = `/${scrapbox.Project.name}/${encodeURIComponent(lightFormat(endTime, 'yyyy/M/d'))}`;
const log = `${lightFormat(this._startTime, 'HH:mm')} -> ${lightFormat(endTime, 'HH:mm')}: [${scrapbox.Page.title}]\n`;
window.open(`${page}?body=${encodeURIComponent(log)}`);
}
break;
case 'work':
{
this._startTime = new Date();
this._state = {state, until: addMilliseconds(new Date(), this._workPeriod)};
// 通知を出す
if (!this._getStartNotificationOption) return;
const {title, ...rest} = this._getStartNotificationOption(this._title, this._image, false);
new Notification(title, rest);
}
break;
case 'break':
this._state = {state, until: addMilliseconds(new Date(), this._breakPeriod)};
break;
}
}
_setUpdateTimerLoop() {
let timer = null;
timer = setInterval(() => {
const {state, until} = this._state;
if ((state !== 'work' && state !== 'break') || !until) {
clearInterval(timer);
return;
}
// 時間切れになったら状態を変える
const now = new Date();
if (isBefore(until, now)) {
clearInterval(timer);
this._changeStateTo(state === 'work' ? 'beforebreak' : 'ready');
return;
}
this._drawTimer(until - now);
}, 1000);
}
_drawTimer(rest) {
// itemの0番目に時計があるとして、その時計を更新する
this._menu.menus.get(this._title).items[0].title = lightFormat(rest, 'mm:ss');
this._menu.emitChange();
}
}
function toString({state, until}) {
return JSON.stringify({state, ...(until ? {until: until.getTime()} : {})});
}
function toObject(objectString) {
let json = objectString ? JSON.parse(objectString) : {};
if (json.until) json.until = new Date(json.until);
return json;
}