Fix toolbox persistence, table evaluation, and onboarding panel styling
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-09 15:02:48 +02:00
parent 0171c9fc98
commit 184314d913
9 changed files with 119 additions and 32 deletions

View file

@ -7,6 +7,15 @@ import { resetSokkoStorage } from "./helpers/storage.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturePath = path.join(__dirname, "fixtures", "toolbox-export.json"); 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) { async function createToolbox(page, name) {
await page.goto("/#/toolboxes"); await page.goto("/#/toolboxes");
await expect(page.getByRole("main")).toBeVisible(); await expect(page.getByRole("main")).toBeVisible();
@ -35,6 +44,11 @@ function tableCell(table, address) {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await resetSokkoStorage(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 }) => { test("creates a toolbox and lazy-loads an added tool", async ({ page }) => {

View file

@ -47,10 +47,11 @@ export function DrawingOverlay({ active, erase, strokes, color, width, onChange,
useEffect(() => { useEffect(() => {
function syncSize() { function syncSize() {
const nextSize = getSurfaceSize?.() || { width: 0, height: 0 }; const nextSize = getSurfaceSize?.() || { width: 0, height: 0 };
setSize({ const nextWidth = Math.max(0, Math.ceil(nextSize.width || 0));
width: Math.max(0, Math.ceil(nextSize.width || 0)), const nextHeight = Math.max(0, Math.ceil(nextSize.height || 0));
height: Math.max(0, Math.ceil(nextSize.height || 0)) setSize((current) => current.width === nextWidth && current.height === nextHeight
}); ? current
: { width: nextWidth, height: nextHeight });
} }
syncSize(); syncSize();

View file

@ -6,6 +6,7 @@ import { DrawingControls } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx"; import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { NotepadToolbar } from "./NotepadToolbar.jsx"; import { NotepadToolbar } from "./NotepadToolbar.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { normalizeNotepadData } from "../storage/toolboxStorage.js";
import { import {
DRAWING_WIDTHS, DRAWING_WIDTHS,
HIGHLIGHT_COLORS, HIGHLIGHT_COLORS,
@ -21,7 +22,7 @@ import {
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) { export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" }); 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 textContent = context.moduleText?.notepad || {};
const notify = context.notify; const notify = context.notify;
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];
@ -43,7 +44,7 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
const [textColor, setTextColor] = useState(TEXT_COLORS[0]); const [textColor, setTextColor] = useState(TEXT_COLORS[0]);
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({}); const [pageTitleDrafts, setPageTitleDrafts] = useState(() => Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title])));
const [activeFormats, setActiveFormats] = useState({ const [activeFormats, setActiveFormats] = useState({
bold: false, bold: false,
italic: false, italic: false,
@ -56,10 +57,6 @@ export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`; const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}:${activePage.id}`;
const [temporaryStrokes, setTemporaryStrokes] = useState([]); const [temporaryStrokes, setTemporaryStrokes] = useState([]);
useEffect(() => {
setPageTitleDrafts(Object.fromEntries(normalizedData.pages.map((page) => [page.id, page.title])));
}, [normalizedData.pages]);
useEffect(() => { useEffect(() => {
const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`; const pageKey = `${toolboxId}:${moduleId}:${activePage.id}`;
if (pageKeyRef.current !== pageKey) { if (pageKeyRef.current !== pageKey) {

View file

@ -15,7 +15,8 @@ import {
import { useTableInteractions } from "./useTableInteractions.js"; import { useTableInteractions } from "./useTableInteractions.js";
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) { 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 textContent = context.moduleText?.table || {};
const evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]); const evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]);
const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]); const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]);

View file

@ -162,6 +162,11 @@ export function evaluateFormula(expression, resolveReference) {
} }
export function evaluateTableCell(address, cells, dimensions, stack = []) { 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(); const raw = String(cells?.[address] || "").trim();
if (!raw) return { value: null, display: "", error: "empty" }; 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) => { const result = evaluateFormula(raw.slice(1), (reference) => {
if (!CELL_REFERENCE_RE.test(reference) || !isCellInBounds(reference, dimensions.rows, dimensions.columns)) throw new Error("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"); if (value.error || value.value == null) throw new Error(value.error === "cycle" ? "cycle" : "reference");
return value.value; return value.value;
}); });
if (result.error) { if (result.error) {
const label = result.error === "cycle" ? "#CYCLE" : result.error === "division" ? "#DIV/0" : "#ERREUR"; 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) { export function evaluateTable(cells, dimensions) {
const evaluated = {}; const evaluated = {};
const cache = new Map();
for (let rowIndex = 0; rowIndex < dimensions.rows; rowIndex += 1) { for (let rowIndex = 0; rowIndex < dimensions.rows; rowIndex += 1) {
for (let columnIndex = 0; columnIndex < dimensions.columns; columnIndex += 1) { for (let columnIndex = 0; columnIndex < dimensions.columns; columnIndex += 1) {
const address = cellAddress(rowIndex, columnIndex); const address = cellAddress(rowIndex, columnIndex);
evaluated[address] = evaluateTableCell(address, cells, dimensions); evaluated[address] = evaluateTableCellWithCache(address, cells, dimensions, [], cache);
} }
} }
return evaluated; return evaluated;

View file

@ -11,7 +11,8 @@ import {
setLinks as dbSetLinks, setLinks as dbSetLinks,
setModuleData as dbSetModuleData, setModuleData as dbSetModuleData,
setSetting as dbSetSetting, setSetting as dbSetSetting,
setToolboxes as dbSetToolboxes setToolboxes as dbSetToolboxes,
setToolboxSnapshot as dbSetToolboxSnapshot
} from "../../../utils/indexedDbStorage.js"; } from "../../../utils/indexedDbStorage.js";
import { import {
compactToolboxesForStorage, compactToolboxesForStorage,
@ -38,6 +39,8 @@ export function useIndexedToolboxes(onError) {
const toolboxesRef = useRef(toolboxes); const toolboxesRef = useRef(toolboxes);
const linksRef = useRef(links); const linksRef = useRef(links);
const moduleDataRef = useRef(moduleData); const moduleDataRef = useRef(moduleData);
const confirmedModuleDataRef = useRef(moduleData);
const moduleWriteQueuesRef = useRef({});
const toastPositionRef = useRef(toastPosition); const toastPositionRef = useRef(toastPosition);
const writeVersionsRef = useRef({ const writeVersionsRef = useRef({
toolboxes: 0, toolboxes: 0,
@ -94,7 +97,9 @@ export function useIndexedToolboxes(onError) {
if (cancelled) return; if (cancelled) return;
commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean)); commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {}); 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"); commitToastPosition(storedToastPosition === "left" ? "left" : "right");
setReady(true); setReady(true);
refreshQuota(); refreshQuota();
@ -148,11 +153,22 @@ export function useIndexedToolboxes(onError) {
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]); writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]);
const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact); const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact);
commitModuleData(nextModuleData); commitModuleData(nextModuleData);
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact); const previousQueue = moduleWriteQueuesRef.current[key] || Promise.resolve();
operation.then(refreshQuota).catch((error) => { const operation = previousQueue.catch(() => {}).then(async () => {
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint])); 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); reportStorageError(error);
}); });
const queuedOperation = operation.finally(() => {
if (moduleWriteQueuesRef.current[key] === queuedOperation) delete moduleWriteQueuesRef.current[key];
});
moduleWriteQueuesRef.current[key] = queuedOperation;
return true; 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) { function setToastPosition(position) {
const normalized = position === "left" ? "left" : "right"; const normalized = position === "left" ? "left" : "right";
const previousToastPosition = toastPositionRef.current; const previousToastPosition = toastPositionRef.current;
@ -206,6 +246,7 @@ export function useIndexedToolboxes(onError) {
removeModuleData, removeModuleData,
removeToolboxModuleData, removeToolboxModuleData,
setToastPosition, setToastPosition,
refreshQuota refreshQuota,
persistToolboxSnapshot
}; };
} }

View file

@ -61,13 +61,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
function deleteToolbox(id) { function deleteToolbox(id) {
const toolbox = store.toolboxes.find((item) => item.id === id); const toolbox = store.toolboxes.find((item) => item.id === id);
store.removeToolboxModuleData(toolbox); const nextToolboxes = store.toolboxes.filter((item) => item.id !== id);
store.setToolboxes(store.toolboxes.filter((item) => item.id !== id)); const nextModuleData = { ...store.moduleData };
(toolbox?.modules || []).forEach((module) => delete nextModuleData[`${id}:${module.id}`]);
const nextLinks = { ...store.links }; const nextLinks = { ...store.links };
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => { Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
if (toolboxId === id) delete nextLinks[gameId]; if (toolboxId === id) delete nextLinks[gameId];
}); });
store.setLinks(nextLinks); return store.persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData);
} }
function linkToolboxToGame(gameId, toolboxId) { function linkToolboxToGame(gameId, toolboxId) {
@ -146,13 +147,19 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
}); });
imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap); imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap);
const normalizedImport = normalizeToolbox(imported); const normalizedImport = normalizeToolbox(imported);
if (!store.setToolboxes([normalizedImport, ...store.toolboxes])) return null; const nextModuleData = { ...store.moduleData };
Object.entries(payload.modules || {}).forEach(([oldId, data]) => { Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
const nextId = moduleIdMap.get(oldId); const nextId = moduleIdMap.get(oldId);
const module = normalizedImport.modules.find((item) => item.id === nextId); 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; return normalizedImport;
} }
@ -181,6 +188,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const nextToolboxId = toolboxIdMap.get(oldToolboxId); const nextToolboxId = toolboxIdMap.get(oldToolboxId);
if (nextToolboxId) nextLinks[gameId] = nextToolboxId; if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
}); });
const nextModuleData = { ...store.moduleData };
Object.entries(payload.modules).forEach(([key, data]) => { Object.entries(payload.modules).forEach(([key, data]) => {
const [oldToolboxId] = key.split(":"); const [oldToolboxId] = key.split(":");
const nextToolboxId = toolboxIdMap.get(oldToolboxId); 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 toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
const module = toolbox?.modules.find((item) => item.id === nextModuleId); const module = toolbox?.modules.find((item) => item.id === nextModuleId);
const compact = compactModuleDataForStorage(module?.type, data); 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]); const saved = await store.persistToolboxSnapshot([...importedToolboxes, ...store.toolboxes], nextLinks, nextModuleData);
store.setLinks(nextLinks); if (!saved) throw new Error("Impossible d'enregistrer l'import global.");
store.refreshQuota();
} }
return { return {

View file

@ -25,7 +25,7 @@ export function HomeOnboardingSection({ content, className = "", onCreateToolbox
const currentSlide = slides[activeSlide] || slides[0]; const currentSlide = slides[activeSlide] || slides[0];
return ( return (
<section className={["home-onboarding nebula-panel", className].filter(Boolean).join(" ")} aria-labelledby="home-onboarding-title"> <section className={["home-onboarding nds-panel", className].filter(Boolean).join(" ")} aria-labelledby="home-onboarding-title">
<div className="home-section-heading home-onboarding-heading"> <div className="home-section-heading home-onboarding-heading">
<p className="eyebrow">{content.eyebrow}</p> <p className="eyebrow">{content.eyebrow}</p>
<h2 id="home-onboarding-title">{content.title}</h2> <h2 id="home-onboarding-title">{content.title}</h2>

View file

@ -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() { export async function getStorageEstimate() {
if (!navigator.storage?.estimate) return { usage: 0, quota: 0 }; if (!navigator.storage?.estimate) return { usage: 0, quota: 0 };
return navigator.storage.estimate(); return navigator.storage.estimate();