diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index adcb02a..4ffa0f2 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -31,3 +31,4 @@ {"id":"int-147c4573ce40577c21afec08775c2811","kind":"field_change","created_at":"2026-09-11T07:16:09.749977471Z","actor":"Shinuwa","issue_id":"sokko-g-55a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Restored recommended purity lint; initialized alert clock in effect; removed post-commit draft synchronization from shared inline editing and reused it in Randomizer. 100 Node tests, lint, production build and all 19 E2E pass. StrictMode hook tests cover input/contentEditable, source updates, commit and Escape; broader C10 tracked in sokko-g-8po."}} {"id":"int-f9e84a155b896c7f68c83ee2454f904d","kind":"field_change","created_at":"2026-09-11T11:56:43.680278965Z","actor":"Shinuwa","issue_id":"sokko-g-sc7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproduced memo draft loss on notification, isolated per-memo drafts using useInlineEdit, preserved multiline/trim/delete behavior. Goal Tree reconciles selection and valid tabs before DOM commit with StrictMode regressions. npm run check:full passed: 100 Node tests and 21 E2E."}} {"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."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 2925eb7..43d4659 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -19,6 +19,9 @@ 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 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 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. +- Pour l'éditeur de couverture, vérifier les décodages concurrents, les erreurs après fermeture et la libération des bitmaps/URLs ; une image en chargement ne doit pas permettre de valider la couverture précédente. - Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante. - Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur. - Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu. diff --git a/e2e/editor-lifecycle.spec.js b/e2e/editor-lifecycle.spec.js new file mode 100644 index 0000000..89beeb6 --- /dev/null +++ b/e2e/editor-lifecycle.spec.js @@ -0,0 +1,166 @@ +// Rôle : vérifie les brouillons RNG et les changements d'image sans état périmé sous StrictMode. +/* global window, document */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/editor-lifecycle-probe.js", import.meta.url))}`; + +async function setup(page, kind, props = {}) { + await page.goto("/"); + await page.evaluate(async ({ url, kind, props }) => { + const { mountEditorProbe, controlImageDecoding } = await import(url); + document.getElementById("app").style.display = "none"; + const element = document.createElement("div"); + element.id = "editor-probe"; + document.body.prepend(element); + if (kind === "cover" || kind === "viewer") { + const canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 400; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#22d3ee"; + ctx.fillRect(0, 0, 800, 400); + window.probeImage = canvas.toDataURL(); + if (kind === "cover") { + window.probeFile = await new Promise((resolve) => canvas.toBlob(resolve)); + window.decoding = controlImageDecoding(); + if (props.existingCover) props.existingCover = window.probeImage; + else props.file = window.probeFile; + } else { + window.viewerImage = { + dataUrl: window.probeImage, canAnnotate: true, + markers: [{ id: "a", label: "First", x: 30, y: 30 }], + onChangeMarkers: () => {}, onChangeDrawings: () => {} + }; + props.image = window.viewerImage; + } + } + window.editorProbe = mountEditorProbe(element, kind, props); + }, { url: probeUrl, kind, props }); + return page.locator("#editor-probe"); +} + +test("RNG drafts follow rate and module identity without losing unrelated edits", async ({ page }) => { + const rates = [ + { id: "a", label: "First", rate: "10", costPerDraw: "12" }, + { id: "b", label: "Second", rate: "20", costPerDraw: "34", occurrenceValue: 2, occurrenceUnit: "perDay" } + ]; + const editor = await setup(page, "rng", { data: { rates, activeRateId: "a" } }); + const cost = editor.getByLabel("Coût d'un tirage"); + await cost.fill("999"); + await page.evaluate(() => window.editorProbe.update({})); + await expect(cost).toHaveValue("999"); + const snapshots = await page.evaluate((rates) => { + window.editorProbe.update({ data: { rates, activeRateId: "b" } }); + return window.editorProbe.snapshots; + }, rates); + expect(snapshots.length).toBeGreaterThan(0); + for (const values of snapshots) { + expect(values).toContain("34"); + expect(values).not.toContain("999"); + } + await editor.getByRole("tab", { name: "Prévisions", exact: true }).click(); + await expect(editor.getByRole("combobox", { name: "Période", exact: true })).toHaveValue("perDay"); + await cost.fill("777"); + await page.evaluate(() => window.editorProbe.update({ moduleId: "other" })); + await expect(cost).toHaveValue("34"); + await cost.fill("56"); + await cost.press("Tab"); + expect(await page.evaluate(() => window.editorProbe.saves.at(-1).rates[1].costPerDraw)).toBe("56"); + await page.evaluate(() => window.editorProbe.update({ editing: true })); + const label = editor.locator('.rng-simulator-rate-form input').first(); + await label.fill("Unsubmitted"); + await page.evaluate(() => window.editorProbe.update({ editing: false })); + await expect(label).toHaveCount(0); + await page.evaluate(() => window.editorProbe.update({ editing: true })); + await expect(label).toHaveValue(""); +}); + +test("viewer resets markers and drawing tools before committing a replacement image", async ({ page }) => { + const editor = await setup(page, "viewer"); + const marker = editor.getByRole("textbox", { name: "Libellé du marqueur 1" }); + await marker.fill("Edited"); + await editor.getByTitle("Dessiner", { exact: true }).click(); + await editor.getByTitle("Gommer", { exact: true }).click(); + await page.evaluate(() => window.editorProbe.update({})); + await expect(marker).toHaveValue("Edited"); + await expect(editor.getByTitle("Gommer", { exact: true })).toHaveAttribute("aria-pressed", "true"); + const snapshots = await page.evaluate(() => { + window.editorProbe.update({ image: { + ...window.viewerImage, drawingMode: "temporary", + markers: [{ id: "b", label: "Second", x: 60, y: 60 }] + } }); + return window.editorProbe.snapshots; + }); + expect(snapshots.length).toBeGreaterThan(0); + for (const values of snapshots) expect(values).toEqual(["Second"]); + await expect(editor.getByTitle("Dessiner", { exact: true })).toHaveAttribute("aria-pressed", "false"); + await editor.getByTitle("Dessiner", { exact: true }).click(); + await expect(editor.getByTitle("Gommer", { exact: true })).toHaveAttribute("aria-pressed", "false"); + await expect(editor.getByRole("button", { name: "Annotations temporaires" })).toBeVisible(); + await page.evaluate(() => window.editorProbe.unmount()); +}); + +test("cover ignores obsolete decodes and releases every bitmap and object URL", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const editor = await setup(page, "cover"); + const save = editor.getByRole("button", { name: "Valider", exact: true }); + await expect(save).toBeDisabled(); + expect(await page.evaluate(() => window.decoding.pending.length)).toBe(2); + await page.evaluate(async () => { + await window.decoding.resolve(0); + await window.decoding.resolve(1); + }); + await expect(save).toBeEnabled(); + await expect(editor.locator("img")).toBeVisible(); + await editor.getByLabel("Zoom", { exact: true }).press("End"); + await save.click(); + expect(await page.evaluate(() => window.editorProbe.saves[0])).toMatch(/^data:image\/(webp|jpeg);base64,/); + await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() })); + await expect(save).toBeDisabled(); + await expect(editor.locator("img")).toHaveCount(0); + await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() })); + await page.evaluate(async () => { + await window.decoding.resolve(3); + window.decoding.reject(2); + }); + await expect(save).toBeEnabled(); + await expect(editor.locator(".toolbox-cover-editor-error")).toHaveCount(0); + await page.evaluate(() => window.editorProbe.update({ file: window.probeFile.slice() })); + await page.evaluate(() => window.decoding.reject(4)); + await expect(save).toBeDisabled(); + await expect(editor.locator(".toolbox-cover-editor-error")).toBeVisible(); + await page.evaluate(() => { + window.editorProbe.update({ file: window.probeFile.slice() }); + window.editorProbe.unmount(); + }); + await page.evaluate(() => window.decoding.resolve(5)); + const resources = await page.evaluate(() => { + window.decoding.restore(); + return { created: window.decoding.created, revoked: window.decoding.revoked, closed: window.decoding.closed }; + }); + expect(resources.revoked.sort()).toEqual(resources.created.sort()); + expect(resources.closed.sort()).toEqual([0, 1, 3, 5]); + expect(errors).toEqual([]); +}); + +test("cover loads an existing image and clears it when the source is removed", async ({ page }) => { + const editor = await setup(page, "cover", { existingCover: true }); + await expect.poll(() => page.evaluate(() => window.decoding.pending.length)).toBe(2); + await page.evaluate(async () => { + await window.decoding.resolve(0); + await window.decoding.resolve(1); + }); + await expect(editor.getByRole("button", { name: "Valider", exact: true })).toBeEnabled(); + await page.evaluate(() => window.editorProbe.update({ existingCover: "" })); + await expect(editor.locator("img")).toHaveCount(0); + await expect(editor.getByRole("button", { name: "Valider", exact: true })).toBeDisabled(); + const resources = await page.evaluate(() => { + window.editorProbe.unmount(); + window.decoding.restore(); + return { created: window.decoding.created, closed: window.decoding.closed }; + }); + expect(resources.created).toEqual([]); + expect(resources.closed.sort()).toEqual([0, 1]); +}); diff --git a/e2e/fixtures/editor-lifecycle-probe.js b/e2e/fixtures/editor-lifecycle-probe.js new file mode 100644 index 0000000..a0726ba --- /dev/null +++ b/e2e/fixtures/editor-lifecycle-probe.js @@ -0,0 +1,85 @@ +// Rôle : monte les éditeurs réels sous StrictMode et contrôle les décodages concurrents. +/* global URL */ +import { createElement, StrictMode, useLayoutEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { ImageViewer } from "../../website/src/components/ImageViewer.jsx"; +import { ToolboxCoverEditor } from "../../website/src/features/toolboxes/ToolboxCoverEditor.jsx"; +import { RngSimulatorModule } from "../../website/src/features/toolboxes/modules/RngSimulatorModule.jsx"; +import { normalizeRngSimulatorData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js"; + +export function mountEditorProbe(element, kind, initialProps) { + const root = createRoot(element); + const snapshots = []; + const saves = []; + let updateProps; + const components = { viewer: ImageViewer, cover: ToolboxCoverEditor, rng: RngSimulatorModule }; + function Probe() { + const [props, setProps] = useState(initialProps); + updateProps = setProps; + useLayoutEffect(() => { + snapshots.push(Array.from(element.querySelectorAll("input"), (input) => input.value)); + }); + const context = { + getModuleData: () => props.data, + normalizeRngSimulatorData, + setModuleData: (_toolbox, _module, data) => { + saves.push(data); + setProps((current) => ({ ...current, data })); + }, + uid: () => "new-rate" + }; + return createElement(components[kind], { + toolboxId: "probe", moduleId: "rng", context, + onClose: () => {}, onSave: (value) => saves.push(value), + setEditing: (editing) => setProps((current) => ({ ...current, editing })), + ...props + }); + } + flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe)))); + return { + snapshots, saves, + update(patch) { + snapshots.length = 0; + flushSync(() => updateProps((props) => ({ ...props, ...patch }))); + }, + unmount: () => flushSync(() => root.unmount()) + }; +} + +export function controlImageDecoding() { + const originalDecode = globalThis.createImageBitmap; + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + const pending = []; + const created = []; + const revoked = []; + const closed = []; + globalThis.createImageBitmap = (file) => new Promise((resolve, reject) => { + pending.push({ file, resolve, reject }); + }); + URL.createObjectURL = (file) => { + const url = originalCreate(file); + created.push(url); + return url; + }; + URL.revokeObjectURL = (url) => { + revoked.push(url); + originalRevoke(url); + }; + return { + pending, created, revoked, closed, + async resolve(index) { + const bitmap = await originalDecode(pending[index].file); + const close = bitmap.close.bind(bitmap); + bitmap.close = () => { closed.push(index); close(); }; + pending[index].resolve(bitmap); + }, + reject: (index) => pending[index].reject(new Error("decode failed")), + restore() { + globalThis.createImageBitmap = originalDecode; + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + } + }; +} diff --git a/website/src/components/ImageViewer.jsx b/website/src/components/ImageViewer.jsx index 5b33b84..ef8dc5b 100644 --- a/website/src/components/ImageViewer.jsx +++ b/website/src/components/ImageViewer.jsx @@ -43,17 +43,19 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. const [eraseActive, setEraseActive] = useState(false); const [drawingColor, setDrawingColor] = useState("#f6c453"); const [drawingWidth, setDrawingWidth] = useState(4); + const [previousImage, setPreviousImage] = useState(image); const viewerRef = useRef(null); const mediaRef = useRef(null); const hasMarkers = viewerMarkers.length > 0; - useEffect(() => { + if (previousImage !== image) { + setPreviousImage(image); setViewerMarkers(Array.isArray(image.markers) ? image.markers : []); setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] }); setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent"); setDrawingActive(false); setEraseActive(false); - }, [image]); + } useEffect(() => { if (!viewerRef.current || !mediaRef.current) return undefined; diff --git a/website/src/features/toolboxes/ToolboxCoverEditor.jsx b/website/src/features/toolboxes/ToolboxCoverEditor.jsx index c39a5ea..5bcf446 100644 --- a/website/src/features/toolboxes/ToolboxCoverEditor.jsx +++ b/website/src/features/toolboxes/ToolboxCoverEditor.jsx @@ -1,7 +1,8 @@ // Rôle : édite une image utilisateur en cover rectangulaire de card toolbox. /* eslint-disable react-refresh/only-export-components -- expose aussi les helpers purs de calcul/export du crop. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Icon } from "../../components/Icon.jsx"; +import { useKeyedState } from "../../hooks/useKeyedState.js"; import { lockBodyScroll } from "../../utils/bodyScrollLock.js"; const OUTPUT_WIDTH = 620; @@ -64,46 +65,43 @@ export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave } const frameRef = useRef(null); const imageBitmapRef = useRef(null); const dragRef = useRef(null); - const [source, setSource] = useState(""); - const [imageSize, setImageSize] = useState({ width: 0, height: 0 }); + const [preview, setPreview] = useKeyedState(file || existingCover, () => ({ source: "", width: 0, height: 0, error: "" })); + const { source, error } = preview; + const imageSize = preview; const [frame, setFrame] = useState({ width: 0, height: 0 }); - const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); - const [baseScale, setBaseScale] = useState(1); - const [error, setError] = useState(""); - - const maxScale = useMemo(() => baseScale * 4, [baseScale]); + const baseScale = frame.width && frame.height && imageSize.width && imageSize.height + ? Math.max(frame.width / imageSize.width, frame.height / imageSize.height) : 1; + const transformKey = JSON.stringify([source, frame.width, frame.height, imageSize.width, imageSize.height]); + const [transform, setTransform] = useKeyedState(transformKey, () => ({ x: 0, y: 0, scale: baseScale })); + const maxScale = baseScale * 4; useEffect(() => lockBodyScroll(), []); - useEffect(() => () => imageBitmapRef.current?.close?.(), []); - useEffect(() => { const nextSource = createPreviewSource(file, existingCover); - setSource(nextSource); - setError(""); - return () => { - if (file && nextSource) URL.revokeObjectURL(nextSource); - }; - }, [existingCover, file]); - - useEffect(() => { - if (!source) return undefined; + if (!nextSource) return undefined; let cancelled = false; - decodePreviewImage(source, file) + let decodedBitmap; + decodePreviewImage(nextSource, file) .then((bitmap) => { if (cancelled) { bitmap.close?.(); return; } - imageBitmapRef.current?.close?.(); + decodedBitmap = bitmap; imageBitmapRef.current = bitmap; - setImageSize({ width: bitmap.width, height: bitmap.height }); + setPreview({ source: nextSource, width: bitmap.width, height: bitmap.height, error: "" }); }) - .catch(() => setError("Image illisible. Choisis un autre fichier.")); + .catch(() => { + if (!cancelled) setPreview({ source: "", width: 0, height: 0, error: "Image illisible. Choisis un autre fichier." }); + }); return () => { cancelled = true; + decodedBitmap?.close?.(); + if (imageBitmapRef.current === decodedBitmap) imageBitmapRef.current = null; + if (file) URL.revokeObjectURL(nextSource); }; - }, [file, source]); + }, [existingCover, file, setPreview]); useEffect(() => { const frameElement = frameRef.current; @@ -118,13 +116,6 @@ export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave } return () => observer.disconnect(); }, []); - useEffect(() => { - if (!frame.width || !frame.height || !imageSize.width || !imageSize.height) return; - const nextBaseScale = Math.max(frame.width / imageSize.width, frame.height / imageSize.height); - setBaseScale(nextBaseScale); - setTransform({ x: 0, y: 0, scale: nextBaseScale }); - }, [frame.height, frame.width, imageSize.height, imageSize.width]); - function clampTransform(nextTransform) { const displayWidth = imageSize.width * nextTransform.scale; const displayHeight = imageSize.height * nextTransform.scale; diff --git a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx index 2515ea6..045732e 100644 --- a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx +++ b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx @@ -165,10 +165,20 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal const [occurrenceDraft, setOccurrenceDraft] = useState(null); const [realDrawsDraft, setRealDrawsDraft] = useState(null); const rateFormRef = useRef(null); - const previousEditingRef = useRef(editing); + const [previousEditing, setPreviousEditing] = useState(editing); const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); const rateFormOpen = editing || Boolean(editingRateId); const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null; + const draftKey = JSON.stringify([toolboxId, moduleId, activeRate?.id, activeRate?.occurrenceUnit]); + const [previousDraftKey, setPreviousDraftKey] = useState(null); + if (previousDraftKey !== draftKey) { + setPreviousDraftKey(draftKey); + setMultiplierDraft(null); + setCostDraft(null); + setOccurrenceDraft(null); + setRealDrawsDraft(null); + setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour"); + } const summary = useMemo(() => activeRate ? calculateRngSummary({ rate: activeRate.rate, multiplier: activeRate.multiplier, @@ -197,14 +207,11 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal const averageExceededDraws = Math.max(0, Math.round(averageDrawsDelta)); const averageDrawsTone = realDrawsSummary?.averageExceeded ? (averageExceededDraws > 0 ? "negative" : "") : "positive"; - useEffect(() => { - const wasEditing = previousEditingRef.current; - if (wasEditing && !editing) setEditingRateId(""); - if (!wasEditing && editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); - previousEditingRef.current = editing; - // Le hook useDraftForm expose reset sans identité stable; cette transition ne doit dépendre que de l'ouverture. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editing, editingRateId]); + if (previousEditing !== editing) { + setPreviousEditing(editing); + if (!editing) setEditingRateId(""); + if (editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE }); + } useEffect(() => { if (!rateFormOpen) return undefined; @@ -219,14 +226,6 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal return () => document.removeEventListener("pointerdown", closeOnOutsidePointer); }, [rateFormOpen, setEditing]); - useEffect(() => { - setMultiplierDraft(null); - setCostDraft(null); - setOccurrenceDraft(null); - setRealDrawsDraft(null); - setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour"); - }, [activeRate?.id, activeRate?.occurrenceUnit]); - function save(nextData) { context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator"); }