327 lines
13 KiB
JavaScript
327 lines
13 KiB
JavaScript
// Rôle : fournit l'outil timer avec chronomètre, comptes à rebours et échange texte.
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Icon } from "../../../components/Icon.jsx";
|
|
import { Tabs } from "../../../components/Tabs.jsx";
|
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
|
import {
|
|
getCountdownTargetMs,
|
|
getDailyTargetMs,
|
|
getIntervalAnchorMs,
|
|
getNowMs,
|
|
getTimePatternTargetMs,
|
|
timePartsToDurationMs,
|
|
timePartsToString
|
|
} from "./timerUtils.js";
|
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
import { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
|
|
import { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
|
|
import {
|
|
ALERT_MIN_DELAY_MS,
|
|
AUTO_REFRESH_COUNTDOWN_TYPES,
|
|
COUNTDOWN_TYPES,
|
|
DEFAULT_COUNTDOWN_FORM,
|
|
TIMER_TABS,
|
|
TOGGLE_REFRESH_COUNTDOWN_TYPES,
|
|
canToggleAutoRefresh,
|
|
getAlertGuardMessage,
|
|
getCountdownValidationError,
|
|
hasTimePartValue,
|
|
isAlertGuarded
|
|
} from "./timerOptions.js";
|
|
|
|
export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
|
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
|
const textContent = context.moduleText?.timer || {};
|
|
const activeTab = data.activeTab;
|
|
const [nowMs, setNowMs] = useState(getNowMs);
|
|
const countdownDraft = useDraftForm(DEFAULT_COUNTDOWN_FORM);
|
|
|
|
useEffect(() => {
|
|
const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50);
|
|
return () => window.clearInterval(intervalId);
|
|
}, []);
|
|
|
|
const stopwatchElapsedMs = data.stopwatch.startedAt
|
|
? data.stopwatch.elapsedMs + Math.max(0, nowMs - data.stopwatch.startedAt)
|
|
: data.stopwatch.elapsedMs;
|
|
const visibleCountdowns = useMemo(() => {
|
|
const countdowns = data.countdowns.map((countdown) => ({ ...countdown, targetMs: getCountdownTargetMs(countdown, nowMs) }));
|
|
if (!data.sortResults) return countdowns;
|
|
return [...countdowns].sort((a, b) => (a.targetMs || Number.POSITIVE_INFINITY) - (b.targetMs || Number.POSITIVE_INFINITY));
|
|
}, [data.countdowns, data.sortResults, nowMs]);
|
|
const countdownReorder = useGroupedReorder({
|
|
namespace: "timer-countdowns",
|
|
items: data.countdowns,
|
|
getItemId: (countdown) => countdown.id,
|
|
getParentId: () => moduleId,
|
|
canMoveItem: () => !data.sortResults,
|
|
onItemMove: (operation) => {
|
|
if (data.sortResults) return;
|
|
save({ ...data, countdowns: moveItem(data.countdowns, operation.sourceId, operation.targetId, operation.placement) });
|
|
},
|
|
hierarchy: { enabled: true, stickyParents: true }
|
|
});
|
|
|
|
function save(nextData) {
|
|
context.setModuleData(toolboxId, moduleId, nextData);
|
|
}
|
|
|
|
function setActiveTab(nextActiveTab) {
|
|
save({ ...data, activeTab: nextActiveTab });
|
|
}
|
|
|
|
function setScrollResults(scrollResults) {
|
|
save({ ...data, scrollResults });
|
|
}
|
|
|
|
function setSortResults(sortResults) {
|
|
save({ ...data, sortResults });
|
|
}
|
|
|
|
function updateStopwatch(stopwatch) {
|
|
save({ ...data, stopwatch });
|
|
}
|
|
|
|
function startStopwatch() {
|
|
if (data.stopwatch.startedAt) return;
|
|
updateStopwatch({ ...data.stopwatch, startedAt: getNowMs() });
|
|
}
|
|
|
|
function pauseStopwatch() {
|
|
if (!data.stopwatch.startedAt) return;
|
|
updateStopwatch({
|
|
...data.stopwatch,
|
|
startedAt: 0,
|
|
elapsedMs: stopwatchElapsedMs
|
|
});
|
|
}
|
|
|
|
function resetStopwatch() {
|
|
updateStopwatch({ ...data.stopwatch, elapsedMs: 0, startedAt: 0 });
|
|
}
|
|
|
|
function addLap() {
|
|
if (!stopwatchElapsedMs) return;
|
|
updateStopwatch({
|
|
...data.stopwatch,
|
|
laps: [
|
|
{
|
|
id: context.uid("timer"),
|
|
label: "",
|
|
elapsedMs: stopwatchElapsedMs
|
|
},
|
|
...data.stopwatch.laps
|
|
]
|
|
});
|
|
}
|
|
|
|
function deleteLap(lapId) {
|
|
updateStopwatch({ ...data.stopwatch, laps: data.stopwatch.laps.filter((lap) => lap.id !== lapId) });
|
|
}
|
|
|
|
function renameLap(lapId, label) {
|
|
updateStopwatch({
|
|
...data.stopwatch,
|
|
laps: data.stopwatch.laps.map((lap) => lap.id === lapId ? { ...lap, label: label.trim() } : lap)
|
|
});
|
|
}
|
|
|
|
function createCountdown(event) {
|
|
countdownDraft.handleSubmit(event, (draft, { updateValues }) => {
|
|
const type = COUNTDOWN_TYPES.includes(draft.type) ? draft.type : "duration";
|
|
const validationError = getCountdownValidationError(type, draft, textContent);
|
|
if (validationError) return validationError;
|
|
const label = draft.label.trim() || textContent.defaultCountdownLabel || "Timer";
|
|
const now = getNowMs();
|
|
let countdown = null;
|
|
|
|
if (type === "duration") {
|
|
const durationMs = timePartsToDurationMs(draft.duration);
|
|
if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs };
|
|
}
|
|
if (type === "daily_time") {
|
|
const time = timePartsToString(draft.time);
|
|
const targetAt = getDailyTargetMs(time, now);
|
|
if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt };
|
|
}
|
|
if (type === "time_pattern") {
|
|
const pattern = hasTimePartValue(draft.pattern) ? timePartsToString(draft.pattern, { allowWildcard: true }) : "";
|
|
if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern };
|
|
}
|
|
if (type === "interval") {
|
|
const intervalMs = timePartsToDurationMs(draft.interval);
|
|
const startTime = hasTimePartValue(draft.intervalStartTime) ? timePartsToString(draft.intervalStartTime) : "";
|
|
const startMode = startTime ? "time" : "now";
|
|
const anchorAt = getIntervalAnchorMs(startMode, startTime, now);
|
|
const targetAt = getCountdownTargetMs({ type, intervalMs, anchorAt, autoRefresh: true }, now);
|
|
if (intervalMs > 0) {
|
|
countdown = {
|
|
id: context.uid("timer"),
|
|
label,
|
|
type,
|
|
intervalMs,
|
|
anchorAt,
|
|
targetAt,
|
|
startMode,
|
|
...(startTime ? { startTime } : {}),
|
|
autoRefresh: intervalMs >= ALERT_MIN_DELAY_MS
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!countdown) return;
|
|
save({ ...data, countdowns: [...data.countdowns, countdown] });
|
|
updateValues({ label: "" });
|
|
});
|
|
}
|
|
|
|
function deleteCountdown(countdownId) {
|
|
save({ ...data, countdowns: data.countdowns.filter((countdown) => countdown.id !== countdownId) });
|
|
}
|
|
|
|
function resetCountdown(countdown) {
|
|
if (countdown.type !== "duration" && countdown.type !== "daily_time" && countdown.type !== "interval") return;
|
|
const now = getNowMs();
|
|
let nextCountdown = null;
|
|
if (countdown.type === "duration") nextCountdown = { ...countdown, targetAt: now + countdown.durationMs };
|
|
if (countdown.type === "daily_time") nextCountdown = { ...countdown, targetAt: getDailyTargetMs(countdown.time, now) };
|
|
if (countdown.type === "interval") {
|
|
const anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now);
|
|
const targetAt = getCountdownTargetMs({ ...countdown, anchorAt, autoRefresh: true }, now);
|
|
nextCountdown = { ...countdown, anchorAt, targetAt };
|
|
}
|
|
if (!nextCountdown) return;
|
|
save({ ...data, countdowns: data.countdowns.map((item) => item.id === countdown.id ? nextCountdown : item) });
|
|
}
|
|
|
|
function toggleCountdownAutoRefresh(countdownId) {
|
|
const countdown = data.countdowns.find((item) => item.id === countdownId);
|
|
if (!TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type)) return;
|
|
if (!countdown.autoRefresh && !canToggleAutoRefresh(countdown)) {
|
|
context.notify?.(getAlertGuardMessage(textContent));
|
|
return;
|
|
}
|
|
const now = getNowMs();
|
|
save({
|
|
...data,
|
|
countdowns: data.countdowns.map((item) => {
|
|
if (item.id !== countdownId) return item;
|
|
const autoRefresh = !item.autoRefresh;
|
|
if (item.type === "daily_time") return { ...item, autoRefresh, targetAt: getDailyTargetMs(item.time, now) };
|
|
if (item.type === "interval") {
|
|
const anchorAt = getIntervalAnchorMs(item.startMode, item.startTime, now);
|
|
const targetAt = getCountdownTargetMs({ ...item, anchorAt, autoRefresh: true }, now);
|
|
return { ...item, autoRefresh, anchorAt, targetAt };
|
|
}
|
|
return item;
|
|
})
|
|
});
|
|
}
|
|
|
|
function renameCountdown(countdownId, label) {
|
|
save({
|
|
...data,
|
|
countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, label: label.trim() } : countdown)
|
|
});
|
|
}
|
|
|
|
function setCountdownAlertMode(countdownId, alertMode) {
|
|
const countdown = data.countdowns.find((item) => item.id === countdownId);
|
|
if (alertMode !== "off" && AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && isAlertGuarded(countdown)) {
|
|
context.notify?.(getAlertGuardMessage(textContent));
|
|
return;
|
|
}
|
|
save({
|
|
...data,
|
|
countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, alertMode } : countdown)
|
|
});
|
|
}
|
|
|
|
function resetActiveResults() {
|
|
if (activeTab === "stopwatch") {
|
|
resetStopwatch();
|
|
return;
|
|
}
|
|
save({ ...data, countdowns: [] });
|
|
}
|
|
|
|
return (
|
|
<div className="timer-module">
|
|
{textExchangeOpen && (
|
|
<div className="module-text-exchange-panel">
|
|
<TextExchangeActions type="timer" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
|
</div>
|
|
)}
|
|
<section className="timer-control-card">
|
|
<Tabs
|
|
className="timer-tabs"
|
|
tabs={[
|
|
{ ...TIMER_TABS[0], label: textContent.stopwatchTab || TIMER_TABS[0].label },
|
|
{ ...TIMER_TABS[1], label: textContent.countdownTab || TIMER_TABS[1].label }
|
|
]}
|
|
activeTab={activeTab}
|
|
onChange={setActiveTab}
|
|
iconOnly
|
|
/>
|
|
{activeTab === "stopwatch" ? (
|
|
<StopwatchControls
|
|
textContent={textContent}
|
|
elapsedMs={stopwatchElapsedMs}
|
|
running={Boolean(data.stopwatch.startedAt)}
|
|
onStart={startStopwatch}
|
|
onPause={pauseStopwatch}
|
|
onReset={resetStopwatch}
|
|
onLap={addLap}
|
|
/>
|
|
) : (
|
|
<CountdownControls
|
|
textContent={textContent}
|
|
form={countdownDraft.values}
|
|
error={countdownDraft.error}
|
|
onChange={countdownDraft.updateValues}
|
|
onSubmit={createCountdown}
|
|
/>
|
|
)}
|
|
</section>
|
|
<section className={`tool-split-results timer-results-card ${data.scrollResults ? "is-scrollable" : ""}`}>
|
|
<div className="tool-split-actions">
|
|
<button className="tool-split-action-button danger" type="button" onClick={resetActiveResults} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
|
<Icon name="rubber" />
|
|
</button>
|
|
{activeTab === "countdown" && (
|
|
<button
|
|
className={`tool-split-scroll-toggle timer-sort-toggle ${data.sortResults ? "active" : ""}`}
|
|
type="button"
|
|
onClick={() => setSortResults(!data.sortResults)}
|
|
aria-pressed={data.sortResults}
|
|
title={textContent.sortTitle || "Trier par temps restant"}
|
|
aria-label={textContent.sortTitle || "Trier par temps restant"}
|
|
>
|
|
<Icon name="sort-time" />
|
|
<i aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
<button
|
|
className={`tool-split-scroll-toggle timer-scroll-toggle ${data.scrollResults ? "active" : ""}`}
|
|
type="button"
|
|
onClick={() => setScrollResults(!data.scrollResults)}
|
|
aria-pressed={data.scrollResults}
|
|
title={textContent.scrollableTitle || "Liste scrollable"}
|
|
aria-label={textContent.scrollableTitle || "Liste scrollable"}
|
|
>
|
|
<Icon name="scrollable" />
|
|
<i aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div className={`tool-split-tree timer-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
|
{activeTab === "stopwatch" ? (
|
|
<StopwatchLapList laps={data.stopwatch.laps} textContent={textContent} onRename={renameLap} onDelete={deleteLap} />
|
|
) : (
|
|
<CountdownList countdowns={visibleCountdowns} nowMs={nowMs} textContent={textContent} reorder={countdownReorder} reorderEnabled={!data.sortResults} parentId={moduleId} onRename={renameCountdown} onAlertModeChange={setCountdownAlertMode} onReset={resetCountdown} onAutoRefreshToggle={toggleCountdownAutoRefresh} onDelete={deleteCountdown} />
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|