Fix Notepad synchronization across pages and external updates
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-12 12:36:31 +02:00
parent 898fd5286f
commit b0e340efc7
7 changed files with 148 additions and 77 deletions

View file

@ -35,3 +35,4 @@
{"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."}} {"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."}}
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}} {"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
{"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}}

View file

@ -20,6 +20,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier l'affichage dans le panneau latéral. - Vérifier l'affichage dans le panneau latéral.
- Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente. - Pour le tutoriel toolbox, vérifier le démarrage unique, la fermeture et le changement de toolbox en cours de visite ; une autre toolbox doit démarrer à la première étape et ne pas consommer les indicateurs de la précédente.
- Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile. - Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile.
- Vérifier qu'un changement de page avec le focus actif remplace immédiatement le contenu de l'éditeur ; une mise à jour externe de la même page attend le blur sans déplacer la sélection pendant la saisie ni reprendre le focus ensuite.
- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé. - Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé.
- Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ. - Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.
- Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image. - Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image.

View file

@ -0,0 +1,45 @@
// Rôle : pilote les mises à jour locales et externes du Bloc Note réel sous StrictMode.
import { createElement, StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { NotepadModule } from "../../website/src/features/toolboxes/modules/NotepadModule.jsx";
import { normalizeNotepadData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js";
export function mountNotepadSyncProbe(element) {
const root = createRoot(element);
const writes = [];
let setInputs;
let inputs;
function Probe() {
const [value, setValue] = useState({
moduleId: "notes",
data: { activePageId: "a", pages: [
{ id: "a", title: "First", html: "<p>Alpha</p>" },
{ id: "b", title: "Second", html: "<p>Bravo</p>", drawingMode: "permanent" }
] }
});
inputs = value;
setInputs = setValue;
const context = {
getModuleData: () => value.data,
normalizeNotepadData,
uid: () => "new-page",
setModuleData: (_toolbox, moduleId, data) => {
writes.push({ moduleId, data });
setValue((current) => ({ ...current, data }));
}
};
return createElement(NotepadModule, { toolboxId: "probe", moduleId: value.moduleId, context });
}
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe))));
return {
writes,
read: () => inputs,
update: (patch) => flushSync(() => setInputs((current) => ({ ...current, ...patch }))),
patchPage: (id, patch) => flushSync(() => setInputs((current) => ({
...current, data: { ...current.data, pages: current.data.pages.map((page) => page.id === id ? { ...page, ...patch } : page) }
}))),
select: (id) => flushSync(() => setInputs((current) => ({ ...current, data: { ...current.data, activePageId: id } }))),
unmount: () => flushSync(() => root.unmount())
};
}

74
e2e/notepad-sync.spec.js Normal file
View file

@ -0,0 +1,74 @@
// Rôle : vérifie le contenu et la sélection du Bloc Note face aux changements de page et aux mises à jour externes.
/* global window, document, NodeFilter */
import { test, expect } from "@playwright/test";
import { fileURLToPath, URL } from "node:url";
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/notepad-sync-probe.js", import.meta.url))}`;
async function setup(page) {
await page.goto("/");
await page.evaluate(async (url) => {
const { mountNotepadSyncProbe } = await import(url);
document.getElementById("app").style.display = "none";
const element = document.createElement("div");
element.id = "notepad-probe";
document.body.prepend(element);
const button = document.createElement("button");
button.id = "outside-editor";
button.textContent = "Outside";
document.body.append(button);
window.notepadProbe = mountNotepadSyncProbe(element);
}, probeUrl);
return page.locator("#notepad-probe .notepad-editor");
}
test("focused page changes never save the old DOM into the new page or module", async ({ page }) => {
const editor = await setup(page);
await expect(editor).toHaveText("Alpha");
await editor.focus();
const content = await page.evaluate(() => {
window.notepadProbe.select("b");
return document.querySelector("#notepad-probe .notepad-editor").innerHTML;
});
expect(content).toBe("<p>Bravo</p>");
await editor.fill("Second edited");
const saved = await page.evaluate(() => window.notepadProbe.writes.at(-1));
expect(saved.data.pages.find((item) => item.id === "a").html).toBe("<p>Alpha</p>");
expect(saved.data.pages.find((item) => item.id === "b").text).toBe("Second edited");
await page.evaluate(() => window.notepadProbe.update({
moduleId: "other", data: { activePageId: "b", pages: [{ id: "b", title: "Other", html: "<p>Other module</p>" }] }
}));
await expect(editor).toHaveText("Other module");
await editor.fill("Other edited");
expect(await page.evaluate(() => window.notepadProbe.writes.at(-1).moduleId)).toBe("other");
await page.evaluate(() => window.notepadProbe.unmount());
});
test("local echoes preserve selection and external content synchronizes after blur", async ({ page }) => {
const editor = await setup(page);
await editor.fill("Local text");
await page.evaluate(() => {
const node = document.createTreeWalker(document.querySelector("#notepad-probe .notepad-editor"), NodeFilter.SHOW_TEXT).nextNode();
const range = document.createRange();
range.setStart(node, 2);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
window.notepadProbe.update({});
});
expect(await page.evaluate(() => document.getSelection().anchorOffset)).toBe(2);
await expect(editor).toHaveText("Local text");
const writeCount = await page.evaluate(() => window.notepadProbe.writes.length);
await page.evaluate(() => window.notepadProbe.patchPage("a", { html: "<p>External text</p>" }));
await expect(editor).toHaveText("Local text");
expect(await page.evaluate(() => document.getSelection().anchorOffset)).toBe(2);
await page.locator("#outside-editor").click();
await expect(editor).toHaveText("External text");
await expect(page.locator("#outside-editor")).toBeFocused();
expect(await page.evaluate(() => window.notepadProbe.writes.length)).toBe(writeCount);
// L'horodatage reste celui de la sauvegarde locale ; il ne suffit pas à reconnaître un écho.
await page.evaluate(() => window.notepadProbe.patchPage("a", { html: "<p>Another update</p>" }));
await expect(editor).toHaveText("Another update");
await page.evaluate(() => window.notepadProbe.unmount());
});

View file

@ -331,7 +331,6 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(notepadToolbar, /FormattingColorDropdown/); assert.match(notepadToolbar, /FormattingColorDropdown/);
assert.match(notepadToolbar, /ListTypeDropdown/); assert.match(notepadToolbar, /ListTypeDropdown/);
assert.match(notepadUtils, /export function getPlainText/); assert.match(notepadUtils, /export function getPlainText/);
assert.match(notepadUtils, /export function restoreSelectionTextOffset/);
assert.match(combosModule, /export function CombosModule/); assert.match(combosModule, /export function CombosModule/);
assert.match(combosUtils, /keyboardMouse/); assert.match(combosUtils, /keyboardMouse/);
assert.match(combosUtils, /Nintendo 64/); assert.match(combosUtils, /Nintendo 64/);

View file

@ -14,14 +14,13 @@ import {
TEXT_COLORS, TEXT_COLORS,
formatUpdatedAt, formatUpdatedAt,
getPlainText, getPlainText,
getSelectionTextOffset,
isHtmlEmpty, isHtmlEmpty,
queryBlockFormat, queryBlockFormat,
queryCommandState, queryCommandState
restoreSelectionTextOffset
} from "./notepadUtils.js"; } from "./notepadUtils.js";
const EMPTY_NOTEPAD_DATA = { text: "" }; const EMPTY_NOTEPAD_DATA = { text: "" };
const EMPTY_FORMATS = { bold: false, italic: false, underline: false, strikeThrough: false, heading: false, bulletList: false, numberList: false };
function readTemporaryStrokes(sessionKey) { function readTemporaryStrokes(sessionKey) {
try { try {
@ -41,9 +40,9 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
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];
const editorRef = useRef(null); const editorRef = useRef(null);
const surfaceRef = useRef(null); const surfaceRef = useRef(null);
const selectionOffsetRef = useRef(null); const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
const localSaveUpdatedAtRef = useRef(""); const [editorFocused, setEditorFocused] = useState(false);
const pageKeyRef = useRef(`${toolboxId}:${moduleId}:${activePage.id}`); const [previousSource, setPreviousSource] = useState({ pageKey, page: activePage, focused: false });
const temporaryStrokesDirtyRef = useRef(false); const temporaryStrokesDirtyRef = useRef(false);
const sessionStorageFailureRef = useRef(""); const sessionStorageFailureRef = useRef("");
const [html, setHtml] = useState(activePage.html); const [html, setHtml] = useState(activePage.html);
@ -58,52 +57,40 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]); const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
const [editingPageId, setEditingPageId] = useState(""); const [editingPageId, setEditingPageId] = useState("");
const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title]))); const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title])));
const [activeFormats, setActiveFormats] = useState({ const [activeFormats, setActiveFormats] = useState(EMPTY_FORMATS);
bold: false,
italic: false,
underline: false,
strikeThrough: false,
heading: false,
bulletList: false,
numberList: false
});
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`; const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`;
const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes); const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes);
useEffect(() => { useLayoutEffect(() => {
notepadDataRef.current = normalizedData; notepadDataRef.current = normalizedData;
}, [normalizedData]); }, [normalizedData]);
useEffect(() => { const pageChanged = previousSource.pageKey !== pageKey;
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; const sourceChanged = ["html", "text", "updatedAt", "drawingMode"].some((field) => previousSource.page[field] !== activePage[field]);
const pageChanged = pageKeyRef.current !== pageKey; if (pageChanged || sourceChanged || previousSource.focused !== editorFocused) {
if (pageChanged) { setPreviousSource({ pageKey, page: activePage, focused: editorFocused });
pageKeyRef.current = pageKey; // Une page différente remplace l'éditeur ; une mise à jour de la page courante attend le blur.
localSaveUpdatedAtRef.current = ""; if (pageChanged || !editorFocused) {
}
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) {
setText(activePage.text);
setUpdatedAt(activePage.updatedAt);
setDrawingMode(activePage.drawingMode);
return;
}
setHtml(activePage.html); setHtml(activePage.html);
setText(activePage.text); setText(activePage.text);
setUpdatedAt(activePage.updatedAt); setUpdatedAt(activePage.updatedAt);
setDrawingMode(activePage.drawingMode); setDrawingMode(activePage.drawingMode);
if (pageChanged || activePage.html !== html || activePage.text !== text || activePage.drawingMode !== drawingMode) {
setDrawingActive(false); setDrawingActive(false);
setEraseActive(false); setEraseActive(false);
}, [toolboxId, moduleId, activePage.id, activePage.html, activePage.text, activePage.updatedAt, activePage.drawingMode]); }
}
if (pageChanged) {
setEditorFocused(false);
setActiveFormats(EMPTY_FORMATS);
}
}
useLayoutEffect(() => { useLayoutEffect(() => {
if (!editorRef.current || editorRef.current.innerHTML === html) return; if (!editorRef.current || editorRef.current.innerHTML === html) return;
if (document.activeElement === editorRef.current) return; if (document.activeElement === editorRef.current) return;
editorRef.current.innerHTML = html; editorRef.current.innerHTML = html;
restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current); }, [html, text, pageKey, editorFocused]);
}, [html, text]);
useEffect(() => { useEffect(() => {
function updateActiveFormats() { function updateActiveFormats() {
@ -167,7 +154,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
function saveContent(nextHtml, nextText) { function saveContent(nextHtml, nextText) {
const nextUpdatedAt = new Date().toISOString(); const nextUpdatedAt = new Date().toISOString();
localSaveUpdatedAtRef.current = nextUpdatedAt;
setHtml(nextHtml); setHtml(nextHtml);
setText(nextText); setText(nextText);
setUpdatedAt(nextUpdatedAt); setUpdatedAt(nextUpdatedAt);
@ -214,7 +200,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
} }
function handleEditorInput() { function handleEditorInput() {
selectionOffsetRef.current = getSelectionTextOffset(editorRef.current);
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);
@ -421,6 +406,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}> <div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
<div <div
key={pageKey}
ref={editorRef} ref={editorRef}
className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`} className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`}
contentEditable={!drawingActive} contentEditable={!drawingActive}
@ -428,6 +414,8 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
data-placeholder={textContent.placeholder || "Bloc Note..."} data-placeholder={textContent.placeholder || "Bloc Note..."}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
onInput={handleEditorInput} onInput={handleEditorInput}
onPaste={handlePaste} onPaste={handlePaste}
/> />

View file

@ -6,43 +6,6 @@ export function getPlainText(element) {
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim(); return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
} }
export function getSelectionTextOffset(container) {
const selection = document.getSelection();
if (!container || !selection?.rangeCount || !container.contains(selection.anchorNode)) return null;
const range = selection.getRangeAt(0);
const prefix = range.cloneRange();
prefix.selectNodeContents(container);
prefix.setEnd(range.startContainer, range.startOffset);
return prefix.toString().length;
}
export function restoreSelectionTextOffset(container, offset) {
if (!container || offset == null) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = offset;
let node = walker.nextNode();
while (node) {
const length = node.textContent.length;
if (remaining <= length) {
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return;
}
remaining -= length;
node = walker.nextNode();
}
const range = document.createRange();
range.selectNodeContents(container);
range.collapse(false);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export function isHtmlEmpty(html, text) { export function isHtmlEmpty(html, text) {
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim(); return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim();
} }