Improve toolbox module loading and goal tree previews
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 15:34:50 +02:00
parent abfa23fb3e
commit 50029b45a2
9 changed files with 259 additions and 177 deletions

View file

@ -25,16 +25,18 @@ import {
parseLocalDateKey,
timeValueToMinutes
} from "./calendarUtils.js";
import {
HOUR_HALF_SLOT_COUNT,
WEEK_COMPACT_EVENT_HEIGHT,
WEEK_SLOT_COUNT,
WEEKDAY_LABELS,
getMonthOccurrenceSegments,
getWeekGridLayout,
getWeekMinutePosition,
layoutConcurrentOccurrences
} from "./calendarLayout.js";
import { ALERT_MODES } from "./timerOptions.js";
const DAY_HOUR_COUNT = 24;
const HOUR_HALF_SLOT_COUNT = 2;
const WEEK_SLOT_COUNT = DAY_HOUR_COUNT * HOUR_HALF_SLOT_COUNT;
const WEEK_HOUR_ROW_HEIGHT = 27;
const WEEK_HIDDEN_ROW_HEIGHT = 20;
const WEEK_COMPACT_EVENT_HEIGHT = WEEK_HOUR_ROW_HEIGHT / HOUR_HALF_SLOT_COUNT;
const WEEKDAY_LABELS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
const CALENDAR_COLOR_VALUES = {
violet: "#8b5cf6",
gold: "#f6c453",
@ -163,149 +165,6 @@ function normalizeDraftAlertOffsets(value) {
return offsets.sort((a, b) => b - a);
}
function occurrencesOverlap(first, second) {
const firstEnd = first.endMs > first.startMs ? first.endMs : first.startMs + WEEK_SLOT_MINUTES * 60000;
const secondEnd = second.endMs > second.startMs ? second.endMs : second.startMs + WEEK_SLOT_MINUTES * 60000;
return first.startMs < secondEnd && second.startMs < firstEnd;
}
function layoutConcurrentOccurrences(dayOccurrences) {
const laneEndMs = [0, 0];
return dayOccurrences
.map((occurrence, index, all) => {
const lane = laneEndMs[0] <= occurrence.startMs ? 0 : 1;
laneEndMs[lane] = occurrence.endMs > occurrence.startMs ? occurrence.endMs : occurrence.startMs + WEEK_SLOT_MINUTES * 60000;
const overlaps = all.some((item, itemIndex) => itemIndex !== index && occurrencesOverlap(occurrence, item));
return { ...occurrence, lane, laneCount: overlaps ? 2 : 1 };
});
}
function eventMinutesRange(occurrence) {
const startMinutes = Math.max(0, Math.min(24 * 60 - 1, Number(occurrence.event.startMinutes) || 0));
const endMinutes = occurrence.event.endMinutes && occurrence.event.endMinutes > startMinutes
? occurrence.event.endMinutes
: startMinutes + WEEK_SLOT_MINUTES;
return {
startMinutes,
endMinutes: Math.max(startMinutes + WEEK_SLOT_MINUTES, Math.min(24 * 60, endMinutes))
};
}
function getWeekGridLayout(occurrences, hideEmptyHours) {
const occupiedHours = Array.from({ length: DAY_HOUR_COUNT }, () => false);
occurrences.forEach((occurrence) => {
const { startMinutes, endMinutes } = eventMinutesRange(occurrence);
for (let hourIndex = 0; hourIndex < DAY_HOUR_COUNT; hourIndex += 1) {
const hourStart = hourIndex * 60;
const hourEnd = hourStart + 60;
if (startMinutes < hourEnd && endMinutes > hourStart) occupiedHours[hourIndex] = true;
}
});
const rows = [];
let hourIndex = 0;
while (hourIndex < DAY_HOUR_COUNT) {
if (!hideEmptyHours || occupiedHours[hourIndex]) {
rows.push({ type: "hour", hourIndex });
hourIndex += 1;
continue;
}
const runStart = hourIndex;
while (hourIndex < DAY_HOUR_COUNT && !occupiedHours[hourIndex]) hourIndex += 1;
const runEnd = hourIndex - 1;
if (runEnd - runStart + 1 >= 3) {
rows.push({ type: "hour", hourIndex: runStart });
rows.push({ type: "hidden", startHour: runStart + 1, endHour: runEnd - 1 });
rows.push({ type: "hour", hourIndex: runEnd });
} else {
for (let visibleHour = runStart; visibleHour <= runEnd; visibleHour += 1) rows.push({ type: "hour", hourIndex: visibleHour });
}
}
const hourTopPx = {};
const hiddenRanges = [];
let topPx = 0;
rows.forEach((row) => {
row.topPx = topPx;
if (row.type === "hidden") {
hiddenRanges.push(row);
topPx += WEEK_HIDDEN_ROW_HEIGHT;
return;
}
hourTopPx[row.hourIndex] = topPx;
topPx += WEEK_HOUR_ROW_HEIGHT;
});
return {
rows,
hourTopPx,
hiddenRanges,
totalHeightPx: topPx,
gridTemplateRows: rows.map((row) => `${row.type === "hidden" ? WEEK_HIDDEN_ROW_HEIGHT : WEEK_HOUR_ROW_HEIGHT}px`).join(" ")
};
}
function getWeekMinutePosition(layout, minutes) {
const boundedMinutes = Math.max(0, Math.min(24 * 60, minutes));
const hourIndex = Math.min(DAY_HOUR_COUNT - 1, Math.floor(boundedMinutes / 60));
const visibleHourTop = layout.hourTopPx[hourIndex];
if (visibleHourTop !== undefined) {
return visibleHourTop + ((boundedMinutes - hourIndex * 60) / 60) * WEEK_HOUR_ROW_HEIGHT;
}
const hiddenRange = layout.hiddenRanges.find((range) => hourIndex >= range.startHour && hourIndex <= range.endHour);
if (hiddenRange) {
const rangeStartMinutes = hiddenRange.startHour * 60;
const rangeMinutes = (hiddenRange.endHour - hiddenRange.startHour + 1) * 60;
return hiddenRange.topPx + ((boundedMinutes - rangeStartMinutes) / rangeMinutes) * WEEK_HIDDEN_ROW_HEIGHT;
}
return layout.totalHeightPx;
}
function getMonthOccurrenceSegments(days, occurrences) {
const laneUsage = days.map(() => [false, false, false]);
const segments = occurrences.flatMap((occurrence) => {
const coveredIndexes = days
.map((day, index) => {
const dayStart = day.date.getTime();
const dayEnd = addDays(day.date, 1).getTime();
const occurrenceEnd = Math.max(occurrence.endMs, occurrence.startMs + WEEK_SLOT_MINUTES * 60000);
return day.inMonth && occurrence.startMs < dayEnd && occurrenceEnd > dayStart ? index : -1;
})
.filter((index) => index >= 0);
if (!coveredIndexes.length) return [];
const lane = [0, 1, 2].find((candidate) => coveredIndexes.every((index) => !laneUsage[index][candidate])) ?? 2;
coveredIndexes.forEach((index) => {
laneUsage[index][lane] = true;
});
const segments = [];
let segmentStart = coveredIndexes[0];
let previous = coveredIndexes[0];
coveredIndexes.slice(1).forEach((index) => {
const sameWeekRow = Math.floor(index / WEEKDAY_LABELS.length) === Math.floor(previous / WEEKDAY_LABELS.length);
if (index === previous + 1 && sameWeekRow) {
previous = index;
return;
}
segments.push({ occurrence, lane, startIndex: segmentStart, endIndex: previous });
segmentStart = index;
previous = index;
});
segments.push({ occurrence, lane, startIndex: segmentStart, endIndex: previous });
return segments;
});
return segments.map((segment) => {
const laneCount = Math.max(1, ...segments
.filter((item) => item.startIndex <= segment.endIndex && item.endIndex >= segment.startIndex)
.map((item) => item.lane + 1));
return { ...segment, laneCount };
});
}
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {};

View file

@ -1,5 +1,5 @@
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx";
@ -173,6 +173,9 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
{ id: "advanced", label: textContent.advancedTab || "Avancé", icon: "settings" }
];
const parentMap = useMemo(() => getGoalTreeParentMap(evaluatedNodes), [evaluatedNodes]);
const save = useCallback((nextData) => {
context.setModuleData(toolboxId, moduleId, nextData, "goalTree");
}, [context, moduleId, toolboxId]);
const reorder = useGroupedReorder({
namespace: "goal-tree",
items: evaluatedNodes,
@ -186,7 +189,7 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
useEffect(() => {
const resetData = applyGoalTreeDueResets(data, Date.now());
if (resetData !== data) save(resetData);
});
}, [data, save]);
useEffect(() => {
if (!selectedNodeId && data.nodes[0]) setSelectedNodeId(data.nodes[0].id);
@ -243,10 +246,6 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
};
}, [connectorTopologyKey, editing, textExchangeOpen, viewScale]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "goalTree");
}
function addNode(parentId = "") {
const parent = data.nodes.find((node) => node.id === parentId);
const node = {
@ -990,7 +989,7 @@ function GoalTreeStepLeafControl({ node, textContent, onChange }) {
}
function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
const items = (node.items || []).slice(0, 3);
const items = node.items || [];
return (
<span className="goal-tree-node-content-preview">
{items.map((item) => (

View file

@ -0,0 +1,153 @@
// Rôle : calcule les constantes et positions de layout pour les vues calendrier.
import { WEEK_SLOT_MINUTES, addDays } from "./calendarUtils.js";
export const DAY_HOUR_COUNT = 24;
export const HOUR_HALF_SLOT_COUNT = 2;
export const WEEK_SLOT_COUNT = DAY_HOUR_COUNT * HOUR_HALF_SLOT_COUNT;
export const WEEK_HOUR_ROW_HEIGHT = 27;
export const WEEK_HIDDEN_ROW_HEIGHT = 20;
export const WEEK_COMPACT_EVENT_HEIGHT = WEEK_HOUR_ROW_HEIGHT / HOUR_HALF_SLOT_COUNT;
export const WEEKDAY_LABELS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
function occurrencesOverlap(first, second) {
const firstEnd = first.endMs > first.startMs ? first.endMs : first.startMs + WEEK_SLOT_MINUTES * 60000;
const secondEnd = second.endMs > second.startMs ? second.endMs : second.startMs + WEEK_SLOT_MINUTES * 60000;
return first.startMs < secondEnd && second.startMs < firstEnd;
}
export function layoutConcurrentOccurrences(dayOccurrences) {
const laneEndMs = [0, 0];
return dayOccurrences
.map((occurrence, index, all) => {
const lane = laneEndMs[0] <= occurrence.startMs ? 0 : 1;
laneEndMs[lane] = occurrence.endMs > occurrence.startMs ? occurrence.endMs : occurrence.startMs + WEEK_SLOT_MINUTES * 60000;
const overlaps = all.some((item, itemIndex) => itemIndex !== index && occurrencesOverlap(occurrence, item));
return { ...occurrence, lane, laneCount: overlaps ? 2 : 1 };
});
}
function eventMinutesRange(occurrence) {
const startMinutes = Math.max(0, Math.min(24 * 60 - 1, Number(occurrence.event.startMinutes) || 0));
const endMinutes = occurrence.event.endMinutes && occurrence.event.endMinutes > startMinutes
? occurrence.event.endMinutes
: startMinutes + WEEK_SLOT_MINUTES;
return {
startMinutes,
endMinutes: Math.max(startMinutes + WEEK_SLOT_MINUTES, Math.min(24 * 60, endMinutes))
};
}
export function getWeekGridLayout(occurrences, hideEmptyHours) {
const occupiedHours = Array.from({ length: DAY_HOUR_COUNT }, () => false);
occurrences.forEach((occurrence) => {
const { startMinutes, endMinutes } = eventMinutesRange(occurrence);
for (let hourIndex = 0; hourIndex < DAY_HOUR_COUNT; hourIndex += 1) {
const hourStart = hourIndex * 60;
const hourEnd = hourStart + 60;
if (startMinutes < hourEnd && endMinutes > hourStart) occupiedHours[hourIndex] = true;
}
});
const rows = [];
let hourIndex = 0;
while (hourIndex < DAY_HOUR_COUNT) {
if (!hideEmptyHours || occupiedHours[hourIndex]) {
rows.push({ type: "hour", hourIndex });
hourIndex += 1;
continue;
}
const runStart = hourIndex;
while (hourIndex < DAY_HOUR_COUNT && !occupiedHours[hourIndex]) hourIndex += 1;
const runEnd = hourIndex - 1;
if (runEnd - runStart + 1 >= 3) {
rows.push({ type: "hour", hourIndex: runStart });
rows.push({ type: "hidden", startHour: runStart + 1, endHour: runEnd - 1 });
rows.push({ type: "hour", hourIndex: runEnd });
} else {
for (let visibleHour = runStart; visibleHour <= runEnd; visibleHour += 1) rows.push({ type: "hour", hourIndex: visibleHour });
}
}
const hourTopPx = {};
const hiddenRanges = [];
let topPx = 0;
rows.forEach((row) => {
row.topPx = topPx;
if (row.type === "hidden") {
hiddenRanges.push(row);
topPx += WEEK_HIDDEN_ROW_HEIGHT;
return;
}
hourTopPx[row.hourIndex] = topPx;
topPx += WEEK_HOUR_ROW_HEIGHT;
});
return {
rows,
hourTopPx,
hiddenRanges,
totalHeightPx: topPx,
gridTemplateRows: rows.map((row) => `${row.type === "hidden" ? WEEK_HIDDEN_ROW_HEIGHT : WEEK_HOUR_ROW_HEIGHT}px`).join(" ")
};
}
export function getWeekMinutePosition(layout, minutes) {
const boundedMinutes = Math.max(0, Math.min(24 * 60, minutes));
const hourIndex = Math.min(DAY_HOUR_COUNT - 1, Math.floor(boundedMinutes / 60));
const visibleHourTop = layout.hourTopPx[hourIndex];
if (visibleHourTop !== undefined) {
return visibleHourTop + ((boundedMinutes - hourIndex * 60) / 60) * WEEK_HOUR_ROW_HEIGHT;
}
const hiddenRange = layout.hiddenRanges.find((range) => hourIndex >= range.startHour && hourIndex <= range.endHour);
if (hiddenRange) {
const rangeStartMinutes = hiddenRange.startHour * 60;
const rangeMinutes = (hiddenRange.endHour - hiddenRange.startHour + 1) * 60;
return hiddenRange.topPx + ((boundedMinutes - rangeStartMinutes) / rangeMinutes) * WEEK_HIDDEN_ROW_HEIGHT;
}
return layout.totalHeightPx;
}
export function getMonthOccurrenceSegments(days, occurrences) {
const laneUsage = days.map(() => [false, false, false]);
const segments = occurrences.flatMap((occurrence) => {
const coveredIndexes = days
.map((day, index) => {
const dayStart = day.date.getTime();
const dayEnd = addDays(day.date, 1).getTime();
const occurrenceEnd = Math.max(occurrence.endMs, occurrence.startMs + WEEK_SLOT_MINUTES * 60000);
return day.inMonth && occurrence.startMs < dayEnd && occurrenceEnd > dayStart ? index : -1;
})
.filter((index) => index >= 0);
if (!coveredIndexes.length) return [];
const lane = [0, 1, 2].find((candidate) => coveredIndexes.every((index) => !laneUsage[index][candidate])) ?? 2;
coveredIndexes.forEach((index) => {
laneUsage[index][lane] = true;
});
const segments = [];
let segmentStart = coveredIndexes[0];
let previous = coveredIndexes[0];
coveredIndexes.slice(1).forEach((index) => {
const sameWeekRow = Math.floor(index / WEEKDAY_LABELS.length) === Math.floor(previous / WEEKDAY_LABELS.length);
if (index === previous + 1 && sameWeekRow) {
previous = index;
return;
}
segments.push({ occurrence, lane, startIndex: segmentStart, endIndex: previous });
segmentStart = index;
previous = index;
});
segments.push({ occurrence, lane, startIndex: segmentStart, endIndex: previous });
return segments;
});
return segments.map((segment) => {
const laneCount = Math.max(1, ...segments
.filter((item) => item.startIndex <= segment.endIndex && item.endIndex >= segment.startIndex)
.map((item) => item.lane + 1));
return { ...segment, laneCount };
});
}

View file

@ -1,5 +1,5 @@
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
import { useEffect, useMemo, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
@ -9,13 +9,9 @@ import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { supportsTextExchange } from "./textImport.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { CalendarModule } from "./CalendarModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { EquipmentPlannerModule } from "./EquipmentPlannerModule.jsx";
import { GoalTreeModule } from "./GoalTreeModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
@ -23,6 +19,11 @@ import { TableModule } from "./TableModule.jsx";
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
const MODULE_COMPONENTS = {
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
@ -40,6 +41,8 @@ const MODULE_COMPONENTS = {
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
};
const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
const MODULE_COLUMN_IDS = ["left", "right"];
export { TOOLBOX_MODULES };
@ -300,6 +303,18 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
scrollable ? "is-scrollable" : ""
].filter(Boolean).join(" ");
const componentProps = {
toolboxId: toolbox.id,
moduleId: module.id,
context,
editing,
setEditing,
openEditor: () => {
setTextExchangeOpen(false);
setEditing(true);
},
textExchangeOpen
};
return (
<article
@ -380,23 +395,22 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component
toolboxId={toolbox.id}
moduleId={module.id}
context={context}
editing={editing}
setEditing={setEditing}
openEditor={() => {
setTextExchangeOpen(false);
setEditing(true);
}}
textExchangeOpen={textExchangeOpen}
/>
{LAZY_MODULE_TYPES.has(module.type) ? (
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<Component {...componentProps} />
</Suspense>
) : (
<Component {...componentProps} />
)}
</div>
</article>
);
}
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,

View file

@ -87,13 +87,17 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
if (!imageFiles.length) return false;
const data = store.getModuleData(toolboxId, moduleId, { images: [] });
const images = [];
for (const file of imageFiles) {
const image = { id: uid("image"), dataUrl: await compressImage(file) };
const label = labelFromFileName(file.name);
if (label) image.label = label;
data.images.unshift(image);
images.unshift(image);
}
return store.updateModuleData(toolboxId, moduleId, data);
return store.updateModuleData(toolboxId, moduleId, {
...data,
images: [...images, ...(Array.isArray(data.images) ? data.images : [])]
});
}
function createChecklistFromList(gameId, list) {

View file

@ -25,6 +25,15 @@
min-width: 0;
}
.module-loading-fallback {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(7, 10, 24, 0.32);
color: var(--color-text-secondary);
font-size: 14px;
}
.module-content.is-scrollable {
max-height: 450px;
overflow-y: auto;