diff --git a/website/src/components/AppErrorBoundary.jsx b/website/src/components/AppErrorBoundary.jsx new file mode 100644 index 0000000..b4181bd --- /dev/null +++ b/website/src/components/AppErrorBoundary.jsx @@ -0,0 +1,31 @@ +// Rôle : récupère les erreurs de rendu et de chargement lazy au niveau de l'application. +import { Component } from "react"; + +export class AppErrorBoundary extends Component { + state = { error: null }; + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidUpdate(previousProps) { + if (previousProps.route !== this.props.route && this.state.error) { + this.setState({ error: null }); + } + } + + render() { + if (!this.state.error) return this.props.children; + return ( + + + Cette page n'a pas pu être affichée + Une erreur est survenue pendant le chargement de cet outil. + window.location.reload()}> + Recharger la page + + + + ); + } +} diff --git a/website/src/features/toolboxes/modules/DrawingOverlay.jsx b/website/src/features/toolboxes/modules/DrawingOverlay.jsx index 95990cf..b0afd87 100644 --- a/website/src/features/toolboxes/modules/DrawingOverlay.jsx +++ b/website/src/features/toolboxes/modules/DrawingOverlay.jsx @@ -45,6 +45,7 @@ export function DrawingOverlay({ active, erase, strokes, color, width, onChange, const [size, setSize] = useState({ width: 0, height: 0 }); useEffect(() => { + if (!active && !strokes.length) return undefined; function syncSize() { const nextSize = getSurfaceSize?.() || { width: 0, height: 0 }; const nextWidth = Math.max(0, Math.ceil(nextSize.width || 0)); @@ -63,7 +64,7 @@ export function DrawingOverlay({ active, erase, strokes, color, width, onChange, observer?.disconnect(); window.removeEventListener("resize", syncSize); }; - }, [getSurfaceSize]); + }, [active, getSurfaceSize, strokes.length]); useEffect(() => { const canvas = canvasRef.current; diff --git a/website/src/features/toolboxes/modules/NotepadModule.jsx b/website/src/features/toolboxes/modules/NotepadModule.jsx index 544046d..adc8d57 100644 --- a/website/src/features/toolboxes/modules/NotepadModule.jsx +++ b/website/src/features/toolboxes/modules/NotepadModule.jsx @@ -20,9 +20,12 @@ import { restoreSelectionTextOffset } from "./notepadUtils.js"; +const EMPTY_NOTEPAD_DATA = { text: "" }; + export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) { - const storedData = context.getModuleData(toolboxId, moduleId, { text: "" }); + const storedData = context.getModuleData(toolboxId, moduleId, EMPTY_NOTEPAD_DATA); const normalizedData = useMemo(() => normalizeNotepadData(storedData), [storedData]); + const notepadDataRef = useRef(normalizedData); const textContent = context.moduleText?.notepad || {}; const notify = context.notify; const activePage = normalizedData.pages.find((page) => page.id === normalizedData.activePageId) || normalizedData.pages[0]; @@ -57,12 +60,18 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`; const [temporaryStrokes, setTemporaryStrokes] = useState([]); + useEffect(() => { + notepadDataRef.current = normalizedData; + }, [normalizedData]); + useEffect(() => { const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; - if (pageKeyRef.current !== pageKey) { + const pageChanged = pageKeyRef.current !== pageKey; + if (pageChanged) { pageKeyRef.current = pageKey; localSaveUpdatedAtRef.current = ""; } + if (!pageChanged && document.activeElement === editorRef.current) return; if (activePage.updatedAt && activePage.updatedAt === localSaveUpdatedAtRef.current) return; const editor = editorRef.current; if (document.activeElement === editor && getPlainText(editor) === activePage.text) { @@ -81,23 +90,18 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } useLayoutEffect(() => { if (!editorRef.current || editorRef.current.innerHTML === html) return; - if (document.activeElement === editorRef.current && getPlainText(editorRef.current) === text) return; + if (document.activeElement === editorRef.current) return; editorRef.current.innerHTML = html; - if (document.activeElement === editorRef.current) restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current); + restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current); }, [html, text]); useEffect(() => { function updateActiveFormats() { const editor = editorRef.current; const selection = document.getSelection(); - if (!editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) { - setActiveFormats((current) => Object.values(current).some(Boolean) - ? { bold: false, italic: false, underline: false, strikeThrough: false, heading: false, bulletList: false, numberList: false } - : current); - return; - } + if (!editor || document.activeElement !== editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) return; const block = queryBlockFormat(); - setActiveFormats({ + const nextFormats = { bold: queryCommandState("bold"), italic: queryCommandState("italic"), underline: queryCommandState("underline"), @@ -105,7 +109,10 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } heading: block === "h3" || block === "heading 3", bulletList: queryCommandState("insertUnorderedList"), numberList: queryCommandState("insertOrderedList") - }); + }; + setActiveFormats((current) => Object.keys(nextFormats).every((key) => current[key] === nextFormats[key]) + ? current + : nextFormats); } document.addEventListener("selectionchange", updateActiveFormats); @@ -147,8 +154,11 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } } function updateActivePage(updater, nextActivePageId = normalizedData.activePageId) { - const pages = normalizedData.pages.map((page) => page.id === activePage.id ? updater(page) : page); - saveNotepad({ ...normalizedData, activePageId: nextActivePageId, pages }); + const currentData = notepadDataRef.current; + const pages = currentData.pages.map((page) => page.id === activePage.id ? updater(page) : page); + const nextData = { ...currentData, activePageId: nextActivePageId, pages }; + notepadDataRef.current = nextData; + saveNotepad(nextData); } function saveContent(nextHtml, nextText) { @@ -204,7 +214,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } const nextHtml = editorRef.current?.innerHTML || ""; const nextText = getPlainText(editorRef.current); saveContent(nextHtml, nextText); - requestAnimationFrame(() => document.dispatchEvent(new Event("selectionchange"))); } function runCommand(command, value = null) { diff --git a/website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx b/website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx index d65ad4d..a46ce1b 100644 --- a/website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx +++ b/website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx @@ -4,6 +4,7 @@ import { Icon } from "../../../../components/Icon.jsx"; import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, getDiceShape, rollDice } from "../randomizerUtils.js"; import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx"; import { DICE_ROLL_DELAYS, DRAW_PAUSE_MS, hasReducedMotion } from "./randomizerUiUtils.js"; +import { useTimeoutQueue } from "./useTimeoutQueue.js"; export function DiceRandomizer({ data, textContent, onChange, context }) { const [displayValue, setDisplayValue] = useState(data.results[0] || 1); @@ -11,7 +12,7 @@ export function DiceRandomizer({ data, textContent, onChange, context }) { const [rolling, setRolling] = useState(false); const [rollingIndex, setRollingIndex] = useState(-1); const [rollDuration, setRollDuration] = useState(DICE_ROLL_DELAYS[0]); - const timersRef = useRef([]); + const { queueTimer, clearTimers: clearDiceTimers } = useTimeoutQueue(); const skipDiceSyncRef = useRef(false); const shape = getDiceShape(data.faces); @@ -25,22 +26,6 @@ export function DiceRandomizer({ data, textContent, onChange, context }) { setDisplayResults(data.results); }, [data.results, rolling]); - useEffect(() => () => { - timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); - timersRef.current = []; - }, []); - - function queueTimer(callback, delay) { - const timerId = window.setTimeout(callback, delay); - timersRef.current.push(timerId); - return timerId; - } - - function clearDiceTimers() { - timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); - timersRef.current = []; - } - function stopDice() { clearDiceTimers(); skipDiceSyncRef.current = true; diff --git a/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx b/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx index ccd128f..6ec555e 100644 --- a/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx +++ b/website/src/features/toolboxes/modules/randomizer/RandomizerTeams.jsx @@ -1,11 +1,12 @@ // Rôle : gère l'onglet équipes équilibrées du Randomizer et ses exclusions. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { CompactDropdown } from "../../../../components/CompactDropdown.jsx"; import { Icon } from "../../../../components/Icon.jsx"; import { useDraftForm } from "../../../../hooks/useDraftForm.js"; import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js"; import { RandomizerHistory } from "./RandomizerShared.jsx"; import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js"; +import { useTimeoutQueue } from "./useTimeoutQueue.js"; export function TeamsRandomizer({ data, textContent, onChange, context }) { const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT); @@ -15,7 +16,7 @@ export function TeamsRandomizer({ data, textContent, onChange, context }) { const [reelIndex, setReelIndex] = useState(0); const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]); const [namesText, setNamesText] = useState(""); - const timersRef = useRef([]); + const { queueTimer, clearTimers: clearTeamTimers } = useTimeoutQueue(); const activePlayers = data.players.filter((player) => player.active); const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount); const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount)); @@ -27,22 +28,6 @@ export function TeamsRandomizer({ data, textContent, onChange, context }) { setSelectionCountDraft(String(storedSelectionCount)); }, [storedSelectionCount]); - useEffect(() => () => { - timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); - timersRef.current = []; - }, []); - - function queueTimer(callback, delay) { - const timerId = window.setTimeout(callback, delay); - timersRef.current.push(timerId); - return timerId; - } - - function clearTeamTimers() { - timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); - timersRef.current = []; - } - function commitSelectionCount(value = selectionCountDraft) { const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount); setSelectionCountDraft(String(nextSelectionCount)); diff --git a/website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx b/website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx index 1b45b5a..94d83b2 100644 --- a/website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx +++ b/website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx @@ -4,6 +4,7 @@ import { Icon } from "../../../../components/Icon.jsx"; import { RANDOMIZER_LIMITS, addRandomizerHistory, buildWheelSegments, clampNumber, drawWeightedEntries } from "../randomizerUtils.js"; import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx"; import { DRAW_PAUSE_MS, WHEEL_COLORS, WHEEL_SPIN_MS, describeWheelArc, getWheelTargetRotation, hasReducedMotion } from "./randomizerUiUtils.js"; +import { useTimeoutQueue } from "./useTimeoutQueue.js"; export function WheelRandomizer({ data, textContent, onChange, context }) { const [rotation, setRotation] = useState(0); @@ -11,7 +12,7 @@ export function WheelRandomizer({ data, textContent, onChange, context }) { const [drawing, setDrawing] = useState(false); const [displayResults, setDisplayResults] = useState(data.results); const [entriesText, setEntriesText] = useState(""); - const timersRef = useRef([]); + const { queueTimer, clearTimers: clearWheelTimers } = useTimeoutQueue(); const skipWheelSyncRef = useRef(false); const segments = useMemo(() => buildWheelSegments(data.entries), [data.entries]); @@ -24,22 +25,6 @@ export function WheelRandomizer({ data, textContent, onChange, context }) { setDisplayResults(data.results); }, [data.results, drawing]); - useEffect(() => () => { - timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); - timersRef.current = []; - }, []); - - function queueTimer(callback, delay) { - const timerId = window.setTimeout(callback, delay); - timersRef.current.push(timerId); - return timerId; - } - - function clearWheelTimers() { - timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); - timersRef.current = []; - } - function stopWheel() { clearWheelTimers(); skipWheelSyncRef.current = true; diff --git a/website/src/features/toolboxes/modules/randomizer/useTimeoutQueue.js b/website/src/features/toolboxes/modules/randomizer/useTimeoutQueue.js new file mode 100644 index 0000000..13133a4 --- /dev/null +++ b/website/src/features/toolboxes/modules/randomizer/useTimeoutQueue.js @@ -0,0 +1,25 @@ +// Rôle : planifie et nettoie les timers d'animation du Randomizer. +import { useCallback, useEffect, useRef } from "react"; + +export function useTimeoutQueue() { + const timersRef = useRef(new Set()); + + const clearTimers = useCallback(() => { + timersRef.current.forEach((timerId) => window.clearTimeout(timerId)); + timersRef.current.clear(); + }, []); + + const queueTimer = useCallback((callback, delay) => { + let timerId; + timerId = window.setTimeout(() => { + timersRef.current.delete(timerId); + callback(); + }, delay); + timersRef.current.add(timerId); + return timerId; + }, []); + + useEffect(() => clearTimers, [clearTimers]); + + return { queueTimer, clearTimers }; +} diff --git a/website/src/main.jsx b/website/src/main.jsx index 8888690..052e8af 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -4,6 +4,7 @@ import { lazy, Suspense, useCallback, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import "./styles/main.scss"; import { AppOverlays } from "./components/AppOverlays.jsx"; +import { AppErrorBoundary } from "./components/AppErrorBoundary.jsx"; import { Shell } from "./components/Shell.jsx"; import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js"; import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js"; @@ -103,32 +104,34 @@ function App() { return ( - + + + {drawerGameId && (
Une erreur est survenue pendant le chargement de cet outil.