diff --git a/package.json b/package.json index 26633c2..4736915 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "lists:index": "node scripts/generate-list-indexes.mjs", "lint": "eslint .", "check": "node --check server.mjs && node --check vite.config.js && npm run lint && npm run lists:index && npm test && npm run build", + "check:full": "npm run check && npm run test:e2e", "test": "node --test", "test:e2e": "playwright test" }, diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index a2aaae7..46c6096 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -180,6 +180,12 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /export function AddToolControls/); assert.match(moduleRegistry, /export function ToolboxModules/); assert.match(moduleRegistry, /MODULE_COMPONENTS/); + assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/); + assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/); + assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/); + assert.match(moduleRegistry, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/); + assert.match(moduleRegistry, /Suspense fallback=\{\}/); + assert.match(moduleRegistry, /LAZY_MODULE_TYPES/); assert.match(moduleRegistry, /notepad:/); assert.match(moduleRegistry, /checklist:/); assert.match(moduleRegistry, /images:/); @@ -365,6 +371,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(goalTreeModule, /GoalTreeIconPicker/); assert.match(goalTreeModule, /GoalTreeContentEditor/); assert.match(goalTreeModule, /GoalTreeNodeContentPreview/); + assert.doesNotMatch(goalTreeModule, /\.slice\(0, 3\)/); assert.match(goalTreeModule, /GoalTreeChecklistInlineControl/); assert.match(goalTreeModule, /GoalTreeStepLeafControl/); assert.match(goalTreeModule, /GoalTreePrerequisitePicker/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 5679067..38416e6 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -5,6 +5,7 @@ import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tab import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js"; import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js"; +import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; @@ -150,6 +151,41 @@ test("text exchange imports table TSV and calculator hierarchy", () => { assert.equal(calculatorImport.data.entries[1].formula, "base*5"); }); +test("calendar layout keeps week compaction and overlapping lanes deterministic", () => { + const base = new Date(2026, 0, 5, 8, 0, 0, 0).getTime(); + const occurrences = [ + { id: "a", startMs: base, endMs: base + 60 * 60000, event: { startMinutes: 8 * 60, endMinutes: 9 * 60 } }, + { id: "b", startMs: base + 30 * 60000, endMs: base + 90 * 60000, event: { startMinutes: 8 * 60 + 30, endMinutes: 9 * 60 + 30 } } + ]; + const layout = getWeekGridLayout(occurrences, true); + assert.ok(layout.rows.some((row) => row.type === "hidden")); + assert.equal(getWeekMinutePosition(layout, 8 * 60), layout.hourTopPx[8]); + assert.equal(getWeekMinutePosition(layout, 24 * 60), layout.totalHeightPx); + assert.deepEqual(layoutConcurrentOccurrences(occurrences).map((occurrence) => [occurrence.id, occurrence.lane, occurrence.laneCount]), [ + ["a", 0, 2], + ["b", 1, 2] + ]); +}); + +test("calendar month layout splits bars on week boundaries and shares lanes", () => { + const days = Array.from({ length: 14 }, (_, index) => ({ + date: new Date(2026, 0, 5 + index), + inMonth: true + })); + const firstStart = days[4].date.getTime(); + const secondStart = days[5].date.getTime(); + const segments = getMonthOccurrenceSegments(days, [ + { id: "a", startMs: firstStart, endMs: days[9].date.getTime(), event: {} }, + { id: "b", startMs: secondStart, endMs: days[7].date.getTime(), event: {} } + ]); + assert.deepEqual(segments.map((segment) => [segment.occurrence.id, segment.lane, segment.startIndex, segment.endIndex]), [ + ["a", 0, 4, 6], + ["a", 0, 7, 8], + ["b", 1, 5, 6] + ]); + assert.equal(segments[0].laneCount, 2); +}); + test("goal tree text exchange imports formulas and hierarchy", () => { const context = createTextImportContext(); const imported = importModuleText("goalTree", "- Breuvage de rage x10\n - Élixir de destruction = base * 3\n - Poudre de flamme x5\n - Sève de cèdre x7\n - Catalyseur magique x10", context); diff --git a/website/src/features/toolboxes/modules/CalendarModule.jsx b/website/src/features/toolboxes/modules/CalendarModule.jsx index f3e4626..72fd63f 100644 --- a/website/src/features/toolboxes/modules/CalendarModule.jsx +++ b/website/src/features/toolboxes/modules/CalendarModule.jsx @@ -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 || {}; diff --git a/website/src/features/toolboxes/modules/GoalTreeModule.jsx b/website/src/features/toolboxes/modules/GoalTreeModule.jsx index e344bf0..0923d03 100644 --- a/website/src/features/toolboxes/modules/GoalTreeModule.jsx +++ b/website/src/features/toolboxes/modules/GoalTreeModule.jsx @@ -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 ( {items.map((item) => ( diff --git a/website/src/features/toolboxes/modules/calendarLayout.js b/website/src/features/toolboxes/modules/calendarLayout.js new file mode 100644 index 0000000..1f500b2 --- /dev/null +++ b/website/src/features/toolboxes/modules/calendarLayout.js @@ -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 }; + }); +} diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx index 82bd3b0..ff51814 100644 --- a/website/src/features/toolboxes/modules/index.jsx +++ b/website/src/features/toolboxes/modules/index.jsx @@ -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 (
- { - setTextExchangeOpen(false); - setEditing(true); - }} - textExchangeOpen={textExchangeOpen} - /> + {LAZY_MODULE_TYPES.has(module.type) ? ( + }> + + + ) : ( + + )}
); } +function ModuleLoadingFallback({ label }) { + return
Chargement de {label}...
; +} + function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) { const inlineEdit = useInlineEdit({ value, diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index f360f04..23fa887 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -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) { diff --git a/website/src/styles/toolboxes/_module-shell.scss b/website/src/styles/toolboxes/_module-shell.scss index c34b484..bdb219c 100644 --- a/website/src/styles/toolboxes/_module-shell.scss +++ b/website/src/styles/toolboxes/_module-shell.scss @@ -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;