Fix library anchor navigation and scroll cleanup
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
99d916f6db
commit
75d4d99a87
4 changed files with 88 additions and 22 deletions
|
|
@ -33,3 +33,4 @@
|
||||||
{"id":"int-4b64d68baf132fa99cb3244d8487034c","kind":"field_change","created_at":"2026-09-11T12:05:04.580370366Z","actor":"Shinuwa","issue_id":"sokko-g-kqo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared keyed state initializes Notepad/ImageAnnotation temporary drawings and RNG history before DOM commit; ignores setters bound to a different active key. Storage keys and write/error paths preserved. npm run check:full passed: 100 Node tests and 23 E2E including StrictMode key switches and real Notepad canvas/page/reload checks."}}
|
{"id":"int-4b64d68baf132fa99cb3244d8487034c","kind":"field_change","created_at":"2026-09-11T12:05:04.580370366Z","actor":"Shinuwa","issue_id":"sokko-g-kqo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared keyed state initializes Notepad/ImageAnnotation temporary drawings and RNG history before DOM commit; ignores setters bound to a different active key. Storage keys and write/error paths preserved. npm run check:full passed: 100 Node tests and 23 E2E including StrictMode key switches and real Notepad canvas/page/reload checks."}}
|
||||||
{"id":"int-80659dd55d71f2a33d1067b008bf2abd","kind":"field_change","created_at":"2026-09-11T17:30:09.794064421Z","actor":"Shinuwa","issue_id":"sokko-g-275","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed RNG draft/edit transitions and ImageViewer pre-commit resets; cover decoding now guards stale completion/errors and releases resources. Four new StrictMode E2E cases pass; npm run check:full passes with 27 E2E. No storage or style changes."}}
|
{"id":"int-80659dd55d71f2a33d1067b008bf2abd","kind":"field_change","created_at":"2026-09-11T17:30:09.794064421Z","actor":"Shinuwa","issue_id":"sokko-g-275","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed RNG draft/edit transitions and ImageViewer pre-commit resets; cover decoding now guards stale completion/errors and releases resources. Four new StrictMode E2E cases pass; npm run check:full passes with 27 E2E. No storage or style changes."}}
|
||||||
{"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}}
|
{"id":"int-6ad8b329d6c9580520514a4234fc5230","kind":"field_change","created_at":"2026-09-11T18:38:38.171744457Z","actor":"Shinuwa","issue_id":"sokko-g-rcs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed lazy game loading without synchronous effect state updates; AbortController reaches editorial, game and nested list requests. Stale outcomes ignored, navigation cache preserved. Three new StrictMode behavior tests; npm run check:full passes with 30 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
|
{"id":"int-9cb5a663c6e65593625066b25976439c","kind":"field_change","created_at":"2026-09-11T19:15:51.529868089Z","actor":"Shinuwa","issue_id":"sokko-g-5i3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Library category now derives from anchor navigation; same-page hash/history navigation works; malformed anchors handled and obsolete RAF/scroll timers cleaned up. Existing library coverage and two new behavioral E2E pass. npm run check:full passes with 32 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
|
||||||
- Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`.
|
- Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`.
|
||||||
- Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell.
|
- Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell.
|
||||||
- Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie.
|
- Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie.
|
||||||
|
- Dans la Librairie, vérifier les liens directs vers un outil, les changements d'ancre et le retour navigateur ; une nouvelle sélection ou une sortie de page doit annuler les défilements différés précédents.
|
||||||
- Ajouter les textes nécessaires dans `website/public/data/site.json`.
|
- Ajouter les textes nécessaires dans `website/public/data/site.json`.
|
||||||
- Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`.
|
- Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`.
|
||||||
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
|
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Rôle : vérifie le chargement des outils de chaque catégorie de la librairie.
|
// Rôle : vérifie le chargement, la navigation par ancres et le nettoyage des défilements de la librairie.
|
||||||
|
/* global window, document */
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js";
|
import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js";
|
||||||
|
|
||||||
|
|
@ -22,3 +23,54 @@ test("loads every library tool through the shared component registry", async ({
|
||||||
}
|
}
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("library follows deep links, same-page hashes and browser history", async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto("/#/library#library-tool-m6");
|
||||||
|
await expect(page.locator('[data-module-type="combos"]')).toBeVisible();
|
||||||
|
await expect(page.locator("#library-category-buildsCommands")).toBeVisible();
|
||||||
|
await page.evaluate(() => { window.location.hash = "/library#library-tool-m8"; });
|
||||||
|
await expect(page.locator('[data-module-type="table"]')).toBeVisible();
|
||||||
|
await expect(page.locator("#library-category-calculationData")).toBeVisible();
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.locator('[data-module-type="combos"]')).toBeVisible();
|
||||||
|
await page.evaluate(() => { window.location.hash = "/library#%E0%A4%A"; });
|
||||||
|
await expect(page.locator("#library-category-notesTracking")).toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("library cancels obsolete scroll retries on selection and unmount", async ({ page }) => {
|
||||||
|
await page.goto("/#/library");
|
||||||
|
await expect(page.locator('[data-module-type="notepad"]')).toBeVisible();
|
||||||
|
await page.clock.install({ time: new Date("2026-09-11T12:00:00Z") });
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-11T12:00:01Z"));
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.libraryScrolls = [];
|
||||||
|
window.scrollTo = (options) => window.libraryScrolls.push(options);
|
||||||
|
});
|
||||||
|
const nav = page.locator(".library-doc-nav");
|
||||||
|
await nav.locator('a[href="#/library#library-tool-m0"]').dispatchEvent("click");
|
||||||
|
await page.clock.runFor(40);
|
||||||
|
expect(await page.evaluate(() => window.libraryScrolls.length)).toBeGreaterThan(0);
|
||||||
|
await nav.locator('a[href="#/library#library-tool-m1"]').dispatchEvent("click");
|
||||||
|
await page.evaluate(() => { window.libraryScrolls = []; });
|
||||||
|
await page.clock.runFor(700);
|
||||||
|
const scrolls = await page.evaluate(() => window.libraryScrolls);
|
||||||
|
expect(scrolls).toHaveLength(4);
|
||||||
|
expect(scrolls.map((scroll) => scroll.behavior)).toEqual(["smooth", "auto", "auto", "auto"]);
|
||||||
|
|
||||||
|
await nav.locator('a[href="#/library#library-tool-m2"]').dispatchEvent("click");
|
||||||
|
await page.clock.runFor(40);
|
||||||
|
await page.evaluate(() => { window.location.hash = "/"; });
|
||||||
|
await expect(page.locator(".library-page")).toHaveCount(0);
|
||||||
|
// Garder une cible homonyme permet de détecter un ancien timer après démontage.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const target = document.createElement("div");
|
||||||
|
target.id = "library-tool-m2";
|
||||||
|
document.body.append(target);
|
||||||
|
window.libraryScrolls = [];
|
||||||
|
});
|
||||||
|
await page.clock.runFor(700);
|
||||||
|
expect(await page.evaluate(() => window.libraryScrolls)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
|
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../components/Icon.jsx";
|
import { Icon } from "../components/Icon.jsx";
|
||||||
import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js";
|
import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js";
|
||||||
import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx";
|
import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx";
|
||||||
|
|
@ -62,7 +62,11 @@ function getLibraryCategoryAnchorId(category) {
|
||||||
|
|
||||||
function getCurrentLibraryAnchor() {
|
function getCurrentLibraryAnchor() {
|
||||||
const match = location.hash.match(/^#\/library#(.+)$/);
|
const match = location.hash.match(/^#\/library#(.+)$/);
|
||||||
return match ? decodeURIComponent(match[1]) : "";
|
try {
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLibraryAnchorOffset() {
|
function getLibraryAnchorOffset() {
|
||||||
|
|
@ -71,17 +75,20 @@ function getLibraryAnchorOffset() {
|
||||||
return topbarHeight + 16;
|
return topbarHeight + 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToLibraryTool(anchorId, replace = false, behavior = "smooth") {
|
function scrollToLibraryTool(anchorId, behavior) {
|
||||||
if (!anchorId) return;
|
let timers = [];
|
||||||
if (replace) history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
|
const frame = requestAnimationFrame(() => {
|
||||||
LIBRARY_ANCHOR_RETRY_DELAYS.forEach((delay, index) => {
|
timers = LIBRARY_ANCHOR_RETRY_DELAYS.map((delay, index) => window.setTimeout(() => {
|
||||||
window.setTimeout(() => {
|
|
||||||
const element = document.getElementById(anchorId);
|
const element = document.getElementById(anchorId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset();
|
const top = element.getBoundingClientRect().top + window.scrollY - getLibraryAnchorOffset();
|
||||||
window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" });
|
window.scrollTo({ top: Math.max(0, top), behavior: index === 0 ? behavior : "auto" });
|
||||||
}, delay);
|
}, delay));
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
timers.forEach((timer) => window.clearTimeout(timer));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCategoryForAnchor(categories, anchorId) {
|
function findCategoryForAnchor(categories, anchorId) {
|
||||||
|
|
@ -114,9 +121,13 @@ export function LibraryPage({ siteContent, actions }) {
|
||||||
const [libraryPayload, setLibraryPayload] = useState(null);
|
const [libraryPayload, setLibraryPayload] = useState(null);
|
||||||
const [libraryError, setLibraryError] = useState("");
|
const [libraryError, setLibraryError] = useState("");
|
||||||
const [moduleData, setModuleData] = useState({});
|
const [moduleData, setModuleData] = useState({});
|
||||||
const [activeCategoryKey, setActiveCategoryKey] = useState("");
|
const [navigation, setNavigation] = useState(() => ({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" }));
|
||||||
const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]);
|
const libraryCategories = useMemo(() => getLibraryCategories(libraryPayload?.toolbox?.modules || [], content), [content, libraryPayload]);
|
||||||
const activeCategory = libraryCategories.find((category) => category.key === activeCategoryKey) || libraryCategories[0] || null;
|
const activeCategory = findCategoryForAnchor(libraryCategories, navigation.anchorId) || libraryCategories[0] || null;
|
||||||
|
const selectAnchor = useCallback((anchorId) => {
|
||||||
|
history.replaceState(null, "", `#/library#${encodeURIComponent(anchorId)}`);
|
||||||
|
setNavigation({ anchorId, behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -137,13 +148,15 @@ export function LibraryPage({ siteContent, actions }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryPayload || !libraryCategories.length) return;
|
const onHashChange = () => setNavigation({ anchorId: getCurrentLibraryAnchor(), behavior: "auto" });
|
||||||
const anchorId = getCurrentLibraryAnchor();
|
window.addEventListener("hashchange", onHashChange);
|
||||||
const category = findCategoryForAnchor(libraryCategories, anchorId) || libraryCategories[0];
|
return () => window.removeEventListener("hashchange", onHashChange);
|
||||||
setActiveCategoryKey(category.key);
|
}, []);
|
||||||
if (!anchorId) return;
|
|
||||||
requestAnimationFrame(() => scrollToLibraryTool(anchorId, false, "auto"));
|
useEffect(() => {
|
||||||
}, [libraryPayload, libraryCategories]);
|
if (!activeCategory?.key || !navigation.anchorId) return undefined;
|
||||||
|
return scrollToLibraryTool(navigation.anchorId, navigation.behavior);
|
||||||
|
}, [activeCategory?.key, navigation]);
|
||||||
|
|
||||||
const moduleContext = useMemo(() => ({
|
const moduleContext = useMemo(() => ({
|
||||||
getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback,
|
getModuleData: (toolboxId, moduleId, fallback) => moduleData[`${toolboxId}:${moduleId}`] || fallback,
|
||||||
|
|
@ -199,9 +212,9 @@ export function LibraryPage({ siteContent, actions }) {
|
||||||
...current,
|
...current,
|
||||||
[`${LIBRARY_TOOLBOX_ID}:${annotationModule.id}`]: { image: dataUrl, markers: [], drawings: { strokes: [] } }
|
[`${LIBRARY_TOOLBOX_ID}:${annotationModule.id}`]: { image: dataUrl, markers: [], drawings: { strokes: [] } }
|
||||||
}));
|
}));
|
||||||
requestAnimationFrame(() => scrollToLibraryTool(getLibraryAnchorId(annotationModule), true));
|
selectAnchor(getLibraryAnchorId(annotationModule));
|
||||||
}
|
}
|
||||||
}), [actions, libraryPayload, moduleData, siteContent.toolboxes.modules]);
|
}), [actions, libraryPayload, moduleData, selectAnchor, siteContent.toolboxes.modules]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="library-page">
|
<div className="library-page">
|
||||||
|
|
@ -235,8 +248,7 @@ export function LibraryPage({ siteContent, actions }) {
|
||||||
categories={libraryCategories}
|
categories={libraryCategories}
|
||||||
activeCategoryKey={activeCategory?.key || ""}
|
activeCategoryKey={activeCategory?.key || ""}
|
||||||
onSelectCategory={(category, anchorId = getLibraryCategoryAnchorId(category)) => {
|
onSelectCategory={(category, anchorId = getLibraryCategoryAnchorId(category)) => {
|
||||||
setActiveCategoryKey(category.key);
|
selectAnchor(anchorId);
|
||||||
requestAnimationFrame(() => scrollToLibraryTool(anchorId, true));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<section className="library-tools" aria-label={content.toolsLabel}>
|
<section className="library-tools" aria-label={content.toolsLabel}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue