chore: initial commit
This commit is contained in:
680
src/main.js
Normal file
680
src/main.js
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* TauClock frontend controller
|
||||
* ----------------------------
|
||||
* Coordinates three features:
|
||||
* 1. Live clocks in local/UTC time
|
||||
* 2. Persistent alarms with keyboard shortcuts and audible chimes
|
||||
* 3. Timer + stopwatch utilities with a shared status message system
|
||||
*
|
||||
* The logic is split by feature area, and each function carries a short
|
||||
* description so new contributors can navigate quickly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Namespaced keys used for browser localStorage.
|
||||
* Keeping the strings centralized makes future migrations trivial.
|
||||
*/
|
||||
const STORAGE_KEYS = {
|
||||
alarms: "tauclock-alarms",
|
||||
};
|
||||
|
||||
/** Cache of DOM nodes we touch frequently to avoid repeated lookups. */
|
||||
const elements = {};
|
||||
|
||||
/** In-memory representation of scheduled alarms. */
|
||||
const alarmState = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
/** Mutable state that drives the countdown timer UI. */
|
||||
const timerState = {
|
||||
initialMs: 0,
|
||||
remainingMs: 0,
|
||||
running: false,
|
||||
targetTime: null,
|
||||
lastInput: "",
|
||||
};
|
||||
|
||||
/** Bookkeeping for the stopwatch including lap history. */
|
||||
const stopwatchState = {
|
||||
running: false,
|
||||
startTime: null,
|
||||
elapsedBefore: 0,
|
||||
laps: [],
|
||||
};
|
||||
|
||||
/** Handle used to clear the transient status message timeout. */
|
||||
let statusTimer;
|
||||
|
||||
/** Keyboard shortcut handlers keyed by the raw KeyboardEvent#code. */
|
||||
const SHORTCUTS = {
|
||||
KeyA: () => focusAndSelect(elements.alarmTime),
|
||||
KeyN: () => elements.alarmForm.requestSubmit(),
|
||||
KeyT: () => focusAndSelect(elements.timerInput),
|
||||
KeyG: () => toggleTimer(),
|
||||
KeyR: () => resetTimer(),
|
||||
KeyW: () => toggleStopwatch(),
|
||||
KeyL: () => addLap(),
|
||||
KeyP: () => resetStopwatch(),
|
||||
KeyH: () => toggleShortcutOverlay(),
|
||||
};
|
||||
|
||||
/** Sequences of oscillator frequencies for the lightweight chime player. */
|
||||
const AUDIO_PATTERNS = {
|
||||
short: [660, 880, 660],
|
||||
alarm: [523, 659, 784, 659, 523],
|
||||
};
|
||||
|
||||
/**
|
||||
* Main entry point once the DOM is ready. We populate caches, hook up
|
||||
* listeners, restore persisted data, and start ticking UI updates.
|
||||
*/
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
loadAlarms();
|
||||
updateClock();
|
||||
renderAlarms();
|
||||
renderTimer();
|
||||
renderStopwatch();
|
||||
setInterval(updateClock, 500);
|
||||
setInterval(() => {
|
||||
updateTimerDisplay();
|
||||
updateStopwatchDisplay();
|
||||
}, 75);
|
||||
setInterval(checkAlarms, 1000);
|
||||
});
|
||||
|
||||
/**
|
||||
* Collect and memoize every DOM element referenced throughout the app.
|
||||
* Running this once avoids repeated querySelector calls inside hot paths.
|
||||
*/
|
||||
function cacheElements() {
|
||||
elements.localTime = document.getElementById("local-time");
|
||||
elements.localDate = document.getElementById("local-date");
|
||||
elements.utcTime = document.getElementById("utc-time");
|
||||
elements.utcDate = document.getElementById("utc-date");
|
||||
elements.alarmForm = document.getElementById("alarm-form");
|
||||
elements.alarmTime = document.getElementById("alarm-time");
|
||||
elements.alarmLabel = document.getElementById("alarm-label");
|
||||
elements.alarmList = document.getElementById("alarm-list");
|
||||
elements.nextAlarm = document.getElementById("next-alarm");
|
||||
elements.timerInput = document.getElementById("timer-input");
|
||||
elements.timerDisplay = document.getElementById("timer-display");
|
||||
elements.timerToggle = document.getElementById("timer-toggle");
|
||||
elements.timerReset = document.getElementById("timer-reset");
|
||||
elements.timerStatus = document.getElementById("timer-status");
|
||||
elements.stopwatchDisplay = document.getElementById("stopwatch-display");
|
||||
elements.stopwatchToggle = document.getElementById("stopwatch-toggle");
|
||||
elements.stopwatchLap = document.getElementById("stopwatch-lap");
|
||||
elements.stopwatchReset = document.getElementById("stopwatch-reset");
|
||||
elements.stopwatchLaps = document.getElementById("stopwatch-laps");
|
||||
elements.statusMessage = document.getElementById("status-message");
|
||||
elements.shortcutOverlay = document.getElementById("shortcut-overlay");
|
||||
elements.shortcutClose = document.getElementById("shortcut-close");
|
||||
elements.shortcutTrigger = document.getElementById("shortcut-trigger");
|
||||
}
|
||||
|
||||
/** Wire up submit/click/keyboard handlers for alarms, timer, and stopwatch. */
|
||||
function bindEvents() {
|
||||
elements.alarmForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
createAlarm();
|
||||
});
|
||||
|
||||
elements.alarmList.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
const id = button.dataset.id;
|
||||
const action = button.dataset.action;
|
||||
if (action === "toggle") toggleAlarm(id);
|
||||
if (action === "delete") deleteAlarm(id);
|
||||
});
|
||||
|
||||
elements.timerToggle.addEventListener("click", () => toggleTimer());
|
||||
elements.timerReset.addEventListener("click", () => resetTimer());
|
||||
elements.timerInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
toggleTimer(true);
|
||||
}
|
||||
});
|
||||
|
||||
elements.stopwatchToggle.addEventListener("click", () => toggleStopwatch());
|
||||
elements.stopwatchLap.addEventListener("click", () => addLap());
|
||||
elements.stopwatchReset.addEventListener("click", () => resetStopwatch());
|
||||
|
||||
elements.shortcutClose.addEventListener("click", () => toggleShortcutOverlay(false));
|
||||
elements.shortcutOverlay.addEventListener("click", (event) => {
|
||||
if (event.target === elements.shortcutOverlay) toggleShortcutOverlay(false);
|
||||
});
|
||||
if (elements.shortcutTrigger) {
|
||||
elements.shortcutTrigger.addEventListener("click", () => toggleShortcutOverlay(true));
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleShortcuts, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render the local and UTC clocks. Called on an interval for a live view.
|
||||
*/
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
elements.localTime.textContent = formatTime(now);
|
||||
elements.localDate.textContent = formatDate(now);
|
||||
const utc = new Date(now.getTime());
|
||||
elements.utcTime.textContent = formatTime(utc, { timeZone: "UTC" });
|
||||
elements.utcDate.textContent = formatDate(utc, { timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** Format a Date instance into HH:MM:SS in the provided locale/timezone. */
|
||||
function formatTime(date, options = {}) {
|
||||
return new Intl.DateTimeFormat("ja-JP", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
...options,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** Format a Date instance into a friendly weekday/month/day triplet. */
|
||||
function formatDate(date, options = {}) {
|
||||
return new Intl.DateTimeFormat("ja-JP", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
...options,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate form input, build an alarm record, persist, and update the list.
|
||||
*/
|
||||
function createAlarm() {
|
||||
const timeValue = elements.alarmTime.value;
|
||||
if (!timeValue) {
|
||||
pushStatus("アラーム時刻を入力してください");
|
||||
return;
|
||||
}
|
||||
const [hour, minute] = timeValue.split(":").map((v) => Number(v));
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
||||
pushStatus("時刻の形式が正しくありません");
|
||||
return;
|
||||
}
|
||||
const label = elements.alarmLabel.value.trim() || "アラーム";
|
||||
const nextTrigger = resolveNextTrigger(hour, minute);
|
||||
const alarm = {
|
||||
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
|
||||
hour,
|
||||
minute,
|
||||
label,
|
||||
active: true,
|
||||
nextTrigger,
|
||||
ringing: false,
|
||||
};
|
||||
alarmState.items.push(alarm);
|
||||
persistAlarms();
|
||||
renderAlarms();
|
||||
elements.alarmForm.reset();
|
||||
pushStatus(`アラーム追加: ${label}`);
|
||||
}
|
||||
|
||||
/** Return the next timestamp (ms) when an alarm should fire for hour/minute. */
|
||||
function resolveNextTrigger(hour, minute) {
|
||||
const now = new Date();
|
||||
const candidate = new Date(now);
|
||||
candidate.setHours(hour, minute, 0, 0);
|
||||
if (candidate.getTime() <= now.getTime()) {
|
||||
candidate.setDate(candidate.getDate() + 1);
|
||||
}
|
||||
return candidate.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the alarm list with action buttons and show the next scheduled alarm.
|
||||
*/
|
||||
function renderAlarms() {
|
||||
const list = elements.alarmList;
|
||||
list.innerHTML = "";
|
||||
if (!alarmState.items.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.textContent = "アラームはまだありません";
|
||||
list.appendChild(empty);
|
||||
} else {
|
||||
const sorted = [...alarmState.items].sort((a, b) => a.nextTrigger - b.nextTrigger);
|
||||
sorted.forEach((alarm) => {
|
||||
const li = document.createElement("li");
|
||||
if (!alarm.active) li.classList.add("muted");
|
||||
if (alarm.ringing) li.classList.add("ringing");
|
||||
const info = document.createElement("div");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `${twoDigits(alarm.hour)}:${twoDigits(alarm.minute)} - ${alarm.label}`;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge";
|
||||
badge.textContent = alarm.active ? "ON" : "OFF";
|
||||
const next = document.createElement("small");
|
||||
next.textContent = `次回: ${formatRelative(alarm.nextTrigger)}`;
|
||||
info.append(title, badge, next);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "alarm-actions";
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.dataset.action = "toggle";
|
||||
toggleBtn.dataset.id = alarm.id;
|
||||
toggleBtn.textContent = alarm.active ? "停止" : "起動";
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.dataset.action = "delete";
|
||||
deleteBtn.dataset.id = alarm.id;
|
||||
deleteBtn.textContent = "削除";
|
||||
actions.append(toggleBtn, deleteBtn);
|
||||
li.append(info, actions);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
updateNextAlarm();
|
||||
}
|
||||
|
||||
/** Update the "next alarm" label to highlight the soonest active alarm. */
|
||||
function updateNextAlarm() {
|
||||
const nextLabel = elements.nextAlarm;
|
||||
const active = alarmState.items.filter((alarm) => alarm.active);
|
||||
if (!active.length) {
|
||||
nextLabel.textContent = "アクティブなアラームはありません";
|
||||
return;
|
||||
}
|
||||
const soonest = active.reduce((acc, alarm) => (alarm.nextTrigger < acc.nextTrigger ? alarm : acc));
|
||||
nextLabel.textContent = `次のアラーム: ${twoDigits(soonest.hour)}:${twoDigits(soonest.minute)} (${soonest.label}) - ${formatRelative(soonest.nextTrigger)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a future timestamp into a concise relative label (e.g. "5分後").
|
||||
*/
|
||||
function formatRelative(timestamp) {
|
||||
const now = Date.now();
|
||||
const diff = timestamp - now;
|
||||
if (diff <= 60 * 1000) return "まもなく";
|
||||
const minutes = Math.round(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}分後`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}時間${mins}分後`;
|
||||
}
|
||||
|
||||
/** Enable/disable alarms as the user toggles their switches. */
|
||||
function toggleAlarm(id) {
|
||||
const alarm = alarmState.items.find((item) => item.id === id);
|
||||
if (!alarm) return;
|
||||
alarm.active = !alarm.active;
|
||||
alarm.nextTrigger = resolveNextTrigger(alarm.hour, alarm.minute);
|
||||
alarm.ringing = false;
|
||||
persistAlarms();
|
||||
renderAlarms();
|
||||
}
|
||||
|
||||
/** Remove an alarm entirely and surface a toast-like status message. */
|
||||
function deleteAlarm(id) {
|
||||
const index = alarmState.items.findIndex((item) => item.id === id);
|
||||
if (index === -1) return;
|
||||
const [removed] = alarmState.items.splice(index, 1);
|
||||
persistAlarms();
|
||||
renderAlarms();
|
||||
pushStatus(`アラーム削除: ${removed.label}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling loop that checks which alarms should fire and plays feedback.
|
||||
*/
|
||||
function checkAlarms() {
|
||||
const now = Date.now();
|
||||
let triggered = false;
|
||||
alarmState.items.forEach((alarm) => {
|
||||
if (!alarm.active) return;
|
||||
if (now >= alarm.nextTrigger) {
|
||||
alarm.ringing = true;
|
||||
alarm.nextTrigger = resolveNextTrigger(alarm.hour, alarm.minute);
|
||||
triggered = true;
|
||||
notifyUser(`アラーム: ${alarm.label}`);
|
||||
setTimeout(() => {
|
||||
alarm.ringing = false;
|
||||
renderAlarms();
|
||||
}, 6000);
|
||||
}
|
||||
});
|
||||
if (triggered) {
|
||||
persistAlarms();
|
||||
renderAlarms();
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize alarms into localStorage for durability across restarts. */
|
||||
function persistAlarms() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.alarms, JSON.stringify(alarmState.items));
|
||||
} catch (error) {
|
||||
console.warn("Failed to save alarms", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hydrate the in-memory alarm list from the persisted browser storage. */
|
||||
function loadAlarms() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.alarms);
|
||||
if (raw) {
|
||||
alarmState.items = JSON.parse(raw).map((alarm) => ({
|
||||
...alarm,
|
||||
nextTrigger: resolveNextTrigger(alarm.hour, alarm.minute),
|
||||
ringing: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load alarms", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper for zero-padding numbers to two digits. */
|
||||
function twoDigits(number) {
|
||||
return number.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept flexible timer strings such as "5:30", "1h 2m", or raw seconds.
|
||||
* Returns milliseconds or null when the input cannot be parsed.
|
||||
*/
|
||||
function parseDuration(value) {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.includes(":")) {
|
||||
const [minutes, seconds] = trimmed.split(":").map((part) => Number(part));
|
||||
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
|
||||
return (minutes * 60 + seconds) * 1000;
|
||||
}
|
||||
const pattern = /^((\d+)h)?\s*((\d+)m)?\s*((\d+)s)?$/i;
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const hours = Number(match[2] || 0);
|
||||
const minutes = Number(match[4] || 0);
|
||||
const seconds = Number(match[6] || 0);
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||
return totalSeconds * 1000;
|
||||
}
|
||||
const seconds = Number(trimmed);
|
||||
if (!Number.isNaN(seconds)) return seconds * 1000;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or pause the countdown timer, optionally re-parsing the duration.
|
||||
*/
|
||||
function toggleTimer(forceParse = false) {
|
||||
if (timerState.running) {
|
||||
pauseTimer();
|
||||
return;
|
||||
}
|
||||
const rawInput = elements.timerInput.value.trim();
|
||||
const needsParse = forceParse || timerState.initialMs === 0 || (rawInput && rawInput !== timerState.lastInput);
|
||||
if (needsParse) {
|
||||
const duration = parseDuration(rawInput);
|
||||
if (!duration || duration < 1000) {
|
||||
pushStatus("1秒以上の時間を入力してください");
|
||||
return;
|
||||
}
|
||||
timerState.initialMs = duration;
|
||||
timerState.remainingMs = duration;
|
||||
timerState.lastInput = rawInput;
|
||||
}
|
||||
if (timerState.remainingMs <= 0) {
|
||||
timerState.remainingMs = timerState.initialMs;
|
||||
}
|
||||
timerState.running = true;
|
||||
timerState.targetTime = Date.now() + timerState.remainingMs;
|
||||
elements.timerToggle.textContent = "一時停止";
|
||||
elements.timerStatus.textContent = "カウント中";
|
||||
}
|
||||
|
||||
/** Snapshot remaining time while keeping the parsed duration intact. */
|
||||
function pauseTimer() {
|
||||
if (!timerState.running) return;
|
||||
timerState.remainingMs = Math.max(0, timerState.targetTime - Date.now());
|
||||
timerState.running = false;
|
||||
timerState.targetTime = null;
|
||||
elements.timerToggle.textContent = "再開";
|
||||
elements.timerStatus.textContent = "一時停止";
|
||||
}
|
||||
|
||||
/** Restore the timer to its initial duration and stop playback. */
|
||||
function resetTimer() {
|
||||
timerState.running = false;
|
||||
timerState.targetTime = null;
|
||||
timerState.remainingMs = timerState.initialMs;
|
||||
elements.timerToggle.textContent = "スタート";
|
||||
elements.timerStatus.textContent = "";
|
||||
updateTimerDisplay();
|
||||
}
|
||||
|
||||
/** Refresh timer state and fire completion notification when needed. */
|
||||
function updateTimerDisplay() {
|
||||
if (timerState.running) {
|
||||
timerState.remainingMs = Math.max(0, timerState.targetTime - Date.now());
|
||||
if (timerState.remainingMs === 0) {
|
||||
timerState.running = false;
|
||||
timerState.targetTime = null;
|
||||
elements.timerToggle.textContent = "スタート";
|
||||
elements.timerStatus.textContent = "完了";
|
||||
notifyUser("タイマー終了");
|
||||
}
|
||||
}
|
||||
renderTimer();
|
||||
}
|
||||
|
||||
/** Format the remaining timer milliseconds for the UI label. */
|
||||
function renderTimer() {
|
||||
const ms = typeof timerState.remainingMs === "number" ? timerState.remainingMs : timerState.initialMs || 0;
|
||||
elements.timerDisplay.textContent = formatDuration(ms);
|
||||
}
|
||||
|
||||
/** Represent milliseconds as MM:SS.hh or HH:MM:SS for long intervals. */
|
||||
function formatDuration(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const hundred = Math.floor((ms % 1000) / 10);
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return `${twoDigits(hours)}:${twoDigits(remMinutes)}:${twoDigits(seconds)}`;
|
||||
}
|
||||
return `${twoDigits(minutes)}:${twoDigits(seconds)}.${twoDigits(hundred)}`;
|
||||
}
|
||||
|
||||
/** Toggle stopwatch run/pause state and align button labels accordingly. */
|
||||
function toggleStopwatch() {
|
||||
if (stopwatchState.running) {
|
||||
stopwatchState.elapsedBefore += Date.now() - stopwatchState.startTime;
|
||||
stopwatchState.running = false;
|
||||
stopwatchState.startTime = null;
|
||||
elements.stopwatchToggle.textContent = "スタート";
|
||||
} else {
|
||||
stopwatchState.running = true;
|
||||
stopwatchState.startTime = Date.now();
|
||||
elements.stopwatchToggle.textContent = "停止";
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute elapsed milliseconds including paused time plus current run. */
|
||||
function getStopwatchElapsed() {
|
||||
return stopwatchState.elapsedBefore + (stopwatchState.running ? Date.now() - stopwatchState.startTime : 0);
|
||||
}
|
||||
|
||||
/** Update the on-screen stopwatch label from the current elapsed time. */
|
||||
function updateStopwatchDisplay() {
|
||||
elements.stopwatchDisplay.textContent = formatStopwatch(getStopwatchElapsed());
|
||||
}
|
||||
|
||||
/** Render both the stopwatch display and lap summary list. */
|
||||
function renderStopwatch() {
|
||||
updateStopwatchDisplay();
|
||||
renderLaps();
|
||||
}
|
||||
|
||||
/** Format stopwatch precision output (MM:SS.mmm). */
|
||||
function formatStopwatch(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const millis = ms % 1000;
|
||||
return `${twoDigits(minutes)}:${twoDigits(seconds)}.${millis.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** Capture the current elapsed time as a lap with delta vs. previous lap. */
|
||||
function addLap() {
|
||||
const elapsed = getStopwatchElapsed();
|
||||
if (elapsed === 0) return;
|
||||
const previous = stopwatchState.laps.at(-1)?.totalMs ?? 0;
|
||||
stopwatchState.laps.push({
|
||||
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
|
||||
totalMs: elapsed,
|
||||
deltaMs: elapsed - previous,
|
||||
});
|
||||
renderLaps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render lap entries newest-first with cumulative and delta timings.
|
||||
*/
|
||||
function renderLaps() {
|
||||
const list = elements.stopwatchLaps;
|
||||
list.innerHTML = "";
|
||||
if (!stopwatchState.laps.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.textContent = "ラップなし";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
stopwatchState.laps
|
||||
.slice()
|
||||
.reverse()
|
||||
.forEach((lap, index) => {
|
||||
const li = document.createElement("li");
|
||||
const label = document.createElement("strong");
|
||||
const lapNumber = stopwatchState.laps.length - index;
|
||||
label.textContent = `Lap ${lapNumber}`;
|
||||
const total = document.createElement("small");
|
||||
total.textContent = `合計 ${formatStopwatch(lap.totalMs)}`;
|
||||
const delta = document.createElement("small");
|
||||
delta.textContent = `+${formatStopwatch(lap.deltaMs)}`;
|
||||
li.append(label, total, delta);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear stopwatch progress and lap history. */
|
||||
function resetStopwatch() {
|
||||
stopwatchState.running = false;
|
||||
stopwatchState.startTime = null;
|
||||
stopwatchState.elapsedBefore = 0;
|
||||
stopwatchState.laps = [];
|
||||
elements.stopwatchToggle.textContent = "スタート";
|
||||
renderStopwatch();
|
||||
}
|
||||
|
||||
/** Focus an input element and select its contents if supported. */
|
||||
function focusAndSelect(element) {
|
||||
if (!element) return;
|
||||
element.focus();
|
||||
if (typeof element.select === "function") element.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* Briefly show a non-blocking status label near the footer of the UI.
|
||||
*/
|
||||
function pushStatus(message) {
|
||||
if (!elements.statusMessage) return;
|
||||
elements.statusMessage.textContent = message;
|
||||
clearTimeout(statusTimer);
|
||||
statusTimer = setTimeout(() => {
|
||||
elements.statusMessage.textContent = "";
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/** Combine a status toast with an audible chime. */
|
||||
function notifyUser(message) {
|
||||
pushStatus(message);
|
||||
playChime();
|
||||
}
|
||||
|
||||
/** Lightweight synthesizer used for alarm/timer notification chimes. */
|
||||
function playChime(patternName = "alarm") {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
playChime.ctx = playChime.ctx || new AudioCtx();
|
||||
if (playChime.ctx.state === "suspended") {
|
||||
playChime.ctx.resume();
|
||||
}
|
||||
const ctx = playChime.ctx;
|
||||
const pattern = AUDIO_PATTERNS[patternName] || AUDIO_PATTERNS.short;
|
||||
let startTime = ctx.currentTime;
|
||||
pattern.forEach((frequency) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = frequency;
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
gain.gain.setValueAtTime(0.0001, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.25, startTime + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + 0.3);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + 0.32);
|
||||
startTime += 0.35;
|
||||
});
|
||||
}
|
||||
|
||||
/** Global keyboard shortcut dispatcher honoring focus/overlay state. */
|
||||
function handleShortcuts(event) {
|
||||
if (event.defaultPrevented) return;
|
||||
const overlayVisible = elements.shortcutOverlay && !elements.shortcutOverlay.hidden;
|
||||
if (event.key === "Escape") {
|
||||
if (overlayVisible) {
|
||||
toggleShortcutOverlay(false);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (document.activeElement && document.activeElement !== document.body) {
|
||||
document.activeElement.blur();
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.altKey || event.metaKey || event.ctrlKey) return;
|
||||
if (overlayVisible && event.code !== "KeyH") return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
const handler = SHORTCUTS[event.code];
|
||||
if (handler) {
|
||||
event.preventDefault();
|
||||
handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the legend that explains available keyboard shortcuts.
|
||||
*/
|
||||
function toggleShortcutOverlay(force) {
|
||||
const overlay = elements.shortcutOverlay;
|
||||
const next = typeof force === "boolean" ? force : overlay.hidden;
|
||||
overlay.hidden = !next;
|
||||
if (next) {
|
||||
overlay.querySelector(".shortcut-card").focus?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine whether a keyboard event originated from a text input area. */
|
||||
function isTypingTarget(target) {
|
||||
if (!target) return false;
|
||||
const element = target instanceof Element ? target : target.parentElement;
|
||||
if (!element) return false;
|
||||
if (element.isContentEditable) return true;
|
||||
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
|
||||
}
|
||||
Reference in New Issue
Block a user