Fix Notepad render loop and stabilize toolbox modules
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-10 13:37:48 +02:00
parent c096cc07fe
commit 6c61e11f9a
8 changed files with 118 additions and 94 deletions

View file

@ -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 (
<main className="startup-error">
<section className="empty nds-empty-state" role="alert">
<h1>Cette page n'a pas pu être affichée</h1>
<p>Une erreur est survenue pendant le chargement de cet outil.</p>
<button className="nds-button" data-size="sm" type="button" onClick={() => window.location.reload()}>
Recharger la page
</button>
</section>
</main>
);
}
}

View file

@ -45,6 +45,7 @@ export function DrawingOverlay({ active, erase, strokes, color, width, onChange,
const [size, setSize] = useState({ width: 0, height: 0 }); const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => { useEffect(() => {
if (!active && !strokes.length) return undefined;
function syncSize() { function syncSize() {
const nextSize = getSurfaceSize?.() || { width: 0, height: 0 }; const nextSize = getSurfaceSize?.() || { width: 0, height: 0 };
const nextWidth = Math.max(0, Math.ceil(nextSize.width || 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(); observer?.disconnect();
window.removeEventListener("resize", syncSize); window.removeEventListener("resize", syncSize);
}; };
}, [getSurfaceSize]); }, [active, getSurfaceSize, strokes.length]);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;

View file

@ -20,9 +20,12 @@ import {
restoreSelectionTextOffset restoreSelectionTextOffset
} from "./notepadUtils.js"; } from "./notepadUtils.js";
const EMPTY_NOTEPAD_DATA = { text: "" };
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) { 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 normalizedData = useMemo(() => normalizeNotepadData(storedData), [storedData]);
const notepadDataRef = useRef(normalizedData);
const textContent = context.moduleText?.notepad || {}; const textContent = context.moduleText?.notepad || {};
const notify = context.notify; const notify = context.notify;
const activePage = normalizedData.pages.find((page) => page.id === normalizedData.activePageId) || normalizedData.pages[0]; 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 sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`;
const [temporaryStrokes, setTemporaryStrokes] = useState([]); const [temporaryStrokes, setTemporaryStrokes] = useState([]);
useEffect(() => {
notepadDataRef.current = normalizedData;
}, [normalizedData]);
useEffect(() => { useEffect(() => {
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
if (pageKeyRef.current !== pageKey) { const pageChanged = pageKeyRef.current !== pageKey;
if (pageChanged) {
pageKeyRef.current = pageKey; pageKeyRef.current = pageKey;
localSaveUpdatedAtRef.current = ""; localSaveUpdatedAtRef.current = "";
} }
if (!pageChanged && document.activeElement === editorRef.current) return;
if (activePage.updatedAt && activePage.updatedAt === localSaveUpdatedAtRef.current) return; if (activePage.updatedAt && activePage.updatedAt === localSaveUpdatedAtRef.current) return;
const editor = editorRef.current; const editor = editorRef.current;
if (document.activeElement === editor && getPlainText(editor) === activePage.text) { if (document.activeElement === editor && getPlainText(editor) === activePage.text) {
@ -81,23 +90,18 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
useLayoutEffect(() => { useLayoutEffect(() => {
if (!editorRef.current || editorRef.current.innerHTML === html) return; 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; editorRef.current.innerHTML = html;
if (document.activeElement === editorRef.current) restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current); restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current);
}, [html, text]); }, [html, text]);
useEffect(() => { useEffect(() => {
function updateActiveFormats() { function updateActiveFormats() {
const editor = editorRef.current; const editor = editorRef.current;
const selection = document.getSelection(); const selection = document.getSelection();
if (!editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) { if (!editor || document.activeElement !== editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) return;
setActiveFormats((current) => Object.values(current).some(Boolean)
? { bold: false, italic: false, underline: false, strikeThrough: false, heading: false, bulletList: false, numberList: false }
: current);
return;
}
const block = queryBlockFormat(); const block = queryBlockFormat();
setActiveFormats({ const nextFormats = {
bold: queryCommandState("bold"), bold: queryCommandState("bold"),
italic: queryCommandState("italic"), italic: queryCommandState("italic"),
underline: queryCommandState("underline"), underline: queryCommandState("underline"),
@ -105,7 +109,10 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
heading: block === "h3" || block === "heading 3", heading: block === "h3" || block === "heading 3",
bulletList: queryCommandState("insertUnorderedList"), bulletList: queryCommandState("insertUnorderedList"),
numberList: queryCommandState("insertOrderedList") numberList: queryCommandState("insertOrderedList")
}); };
setActiveFormats((current) => Object.keys(nextFormats).every((key) => current[key] === nextFormats[key])
? current
: nextFormats);
} }
document.addEventListener("selectionchange", updateActiveFormats); document.addEventListener("selectionchange", updateActiveFormats);
@ -147,8 +154,11 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
} }
function updateActivePage(updater, nextActivePageId = normalizedData.activePageId) { function updateActivePage(updater, nextActivePageId = normalizedData.activePageId) {
const pages = normalizedData.pages.map((page) => page.id === activePage.id ? updater(page) : page); const currentData = notepadDataRef.current;
saveNotepad({ ...normalizedData, activePageId: nextActivePageId, pages }); 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) { function saveContent(nextHtml, nextText) {
@ -204,7 +214,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
const nextHtml = editorRef.current?.innerHTML || ""; const nextHtml = editorRef.current?.innerHTML || "";
const nextText = getPlainText(editorRef.current); const nextText = getPlainText(editorRef.current);
saveContent(nextHtml, nextText); saveContent(nextHtml, nextText);
requestAnimationFrame(() => document.dispatchEvent(new Event("selectionchange")));
} }
function runCommand(command, value = null) { function runCommand(command, value = null) {

View file

@ -4,6 +4,7 @@ import { Icon } from "../../../../components/Icon.jsx";
import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, getDiceShape, rollDice } from "../randomizerUtils.js"; import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, getDiceShape, rollDice } from "../randomizerUtils.js";
import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx"; import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx";
import { DICE_ROLL_DELAYS, DRAW_PAUSE_MS, hasReducedMotion } from "./randomizerUiUtils.js"; import { DICE_ROLL_DELAYS, DRAW_PAUSE_MS, hasReducedMotion } from "./randomizerUiUtils.js";
import { useTimeoutQueue } from "./useTimeoutQueue.js";
export function DiceRandomizer({ data, textContent, onChange, context }) { export function DiceRandomizer({ data, textContent, onChange, context }) {
const [displayValue, setDisplayValue] = useState(data.results[0] || 1); 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 [rolling, setRolling] = useState(false);
const [rollingIndex, setRollingIndex] = useState(-1); const [rollingIndex, setRollingIndex] = useState(-1);
const [rollDuration, setRollDuration] = useState(DICE_ROLL_DELAYS[0]); const [rollDuration, setRollDuration] = useState(DICE_ROLL_DELAYS[0]);
const timersRef = useRef([]); const { queueTimer, clearTimers: clearDiceTimers } = useTimeoutQueue();
const skipDiceSyncRef = useRef(false); const skipDiceSyncRef = useRef(false);
const shape = getDiceShape(data.faces); const shape = getDiceShape(data.faces);
@ -25,22 +26,6 @@ export function DiceRandomizer({ data, textContent, onChange, context }) {
setDisplayResults(data.results); setDisplayResults(data.results);
}, [data.results, rolling]); }, [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() { function stopDice() {
clearDiceTimers(); clearDiceTimers();
skipDiceSyncRef.current = true; skipDiceSyncRef.current = true;

View file

@ -1,11 +1,12 @@
// Rôle : gère l'onglet équipes équilibrées du Randomizer et ses exclusions. // 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 { CompactDropdown } from "../../../../components/CompactDropdown.jsx";
import { Icon } from "../../../../components/Icon.jsx"; import { Icon } from "../../../../components/Icon.jsx";
import { useDraftForm } from "../../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../../hooks/useDraftForm.js";
import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js"; import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js";
import { RandomizerHistory } from "./RandomizerShared.jsx"; import { RandomizerHistory } from "./RandomizerShared.jsx";
import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js"; import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js";
import { useTimeoutQueue } from "./useTimeoutQueue.js";
export function TeamsRandomizer({ data, textContent, onChange, context }) { export function TeamsRandomizer({ data, textContent, onChange, context }) {
const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT); const exclusionDraft = useDraftForm(DEFAULT_EXCLUSION_DRAFT);
@ -15,7 +16,7 @@ export function TeamsRandomizer({ data, textContent, onChange, context }) {
const [reelIndex, setReelIndex] = useState(0); const [reelIndex, setReelIndex] = useState(0);
const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]); const [reelDuration, setReelDuration] = useState(TEAM_REEL_DELAYS[0]);
const [namesText, setNamesText] = useState(""); const [namesText, setNamesText] = useState("");
const timersRef = useRef([]); const { queueTimer, clearTimers: clearTeamTimers } = useTimeoutQueue();
const activePlayers = data.players.filter((player) => player.active); const activePlayers = data.players.filter((player) => player.active);
const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount); const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount);
const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount)); const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount));
@ -27,22 +28,6 @@ export function TeamsRandomizer({ data, textContent, onChange, context }) {
setSelectionCountDraft(String(storedSelectionCount)); setSelectionCountDraft(String(storedSelectionCount));
}, [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) { function commitSelectionCount(value = selectionCountDraft) {
const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount); const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
setSelectionCountDraft(String(nextSelectionCount)); setSelectionCountDraft(String(nextSelectionCount));

View file

@ -4,6 +4,7 @@ import { Icon } from "../../../../components/Icon.jsx";
import { RANDOMIZER_LIMITS, addRandomizerHistory, buildWheelSegments, clampNumber, drawWeightedEntries } from "../randomizerUtils.js"; import { RANDOMIZER_LIMITS, addRandomizerHistory, buildWheelSegments, clampNumber, drawWeightedEntries } from "../randomizerUtils.js";
import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx"; import { ResultChips, RandomizerHistory } from "./RandomizerShared.jsx";
import { DRAW_PAUSE_MS, WHEEL_COLORS, WHEEL_SPIN_MS, describeWheelArc, getWheelTargetRotation, hasReducedMotion } from "./randomizerUiUtils.js"; 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 }) { export function WheelRandomizer({ data, textContent, onChange, context }) {
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
@ -11,7 +12,7 @@ export function WheelRandomizer({ data, textContent, onChange, context }) {
const [drawing, setDrawing] = useState(false); const [drawing, setDrawing] = useState(false);
const [displayResults, setDisplayResults] = useState(data.results); const [displayResults, setDisplayResults] = useState(data.results);
const [entriesText, setEntriesText] = useState(""); const [entriesText, setEntriesText] = useState("");
const timersRef = useRef([]); const { queueTimer, clearTimers: clearWheelTimers } = useTimeoutQueue();
const skipWheelSyncRef = useRef(false); const skipWheelSyncRef = useRef(false);
const segments = useMemo(() => buildWheelSegments(data.entries), [data.entries]); const segments = useMemo(() => buildWheelSegments(data.entries), [data.entries]);
@ -24,22 +25,6 @@ export function WheelRandomizer({ data, textContent, onChange, context }) {
setDisplayResults(data.results); setDisplayResults(data.results);
}, [data.results, drawing]); }, [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() { function stopWheel() {
clearWheelTimers(); clearWheelTimers();
skipWheelSyncRef.current = true; skipWheelSyncRef.current = true;

View file

@ -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 };
}

View file

@ -4,6 +4,7 @@ import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./styles/main.scss"; import "./styles/main.scss";
import { AppOverlays } from "./components/AppOverlays.jsx"; import { AppOverlays } from "./components/AppOverlays.jsx";
import { AppErrorBoundary } from "./components/AppErrorBoundary.jsx";
import { Shell } from "./components/Shell.jsx"; import { Shell } from "./components/Shell.jsx";
import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js"; import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js";
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js"; import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
@ -103,32 +104,34 @@ function App() {
return ( return (
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}> <Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
<RouteContent <AppErrorBoundary route={route}>
route={route} <RouteContent
siteContent={siteContent} route={route}
games={games} siteContent={siteContent}
gamesError={gamesError} games={games}
mhwilds={mhwilds} gamesError={gamesError}
diablo4={diablo4} mhwilds={mhwilds}
filters={filters} diablo4={diablo4}
setFilters={setFilters} filters={filters}
t={t} setFilters={setFilters}
toolboxes={store.toolboxes} t={t}
links={store.links} toolboxes={store.toolboxes}
getGame={getGame} links={store.links}
getToolboxGame={getToolboxGame} getGame={getGame}
actions={actions} getToolboxGame={getToolboxGame}
storageUsage={store.storageUsage} actions={actions}
storageReady={store.ready} storageUsage={store.storageUsage}
toastPosition={store.toastPosition} storageReady={store.ready}
setToastPosition={store.setToastPosition} toastPosition={store.toastPosition}
getModuleData={store.getModuleData} setToastPosition={store.setToastPosition}
updateToolbox={actions.updateToolbox} getModuleData={store.getModuleData}
updateModuleData={store.updateModuleData} updateToolbox={actions.updateToolbox}
addImageFiles={actions.addImageFiles} updateModuleData={store.updateModuleData}
pendingTutorialToolboxId={pendingTutorialToolboxId} addImageFiles={actions.addImageFiles}
setPendingTutorialToolboxId={setPendingTutorialToolboxId} pendingTutorialToolboxId={pendingTutorialToolboxId}
/> setPendingTutorialToolboxId={setPendingTutorialToolboxId}
/>
</AppErrorBoundary>
{drawerGameId && ( {drawerGameId && (
<Suspense fallback={null}> <Suspense fallback={null}>
<ToolboxDrawer <ToolboxDrawer