Improve toolbox module loading and goal tree previews
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
abfa23fb3e
commit
50029b45a2
9 changed files with 259 additions and 177 deletions
|
|
@ -11,6 +11,7 @@
|
||||||
"lists:index": "node scripts/generate-list-indexes.mjs",
|
"lists:index": "node scripts/generate-list-indexes.mjs",
|
||||||
"lint": "eslint .",
|
"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": "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": "node --test",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 AddToolControls/);
|
||||||
assert.match(moduleRegistry, /export function ToolboxModules/);
|
assert.match(moduleRegistry, /export function ToolboxModules/);
|
||||||
assert.match(moduleRegistry, /MODULE_COMPONENTS/);
|
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=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
|
||||||
|
assert.match(moduleRegistry, /LAZY_MODULE_TYPES/);
|
||||||
assert.match(moduleRegistry, /notepad:/);
|
assert.match(moduleRegistry, /notepad:/);
|
||||||
assert.match(moduleRegistry, /checklist:/);
|
assert.match(moduleRegistry, /checklist:/);
|
||||||
assert.match(moduleRegistry, /images:/);
|
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, /GoalTreeIconPicker/);
|
||||||
assert.match(goalTreeModule, /GoalTreeContentEditor/);
|
assert.match(goalTreeModule, /GoalTreeContentEditor/);
|
||||||
assert.match(goalTreeModule, /GoalTreeNodeContentPreview/);
|
assert.match(goalTreeModule, /GoalTreeNodeContentPreview/);
|
||||||
|
assert.doesNotMatch(goalTreeModule, /\.slice\(0, 3\)/);
|
||||||
assert.match(goalTreeModule, /GoalTreeChecklistInlineControl/);
|
assert.match(goalTreeModule, /GoalTreeChecklistInlineControl/);
|
||||||
assert.match(goalTreeModule, /GoalTreeStepLeafControl/);
|
assert.match(goalTreeModule, /GoalTreeStepLeafControl/);
|
||||||
assert.match(goalTreeModule, /GoalTreePrerequisitePicker/);
|
assert.match(goalTreeModule, /GoalTreePrerequisitePicker/);
|
||||||
|
|
|
||||||
|
|
@ -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 { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
|
||||||
import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.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 { 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 { 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 { 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";
|
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");
|
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", () => {
|
test("goal tree text exchange imports formulas and hierarchy", () => {
|
||||||
const context = createTextImportContext();
|
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);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,18 @@ import {
|
||||||
parseLocalDateKey,
|
parseLocalDateKey,
|
||||||
timeValueToMinutes
|
timeValueToMinutes
|
||||||
} from "./calendarUtils.js";
|
} 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";
|
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 = {
|
const CALENDAR_COLOR_VALUES = {
|
||||||
violet: "#8b5cf6",
|
violet: "#8b5cf6",
|
||||||
gold: "#f6c453",
|
gold: "#f6c453",
|
||||||
|
|
@ -163,149 +165,6 @@ function normalizeDraftAlertOffsets(value) {
|
||||||
return offsets.sort((a, b) => b - a);
|
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 }) {
|
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
|
||||||
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
|
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
|
||||||
const textContent = context.moduleText?.calendar || {};
|
const textContent = context.moduleText?.calendar || {};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
|
// 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 { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||||
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
|
||||||
import { Icon } from "../../../components/Icon.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" }
|
{ id: "advanced", label: textContent.advancedTab || "Avancé", icon: "settings" }
|
||||||
];
|
];
|
||||||
const parentMap = useMemo(() => getGoalTreeParentMap(evaluatedNodes), [evaluatedNodes]);
|
const parentMap = useMemo(() => getGoalTreeParentMap(evaluatedNodes), [evaluatedNodes]);
|
||||||
|
const save = useCallback((nextData) => {
|
||||||
|
context.setModuleData(toolboxId, moduleId, nextData, "goalTree");
|
||||||
|
}, [context, moduleId, toolboxId]);
|
||||||
const reorder = useGroupedReorder({
|
const reorder = useGroupedReorder({
|
||||||
namespace: "goal-tree",
|
namespace: "goal-tree",
|
||||||
items: evaluatedNodes,
|
items: evaluatedNodes,
|
||||||
|
|
@ -186,7 +189,7 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resetData = applyGoalTreeDueResets(data, Date.now());
|
const resetData = applyGoalTreeDueResets(data, Date.now());
|
||||||
if (resetData !== data) save(resetData);
|
if (resetData !== data) save(resetData);
|
||||||
});
|
}, [data, save]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedNodeId && data.nodes[0]) setSelectedNodeId(data.nodes[0].id);
|
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]);
|
}, [connectorTopologyKey, editing, textExchangeOpen, viewScale]);
|
||||||
|
|
||||||
function save(nextData) {
|
|
||||||
context.setModuleData(toolboxId, moduleId, nextData, "goalTree");
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNode(parentId = "") {
|
function addNode(parentId = "") {
|
||||||
const parent = data.nodes.find((node) => node.id === parentId);
|
const parent = data.nodes.find((node) => node.id === parentId);
|
||||||
const node = {
|
const node = {
|
||||||
|
|
@ -990,7 +989,7 @@ function GoalTreeStepLeafControl({ node, textContent, onChange }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
|
function GoalTreeNodeContentPreview({ node, editing, textContent, onAddContentItem, onUpdateItem }) {
|
||||||
const items = (node.items || []).slice(0, 3);
|
const items = node.items || [];
|
||||||
return (
|
return (
|
||||||
<span className="goal-tree-node-content-preview">
|
<span className="goal-tree-node-content-preview">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|
|
||||||
153
website/src/features/toolboxes/modules/calendarLayout.js
Normal file
153
website/src/features/toolboxes/modules/calendarLayout.js
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
|
// 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 { createPortal } from "react-dom";
|
||||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||||
import { Icon } from "../../../components/Icon.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 { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
|
||||||
import { supportsTextExchange } from "./textImport.js";
|
import { supportsTextExchange } from "./textImport.js";
|
||||||
import { CalculatorModule } from "./CalculatorModule.jsx";
|
import { CalculatorModule } from "./CalculatorModule.jsx";
|
||||||
import { CalendarModule } from "./CalendarModule.jsx";
|
|
||||||
import { ChecklistModule } from "./ChecklistModule.jsx";
|
import { ChecklistModule } from "./ChecklistModule.jsx";
|
||||||
import { CombosModule } from "./CombosModule.jsx";
|
|
||||||
import { CountersModule } from "./CountersModule.jsx";
|
import { CountersModule } from "./CountersModule.jsx";
|
||||||
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
|
||||||
import { EquipmentPlannerModule } from "./EquipmentPlannerModule.jsx";
|
|
||||||
import { GoalTreeModule } from "./GoalTreeModule.jsx";
|
|
||||||
import { LinksModule } from "./LinksModule.jsx";
|
import { LinksModule } from "./LinksModule.jsx";
|
||||||
import { NotepadModule } from "./NotepadModule.jsx";
|
import { NotepadModule } from "./NotepadModule.jsx";
|
||||||
import { ImagesModule } from "./ImagesModule.jsx";
|
import { ImagesModule } from "./ImagesModule.jsx";
|
||||||
|
|
@ -23,6 +19,11 @@ import { TableModule } from "./TableModule.jsx";
|
||||||
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
|
||||||
import { TimerModule } from "./TimerModule.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 = {
|
const MODULE_COMPONENTS = {
|
||||||
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
|
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
|
||||||
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
|
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
|
||||||
|
|
@ -40,6 +41,8 @@ const MODULE_COMPONENTS = {
|
||||||
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
|
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
|
||||||
|
|
||||||
const MODULE_COLUMN_IDS = ["left", "right"];
|
const MODULE_COLUMN_IDS = ["left", "right"];
|
||||||
|
|
||||||
export { TOOLBOX_MODULES };
|
export { TOOLBOX_MODULES };
|
||||||
|
|
@ -300,6 +303,18 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
|
||||||
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
|
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
|
||||||
scrollable ? "is-scrollable" : ""
|
scrollable ? "is-scrollable" : ""
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
|
const componentProps = {
|
||||||
|
toolboxId: toolbox.id,
|
||||||
|
moduleId: module.id,
|
||||||
|
context,
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
openEditor: () => {
|
||||||
|
setTextExchangeOpen(false);
|
||||||
|
setEditing(true);
|
||||||
|
},
|
||||||
|
textExchangeOpen
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|
@ -380,23 +395,22 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
|
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||||
<Component
|
{LAZY_MODULE_TYPES.has(module.type) ? (
|
||||||
toolboxId={toolbox.id}
|
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
|
||||||
moduleId={module.id}
|
<Component {...componentProps} />
|
||||||
context={context}
|
</Suspense>
|
||||||
editing={editing}
|
) : (
|
||||||
setEditing={setEditing}
|
<Component {...componentProps} />
|
||||||
openEditor={() => {
|
)}
|
||||||
setTextExchangeOpen(false);
|
|
||||||
setEditing(true);
|
|
||||||
}}
|
|
||||||
textExchangeOpen={textExchangeOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModuleLoadingFallback({ label }) {
|
||||||
|
return <div className="module-loading-fallback">Chargement de {label}...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
|
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
|
||||||
const inlineEdit = useInlineEdit({
|
const inlineEdit = useInlineEdit({
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,17 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
|
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
|
||||||
if (!imageFiles.length) return false;
|
if (!imageFiles.length) return false;
|
||||||
const data = store.getModuleData(toolboxId, moduleId, { images: [] });
|
const data = store.getModuleData(toolboxId, moduleId, { images: [] });
|
||||||
|
const images = [];
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
const image = { id: uid("image"), dataUrl: await compressImage(file) };
|
const image = { id: uid("image"), dataUrl: await compressImage(file) };
|
||||||
const label = labelFromFileName(file.name);
|
const label = labelFromFileName(file.name);
|
||||||
if (label) image.label = label;
|
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) {
|
function createChecklistFromList(gameId, list) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@
|
||||||
min-width: 0;
|
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 {
|
.module-content.is-scrollable {
|
||||||
max-height: 450px;
|
max-height: 450px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue