diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 154efeb..84e4d35 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -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-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-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."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 481b0c9..0c4f338 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -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. - 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. +- 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 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. diff --git a/e2e/fixtures/notepad-sync-probe.js b/e2e/fixtures/notepad-sync-probe.js new file mode 100644 index 0000000..fbcb15a --- /dev/null +++ b/e2e/fixtures/notepad-sync-probe.js @@ -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: "
Alpha
" }, + { id: "b", title: "Second", html: "Bravo
", 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()) + }; +} diff --git a/e2e/notepad-sync.spec.js b/e2e/notepad-sync.spec.js new file mode 100644 index 0000000..42ad2e4 --- /dev/null +++ b/e2e/notepad-sync.spec.js @@ -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("Bravo
"); + 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("Alpha
"); + 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: "Other module
" }] } + })); + 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: "External text
" })); + 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: "Another update
" })); + await expect(editor).toHaveText("Another update"); + await page.evaluate(() => window.notepadProbe.unmount()); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 8cac18b..c52a661 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -331,7 +331,6 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(notepadToolbar, /FormattingColorDropdown/); assert.match(notepadToolbar, /ListTypeDropdown/); assert.match(notepadUtils, /export function getPlainText/); - assert.match(notepadUtils, /export function restoreSelectionTextOffset/); assert.match(combosModule, /export function CombosModule/); assert.match(combosUtils, /keyboardMouse/); assert.match(combosUtils, /Nintendo 64/); diff --git a/website/src/features/toolboxes/modules/NotepadModule.jsx b/website/src/features/toolboxes/modules/NotepadModule.jsx index a62ca8d..a58e371 100644 --- a/website/src/features/toolboxes/modules/NotepadModule.jsx +++ b/website/src/features/toolboxes/modules/NotepadModule.jsx @@ -14,14 +14,13 @@ import { TEXT_COLORS, formatUpdatedAt, getPlainText, - getSelectionTextOffset, isHtmlEmpty, queryBlockFormat, - queryCommandState, - restoreSelectionTextOffset + queryCommandState } from "./notepadUtils.js"; 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) { 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 editorRef = useRef(null); const surfaceRef = useRef(null); - const selectionOffsetRef = useRef(null); - const localSaveUpdatedAtRef = useRef(""); - const pageKeyRef = useRef(`${toolboxId}:${moduleId}:${activePage.id}`); + const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; + const [editorFocused, setEditorFocused] = useState(false); + const [previousSource, setPreviousSource] = useState({ pageKey, page: activePage, focused: false }); const temporaryStrokesDirtyRef = useRef(false); const sessionStorageFailureRef = useRef(""); 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 [editingPageId, setEditingPageId] = useState(""); const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title]))); - const [activeFormats, setActiveFormats] = useState({ - bold: false, - italic: false, - underline: false, - strikeThrough: false, - heading: false, - bulletList: false, - numberList: false - }); + const [activeFormats, setActiveFormats] = useState(EMPTY_FORMATS); const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`; const [temporaryStrokes, setTemporaryStrokes] = useKeyedState(sessionKey, readTemporaryStrokes); - useEffect(() => { + useLayoutEffect(() => { notepadDataRef.current = normalizedData; }, [normalizedData]); - useEffect(() => { - const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; - 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) { + const pageChanged = previousSource.pageKey !== pageKey; + const sourceChanged = ["html", "text", "updatedAt", "drawingMode"].some((field) => previousSource.page[field] !== activePage[field]); + if (pageChanged || sourceChanged || previousSource.focused !== editorFocused) { + setPreviousSource({ pageKey, page: activePage, focused: editorFocused }); + // Une page différente remplace l'éditeur ; une mise à jour de la page courante attend le blur. + if (pageChanged || !editorFocused) { + setHtml(activePage.html); setText(activePage.text); setUpdatedAt(activePage.updatedAt); setDrawingMode(activePage.drawingMode); - return; + if (pageChanged || activePage.html !== html || activePage.text !== text || activePage.drawingMode !== drawingMode) { + setDrawingActive(false); + setEraseActive(false); + } } - setHtml(activePage.html); - setText(activePage.text); - setUpdatedAt(activePage.updatedAt); - setDrawingMode(activePage.drawingMode); - setDrawingActive(false); - setEraseActive(false); - }, [toolboxId, moduleId, activePage.id, activePage.html, activePage.text, activePage.updatedAt, activePage.drawingMode]); + if (pageChanged) { + setEditorFocused(false); + setActiveFormats(EMPTY_FORMATS); + } + } useLayoutEffect(() => { if (!editorRef.current || editorRef.current.innerHTML === html) return; if (document.activeElement === editorRef.current) return; editorRef.current.innerHTML = html; - restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current); - }, [html, text]); + }, [html, text, pageKey, editorFocused]); useEffect(() => { function updateActiveFormats() { @@ -167,7 +154,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } function saveContent(nextHtml, nextText) { const nextUpdatedAt = new Date().toISOString(); - localSaveUpdatedAtRef.current = nextUpdatedAt; setHtml(nextHtml); setText(nextText); setUpdatedAt(nextUpdatedAt); @@ -214,7 +200,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } } function handleEditorInput() { - selectionOffsetRef.current = getSelectionTextOffset(editorRef.current); const nextHtml = editorRef.current?.innerHTML || ""; const nextText = getPlainText(editorRef.current); saveContent(nextHtml, nextText); @@ -421,6 +406,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }