From 184314d913a2104cc4646c6c9b944352d36510c8 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Wed, 9 Sep 2026 15:02:48 +0200 Subject: [PATCH] Fix toolbox persistence, table evaluation, and onboarding panel styling --- e2e/toolbox.spec.js | 14 +++++ .../toolboxes/modules/DrawingOverlay.jsx | 9 ++-- .../toolboxes/modules/NotepadModule.jsx | 9 ++-- .../toolboxes/modules/TableModule.jsx | 3 +- .../toolboxes/modules/tableFormulaEngine.js | 18 +++++-- .../toolboxes/storage/useIndexedToolboxes.js | 53 ++++++++++++++++--- .../features/toolboxes/useToolboxActions.js | 27 ++++++---- website/src/pages/HomeOnboardingSection.jsx | 2 +- website/src/utils/indexedDbStorage.js | 16 ++++++ 9 files changed, 119 insertions(+), 32 deletions(-) diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js index 6f28432..d35e0ea 100644 --- a/e2e/toolbox.spec.js +++ b/e2e/toolbox.spec.js @@ -7,6 +7,15 @@ import { resetSokkoStorage } from "./helpers/storage.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturePath = path.join(__dirname, "fixtures", "toolbox-export.json"); +async function collectBrowserErrors(page) { + const errors = []; + page.on("pageerror", (error) => errors.push(`pageerror: ${error.stack || error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`console: ${message.text()}`); + }); + return errors; +} + async function createToolbox(page, name) { await page.goto("/#/toolboxes"); await expect(page.getByRole("main")).toBeVisible(); @@ -35,6 +44,11 @@ function tableCell(table, address) { test.beforeEach(async ({ page }) => { await resetSokkoStorage(page); + page.browserErrors = await collectBrowserErrors(page); +}); + +test.afterEach(async ({ page }) => { + expect(page.browserErrors, page.browserErrors.join("\n")).toEqual([]); }); test("creates a toolbox and lazy-loads an added tool", async ({ page }) => { diff --git a/website/src/features/toolboxes/modules/DrawingOverlay.jsx b/website/src/features/toolboxes/modules/DrawingOverlay.jsx index ad80fd8..95990cf 100644 --- a/website/src/features/toolboxes/modules/DrawingOverlay.jsx +++ b/website/src/features/toolboxes/modules/DrawingOverlay.jsx @@ -47,10 +47,11 @@ export function DrawingOverlay({ active, erase, strokes, color, width, onChange, useEffect(() => { function syncSize() { const nextSize = getSurfaceSize?.() || { width: 0, height: 0 }; - setSize({ - width: Math.max(0, Math.ceil(nextSize.width || 0)), - height: Math.max(0, Math.ceil(nextSize.height || 0)) - }); + const nextWidth = Math.max(0, Math.ceil(nextSize.width || 0)); + const nextHeight = Math.max(0, Math.ceil(nextSize.height || 0)); + setSize((current) => current.width === nextWidth && current.height === nextHeight + ? current + : { width: nextWidth, height: nextHeight }); } syncSize(); diff --git a/website/src/features/toolboxes/modules/NotepadModule.jsx b/website/src/features/toolboxes/modules/NotepadModule.jsx index aee9c5e..544046d 100644 --- a/website/src/features/toolboxes/modules/NotepadModule.jsx +++ b/website/src/features/toolboxes/modules/NotepadModule.jsx @@ -6,6 +6,7 @@ import { DrawingControls } from "./DrawingControls.jsx"; import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { NotepadToolbar } from "./NotepadToolbar.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; +import { normalizeNotepadData } from "../storage/toolboxStorage.js"; import { DRAWING_WIDTHS, HIGHLIGHT_COLORS, @@ -21,7 +22,7 @@ import { export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) { const storedData = context.getModuleData(toolboxId, moduleId, { text: "" }); - const normalizedData = useMemo(() => context.normalizeNotepadData(storedData), [context, storedData]); + const normalizedData = useMemo(() => normalizeNotepadData(storedData), [storedData]); const textContent = context.moduleText?.notepad || {}; const notify = context.notify; const activePage = normalizedData.pages.find((page) => page.id === normalizedData.activePageId) || normalizedData.pages[0]; @@ -43,7 +44,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } const [textColor, setTextColor] = useState(TEXT_COLORS[0]); const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]); const [editingPageId, setEditingPageId] = useState(""); - const [pageTitleDrafts, setPageTitleDrafts] = useState({}); + const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title]))); const [activeFormats, setActiveFormats] = useState({ bold: false, italic: false, @@ -56,10 +57,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen } const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`; const [temporaryStrokes, setTemporaryStrokes] = useState([]); - useEffect(() => { - setPageTitleDrafts(Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title]))); - }, [normalizedData.pages]); - useEffect(() => { const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; if (pageKeyRef.current !== pageKey) { diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx index a353c0d..3e5a062 100644 --- a/website/src/features/toolboxes/modules/TableModule.jsx +++ b/website/src/features/toolboxes/modules/TableModule.jsx @@ -15,7 +15,8 @@ import { import { useTableInteractions } from "./useTableInteractions.js"; export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) { - const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} })); + const rawData = context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }); + const data = useMemo(() => context.normalizeTableData(rawData), [context, rawData]); const textContent = context.moduleText?.table || {}; const evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]); const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]); diff --git a/website/src/features/toolboxes/modules/tableFormulaEngine.js b/website/src/features/toolboxes/modules/tableFormulaEngine.js index 271205c..0705e73 100644 --- a/website/src/features/toolboxes/modules/tableFormulaEngine.js +++ b/website/src/features/toolboxes/modules/tableFormulaEngine.js @@ -162,6 +162,11 @@ export function evaluateFormula(expression, resolveReference) { } export function evaluateTableCell(address, cells, dimensions, stack = []) { + return evaluateTableCellWithCache(address, cells, dimensions, stack, new Map()); +} + +function evaluateTableCellWithCache(address, cells, dimensions, stack, cache) { + if (!stack.length && cache.has(address)) return cache.get(address); const raw = String(cells?.[address] || "").trim(); if (!raw) return { value: null, display: "", error: "empty" }; @@ -176,25 +181,30 @@ export function evaluateTableCell(address, cells, dimensions, stack = []) { const result = evaluateFormula(raw.slice(1), (reference) => { if (!CELL_REFERENCE_RE.test(reference) || !isCellInBounds(reference, dimensions.rows, dimensions.columns)) throw new Error("reference"); - const value = evaluateTableCell(reference, cells, dimensions, [...stack, address]); + const value = evaluateTableCellWithCache(reference, cells, dimensions, [...stack, address], cache); if (value.error || value.value == null) throw new Error(value.error === "cycle" ? "cycle" : "reference"); return value.value; }); if (result.error) { const label = result.error === "cycle" ? "#CYCLE" : result.error === "division" ? "#DIV/0" : "#ERREUR"; - return { value: null, display: label, error: result.error }; + const evaluated = { value: null, display: label, error: result.error }; + if (!stack.length) cache.set(address, evaluated); + return evaluated; } - return { value: result.value, display: formatNumber(result.value), error: "" }; + const evaluated = { value: result.value, display: formatNumber(result.value), error: "" }; + if (!stack.length) cache.set(address, evaluated); + return evaluated; } export function evaluateTable(cells, dimensions) { const evaluated = {}; + const cache = new Map(); for (let rowIndex = 0; rowIndex < dimensions.rows; rowIndex += 1) { for (let columnIndex = 0; columnIndex < dimensions.columns; columnIndex += 1) { const address = cellAddress(rowIndex, columnIndex); - evaluated[address] = evaluateTableCell(address, cells, dimensions); + evaluated[address] = evaluateTableCellWithCache(address, cells, dimensions, [], cache); } } return evaluated; diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index 902e1dc..bc1429a 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -11,7 +11,8 @@ import { setLinks as dbSetLinks, setModuleData as dbSetModuleData, setSetting as dbSetSetting, - setToolboxes as dbSetToolboxes + setToolboxes as dbSetToolboxes, + setToolboxSnapshot as dbSetToolboxSnapshot } from "../../../utils/indexedDbStorage.js"; import { compactToolboxesForStorage, @@ -38,6 +39,8 @@ export function useIndexedToolboxes(onError) { const toolboxesRef = useRef(toolboxes); const linksRef = useRef(links); const moduleDataRef = useRef(moduleData); + const confirmedModuleDataRef = useRef(moduleData); + const moduleWriteQueuesRef = useRef({}); const toastPositionRef = useRef(toastPosition); const writeVersionsRef = useRef({ toolboxes: 0, @@ -94,7 +97,9 @@ export function useIndexedToolboxes(onError) { if (cancelled) return; commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean)); commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {}); - commitModuleData(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data]))); + const loadedModuleData = Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])); + confirmedModuleDataRef.current = loadedModuleData; + commitModuleData(loadedModuleData); commitToastPosition(storedToastPosition === "left" ? "left" : "right"); setReady(true); refreshQuota(); @@ -148,11 +153,22 @@ export function useIndexedToolboxes(onError) { writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]); const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact); commitModuleData(nextModuleData); - const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact); - operation.then(refreshQuota).catch((error) => { - commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint])); + const previousQueue = moduleWriteQueuesRef.current[key] || Promise.resolve(); + const operation = previousQueue.catch(() => {}).then(async () => { + if (compact == null) await dbRemoveModuleData(key); + else await dbSetModuleData(key, compact); + confirmedModuleDataRef.current = applyModuleDataValue(confirmedModuleDataRef.current, key, compact); + await refreshQuota(); + }).catch((error) => { + if (moduleDataRef.current[key] === compact) { + commitModuleData(applyModuleDataValue(moduleDataRef.current, key, confirmedModuleDataRef.current[key])); + } reportStorageError(error); }); + const queuedOperation = operation.finally(() => { + if (moduleWriteQueuesRef.current[key] === queuedOperation) delete moduleWriteQueuesRef.current[key]; + }); + moduleWriteQueuesRef.current[key] = queuedOperation; return true; } @@ -179,6 +195,30 @@ export function useIndexedToolboxes(onError) { }); } + async function persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData) { + const normalizedToolboxes = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); + const previous = { + toolboxes: toolboxesRef.current, + links: linksRef.current, + moduleData: moduleDataRef.current + }; + commitToolboxes(normalizedToolboxes); + commitLinks(nextLinks); + commitModuleData(nextModuleData); + try { + await dbSetToolboxSnapshot(compactToolboxesForStorage(normalizedToolboxes), nextLinks, nextModuleData); + confirmedModuleDataRef.current = nextModuleData; + await refreshQuota(); + return true; + } catch (error) { + commitToolboxes(previous.toolboxes); + commitLinks(previous.links); + commitModuleData(previous.moduleData); + reportStorageError(error); + return false; + } + } + function setToastPosition(position) { const normalized = position === "left" ? "left" : "right"; const previousToastPosition = toastPositionRef.current; @@ -206,6 +246,7 @@ export function useIndexedToolboxes(onError) { removeModuleData, removeToolboxModuleData, setToastPosition, - refreshQuota + refreshQuota, + persistToolboxSnapshot }; } diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index 862ea59..9b1614d 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -61,13 +61,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM function deleteToolbox(id) { const toolbox = store.toolboxes.find((item) => item.id === id); - store.removeToolboxModuleData(toolbox); - store.setToolboxes(store.toolboxes.filter((item) => item.id !== id)); + const nextToolboxes = store.toolboxes.filter((item) => item.id !== id); + const nextModuleData = { ...store.moduleData }; + (toolbox?.modules || []).forEach((module) => delete nextModuleData[`${id}:${module.id}`]); const nextLinks = { ...store.links }; Object.entries(nextLinks).forEach(([gameId, toolboxId]) => { if (toolboxId === id) delete nextLinks[gameId]; }); - store.setLinks(nextLinks); + return store.persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData); } function linkToolboxToGame(gameId, toolboxId) { @@ -146,13 +147,19 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM }); imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap); const normalizedImport = normalizeToolbox(imported); - if (!store.setToolboxes([normalizedImport, ...store.toolboxes])) return null; + const nextModuleData = { ...store.moduleData }; Object.entries(payload.modules || {}).forEach(([oldId, data]) => { const nextId = moduleIdMap.get(oldId); const module = normalizedImport.modules.find((item) => item.id === nextId); - if (nextId) store.updateModuleData(normalizedImport.id, nextId, data, module?.type); + if (nextId) { + const compact = compactModuleDataForStorage(module?.type, data); + if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact; + } }); - if (gameId) linkToolboxToGame(gameId, normalizedImport.id); + const nextLinks = { ...store.links }; + if (gameId) nextLinks[gameId] = normalizedImport.id; + const saved = await store.persistToolboxSnapshot([normalizedImport, ...store.toolboxes], nextLinks, nextModuleData); + if (!saved) return null; return normalizedImport; } @@ -181,6 +188,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM const nextToolboxId = toolboxIdMap.get(oldToolboxId); if (nextToolboxId) nextLinks[gameId] = nextToolboxId; }); + const nextModuleData = { ...store.moduleData }; Object.entries(payload.modules).forEach(([key, data]) => { const [oldToolboxId] = key.split(":"); const nextToolboxId = toolboxIdMap.get(oldToolboxId); @@ -188,11 +196,10 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId); const module = toolbox?.modules.find((item) => item.id === nextModuleId); const compact = compactModuleDataForStorage(module?.type, data); - if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type); + if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact; }); - store.setToolboxes([...importedToolboxes, ...store.toolboxes]); - store.setLinks(nextLinks); - store.refreshQuota(); + const saved = await store.persistToolboxSnapshot([...importedToolboxes, ...store.toolboxes], nextLinks, nextModuleData); + if (!saved) throw new Error("Impossible d'enregistrer l'import global."); } return { diff --git a/website/src/pages/HomeOnboardingSection.jsx b/website/src/pages/HomeOnboardingSection.jsx index 88eaef2..010df79 100644 --- a/website/src/pages/HomeOnboardingSection.jsx +++ b/website/src/pages/HomeOnboardingSection.jsx @@ -25,7 +25,7 @@ export function HomeOnboardingSection({ content, className = "", onCreateToolbox const currentSlide = slides[activeSlide] || slides[0]; return ( -
+

{content.eyebrow}

{content.title}

diff --git a/website/src/utils/indexedDbStorage.js b/website/src/utils/indexedDbStorage.js index f0eebec..92ac88c 100644 --- a/website/src/utils/indexedDbStorage.js +++ b/website/src/utils/indexedDbStorage.js @@ -102,6 +102,22 @@ export async function removeModuleDataKeys(keys) { }); } +export async function setToolboxSnapshot(toolboxes, links, moduleData) { + const db = await openDatabase(); + await new Promise((resolve, reject) => { + const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite"); + const kv = transaction.objectStore(KV_STORE); + const modules = transaction.objectStore(MODULE_STORE); + kv.put(toolboxes, "toolboxes"); + kv.put(links, "links"); + modules.clear(); + Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data })); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + export async function getStorageEstimate() { if (!navigator.storage?.estimate) return { usage: 0, quota: 0 }; return navigator.storage.estimate();