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

@ -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();

View file

@ -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) {

View file

@ -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]);

View file

@ -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;

View file

@ -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
};
}

View file

@ -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 {

View file

@ -25,7 +25,7 @@ export function HomeOnboardingSection({ content, className = "", onCreateToolbox
const currentSlide = slides[activeSlide] || slides[0];
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">
<p className="eyebrow">{content.eyebrow}</p>
<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() {
if (!navigator.storage?.estimate) return { usage: 0, quota: 0 };
return navigator.storage.estimate();