diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index ffe4445..3ac475d 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -145,7 +145,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const combosEditor = await readFile("website/src/features/toolboxes/modules/CombosEditor.jsx", "utf8");
const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.jsx", "utf8");
const combosList = await readFile("website/src/features/toolboxes/modules/CombosList.jsx", "utf8");
- const combosBundle = `${combosModule}\n${combosEditor}\n${combosParts}\n${combosList}`;
+ const combosEditorState = await readFile("website/src/features/toolboxes/modules/useCombosEditorState.js", "utf8");
+ const combosReorder = await readFile("website/src/features/toolboxes/modules/useCombosReorder.js", "utf8");
+ const combosBundle = `${combosModule}\n${combosEditor}\n${combosParts}\n${combosList}\n${combosEditorState}\n${combosReorder}`;
const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
const calculatorEntries = await readFile("website/src/features/toolboxes/modules/CalculatorEntries.jsx", "utf8");
@@ -155,27 +157,37 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const tableGrid = await readFile("website/src/features/toolboxes/modules/TableGrid.jsx", "utf8");
const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8");
const tableToolbar = await readFile("website/src/features/toolboxes/modules/TableToolbar.jsx", "utf8");
- const tableBundle = `${tableModule}\n${tableGrid}\n${tableGridParts}\n${tableToolbar}`;
+ const tableInteractions = await readFile("website/src/features/toolboxes/modules/useTableInteractions.js", "utf8");
+ const tableClipboard = await readFile("website/src/features/toolboxes/modules/useTableClipboard.js", "utf8");
+ const tableBundle = `${tableModule}\n${tableGrid}\n${tableGridParts}\n${tableToolbar}\n${tableInteractions}\n${tableClipboard}`;
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8");
+ const calendarContent = await readFile("website/src/features/toolboxes/modules/CalendarContent.jsx", "utf8");
const calendarDraft = await readFile("website/src/features/toolboxes/modules/calendarDraft.js", "utf8");
const calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "utf8");
+ const calendarEventControls = await readFile("website/src/features/toolboxes/modules/CalendarEventControls.jsx", "utf8");
+ const calendarMonthDatePicker = await readFile("website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx", "utf8");
const calendarViews = await readFile("website/src/features/toolboxes/modules/CalendarViews.jsx", "utf8");
- const calendarBundle = `${calendarModule}\n${calendarDraft}\n${calendarEventForm}\n${calendarViews}`;
+ const calendarBundle = `${calendarModule}\n${calendarContent}\n${calendarDraft}\n${calendarEventForm}\n${calendarEventControls}\n${calendarMonthDatePicker}\n${calendarViews}`;
const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8");
- const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerItemPanels}`;
+ const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "utf8");
+ const taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8");
+ const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.jsx", "utf8");
+ const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
const goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.jsx", "utf8");
- const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}`;
+ const goalTreeController = await readFile("website/src/features/toolboxes/modules/useGoalTreeController.js", "utf8");
+ const goalTreeView = await readFile("website/src/features/toolboxes/modules/GoalTreeView.jsx", "utf8");
+ const goalTreeBundle = `${goalTreeModule}\n${goalTreeEditor}\n${goalTreeParts}\n${goalTreeController}\n${goalTreeView}`;
const goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8");
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.js", "utf8");
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
@@ -264,23 +276,23 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(combosUtils, /Nintendo 64/);
assert.match(combosParts, /export function ComboInputToken/);
assert.match(combosParts, /export function ComboSequence/);
- assert.match(combosModule, /keyboardLayout/);
- assert.match(combosModule, /simultaneousMode/);
+ assert.match(combosBundle, /keyboardLayout/);
+ assert.match(combosBundle, /simultaneousMode/);
assert.match(combosUtils, /SPECIAL_INPUT_GROUP/);
assert.match(combosUtils, /grab/);
assert.match(combosUtils, /protect/);
assert.match(combosUtils, /shoot/);
assert.match(combosBundle, /holdMs/);
- assert.match(combosModule, /toggleSelectedInputHold/);
+ assert.match(combosBundle, /toggleSelectedInputHold/);
assert.match(combosEditor, /combo-hold-field/);
assert.match(combosParts, /useInlineEdit/);
- assert.match(combosModule, /useGroupedReorder/);
- assert.match(combosModule, /reorderFeatures/);
- assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
+ assert.match(combosBundle, /useGroupedReorder/);
+ assert.match(combosBundle, /reorderFeatures/);
+ assert.match(combosBundle, /namespace: `combos-\$\{moduleId\}`/);
assert.match(combosBundle, /getGroupBoundaryProps/);
assert.match(combosBundle, /combos-category-boundary-drop-zone/);
assert.match(combosUtils, /function setComboItemCategory/);
- assert.match(combosModule, /setItemGroup: setComboItemCategory/);
+ assert.match(combosBundle, /setItemGroup: setComboItemCategory/);
assert.doesNotMatch(combosModule, /canMoveItem:/);
assert.doesNotMatch(combosModule, /canMoveGroup:/);
assert.match(calculatorModule, /export function CalculatorModule/);
@@ -313,29 +325,29 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(tableBundle, /Icon name="add-column"/);
assert.match(tableBundle, /Icon name="remove-row"/);
assert.match(tableBundle, /Icon name="remove-column"/);
- assert.match(tableModule, /insertFormulaReferenceSelection/);
- assert.match(tableModule, /startFormulaSelection/);
- assert.match(tableModule, /copyTsv/);
+ assert.match(tableBundle, /insertFormulaReferenceSelection/);
+ assert.match(tableBundle, /startFormulaSelection/);
+ assert.match(tableBundle, /copyTsv/);
assert.match(tableBundle, /notepad-toolbar table-toolbar/);
assert.match(tableBundle, /notepad-toolbar-group/);
assert.match(tableBundle, /notepad-toolbar-button/);
- assert.match(tableModule, /selection/);
- assert.match(tableModule, /draggingSelectionRef/);
- assert.match(tableModule, /movingSelectionRef/);
- assert.match(tableModule, /copiedSelectionRef/);
+ assert.match(tableBundle, /selection/);
+ assert.match(tableBundle, /draggingSelectionRef/);
+ assert.match(tableBundle, /movingSelectionRef/);
+ assert.match(tableBundle, /copiedSelectionRef/);
assert.match(tableUtils, /moveSelectionCells/);
assert.match(tableUtils, /pasteCellBlock/);
assert.match(tableUtils, /parseTsvBlock/);
assert.match(tableGridParts, /is-move-target/);
assert.match(tableUtils, /isMultiCellSelection/);
assert.match(tableGrid, /export function TableGrid/);
- assert.match(tableModule, /handleCellKeyDown/);
- assert.match(tableModule, /clearSelection/);
- assert.match(tableModule, /event\.key === "Delete"/);
- assert.match(tableModule, /ArrowUp/);
- assert.match(tableModule, /ArrowDown/);
- assert.match(tableModule, /ArrowLeft/);
- assert.match(tableModule, /ArrowRight/);
+ assert.match(tableBundle, /handleCellKeyDown/);
+ assert.match(tableBundle, /clearSelection/);
+ assert.match(tableBundle, /event\.key === "Delete"/);
+ assert.match(tableBundle, /ArrowUp/);
+ assert.match(tableBundle, /ArrowDown/);
+ assert.match(tableBundle, /ArrowLeft/);
+ assert.match(tableBundle, /ArrowRight/);
assert.match(tableGridParts, /export function HeaderLabelInput/);
assert.match(tableGridParts, /useInlineEdit/);
assert.match(tableModule, /TextExchangeActions/);
@@ -371,8 +383,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calendarBundle, /durationMinutes < 120/);
assert.match(calendarBundle, /is-compact/);
assert.match(calendarModule, /handleKeyDown/);
- assert.match(calendarModule, /TextExchangeActions/);
- assert.match(calendarModule, /type="calendar"/);
+ assert.match(calendarContent, /TextExchangeActions/);
+ assert.match(calendarContent, /type="calendar"/);
assert.match(calendarBundle, /repeatLabel/);
assert.match(calendarBundle, /CalendarAlertModeSwitch/);
assert.match(calendarDraft, /export function createDefaultDraft/);
@@ -387,10 +399,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
assert.match(taskPlannerModule, /applyDueResets/);
assert.match(taskPlannerModule, /TaskPlannerBranch/);
- assert.match(taskPlannerModule, /useGroupedReorder/);
+ assert.match(taskPlannerBundle, /useGroupedReorder/);
assert.match(taskPlannerModule, /useDraftForm/);
- assert.match(taskPlannerModule, /TextExchangeActions/);
- assert.match(taskPlannerModule, /reorderFeatures/);
+ assert.match(taskPlannerBundle, /TextExchangeActions/);
+ assert.match(taskPlannerBundle, /reorderFeatures/);
assert.match(taskPlannerItems, /export function TaskPlannerBranch/);
assert.match(taskPlannerItems, /function TaskPlannerItem/);
assert.match(taskPlannerBundle, /task-planner-warning/);
@@ -403,10 +415,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
assert.match(goalTreeModule, /export function GoalTreeModule/);
- assert.match(goalTreeModule, /normalizeGoalTreeData/);
- assert.match(goalTreeModule, /applyGoalTreeDueResets/);
+ assert.match(goalTreeBundle, /normalizeGoalTreeData/);
+ assert.match(goalTreeBundle, /applyGoalTreeDueResets/);
assert.match(goalTreeModule, /TextExchangeActions/);
- assert.match(goalTreeModule, /DiagramConnectors/);
+ assert.match(goalTreeBundle, /DiagramConnectors/);
assert.match(goalTreeEditor, /export function GoalTreeEditor/);
assert.match(goalTreeEditor, /Tabs/);
assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
@@ -419,20 +431,20 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(goalTreeParts, /GoalTreeChecklistInlineControl/);
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
- assert.match(goalTreeModule, /function toggleBranch/);
- assert.match(goalTreeModule, /function editNode/);
- assert.match(goalTreeModule, /function addNodeContentItem/);
- assert.match(goalTreeModule, /closedSiblingIds/);
- assert.match(goalTreeModule, /useGroupedReorder/);
- assert.match(goalTreeModule, /const treeOrientation = data\.orientation \|\| "horizontal"/);
- assert.match(goalTreeModule, /const reorderOrientation = treeOrientation === "horizontal" \? "vertical" : "horizontal"/);
- assert.match(goalTreeModule, /orientation: reorderOrientation/);
- assert.match(goalTreeModule, /orientation={treeOrientation}/);
- assert.match(goalTreeModule, /module-scroll-button goal-tree-orientation-switch/);
- assert.match(goalTreeModule, /Icon name="rotate"/);
- assert.match(goalTreeModule, /function toggleLinkedParent/);
+ assert.match(goalTreeBundle, /function toggleBranch/);
+ assert.match(goalTreeBundle, /function editNode/);
+ assert.match(goalTreeBundle, /function addNodeContentItem/);
+ assert.match(goalTreeBundle, /closedSiblingIds/);
+ assert.match(goalTreeBundle, /useGroupedReorder/);
+ assert.match(goalTreeBundle, /const treeOrientation = data\.orientation \|\| "horizontal"/);
+ assert.match(goalTreeBundle, /const reorderOrientation = treeOrientation === "horizontal" \? "vertical" : "horizontal"/);
+ assert.match(goalTreeBundle, /orientation: reorderOrientation/);
+ assert.match(goalTreeView, /orientation={treeOrientation}/);
+ assert.match(goalTreeView, /module-scroll-button goal-tree-orientation-switch/);
+ assert.match(goalTreeView, /Icon name="rotate"/);
+ assert.match(goalTreeBundle, /function toggleLinkedParent/);
assert.match(goalTreeDisplay, /grouped: false/);
- assert.match(goalTreeModule, /getGoalTreeConvergenceKeepOpenIds/);
+ assert.match(goalTreeBundle, /getGoalTreeConvergenceKeepOpenIds/);
assert.match(goalTreeDisplay, /progress\.current/);
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
assert.doesNotMatch(goalTreeUtils, /"calculator"/);
diff --git a/website/src/features/toolboxes/modules/CalendarContent.jsx b/website/src/features/toolboxes/modules/CalendarContent.jsx
new file mode 100644
index 0000000..9250cc8
--- /dev/null
+++ b/website/src/features/toolboxes/modules/CalendarContent.jsx
@@ -0,0 +1,144 @@
+// Rôle : rend le contenu interactif du calendrier une fois initialisé.
+import { Icon } from "../../../components/Icon.jsx";
+import { CalendarEventForm } from "./CalendarEventForm.jsx";
+import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
+import { TextExchangeActions } from "./TextExchangeActions.jsx";
+import {
+ addMonths,
+ formatLocalDateKey,
+ getMonthStart,
+ minutesToTimeValue
+} from "./calendarUtils.js";
+
+export function CalendarContent({
+ data,
+ context,
+ draft,
+ error,
+ activeOccurrenceDate,
+ monthStart,
+ monthGridDays,
+ occurrences,
+ textContent,
+ activeEventId,
+ formOpen,
+ draftSelection,
+ selection,
+ monthSelection,
+ days,
+ weekGridLayout,
+ now,
+ nowDayIndex,
+ nowMinutes,
+ hideEmptyHours,
+ textExchangeOpen,
+ onKeyDown,
+ onSubmit,
+ onDraftChange,
+ onAlertModeChange,
+ onAlertOffsetToggle,
+ onDeleteEvent,
+ onDeleteOccurrence,
+ onClose,
+ onDisplayedMonthChange,
+ onImport,
+ onHideEmptyHoursToggle,
+ onMonthPointerDown,
+ onMonthPointerEnter,
+ onMonthPointerUp,
+ onSlotPointerDown,
+ onSlotPointerEnter,
+ onSlotPointerUp,
+ onEditEvent
+}) {
+ const selectedAlertOffsets = draft.alertMode === "off" ? [] : draft.alertOffsetMinutes;
+
+ return (
+
+ {formOpen && (
+
+ )}
+ {textExchangeOpen && (
+
+
+
+ )}
+
+ {data.viewMode === "week" && (
+
+ )}
+
+ {data.viewMode === "month" ? (
+
onDisplayedMonthChange(getMonthStart(addMonths(monthStart, -1)))}
+ onCurrentMonth={() => onDisplayedMonthChange(getMonthStart(new Date()))}
+ onNextMonth={() => onDisplayedMonthChange(getMonthStart(addMonths(monthStart, 1)))}
+ onPointerDown={onMonthPointerDown}
+ onPointerEnter={onMonthPointerEnter}
+ onPointerUp={onMonthPointerUp}
+ onEditEvent={onEditEvent}
+ />
+ ) : (
+
+ )}
+
+ );
+}
+
+function CalendarWeekOptions({ textContent, hideEmptyHours, onToggle }) {
+ return (
+
+ {textContent.hideEmptyHoursLabel || "Masquer les heures vides"}
+
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/CalendarEventControls.jsx b/website/src/features/toolboxes/modules/CalendarEventControls.jsx
new file mode 100644
index 0000000..0fdc60d
--- /dev/null
+++ b/website/src/features/toolboxes/modules/CalendarEventControls.jsx
@@ -0,0 +1,145 @@
+// Rôle : regroupe les switches, alertes et choix de couleur du formulaire calendrier.
+import { Icon } from "../../../components/Icon.jsx";
+import {
+ CALENDAR_ALERT_OFFSETS,
+ CALENDAR_COLORS
+} from "./calendarUtils.js";
+import { ALERT_MODES } from "./timerOptions.js";
+
+const CALENDAR_COLOR_VALUES = {
+ violet: "#8b5cf6",
+ gold: "#f6c453",
+ cyan: "#22d3ee",
+ pink: "#d946ef",
+ green: "#22c55e",
+ gray: "#7d879f"
+};
+
+export function MonthlyRepeatSwitch({ value, textContent, onChange }) {
+ const options = [
+ { value: "monthDay", icon: "day-number", label: textContent.monthlyRepeatMonthDay || "Numéro du jour" },
+ { value: "weekday", icon: "day-name", label: textContent.monthlyRepeatWeekday || "Jour de semaine" }
+ ];
+
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+export function MonthEventRepeatSwitch({ value, monthlyRepeatBy, textContent, onChange }) {
+ const options = [
+ { value: "none", icon: "close", label: textContent.repeatNone || "Aucune" },
+ { value: "weekly", icon: "calendar-week", label: textContent.repeatWeekly || "Chaque semaine", disabled: monthlyRepeatBy === "monthDay" },
+ { value: "monthly", icon: "calendar-month", label: textContent.repeatMonthly || "Chaque mois" }
+ ];
+ const safeValue = monthlyRepeatBy === "monthDay" && value === "weekly" ? "none" : value;
+
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+export function CalendarAlertOffsets({ selectedOffsets, textContent, onToggle }) {
+ const labels = {
+ 10: textContent.alertOffset10 || "10 min avant",
+ 5: textContent.alertOffset5 || "5 min avant",
+ 0: textContent.alertOffset0 || "Au début"
+ };
+
+ return (
+
+ );
+}
+
+export function CalendarColorPicker({ value, textContent, onChange }) {
+ const label = textContent.colorLabel || "Couleur";
+ return (
+
+
{label}
+
+
+ {CALENDAR_COLORS.map((color) => (
+
+
+ );
+}
+
+export function CalendarAlertModeSwitch({ mode, textContent, onChange }) {
+ const labels = {
+ off: textContent.alertOffTitle || "Pas d'alerte",
+ visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
+ site: textContent.alertSiteTitle || "Alerte sur tout le site"
+ };
+
+ return (
+
+ {ALERT_MODES.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/CalendarEventForm.jsx b/website/src/features/toolboxes/modules/CalendarEventForm.jsx
index 7b6d62c..470ddaa 100644
--- a/website/src/features/toolboxes/modules/CalendarEventForm.jsx
+++ b/website/src/features/toolboxes/modules/CalendarEventForm.jsx
@@ -1,61 +1,11 @@
// Rôle : rend le formulaire calendrier et ses contrôles de date, répétition, couleur et alertes.
import { Icon } from "../../../components/Icon.jsx";
import {
- CALENDAR_ALERT_OFFSETS,
- CALENDAR_COLORS,
- WEEK_TIME_INPUT_STEP_MINUTES,
- addMonths,
- formatLocalDateKey,
- getLocalDayIndex,
- getMonthGridDays,
- getMonthStart,
- pad2,
- parseLocalDateKey
+ WEEK_TIME_INPUT_STEP_MINUTES
} from "./calendarUtils.js";
import { WEEKDAY_LABELS } from "./calendarLayout.js";
-import { ALERT_MODES } from "./timerOptions.js";
-
-const CALENDAR_COLOR_VALUES = {
- violet: "#8b5cf6",
- gold: "#f6c453",
- cyan: "#22d3ee",
- pink: "#d946ef",
- green: "#22c55e",
- gray: "#7d879f"
-};
-
-function normalizeDraftDateKey(value) {
- const date = parseLocalDateKey(value);
- return date ? formatLocalDateKey(date) : "";
-}
-
-function formatMonthDateLabel(dateKey, mode) {
- const date = parseLocalDateKey(dateKey);
- if (!date) return "";
- const weekday = WEEKDAY_LABELS[getLocalDayIndex(date)];
- const dayLabel = date.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
- if (mode !== "weekday") return dayLabel;
- const ordinal = Math.floor((date.getDate() - 1) / 7) + 1;
- return `S${pad2(ordinal)} - ${weekday}`;
-}
-
-function formatMonthTitle(dateLike) {
- return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
-}
-
-function getWeekdayPickerRows(monthDate) {
- const monthGridDays = getMonthGridDays(monthDate).filter((day) => day.inMonth);
- return Array.from({ length: 5 }, (_, rowIndex) => {
- const ordinal = rowIndex + 1;
- return {
- ordinal,
- days: WEEKDAY_LABELS.map((_, dayIndex) => {
- const matches = monthGridDays.filter((day) => day.dayIndex === dayIndex);
- return matches[rowIndex] || null;
- })
- };
- });
-}
+import { CalendarAlertModeSwitch, CalendarAlertOffsets, CalendarColorPicker, MonthEventRepeatSwitch, MonthlyRepeatSwitch } from "./CalendarEventControls.jsx";
+import { MonthDateDropdown } from "./CalendarMonthDatePicker.jsx";
export function CalendarEventForm({
data,
@@ -189,204 +139,3 @@ export function CalendarEventForm({
);
}
-
-function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
- const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
- return (
-
-
-
-
- );
-}
-
-function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
- const days = getMonthGridDays(displayedMonth);
- const weekdayRows = getWeekdayPickerRows(displayedMonth);
- const minDateKey = normalizeDraftDateKey(minDate);
- return (
-
- {mode === "weekday" ? (
- <>
- {weekdayRows.flatMap((row) => [
-
{`S${pad2(row.ordinal)}`},
- ...row.days.map((day, dayIndex) => {
- const dateKey = day ? formatLocalDateKey(day.date) : "";
- const disabled = !day || (minDateKey && dateKey < minDateKey);
- return (
-
- );
- })
- ])}
- >
- ) : (
- <>
-
-
- {formatMonthTitle(displayedMonth)}
-
-
- {WEEKDAY_LABELS.map((day) =>
{day.slice(0, 3)})}
- {days.map((day) => {
- const dateKey = formatLocalDateKey(day.date);
- const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
- return (
-
- );
- })}
- >
- )}
-
- );
-}
-
-function MonthlyRepeatSwitch({ value, textContent, onChange }) {
- const options = [
- { value: "monthDay", icon: "day-number", label: textContent.monthlyRepeatMonthDay || "Numéro du jour" },
- { value: "weekday", icon: "day-name", label: textContent.monthlyRepeatWeekday || "Jour de semaine" }
- ];
-
- return (
-
- {options.map((option) => (
-
- ))}
-
- );
-}
-
-function MonthEventRepeatSwitch({ value, monthlyRepeatBy, textContent, onChange }) {
- const options = [
- { value: "none", icon: "close", label: textContent.repeatNone || "Aucune" },
- { value: "weekly", icon: "calendar-week", label: textContent.repeatWeekly || "Chaque semaine", disabled: monthlyRepeatBy === "monthDay" },
- { value: "monthly", icon: "calendar-month", label: textContent.repeatMonthly || "Chaque mois" }
- ];
- const safeValue = monthlyRepeatBy === "monthDay" && value === "weekly" ? "none" : value;
-
- return (
-
- {options.map((option) => (
-
- ))}
-
- );
-}
-
-function CalendarAlertOffsets({ selectedOffsets, textContent, onToggle }) {
- const labels = {
- 10: textContent.alertOffset10 || "10 min avant",
- 5: textContent.alertOffset5 || "5 min avant",
- 0: textContent.alertOffset0 || "Au début"
- };
-
- return (
-
- );
-}
-
-function CalendarColorPicker({ value, textContent, onChange }) {
- const label = textContent.colorLabel || "Couleur";
- return (
-
-
{label}
-
-
- {CALENDAR_COLORS.map((color) => (
-
-
- );
-}
-
-function CalendarAlertModeSwitch({ mode, textContent, onChange }) {
- const labels = {
- off: textContent.alertOffTitle || "Pas d'alerte",
- visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
- site: textContent.alertSiteTitle || "Alerte sur tout le site"
- };
-
- return (
-
- {ALERT_MODES.map((option) => (
-
- ))}
-
- );
-}
diff --git a/website/src/features/toolboxes/modules/CalendarModule.jsx b/website/src/features/toolboxes/modules/CalendarModule.jsx
index f9e9e33..b7e264b 100644
--- a/website/src/features/toolboxes/modules/CalendarModule.jsx
+++ b/website/src/features/toolboxes/modules/CalendarModule.jsx
@@ -1,15 +1,11 @@
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Icon } from "../../../components/Icon.jsx";
-import { CalendarEventForm } from "./CalendarEventForm.jsx";
-import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
-import { TextExchangeActions } from "./TextExchangeActions.jsx";
+import { CalendarContent } from "./CalendarContent.jsx";
import {
CALENDAR_ALERT_MODES,
CALENDAR_COLORS,
WEEK_SLOT_MINUTES,
addDays,
- addMonths,
formatLocalDateKey,
getCalendarOccurrences,
getLocalDayIndex,
@@ -391,88 +387,47 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
);
}
- const selectedAlertOffsets = draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
-
return (
-
- {formOpen && (
-
- )}
- {textExchangeOpen && (
-
- save(nextData)} />
-
- )}
-
- {data.viewMode === "week" && (
-
- {textContent.hideEmptyHoursLabel || "Masquer les heures vides"}
-
-
- )}
-
- {data.viewMode === "month" ? (
-
setDisplayedMonth(getMonthStart(addMonths(monthStart, -1)))}
- onCurrentMonth={() => setDisplayedMonth(getMonthStart(new Date()))}
- onNextMonth={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, 1)))}
- onPointerDown={handleMonthPointerDown}
- onPointerEnter={handleMonthPointerEnter}
- onPointerUp={() => setMonthSelection(null)}
- onEditEvent={editEvent}
- />
- ) : (
- setSelection(null)}
- onEditEvent={editEvent}
- />
- )}
-
+ save(nextData)}
+ onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
+ onMonthPointerDown={handleMonthPointerDown}
+ onMonthPointerEnter={handleMonthPointerEnter}
+ onMonthPointerUp={() => setMonthSelection(null)}
+ onSlotPointerDown={handleSlotPointerDown}
+ onSlotPointerEnter={handleSlotPointerEnter}
+ onSlotPointerUp={() => setSelection(null)}
+ onEditEvent={editEvent}
+ />
);
}
diff --git a/website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx b/website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx
new file mode 100644
index 0000000..8b1455f
--- /dev/null
+++ b/website/src/features/toolboxes/modules/CalendarMonthDatePicker.jsx
@@ -0,0 +1,117 @@
+// Rôle : rend le sélecteur de date mensuel du formulaire calendrier.
+import { Icon } from "../../../components/Icon.jsx";
+import {
+ addMonths,
+ formatLocalDateKey,
+ getLocalDayIndex,
+ getMonthGridDays,
+ getMonthStart,
+ pad2,
+ parseLocalDateKey
+} from "./calendarUtils.js";
+import { WEEKDAY_LABELS } from "./calendarLayout.js";
+
+function normalizeDraftDateKey(value) {
+ const date = parseLocalDateKey(value);
+ return date ? formatLocalDateKey(date) : "";
+}
+
+function formatMonthDateLabel(dateKey, mode) {
+ const date = parseLocalDateKey(dateKey);
+ if (!date) return "";
+ const weekday = WEEKDAY_LABELS[getLocalDayIndex(date)];
+ const dayLabel = date.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
+ if (mode !== "weekday") return dayLabel;
+ const ordinal = Math.floor((date.getDate() - 1) / 7) + 1;
+ return `S${pad2(ordinal)} - ${weekday}`;
+}
+
+function formatMonthTitle(dateLike) {
+ return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
+}
+
+function getWeekdayPickerRows(monthDate) {
+ const monthGridDays = getMonthGridDays(monthDate).filter((day) => day.inMonth);
+ return Array.from({ length: 5 }, (_, rowIndex) => {
+ const ordinal = rowIndex + 1;
+ return {
+ ordinal,
+ days: WEEKDAY_LABELS.map((_, dayIndex) => {
+ const matches = monthGridDays.filter((day) => day.dayIndex === dayIndex);
+ return matches[rowIndex] || null;
+ })
+ };
+ });
+}
+
+export function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
+ const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
+ return (
+
+
+
+
+ );
+}
+
+function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
+ const days = getMonthGridDays(displayedMonth);
+ const weekdayRows = getWeekdayPickerRows(displayedMonth);
+ const minDateKey = normalizeDraftDateKey(minDate);
+ return (
+
+ {mode === "weekday" ? (
+ <>
+ {weekdayRows.flatMap((row) => [
+
{`S${pad2(row.ordinal)}`},
+ ...row.days.map((day, dayIndex) => {
+ const dateKey = day ? formatLocalDateKey(day.date) : "";
+ const disabled = !day || (minDateKey && dateKey < minDateKey);
+ return (
+
+ );
+ })
+ ])}
+ >
+ ) : (
+ <>
+
+
+ {formatMonthTitle(displayedMonth)}
+
+
+ {WEEKDAY_LABELS.map((day) =>
{day.slice(0, 3)})}
+ {days.map((day) => {
+ const dateKey = formatLocalDateKey(day.date);
+ const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
+ return (
+
+ );
+ })}
+ >
+ )}
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx
index 2ee0d8c..18101dc 100644
--- a/website/src/features/toolboxes/modules/CombosModule.jsx
+++ b/website/src/features/toolboxes/modules/CombosModule.jsx
@@ -1,308 +1,30 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
-import { useMemo, useState } from "react";
-import {
- applyGroupedReorderOperation,
- getGroupedEntries,
- useGroupedReorder
-} from "../../../hooks/useGroupedReorder.js";
+import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { CombosEditor } from "./CombosEditor.jsx";
import { CombosList } from "./CombosList.jsx";
import {
- createCombo,
- createInput,
- getComboCategory,
- getPaletteColumns,
- getPaletteGroups,
- normalizeCustomKey,
- parseHoldSeconds,
- setComboItemCategory
+ getComboCategory
} from "./combosUtils.js";
+import { useCombosEditorState } from "./useCombosEditorState.js";
+import { useCombosReorder } from "./useCombosReorder.js";
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {};
- const [comboCategory, setComboCategory] = useState("");
- const [comboName, setComboName] = useState("");
- const [draftInputs, setDraftInputs] = useState([[]]);
- const [activeStepIndex, setActiveStepIndex] = useState(0);
- const [selectedInput, setSelectedInput] = useState(null);
- const [simultaneousMode, setSimultaneousMode] = useState(false);
- const [keyboardLayout, setKeyboardLayout] = useState("azerty");
- const [customKey, setCustomKey] = useState("");
- const [editingComboId, setEditingComboId] = useState("");
- const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]);
- const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]);
- const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length);
- const selectedDraftInput = Number.isInteger(selectedInput?.stepIndex) && Number.isInteger(selectedInput?.inputIndex)
- ? draftInputs[selectedInput.stepIndex]?.[selectedInput.inputIndex] || null
- : null;
const entries = getGroupedEntries(data.combos, {
groupOrder: data.categoryOrder,
getItemGroup: getComboCategory,
groupIdKey: "category",
groupItemKey: "combos"
});
- const {
- itemReorder,
- groupReorder,
- getItemProps,
- getGroupProps,
- getGroupBoundaryProps,
- isItemDragging,
- isItemDropTarget,
- isGroupDragging,
- isGroupDropTarget,
- isBoundaryDropTarget,
- getDropPlacement,
- shouldShowGroupBoundaries
- } = useGroupedReorder({
- namespace: `combos-${moduleId}`,
- items: data.combos,
- getItemId: (combo) => combo.id,
- getItemGroup: getComboCategory,
- reorderFeatures: {
- item: {
- groupChange: true,
- boundaryDrop: true,
- ungroupedDrop: true
- },
- group: {
- reorder: true,
- boundaryDrop: true,
- ungroupedDrop: true,
- itemDrop: true
- }
- },
- onItemMove: applyComboReorder,
- onGroupMove: applyComboReorder,
- hierarchy: { enabled: false, stickyParents: false }
- });
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "combos");
}
- function updateDevice(device) {
- const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device };
- save({
- ...data,
- device,
- combos: data.combos.map(stampComboDevice)
- });
- }
-
- function applyComboReorder(operation) {
- save(applyGroupedReorderOperation(data, {
- operation,
- itemsKey: "combos",
- groupOrderKey: "categoryOrder",
- collapsedGroupsKey: "collapsedCategories",
- getItemGroup: getComboCategory,
- setItemGroup: setComboItemCategory
- }));
- }
-
- function addInputToStep(kind, value, stepIndex) {
- const input = createInput(kind, value);
- setDraftInputs((steps) => {
- const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
- const targetIndex = Math.min(Math.max(0, stepIndex), nextSteps.length - 1);
- nextSteps[targetIndex] = [...nextSteps[targetIndex], input];
- setSelectedInput({ stepIndex: targetIndex, inputIndex: nextSteps[targetIndex].length - 1 });
- return nextSteps;
- });
- }
-
- function addDraftInput(kind, value) {
- const input = createInput(kind, value);
- if (simultaneousMode) {
- addInputToStep(kind, value, activeStepIndex);
- return;
- }
-
- setDraftInputs((steps) => {
- const filledSteps = steps.filter((step) => step.length);
- const nextSteps = [...filledSteps, [input]];
- setActiveStepIndex(nextSteps.length - 1);
- setSelectedInput({ stepIndex: nextSteps.length - 1, inputIndex: 0 });
- return nextSteps;
- });
- }
-
- function dragPaletteInput(event, kind, value) {
- event.dataTransfer.effectAllowed = "copy";
- event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify(createInput(kind, value)));
- }
-
- function dragDraftInput(event, stepIndex, inputIndex, input) {
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify({
- ...input,
- sourceStepIndex: stepIndex,
- sourceInputIndex: inputIndex
- }));
- }
-
- function dropInputOnStep(event, stepIndex) {
- const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
- if (!rawInput) return;
- try {
- const input = JSON.parse(rawInput);
- if (!input?.kind || !input?.value) return;
- if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
- moveDraftInput(input.sourceStepIndex, input.sourceInputIndex, stepIndex);
- } else {
- addInputToStep(input.kind, input.value, stepIndex);
- }
- setActiveStepIndex(stepIndex);
- } catch {
- // Ignore invalid drag payloads from outside the app.
- }
- }
-
- function dropInputAsNewStep(event) {
- const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
- if (!rawInput) return;
- try {
- const input = JSON.parse(rawInput);
- if (!input?.kind || !input?.value) return;
- if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
- moveDraftInputToNewStep(input.sourceStepIndex, input.sourceInputIndex);
- } else {
- addDraftInput(input.kind, input.value);
- }
- } catch {
- // Ignore invalid drag payloads from outside the app.
- }
- }
-
- function moveDraftInput(sourceStepIndex, sourceInputIndex, targetStepIndex) {
- setDraftInputs((steps) => {
- if (sourceStepIndex === targetStepIndex) return steps;
- const nextSteps = steps.map((step) => [...step]);
- const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
- if (!input || !nextSteps[targetStepIndex]) return steps;
- nextSteps[targetStepIndex].push(input);
- setSelectedInput({ stepIndex: targetStepIndex, inputIndex: nextSteps[targetStepIndex].length - 1 });
- return nextSteps.filter((step, index) => step.length || index === targetStepIndex);
- });
- }
-
- function moveDraftInputToNewStep(sourceStepIndex, sourceInputIndex) {
- setDraftInputs((steps) => {
- const nextSteps = steps.map((step) => [...step]);
- const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
- if (!input) return steps;
- const safeSteps = nextSteps.filter((step) => step.length);
- setActiveStepIndex(safeSteps.length);
- setSelectedInput({ stepIndex: safeSteps.length, inputIndex: 0 });
- return [...safeSteps, [input]];
- });
- }
-
- function removeActiveStep() {
- setDraftInputs((steps) => {
- const nextSteps = steps.filter((_, index) => index !== activeStepIndex);
- const safeSteps = nextSteps.length ? nextSteps : [[]];
- setActiveStepIndex(Math.min(activeStepIndex, safeSteps.length - 1));
- setSelectedInput(null);
- return safeSteps;
- });
- }
-
- function removeLastInput() {
- setDraftInputs((steps) => {
- const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
- let targetIndex = -1;
- for (let index = nextSteps.length - 1; index >= 0; index -= 1) {
- if (nextSteps[index].length) {
- targetIndex = index;
- break;
- }
- }
- if (targetIndex < 0) return [[]];
- nextSteps[targetIndex].pop();
- const safeSteps = nextSteps.filter((step) => step.length);
- setActiveStepIndex(Math.max(0, safeSteps.length - 1));
- setSelectedInput(null);
- return safeSteps.length ? safeSteps : [[]];
- });
- }
-
- function updateSelectedInputHold(value) {
- if (!selectedDraftInput) return;
- const holdMs = Math.round(parseHoldSeconds(value) * 1000);
- setDraftInputs((steps) => steps.map((step, stepIndex) => step.map((input, inputIndex) => {
- if (stepIndex !== selectedInput.stepIndex || inputIndex !== selectedInput.inputIndex) return input;
- const nextInput = { ...input };
- if (holdMs > 0) {
- nextInput.hold = true;
- nextInput.holdMs = holdMs;
- }
- else delete nextInput.holdMs;
- return nextInput;
- })));
- }
-
- function toggleSelectedInputHold() {
- if (!selectedDraftInput) return;
- setDraftInputs((steps) => steps.map((step, stepIndex) => step.map((input, inputIndex) => {
- if (stepIndex !== selectedInput.stepIndex || inputIndex !== selectedInput.inputIndex) return input;
- const nextInput = { ...input };
- if (nextInput.hold || nextInput.holdMs) {
- delete nextInput.hold;
- delete nextInput.holdMs;
- } else {
- nextInput.hold = true;
- }
- return nextInput;
- })));
- }
-
- function selectDraftStep(stepIndex) {
- setActiveStepIndex(stepIndex);
- setSelectedInput(null);
- }
-
- function addCustomKey(event) {
- event.preventDefault();
- const value = normalizeCustomKey(customKey);
- if (!value || data.device !== "keyboardMouse") return;
- addDraftInput("key", value.toLowerCase());
- setCustomKey("");
- }
-
- function saveCombo(event) {
- event.preventDefault();
- if (!comboName.trim() && !cleanDraftInputs.length) return;
- const combo = createCombo(context, editingComboId, comboName, comboCategory, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device);
- const nextCombos = data.combos.filter((item) => item.id !== editingComboId);
- const nextCategoryOrder = combo.category && !data.categoryOrder.includes(combo.category)
- ? [...data.categoryOrder, combo.category]
- : data.categoryOrder;
- save({ ...data, combos: [...nextCombos, combo], categoryOrder: nextCategoryOrder });
- cancelComboEdit();
- }
-
- function startComboEdit(combo) {
- setComboCategory(getComboCategory(combo));
- setEditingComboId(combo.id);
- setComboName(combo.name);
- setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]);
- setActiveStepIndex(0);
- setSelectedInput(null);
- }
-
- function cancelComboEdit() {
- setEditingComboId("");
- setComboName("");
- setComboCategory("");
- setDraftInputs([[]]);
- setActiveStepIndex(0);
- setSelectedInput(null);
- setSimultaneousMode(false);
- }
+ const editorState = useCombosEditorState({ data, context, textContent, save });
+ const reorder = useCombosReorder({ data, moduleId, save });
function updateCombo(comboId, updater) {
save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) });
@@ -310,7 +32,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
function deleteCombo(comboId) {
updateCombo(comboId, () => ({ name: "", inputs: [] }));
- if (editingComboId === comboId) cancelComboEdit();
+ if (editorState.editingComboId === comboId) editorState.cancelComboEdit();
}
function toggleCategoryCollapsed(category) {
@@ -322,45 +44,42 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
return (
- {(editing || editingComboId) && (
+ {(editing || editorState.editingComboId) && (
{
- setActiveStepIndex(stepIndex);
- setSelectedInput({ stepIndex, inputIndex });
- }}
- onSelectedInputHoldToggle={toggleSelectedInputHold}
- onSelectedInputHoldChange={updateSelectedInputHold}
- onSimultaneousModeToggle={() => setSimultaneousMode((enabled) => !enabled)}
- onLastInputRemove={removeLastInput}
- onActiveStepRemove={removeActiveStep}
- onComboEditCancel={cancelComboEdit}
+ comboCategory={editorState.comboCategory}
+ comboName={editorState.comboName}
+ customKey={editorState.customKey}
+ keyboardLayout={editorState.keyboardLayout}
+ paletteColumns={editorState.paletteColumns}
+ draftInputs={editorState.draftInputs}
+ cleanDraftInputs={editorState.cleanDraftInputs}
+ activeStepIndex={editorState.activeStepIndex}
+ selectedInput={editorState.selectedInput}
+ selectedDraftInput={editorState.selectedDraftInput}
+ simultaneousMode={editorState.simultaneousMode}
+ editingComboId={editorState.editingComboId}
+ onDeviceChange={editorState.updateDevice}
+ onKeyboardLayoutChange={editorState.setKeyboardLayout}
+ onComboCategoryChange={editorState.setComboCategory}
+ onComboNameChange={editorState.setComboName}
+ onCustomKeyChange={editorState.setCustomKey}
+ onCustomKeyAdd={editorState.addCustomKey}
+ onComboSave={editorState.saveCombo}
+ onDraftInputAdd={editorState.addDraftInput}
+ onPaletteInputDrag={editorState.dragPaletteInput}
+ onDraftInputDrag={editorState.dragDraftInput}
+ onInputDrop={editorState.dropInputOnStep}
+ onNewStepDrop={editorState.dropInputAsNewStep}
+ onStepSelect={editorState.selectDraftStep}
+ onInputSelect={editorState.selectDraftInput}
+ onSelectedInputHoldToggle={editorState.toggleSelectedInputHold}
+ onSelectedInputHoldChange={editorState.updateSelectedInputHold}
+ onSimultaneousModeToggle={editorState.toggleSimultaneousMode}
+ onLastInputRemove={editorState.removeLastInput}
+ onActiveStepRemove={editorState.removeActiveStep}
+ onComboEditCancel={editorState.cancelComboEdit}
/>
)}
{textExchangeOpen && (
@@ -378,21 +97,10 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
data={data}
textContent={textContent}
reorder={{
- itemReorder,
- groupReorder,
- getItemProps,
- getGroupProps,
- getGroupBoundaryProps,
- isItemDragging,
- isItemDropTarget,
- isGroupDragging,
- isGroupDropTarget,
- isBoundaryDropTarget,
- getDropPlacement,
- shouldShowGroupBoundaries
+ ...reorder
}}
onComboUpdate={updateCombo}
- onComboEdit={startComboEdit}
+ onComboEdit={editorState.startComboEdit}
onComboDelete={deleteCombo}
onCategoryToggle={toggleCategoryCollapsed}
/>
diff --git a/website/src/features/toolboxes/modules/GoalTreeModule.jsx b/website/src/features/toolboxes/modules/GoalTreeModule.jsx
index 4dbd417..e658cc9 100644
--- a/website/src/features/toolboxes/modules/GoalTreeModule.jsx
+++ b/website/src/features/toolboxes/modules/GoalTreeModule.jsx
@@ -1,404 +1,63 @@
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
-import { Icon } from "../../../components/Icon.jsx";
-import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { GoalTreeEditor } from "./GoalTreeEditor.jsx";
+import { GoalTreeView } from "./GoalTreeView.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-import { GoalTreeBranch } from "./GoalTreeParts.jsx";
-import {
- GOAL_TREE_VIEW_SCALE,
- KIND_ICONS,
- getDefaultGoalTreeItem,
- getEffectiveGoalTreeProgressMode,
- getGoalTreeConvergenceKeepOpenIds,
- getGoalTreeLinkedControlIds,
- getVisibleGoalTreeEdges,
- isSingleValueGoalTreeNode
-} from "./goalTreeDisplay.js";
-import {
- applyGoalTreeDueResets,
- evaluateGoalTreeNodes,
- getGoalTreeChildren,
- getGoalTreeDescendantIds,
- getGoalTreeParentIds,
- getGoalTreeParentMap
-} from "./goalTreeUtils.js";
+import { useGoalTreeController } from "./useGoalTreeController.js";
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
- const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
- const textContent = context.moduleText?.goalTree || {};
- const treeOrientation = data.orientation || "horizontal";
- const verticalView = treeOrientation === "vertical";
- const reorderOrientation = treeOrientation === "horizontal" ? "vertical" : "horizontal";
- const viewScale = editing ? GOAL_TREE_VIEW_SCALE : 1;
- const evaluatedNodes = useMemo(() => evaluateGoalTreeNodes(data.nodes), [data.nodes]);
- const evaluatedData = useMemo(() => ({ ...data, nodes: evaluatedNodes }), [data, evaluatedNodes]);
- const evaluatedNodeMap = useMemo(() => new Map(evaluatedNodes.map((node) => [node.id, node])), [evaluatedNodes]);
- const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
- const treeViewRef = useRef(null);
- const treeEdges = useMemo(() => getVisibleGoalTreeEdges(evaluatedNodes), [evaluatedNodes]);
- const storageReady = context.storageReady !== false;
- const connectorTopologyKey = useMemo(() => [
- treeOrientation,
- treeEdges.map((edge) => `${edge.from}>${edge.to}`).join("|"),
- evaluatedNodes.map((node) => `${node.id}:${node.kind}:${node.collapsed ? 1 : 0}:${node.progress.ratio}`).join("|")
- ].join("::"), [evaluatedNodes, treeEdges, treeOrientation]);
- const [connectorRefreshTick, setConnectorRefreshTick] = useState(0);
- const connectorRefreshKey = `${storageReady ? "ready" : "loading"}::${connectorTopologyKey}::${connectorRefreshTick}`;
- const [selectedNodeId, setSelectedNodeId] = useState(() => data.nodes[0]?.id || "");
- const [activeFormTab, setActiveFormTab] = useState("base");
- const selectedNode = evaluatedNodes.find((node) => node.id === selectedNodeId) || evaluatedNodes[0] || null;
- const selectedNodeIsStep = selectedNode?.kind === "step";
- const selectedNodeChildren = selectedNode ? getGoalTreeChildren(evaluatedNodes, selectedNode.id) : [];
- const selectedProgressMode = selectedNode ? getEffectiveGoalTreeProgressMode(selectedNode, selectedNodeChildren) : "auto";
- const formTabs = [
- { id: "base", label: textContent.baseTab || "Base", icon: "cube" },
- ...(!selectedNodeIsStep ? [{ id: "content", label: textContent.contentTab || "Contenu", icon: "notepad" }] : []),
- { 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,
- orientation: reorderOrientation,
- getItemId: (node) => node.id,
- getParentId: (node) => parentMap.get(node.id) || "",
- onItemMove: (operation) => save({ ...data, nodes: moveItem(data.nodes, operation.sourceId, operation.targetId, operation.placement) }),
- hierarchy: { enabled: true, stickyParents: true }
- });
-
- 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);
- if (selectedNodeId && !data.nodes.some((node) => node.id === selectedNodeId)) setSelectedNodeId(data.nodes[0]?.id || "");
- }, [data.nodes, selectedNodeId]);
-
- useEffect(() => {
- if (activeFormTab === "content" && selectedNodeIsStep) setActiveFormTab("base");
- }, [activeFormTab, selectedNodeIsStep]);
-
- useLayoutEffect(() => {
- if (!storageReady || !evaluatedNodes.length) return undefined;
- let firstFrameId = 0;
- let secondFrameId = 0;
- const timeoutIds = [];
- const refreshConnectors = () => setConnectorRefreshTick((value) => value + 1);
- firstFrameId = requestAnimationFrame(() => {
- refreshConnectors();
- secondFrameId = requestAnimationFrame(refreshConnectors);
- });
- timeoutIds.push(setTimeout(refreshConnectors, 80));
- timeoutIds.push(setTimeout(refreshConnectors, 240));
- window.addEventListener("load", refreshConnectors);
- return () => {
- cancelAnimationFrame(firstFrameId);
- cancelAnimationFrame(secondFrameId);
- timeoutIds.forEach(clearTimeout);
- window.removeEventListener("load", refreshConnectors);
- };
- }, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing, textExchangeOpen]);
-
- useLayoutEffect(() => {
- const canvas = treeViewRef.current;
- if (!canvas) return undefined;
- let frameId = 0;
- const measureCanvas = () => {
- const rect = canvas.getBoundingClientRect();
- const nextSize = {
- width: Math.ceil(Math.max(canvas.scrollWidth, canvas.offsetWidth, rect.width / viewScale)),
- height: Math.ceil(Math.max(canvas.scrollHeight, canvas.offsetHeight, rect.height / viewScale))
- };
- setCanvasSize((current) => current.width === nextSize.width && current.height === nextSize.height ? current : nextSize);
- };
- const scheduleMeasure = () => {
- cancelAnimationFrame(frameId);
- frameId = requestAnimationFrame(measureCanvas);
- };
- const resizeObserver = new ResizeObserver(scheduleMeasure);
- resizeObserver.observe(canvas);
- scheduleMeasure();
- return () => {
- cancelAnimationFrame(frameId);
- resizeObserver.disconnect();
- };
- }, [connectorTopologyKey, editing, textExchangeOpen, viewScale]);
-
- function addNode(parentId = "") {
- const parent = data.nodes.find((node) => node.id === parentId);
- const node = {
- id: context.uid("goal"),
- parentId,
- title: parent ? textContent.defaultChildTitle || "Nouvelle étape" : textContent.defaultRootTitle || "Nouvel objectif",
- kind: "step",
- icon: "cube",
- color: "gold",
- qtyTarget: 1,
- qtyCurrent: 0,
- formula: parent ? "base" : "",
- resetType: "none",
- resetTime: "00:00",
- weeklyResetDay: 1,
- items: [],
- checkedAt: 0,
- collapsed: false
- };
- save({ ...data, nodes: [...data.nodes, node] });
- setSelectedNodeId(node.id);
- }
-
- function editNode(nodeId) {
- const node = data.nodes.find((item) => item.id === nodeId);
- if (!node) return;
- setSelectedNodeId(nodeId);
- setActiveFormTab(node.kind === "step" ? "base" : "content");
- openEditor();
- }
-
- function addNodeContentItem(nodeId) {
- const node = data.nodes.find((item) => item.id === nodeId);
- if (!node || node.kind === "step") {
- addNode(nodeId);
- return;
- }
- setSelectedNodeId(nodeId);
- setActiveFormTab("content");
- openEditor();
- addNodeItem(nodeId);
- }
-
- function updateNode(nodeId, updater) {
- save({ ...data, nodes: data.nodes.map((node) => node.id === nodeId ? updater(node) : node) });
- }
-
- function toggleBranch(nodeId) {
- const target = data.nodes.find((node) => node.id === nodeId);
- if (!target) return;
- const nextCollapsed = !target.collapsed;
- const keepOpenIds = target.collapsed ? getGoalTreeConvergenceKeepOpenIds(data.nodes, nodeId) : new Set();
- const linkedControlIds = getGoalTreeLinkedControlIds(data.nodes, nodeId);
- const siblingIds = data.nodes
- .filter((node) => node.parentId === target.parentId && node.id !== nodeId)
- .map((node) => node.id);
- const closedSiblingIds = new Set(siblingIds.flatMap((id) => [id, ...getGoalTreeDescendantIds(data.nodes, id)]));
- save({
- ...data,
- nodes: data.nodes.map((node) => {
- if (linkedControlIds.has(node.id)) return { ...node, collapsed: nextCollapsed };
- if (!target.collapsed) return node;
- if (keepOpenIds.has(node.id)) return { ...node, collapsed: false };
- return closedSiblingIds.has(node.id) ? { ...node, collapsed: true } : node;
- })
- });
- }
-
- function deleteNode(nodeId) {
- const deletedIds = new Set([nodeId, ...getGoalTreeDescendantIds(data.nodes, nodeId)]);
- save({ ...data, nodes: data.nodes.filter((node) => !deletedIds.has(node.id)) });
- if (deletedIds.has(selectedNodeId)) setSelectedNodeId(data.nodes.find((node) => !deletedIds.has(node.id))?.id || "");
- }
-
- function setStepTarget(nodeId, value) {
- const target = String(value || "").replaceAll(",", ".").trim();
- const numericTarget = Number(target);
- updateNode(nodeId, (node) => {
- if (target && Number.isFinite(numericTarget)) {
- return { ...node, qtyTarget: Math.max(0, numericTarget), formula: "" };
- }
- return { ...node, formula: target, qtyTarget: target ? node.qtyTarget : 1 };
- });
- }
-
- function setKind(nodeId, kind) {
- updateNode(nodeId, (node) => ({
- ...node,
- kind,
- icon: kind === "step" ? node.icon || "cube" : KIND_ICONS[kind],
- color: kind === "step" ? node.color || "gold" : node.color || "gold",
- qtyTarget: kind === "step" ? node.qtyTarget : 1,
- qtyCurrent: kind === "step" ? node.qtyCurrent : 0,
- formula: kind === "step" ? node.formula : "",
- items: kind === "step" ? [] : (node.items || []).map((item) => kind === "checklist" ? {
- ...item,
- formula: item.formula || (node.parentId ? "base" : "1"),
- qtyTarget: item.qtyTarget || 1,
- qtyCurrent: item.qtyCurrent || 0
- } : { id: item.id, label: item.label })
- }));
- }
-
- function addNodeItem(nodeId) {
- updateNode(nodeId, (node) => ({
- ...node,
- items: [...(node.items || []), getDefaultGoalTreeItem(node.kind, context, textContent, node.parentId)]
- }));
- }
-
- function setStepLeafQuantity(nodeId, quantity) {
- updateNode(nodeId, (node) => ({
- ...node,
- qtyCurrent: quantity,
- checkedAt: quantity > 0 ? Date.now() : 0
- }));
- }
-
- function setStepChildrenCompletion(nodeId, checked) {
- const childIds = new Set(getGoalTreeChildren(evaluatedNodes, nodeId).filter(isSingleValueGoalTreeNode).map((node) => node.id));
- if (!childIds.size) return;
- save({
- ...data,
- nodes: data.nodes.map((node) => {
- if (!childIds.has(node.id)) return node;
- const evaluatedNode = evaluatedNodeMap.get(node.id) || node;
- if (node.kind === "checklist") {
- const evaluatedItems = new Map((evaluatedNode.items || []).map((item) => [item.id, item]));
- return {
- ...node,
- items: (node.items || []).map((item) => {
- const evaluatedItem = evaluatedItems.get(item.id) || item;
- const target = Math.max(1, evaluatedItem.qtyTarget || item.qtyTarget || 1);
- return { ...item, qtyCurrent: checked ? target : 0 };
- }),
- checkedAt: checked ? Date.now() : 0
- };
- }
- const target = Math.max(1, evaluatedNode.computedQtyTarget || node.qtyTarget || 1);
- return {
- ...node,
- qtyCurrent: checked ? target : 0,
- checkedAt: checked ? Date.now() : 0
- };
- })
- });
- }
-
- function updateNodeItem(nodeId, itemId, updater) {
- updateNode(nodeId, (node) => ({
- ...node,
- items: (node.items || []).map((item) => item.id === itemId ? updater(item) : item)
- }));
- }
-
- function deleteNodeItem(nodeId, itemId) {
- updateNode(nodeId, (node) => ({
- ...node,
- items: (node.items || []).filter((item) => item.id !== itemId)
- }));
- }
-
- function importData(nextData) {
- save(nextData);
- setSelectedNodeId(nextData.nodes[0]?.id || "");
- }
-
- function toggleTreeOrientation() {
- save({ ...data, orientation: verticalView ? "horizontal" : "vertical" });
- }
-
- function toggleLinkedParent(nodeId, parentId) {
- updateNode(nodeId, (node) => {
- const parentIds = new Set(getGoalTreeParentIds(node));
- if (parentIds.has(parentId)) parentIds.delete(parentId);
- else parentIds.add(parentId);
- if (node.parentId) parentIds.add(node.parentId);
- return { ...node, parentIds: [...parentIds] };
- });
- }
+ const controller = useGoalTreeController({ toolboxId, moduleId, context, editing, setEditing, openEditor });
return (
{editing && (
setEditing(false)}
- onDeleteNode={deleteNode}
- onSetKind={setKind}
- onUpdateNode={updateNode}
- onSetStepTarget={setStepTarget}
- onAddNodeItem={addNodeItem}
- onUpdateNodeItem={updateNodeItem}
- onDeleteNodeItem={deleteNodeItem}
- onToggleLinkedParent={toggleLinkedParent}
+ selectedNode={controller.selectedNode}
+ selectedNodeIsStep={controller.selectedNodeIsStep}
+ selectedProgressMode={controller.selectedProgressMode}
+ formTabs={controller.formTabs}
+ activeFormTab={controller.activeFormTab}
+ evaluatedNodes={controller.evaluatedNodes}
+ textContent={controller.textContent}
+ onTabChange={controller.setActiveFormTab}
+ onClose={() => controller.setEditing(false)}
+ onDeleteNode={controller.deleteNode}
+ onSetKind={controller.setKind}
+ onUpdateNode={controller.updateNode}
+ onSetStepTarget={controller.setStepTarget}
+ onAddNodeItem={controller.addNodeItem}
+ onUpdateNodeItem={controller.updateNodeItem}
+ onDeleteNodeItem={controller.deleteNodeItem}
+ onToggleLinkedParent={controller.toggleLinkedParent}
/>
)}
{textExchangeOpen && (
-
+
)}
-
-
-
-
- {editing && (
-
- )}
-
- {evaluatedNodes.length ? (
-
- ) : (
-
-
{textContent.emptyTree || "Aucun objectif."}
-
- )}
-
-
+
);
}
diff --git a/website/src/features/toolboxes/modules/GoalTreeView.jsx b/website/src/features/toolboxes/modules/GoalTreeView.jsx
new file mode 100644
index 0000000..82919da
--- /dev/null
+++ b/website/src/features/toolboxes/modules/GoalTreeView.jsx
@@ -0,0 +1,147 @@
+// Rôle : rend le canvas de l'arbre d'objectifs, son orientation et ses connecteurs.
+import { useLayoutEffect, useMemo, useRef, useState } from "react";
+import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
+import { Icon } from "../../../components/Icon.jsx";
+import { GoalTreeBranch } from "./GoalTreeParts.jsx";
+import { getVisibleGoalTreeEdges } from "./goalTreeDisplay.js";
+
+export function GoalTreeView({
+ evaluatedNodes,
+ selectedNode,
+ editing,
+ reorder,
+ textContent,
+ treeOrientation,
+ verticalView,
+ viewScale,
+ storageReady,
+ textExchangeOpen,
+ onSelect,
+ onAdd,
+ onAddContentItem,
+ onSetStepLeafQuantity,
+ onSetStepChildrenCompletion,
+ onUpdateNodeItem,
+ onEditNode,
+ onToggleBranch,
+ onDeleteNode,
+ onToggleOrientation
+}) {
+ const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
+ const treeViewRef = useRef(null);
+ const treeEdges = useMemo(() => getVisibleGoalTreeEdges(evaluatedNodes), [evaluatedNodes]);
+ const connectorTopologyKey = useMemo(() => [
+ treeOrientation,
+ treeEdges.map((edge) => `${edge.from}>${edge.to}`).join("|"),
+ evaluatedNodes.map((node) => `${node.id}:${node.kind}:${node.collapsed ? 1 : 0}:${node.progress.ratio}`).join("|")
+ ].join("::"), [evaluatedNodes, treeEdges, treeOrientation]);
+ const [connectorRefreshTick, setConnectorRefreshTick] = useState(0);
+ const connectorRefreshKey = `${storageReady ? "ready" : "loading"}::${connectorTopologyKey}::${connectorRefreshTick}`;
+
+ useLayoutEffect(() => {
+ if (!storageReady || !evaluatedNodes.length) return undefined;
+ let firstFrameId = 0;
+ let secondFrameId = 0;
+ const timeoutIds = [];
+ const refreshConnectors = () => setConnectorRefreshTick((value) => value + 1);
+ firstFrameId = requestAnimationFrame(() => {
+ refreshConnectors();
+ secondFrameId = requestAnimationFrame(refreshConnectors);
+ });
+ timeoutIds.push(setTimeout(refreshConnectors, 80));
+ timeoutIds.push(setTimeout(refreshConnectors, 240));
+ window.addEventListener("load", refreshConnectors);
+ return () => {
+ cancelAnimationFrame(firstFrameId);
+ cancelAnimationFrame(secondFrameId);
+ timeoutIds.forEach(clearTimeout);
+ window.removeEventListener("load", refreshConnectors);
+ };
+ }, [storageReady, connectorTopologyKey, evaluatedNodes.length, editing, textExchangeOpen]);
+
+ useLayoutEffect(() => {
+ const canvas = treeViewRef.current;
+ if (!canvas) return undefined;
+ let frameId = 0;
+ const measureCanvas = () => {
+ const rect = canvas.getBoundingClientRect();
+ const nextSize = {
+ width: Math.ceil(Math.max(canvas.scrollWidth, canvas.offsetWidth, rect.width / viewScale)),
+ height: Math.ceil(Math.max(canvas.scrollHeight, canvas.offsetHeight, rect.height / viewScale))
+ };
+ setCanvasSize((current) => current.width === nextSize.width && current.height === nextSize.height ? current : nextSize);
+ };
+ const scheduleMeasure = () => {
+ cancelAnimationFrame(frameId);
+ frameId = requestAnimationFrame(measureCanvas);
+ };
+ const resizeObserver = new ResizeObserver(scheduleMeasure);
+ resizeObserver.observe(canvas);
+ scheduleMeasure();
+ return () => {
+ cancelAnimationFrame(frameId);
+ resizeObserver.disconnect();
+ };
+ }, [connectorTopologyKey, editing, textExchangeOpen, viewScale]);
+
+ return (
+
+
+
+
+ {editing && (
+
+ )}
+
+ {evaluatedNodes.length ? (
+
+ ) : (
+
+
{textContent.emptyTree || "Aucun objectif."}
+
+ )}
+
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx
index faed251..a353c0d 100644
--- a/website/src/features/toolboxes/modules/TableModule.jsx
+++ b/website/src/features/toolboxes/modules/TableModule.jsx
@@ -1,142 +1,30 @@
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useMemo } from "react";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
+import { columnIndexToName, evaluateTable } from "./tableFormulaEngine.js";
import { TableGrid } from "./TableGrid.jsx";
import { TableToolbar } from "./TableToolbar.jsx";
import {
DEFAULT_COLUMNS,
DEFAULT_ROWS,
- clampCellPosition,
cleanCells,
cleanLabels,
- getBoundsFromStartAndSize,
- getCellBlock,
- getColumnWidths,
- getFormulaRangeText,
getRowHeaderWidth,
- getSelectionBounds,
- isCellSelected,
- isMultiCellSelection,
- isSameBounds,
- moveSelectionCells,
- parseTsvBlock,
- pasteCellBlock
+ getColumnWidths
} from "./tableUtils.js";
+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 textContent = context.moduleText?.table || {};
- const [editingCell, setEditingCell] = useState("");
- const [highlightedCell, setHighlightedCell] = useState("");
- const [selection, setSelection] = useState(null);
- const [movePreview, setMovePreview] = useState(null);
- const draggingSelectionRef = useRef(false);
- const movingSelectionRef = useRef(null);
- const copiedSelectionRef = useRef(null);
- const formulaSelectionRef = useRef(null);
- const inputRefs = useRef(new Map());
- const highlightTimeoutRef = useRef(0);
const evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]);
const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]);
const rowHeaderWidth = useMemo(() => getRowHeaderWidth(data), [data]);
- useEffect(() => {
- function stopSelectionDrag() {
- if (movingSelectionRef.current) {
- finishMovingSelection();
- return;
- }
- if (formulaSelectionRef.current) {
- const formulaSelection = formulaSelectionRef.current;
- formulaSelectionRef.current = null;
- draggingSelectionRef.current = false;
- insertFormulaReferenceSelection(formulaSelection);
- return;
- }
- draggingSelectionRef.current = false;
- }
-
- window.addEventListener("pointerup", stopSelectionDrag);
- return () => window.removeEventListener("pointerup", stopSelectionDrag);
- });
-
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "table");
}
- function setCellValue(address, value) {
- const nextCells = { ...data.cells };
- if (String(value || "").trim()) {
- nextCells[address] = value;
- } else {
- delete nextCells[address];
- }
- save({ ...data, cells: nextCells });
- }
-
- function clearSelection(rowIndex, columnIndex) {
- const nextCells = { ...data.cells };
- const bounds = getSelectionBounds(selection) || {
- startRow: rowIndex,
- endRow: rowIndex,
- startColumn: columnIndex,
- endColumn: columnIndex
- };
-
- for (let selectedRowIndex = bounds.startRow; selectedRowIndex <= bounds.endRow; selectedRowIndex += 1) {
- for (let selectedColumnIndex = bounds.startColumn; selectedColumnIndex <= bounds.endColumn; selectedColumnIndex += 1) {
- delete nextCells[cellAddress(selectedRowIndex, selectedColumnIndex)];
- }
- }
-
- save({ ...data, cells: nextCells });
- const focusedAddress = cellAddress(rowIndex, columnIndex);
- window.requestAnimationFrame(() => inputRefs.current.get(focusedAddress)?.focus());
- }
-
- function startMovingSelection(rowIndex, columnIndex) {
- const sourceBounds = getSelectionBounds(selection);
- if (!sourceBounds || !isCellSelected(selection, rowIndex, columnIndex)) return false;
- const height = sourceBounds.endRow - sourceBounds.startRow + 1;
- const width = sourceBounds.endColumn - sourceBounds.startColumn + 1;
- const targetBounds = getBoundsFromStartAndSize(sourceBounds.startRow, sourceBounds.startColumn, height, width);
- movingSelectionRef.current = { sourceBounds, targetBounds, height, width };
- setMovePreview(targetBounds);
- return true;
- }
-
- function updateMovePreview(rowIndex, columnIndex) {
- const movingSelection = movingSelectionRef.current;
- if (!movingSelection) return false;
- const targetRow = Math.min(data.rows - movingSelection.height, Math.max(0, rowIndex));
- const targetColumn = Math.min(data.columns - movingSelection.width, Math.max(0, columnIndex));
- const targetBounds = getBoundsFromStartAndSize(targetRow, targetColumn, movingSelection.height, movingSelection.width);
- movingSelectionRef.current = { ...movingSelection, targetBounds };
- setMovePreview(targetBounds);
- return true;
- }
-
- function finishMovingSelection() {
- const movingSelection = movingSelectionRef.current;
- movingSelectionRef.current = null;
- draggingSelectionRef.current = false;
- setMovePreview(null);
- if (!movingSelection || isSameBounds(movingSelection.sourceBounds, movingSelection.targetBounds)) return;
- const nextCells = moveSelectionCells(data.cells, movingSelection.sourceBounds, movingSelection.targetBounds);
- save({ ...data, cells: nextCells });
- const anchor = {
- rowIndex: movingSelection.targetBounds.startRow,
- columnIndex: movingSelection.targetBounds.startColumn
- };
- const focus = {
- rowIndex: movingSelection.targetBounds.endRow,
- columnIndex: movingSelection.targetBounds.endColumn
- };
- setSelection({ anchor, focus });
- focusCell(anchor.rowIndex, anchor.columnIndex);
- }
-
function resize(rows, columns) {
save({
rows,
@@ -168,239 +56,7 @@ export function TableModule({ toolboxId, moduleId, context, textExchangeOpen })
}
save({ ...data, rowLabels: nextLabels });
}
-
- function registerInput(address, element) {
- if (element) {
- inputRefs.current.set(address, element);
- } else {
- inputRefs.current.delete(address);
- }
- }
-
- function getActiveFormulaInput() {
- if (!editingCell) return null;
- const input = inputRefs.current.get(editingCell);
- const value = data.cells[editingCell] || "";
- return input && value.startsWith("=") ? input : null;
- }
-
- function focusCell(rowIndex, columnIndex, options = {}) {
- const position = clampCellPosition(rowIndex, columnIndex, data.rows, data.columns);
- const address = cellAddress(position.rowIndex, position.columnIndex);
- window.requestAnimationFrame(() => {
- const input = inputRefs.current.get(address);
- input?.focus();
- if (options.select) {
- input?.select();
- } else {
- input?.setSelectionRange(input.value.length, input.value.length);
- }
- });
- }
-
- function startEditing(address, initialValue = null, options = {}) {
- const nextValue = initialValue == null ? data.cells[address] || "" : initialValue;
- if (initialValue != null) setCellValue(address, initialValue);
- setEditingCell(address);
- window.requestAnimationFrame(() => {
- const input = inputRefs.current.get(address);
- input?.focus();
- if (options.select) {
- input?.select();
- } else {
- const position = options.caret ?? nextValue.length;
- input?.setSelectionRange(position, position);
- }
- });
- }
-
- function insertFormulaText(text, options = {}) {
- const input = getActiveFormulaInput();
- if (!input || !text) return false;
-
- const currentValue = data.cells[editingCell] || "";
- const start = options.start ?? input.selectionStart ?? currentValue.length;
- const end = options.end ?? input.selectionEnd ?? start;
- const nextValue = `${currentValue.slice(0, start)}${text}${currentValue.slice(end)}`;
- setCellValue(editingCell, nextValue);
- setHighlightedCell(options.highlightedCell || text);
- window.clearTimeout(highlightTimeoutRef.current);
- highlightTimeoutRef.current = window.setTimeout(() => setHighlightedCell(""), 700);
- window.requestAnimationFrame(() => {
- input.focus();
- input.setSelectionRange(start + text.length, start + text.length);
- });
- return true;
- }
-
- function insertFormulaReferenceSelection(formulaSelection) {
- const text = getFormulaRangeText({ anchor: formulaSelection.anchor, focus: formulaSelection.focus });
- insertFormulaText(text, {
- start: formulaSelection.start,
- end: formulaSelection.end,
- highlightedCell: cellAddress(formulaSelection.focus.rowIndex, formulaSelection.focus.columnIndex)
- });
- }
-
- function getTsv(bounds = null) {
- const rows = [];
- const startRow = bounds?.startRow ?? 0;
- const endRow = bounds?.endRow ?? data.rows - 1;
- const startColumn = bounds?.startColumn ?? 0;
- const endColumn = bounds?.endColumn ?? data.columns - 1;
-
- for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) {
- const columns = [];
- for (let columnIndex = startColumn; columnIndex <= endColumn; columnIndex += 1) {
- columns.push(evaluatedCells[cellAddress(rowIndex, columnIndex)]?.display || "");
- }
- rows.push(columns.join("\t"));
- }
- return rows.join("\n");
- }
-
- async function copyTsv() {
- const bounds = isMultiCellSelection(selection) ? getSelectionBounds(selection) : null;
- copiedSelectionRef.current = getCellBlock(data.cells, bounds || getBoundsFromStartAndSize(0, 0, data.rows, data.columns));
- const text = getTsv(bounds);
- if (!await context.copyText(text)) return;
- context.notify?.(textContent.copiedTitle || "Tableau copié");
- }
-
- async function copySelection(event) {
- if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
- event.preventDefault();
- const bounds = getSelectionBounds(selection);
- copiedSelectionRef.current = getCellBlock(data.cells, bounds);
- const text = getTsv(bounds);
- await context.copyText(text);
- }
-
- function pasteBlock(block, rowIndex, columnIndex) {
- const result = pasteCellBlock(data.cells, block, rowIndex, columnIndex, data.rows, data.columns);
- if (!result) return false;
- save({ ...data, cells: result.cells });
- setSelection({
- anchor: { rowIndex: result.bounds.startRow, columnIndex: result.bounds.startColumn },
- focus: { rowIndex: result.bounds.endRow, columnIndex: result.bounds.endColumn }
- });
- focusCell(result.bounds.startRow, result.bounds.startColumn);
- return true;
- }
-
- function pasteSelection(event, rowIndex, columnIndex, isEditing) {
- if (isEditing) return;
- const text = event.clipboardData?.getData("text/plain") || "";
- const block = parseTsvBlock(text) || copiedSelectionRef.current;
- if (!block) return;
- event.preventDefault();
- pasteBlock(block, rowIndex, columnIndex);
- }
-
- function selectCell(rowIndex, columnIndex, extend = false) {
- const cell = { rowIndex, columnIndex };
- setSelection((currentSelection) => extend && currentSelection?.anchor
- ? { anchor: currentSelection.anchor, focus: cell }
- : { anchor: cell, focus: cell });
- }
-
- function extendSelection(rowIndex, columnIndex) {
- const cell = { rowIndex, columnIndex };
- setSelection((currentSelection) => currentSelection?.anchor
- ? { anchor: currentSelection.anchor, focus: cell }
- : { anchor: cell, focus: cell });
- }
-
- function startFormulaSelection(rowIndex, columnIndex) {
- const input = getActiveFormulaInput();
- const formulaCell = parseCellAddress(editingCell);
- if (!input || (formulaCell?.rowIndex === rowIndex && formulaCell?.columnIndex === columnIndex)) return false;
- const cell = { rowIndex, columnIndex };
- formulaSelectionRef.current = {
- anchor: cell,
- focus: cell,
- start: input.selectionStart ?? (data.cells[editingCell] || "").length,
- end: input.selectionEnd ?? input.selectionStart ?? (data.cells[editingCell] || "").length
- };
- setSelection({ anchor: cell, focus: cell });
- draggingSelectionRef.current = true;
- return true;
- }
-
- function extendFormulaSelection(rowIndex, columnIndex) {
- if (!formulaSelectionRef.current) return false;
- const focus = { rowIndex, columnIndex };
- formulaSelectionRef.current = { ...formulaSelectionRef.current, focus };
- setSelection({ anchor: formulaSelectionRef.current.anchor, focus });
- return true;
- }
-
- function handleCellKeyDown(event, rowIndex, columnIndex, address, isEditing) {
- if (!isEditing && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
- copySelection(event);
- return;
- }
-
- if (!isEditing && event.key === "Delete") {
- event.preventDefault();
- clearSelection(rowIndex, columnIndex);
- return;
- }
-
- if (event.key === "Escape") {
- event.preventDefault();
- formulaSelectionRef.current = null;
- draggingSelectionRef.current = false;
- setEditingCell("");
- setSelection({ anchor: { rowIndex, columnIndex }, focus: { rowIndex, columnIndex } });
- focusCell(rowIndex, columnIndex);
- return;
- }
-
- if (!isEditing && event.key === "Enter") {
- event.preventDefault();
- startEditing(address, null, { select: true });
- return;
- }
-
- const deltas = {
- ArrowUp: [-1, 0],
- ArrowDown: [1, 0],
- ArrowLeft: [0, -1],
- ArrowRight: [0, 1],
- Enter: [event.shiftKey ? -1 : 1, 0],
- Tab: [0, event.shiftKey ? -1 : 1]
- };
- const delta = deltas[event.key];
- if (!delta) {
- if (!isEditing && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
- event.preventDefault();
- startEditing(address, event.key, { caret: event.key.length });
- }
- return;
- }
-
- const input = event.currentTarget;
- const valueLength = input.value.length;
- const caretStart = input.selectionStart ?? valueLength;
- const caretEnd = input.selectionEnd ?? caretStart;
- const hasTextSelection = caretStart !== caretEnd;
- const isHorizontalArrow = event.key === "ArrowLeft" || event.key === "ArrowRight";
- const canMoveHorizontal = event.key === "ArrowLeft" ? caretStart === 0 : caretEnd === valueLength;
- const shouldNavigate = !isEditing || event.key === "Tab" || event.key === "Enter" || event.shiftKey || !isHorizontalArrow || (!hasTextSelection && canMoveHorizontal);
-
- if (!shouldNavigate) return;
-
- event.preventDefault();
- setEditingCell("");
- const nextCell = clampCellPosition(rowIndex + delta[0], columnIndex + delta[1], data.rows, data.columns);
- if (event.shiftKey && event.key.startsWith("Arrow")) {
- extendSelection(nextCell.rowIndex, nextCell.columnIndex);
- } else {
- selectCell(nextCell.rowIndex, nextCell.columnIndex);
- }
- focusCell(nextCell.rowIndex, nextCell.columnIndex);
- }
+ const tableInteractions = useTableInteractions({ data, context, textContent, evaluatedCells, save });
return (
@@ -409,32 +65,32 @@ export function TableModule({ toolboxId, moduleId, context, textExchangeOpen })
context.setModuleData(toolboxId, moduleId, nextData, "table")} />
)}
-
+
setEditingCell("")}
- registerInput={registerInput}
+ onCellChange={tableInteractions.setCellValue}
+ onFormulaSelectionStart={tableInteractions.startFormulaSelection}
+ onFormulaSelectionExtend={tableInteractions.extendFormulaSelection}
+ onStartMoveSelection={tableInteractions.startMovingSelection}
+ onMoveSelection={tableInteractions.updateMovePreview}
+ onSelectCell={tableInteractions.selectCell}
+ onExtendSelection={tableInteractions.extendSelection}
+ onPasteSelection={tableInteractions.pasteSelection}
+ onCellKeyDown={tableInteractions.handleCellKeyDown}
+ onStartEditing={tableInteractions.startEditing}
+ onEditingCellClear={tableInteractions.clearEditingCell}
+ registerInput={tableInteractions.registerInput}
/>
);
diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
index 50ade2f..a9fb498 100644
--- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
+++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
@@ -1,22 +1,14 @@
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
import { useEffect, useMemo, useRef, useState } from "react";
-import { Icon } from "../../../components/Icon.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
-import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
-import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { TaskPlannerBranch } from "./TaskPlannerItems.jsx";
+import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
+import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
import {
- TASK_TYPES,
- WEEK_DAYS,
applyDueResets,
- getBoundaryTaskId,
- getRootTaskIdsForCategory,
- getTaskCategory,
- getTaskCategoryDataset,
- getTaskParentMap,
- getTaskTypeLabel,
- moveTask
+ getTaskParentMap
} from "./taskPlannerUtils.js";
+import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
@@ -29,117 +21,11 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
const [prerequisiteNoticeTaskIds, setPrerequisiteNoticeTaskIds] = useState(() => new Set());
const prerequisiteNoticeTimeouts = useRef(new Map());
const parentMap = useMemo(() => getTaskParentMap(data.tasks, data.relations), [data.tasks, data.relations]);
- const {
- itemReorder,
- groupReorder,
- getItemProps,
- getGroupProps,
- getGroupBoundaryProps,
- isItemDragging,
- isItemDropTarget,
- isGroupDragging,
- isGroupDropTarget,
- isBoundaryDropTarget,
- getDropPlacement,
- shouldShowGroupBoundaries
- } = useGroupedReorder({
- namespace: "task-planner",
- items: data.tasks,
- getItemId: (task) => task.id,
- getParentId: (task) => parentMap.get(task.id) || "",
- getItemGroup: (task) => getTaskCategoryDataset(task, data.tasks, parentMap),
- getEffectiveGroup: (task) => getTaskCategoryDataset(task, data.tasks, parentMap),
- reorderFeatures: {
- item: {
- groupChange: true,
- boundaryDrop: true,
- rootOnly: true
- },
- group: {
- reorder: true,
- boundaryDrop: true,
- itemDrop: true,
- rootOnly: true
- }
- },
- onItemMove: (operation) => {
- const fromTaskId = operation.sourceId;
- const fromTask = data.tasks.find((task) => task.id === fromTaskId);
- if (!fromTask) return;
- function setRootTaskCategory(tasks, category) {
- return tasks.map((task) => {
- if (task.id !== fromTaskId) return task;
- const nextTask = { ...task };
- if (category) nextTask.category = category;
- else delete nextTask.category;
- return nextTask;
- });
- }
- function moveToCategory(category, placement = operation.placement) {
- const nextTasks = setRootTaskCategory(data.tasks, category);
- const targetIds = category
- ? getRootTaskIdsForCategory(data.tasks, parentMap, category).filter((taskId) => taskId !== fromTaskId)
- : data.tasks.filter((task) => !parentMap.get(task.id) && !getTaskCategory(task) && task.id !== fromTaskId).map((task) => task.id);
- const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement);
- save({ ...data, tasks: boundaryTaskId ? moveTask(nextTasks, fromTaskId, boundaryTaskId, placement) : nextTasks });
- }
- if (operation.targetType === "boundary") {
- const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, operation.targetGroup);
- const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, operation.placement);
- if (!boundaryTaskId) return;
- const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
- const tasks = sourceCategory ? setRootTaskCategory(data.tasks, "") : data.tasks;
- save({ ...data, tasks: moveTask(tasks, fromTaskId, boundaryTaskId, operation.placement) });
- return;
- }
- if (operation.targetType === "group") {
- const targetCategory = operation.targetGroup;
- const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
- if (!sourceCategory) {
- const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, targetCategory);
- const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, operation.placement);
- if (boundaryTaskId) save({ ...data, tasks: moveTask(data.tasks, fromTaskId, boundaryTaskId, operation.placement) });
- return;
- }
- moveToCategory(targetCategory);
- return;
- }
- const targetTask = data.tasks.find((task) => task.id === operation.targetId);
- const targetCategory = targetTask ? getTaskCategoryDataset(targetTask, data.tasks, parentMap) : "";
- const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
- const shouldChangeRootCategory = !parentMap.get(fromTaskId) && targetTask && !parentMap.get(operation.targetId) && targetCategory !== sourceCategory;
- const tasks = shouldChangeRootCategory ? setRootTaskCategory(data.tasks, targetCategory) : data.tasks;
- save({ ...data, tasks: moveTask(tasks, fromTaskId, operation.targetId, operation.placement) });
- },
- onGroupMove: (operation) => {
- save(applyGroupedReorderOperation(data, {
- operation,
- itemsKey: "tasks",
- groupOrderKey: "categoryOrder",
- getItemGroup: (task) => parentMap.get(task.id) ? "" : getTaskCategory(task),
- setItemGroup: (task) => task
- }));
- },
- hierarchy: { enabled: true, stickyParents: true }
- });
- const reorder = {
- itemReorder,
- groupReorder,
- getItemProps,
- getGroupProps,
- getGroupBoundaryProps,
- isItemDragging,
- isItemDropTarget,
- isGroupDragging,
- isGroupDropTarget,
- isBoundaryDropTarget,
- getDropPlacement,
- shouldShowGroupBoundaries
- };
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "taskPlanner");
}
+ const reorder = useTaskPlannerReorder({ data, parentMap, save });
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
@@ -280,75 +166,28 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
return (
-
-
-
-
-
- {editing && (
-
-
-
- )}
- {textExchangeOpen && (
-
- save(nextData)} />
-
- )}
-
+
save({ ...data, hideCompleted: !data.hideCompleted })}
+ onSettingsToggle={() => setSettingsOpen((value) => !value)}
+ onImport={(nextData) => save(nextData)}
+ />
{settingsOpen && (
-
+ save({ ...data, weeklyResetDay })}
+ onResetTimeChange={(resetTime) => save({ ...data, resetTime })}
+ onClearCompletedUniqueTasks={clearCompletedUniqueTasks}
+ />
)}
diff --git a/website/src/features/toolboxes/modules/TaskPlannerSettings.jsx b/website/src/features/toolboxes/modules/TaskPlannerSettings.jsx
new file mode 100644
index 0000000..e9336c8
--- /dev/null
+++ b/website/src/features/toolboxes/modules/TaskPlannerSettings.jsx
@@ -0,0 +1,33 @@
+// Rôle : rend les réglages globaux de reset et nettoyage du task planner.
+import { Icon } from "../../../components/Icon.jsx";
+import { WEEK_DAYS } from "./taskPlannerUtils.js";
+
+export function TaskPlannerSettings({ data, textContent, onWeeklyResetDayChange, onResetTimeChange, onClearCompletedUniqueTasks }) {
+ return (
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx b/website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx
new file mode 100644
index 0000000..001d254
--- /dev/null
+++ b/website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx
@@ -0,0 +1,66 @@
+// Rôle : rend la barre haute du task planner, l'ajout rapide et l'échange texte.
+import { Icon } from "../../../components/Icon.jsx";
+import { TextExchangeActions } from "./TextExchangeActions.jsx";
+import {
+ TASK_TYPES,
+ getTaskTypeLabel
+} from "./taskPlannerUtils.js";
+
+export function TaskPlannerTopbar({
+ data,
+ context,
+ editing,
+ settingsOpen,
+ taskDraft,
+ textContent,
+ textExchangeOpen,
+ onAddTask,
+ onHideCompletedToggle,
+ onSettingsToggle,
+ onImport
+}) {
+ return (
+
+
+
+
+
+ {editing && (
+
+
+
+ )}
+ {textExchangeOpen && (
+
+
+
+ )}
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/useCombosEditorState.js b/website/src/features/toolboxes/modules/useCombosEditorState.js
new file mode 100644
index 0000000..f91c339
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useCombosEditorState.js
@@ -0,0 +1,280 @@
+// Rôle : pilote le brouillon d'édition des combos, palettes d'inputs et interactions drag/drop.
+import { useMemo, useState } from "react";
+import {
+ createCombo,
+ createInput,
+ getComboCategory,
+ getPaletteColumns,
+ getPaletteGroups,
+ normalizeCustomKey,
+ parseHoldSeconds
+} from "./combosUtils.js";
+
+export function useCombosEditorState({ data, context, textContent, save }) {
+ const [comboCategory, setComboCategory] = useState("");
+ const [comboName, setComboName] = useState("");
+ const [draftInputs, setDraftInputs] = useState([[]]);
+ const [activeStepIndex, setActiveStepIndex] = useState(0);
+ const [selectedInput, setSelectedInput] = useState(null);
+ const [simultaneousMode, setSimultaneousMode] = useState(false);
+ const [keyboardLayout, setKeyboardLayout] = useState("azerty");
+ const [customKey, setCustomKey] = useState("");
+ const [editingComboId, setEditingComboId] = useState("");
+ const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]);
+ const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]);
+ const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length);
+ const selectedDraftInput = Number.isInteger(selectedInput?.stepIndex) && Number.isInteger(selectedInput?.inputIndex)
+ ? draftInputs[selectedInput.stepIndex]?.[selectedInput.inputIndex] || null
+ : null;
+
+ function updateDevice(device) {
+ const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device };
+ save({
+ ...data,
+ device,
+ combos: data.combos.map(stampComboDevice)
+ });
+ }
+
+ function addInputToStep(kind, value, stepIndex) {
+ const input = createInput(kind, value);
+ setDraftInputs((steps) => {
+ const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
+ const targetIndex = Math.min(Math.max(0, stepIndex), nextSteps.length - 1);
+ nextSteps[targetIndex] = [...nextSteps[targetIndex], input];
+ setSelectedInput({ stepIndex: targetIndex, inputIndex: nextSteps[targetIndex].length - 1 });
+ return nextSteps;
+ });
+ }
+
+ function addDraftInput(kind, value) {
+ const input = createInput(kind, value);
+ if (simultaneousMode) {
+ addInputToStep(kind, value, activeStepIndex);
+ return;
+ }
+
+ setDraftInputs((steps) => {
+ const filledSteps = steps.filter((step) => step.length);
+ const nextSteps = [...filledSteps, [input]];
+ setActiveStepIndex(nextSteps.length - 1);
+ setSelectedInput({ stepIndex: nextSteps.length - 1, inputIndex: 0 });
+ return nextSteps;
+ });
+ }
+
+ function dragPaletteInput(event, kind, value) {
+ event.dataTransfer.effectAllowed = "copy";
+ event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify(createInput(kind, value)));
+ }
+
+ function dragDraftInput(event, stepIndex, inputIndex, input) {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify({
+ ...input,
+ sourceStepIndex: stepIndex,
+ sourceInputIndex: inputIndex
+ }));
+ }
+
+ function dropInputOnStep(event, stepIndex) {
+ const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
+ if (!rawInput) return;
+ try {
+ const input = JSON.parse(rawInput);
+ if (!input?.kind || !input?.value) return;
+ if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
+ moveDraftInput(input.sourceStepIndex, input.sourceInputIndex, stepIndex);
+ } else {
+ addInputToStep(input.kind, input.value, stepIndex);
+ }
+ setActiveStepIndex(stepIndex);
+ } catch {
+ // Ignore invalid drag payloads from outside the app.
+ }
+ }
+
+ function dropInputAsNewStep(event) {
+ const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
+ if (!rawInput) return;
+ try {
+ const input = JSON.parse(rawInput);
+ if (!input?.kind || !input?.value) return;
+ if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
+ moveDraftInputToNewStep(input.sourceStepIndex, input.sourceInputIndex);
+ } else {
+ addDraftInput(input.kind, input.value);
+ }
+ } catch {
+ // Ignore invalid drag payloads from outside the app.
+ }
+ }
+
+ function moveDraftInput(sourceStepIndex, sourceInputIndex, targetStepIndex) {
+ setDraftInputs((steps) => {
+ if (sourceStepIndex === targetStepIndex) return steps;
+ const nextSteps = steps.map((step) => [...step]);
+ const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
+ if (!input || !nextSteps[targetStepIndex]) return steps;
+ nextSteps[targetStepIndex].push(input);
+ setSelectedInput({ stepIndex: targetStepIndex, inputIndex: nextSteps[targetStepIndex].length - 1 });
+ return nextSteps.filter((step, index) => step.length || index === targetStepIndex);
+ });
+ }
+
+ function moveDraftInputToNewStep(sourceStepIndex, sourceInputIndex) {
+ setDraftInputs((steps) => {
+ const nextSteps = steps.map((step) => [...step]);
+ const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
+ if (!input) return steps;
+ const safeSteps = nextSteps.filter((step) => step.length);
+ setActiveStepIndex(safeSteps.length);
+ setSelectedInput({ stepIndex: safeSteps.length, inputIndex: 0 });
+ return [...safeSteps, [input]];
+ });
+ }
+
+ function removeActiveStep() {
+ setDraftInputs((steps) => {
+ const nextSteps = steps.filter((_, index) => index !== activeStepIndex);
+ const safeSteps = nextSteps.length ? nextSteps : [[]];
+ setActiveStepIndex(Math.min(activeStepIndex, safeSteps.length - 1));
+ setSelectedInput(null);
+ return safeSteps;
+ });
+ }
+
+ function removeLastInput() {
+ setDraftInputs((steps) => {
+ const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
+ let targetIndex = -1;
+ for (let index = nextSteps.length - 1; index >= 0; index -= 1) {
+ if (nextSteps[index].length) {
+ targetIndex = index;
+ break;
+ }
+ }
+ if (targetIndex < 0) return [[]];
+ nextSteps[targetIndex].pop();
+ const safeSteps = nextSteps.filter((step) => step.length);
+ setActiveStepIndex(Math.max(0, safeSteps.length - 1));
+ setSelectedInput(null);
+ return safeSteps.length ? safeSteps : [[]];
+ });
+ }
+
+ function updateSelectedInputHold(value) {
+ if (!selectedDraftInput) return;
+ const holdMs = Math.round(parseHoldSeconds(value) * 1000);
+ setDraftInputs((steps) => steps.map((step, stepIndex) => step.map((input, inputIndex) => {
+ if (stepIndex !== selectedInput.stepIndex || inputIndex !== selectedInput.inputIndex) return input;
+ const nextInput = { ...input };
+ if (holdMs > 0) {
+ nextInput.hold = true;
+ nextInput.holdMs = holdMs;
+ }
+ else delete nextInput.holdMs;
+ return nextInput;
+ })));
+ }
+
+ function toggleSelectedInputHold() {
+ if (!selectedDraftInput) return;
+ setDraftInputs((steps) => steps.map((step, stepIndex) => step.map((input, inputIndex) => {
+ if (stepIndex !== selectedInput.stepIndex || inputIndex !== selectedInput.inputIndex) return input;
+ const nextInput = { ...input };
+ if (nextInput.hold || nextInput.holdMs) {
+ delete nextInput.hold;
+ delete nextInput.holdMs;
+ } else {
+ nextInput.hold = true;
+ }
+ return nextInput;
+ })));
+ }
+
+ function selectDraftStep(stepIndex) {
+ setActiveStepIndex(stepIndex);
+ setSelectedInput(null);
+ }
+
+ function addCustomKey(event) {
+ event.preventDefault();
+ const value = normalizeCustomKey(customKey);
+ if (!value || data.device !== "keyboardMouse") return;
+ addDraftInput("key", value.toLowerCase());
+ setCustomKey("");
+ }
+
+ function saveCombo(event) {
+ event.preventDefault();
+ if (!comboName.trim() && !cleanDraftInputs.length) return;
+ const combo = createCombo(context, editingComboId, comboName, comboCategory, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device);
+ const nextCombos = data.combos.filter((item) => item.id !== editingComboId);
+ const nextCategoryOrder = combo.category && !data.categoryOrder.includes(combo.category)
+ ? [...data.categoryOrder, combo.category]
+ : data.categoryOrder;
+ save({ ...data, combos: [...nextCombos, combo], categoryOrder: nextCategoryOrder });
+ cancelComboEdit();
+ }
+
+ function startComboEdit(combo) {
+ setComboCategory(getComboCategory(combo));
+ setEditingComboId(combo.id);
+ setComboName(combo.name);
+ setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]);
+ setActiveStepIndex(0);
+ setSelectedInput(null);
+ }
+
+ function cancelComboEdit() {
+ setEditingComboId("");
+ setComboName("");
+ setComboCategory("");
+ setDraftInputs([[]]);
+ setActiveStepIndex(0);
+ setSelectedInput(null);
+ setSimultaneousMode(false);
+ }
+
+ function selectDraftInput(stepIndex, inputIndex) {
+ setActiveStepIndex(stepIndex);
+ setSelectedInput({ stepIndex, inputIndex });
+ }
+
+ return {
+ comboCategory,
+ comboName,
+ customKey,
+ keyboardLayout,
+ paletteColumns,
+ draftInputs,
+ cleanDraftInputs,
+ activeStepIndex,
+ selectedInput,
+ selectedDraftInput,
+ simultaneousMode,
+ editingComboId,
+ updateDevice,
+ setKeyboardLayout,
+ setComboCategory,
+ setComboName,
+ setCustomKey,
+ addCustomKey,
+ saveCombo,
+ addDraftInput,
+ dragPaletteInput,
+ dragDraftInput,
+ dropInputOnStep,
+ dropInputAsNewStep,
+ selectDraftStep,
+ selectDraftInput,
+ toggleSelectedInputHold,
+ updateSelectedInputHold,
+ toggleSimultaneousMode: () => setSimultaneousMode((enabled) => !enabled),
+ removeLastInput,
+ removeActiveStep,
+ startComboEdit,
+ cancelComboEdit
+ };
+}
diff --git a/website/src/features/toolboxes/modules/useCombosReorder.js b/website/src/features/toolboxes/modules/useCombosReorder.js
new file mode 100644
index 0000000..2b8e026
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useCombosReorder.js
@@ -0,0 +1,73 @@
+// Rôle : configure la réorganisation des combos et catégories via le hook partagé.
+import {
+ applyGroupedReorderOperation,
+ useGroupedReorder
+} from "../../../hooks/useGroupedReorder.js";
+import {
+ getComboCategory,
+ setComboItemCategory
+} from "./combosUtils.js";
+
+export function useCombosReorder({ data, moduleId, save }) {
+ function applyComboReorder(operation) {
+ save(applyGroupedReorderOperation(data, {
+ operation,
+ itemsKey: "combos",
+ groupOrderKey: "categoryOrder",
+ collapsedGroupsKey: "collapsedCategories",
+ getItemGroup: getComboCategory,
+ setItemGroup: setComboItemCategory
+ }));
+ }
+
+ const {
+ itemReorder,
+ groupReorder,
+ getItemProps,
+ getGroupProps,
+ getGroupBoundaryProps,
+ isItemDragging,
+ isItemDropTarget,
+ isGroupDragging,
+ isGroupDropTarget,
+ isBoundaryDropTarget,
+ getDropPlacement,
+ shouldShowGroupBoundaries
+ } = useGroupedReorder({
+ namespace: `combos-${moduleId}`,
+ items: data.combos,
+ getItemId: (combo) => combo.id,
+ getItemGroup: getComboCategory,
+ reorderFeatures: {
+ item: {
+ groupChange: true,
+ boundaryDrop: true,
+ ungroupedDrop: true
+ },
+ group: {
+ reorder: true,
+ boundaryDrop: true,
+ ungroupedDrop: true,
+ itemDrop: true
+ }
+ },
+ onItemMove: applyComboReorder,
+ onGroupMove: applyComboReorder,
+ hierarchy: { enabled: false, stickyParents: false }
+ });
+
+ return {
+ itemReorder,
+ groupReorder,
+ getItemProps,
+ getGroupProps,
+ getGroupBoundaryProps,
+ isItemDragging,
+ isItemDropTarget,
+ isGroupDragging,
+ isGroupDropTarget,
+ isBoundaryDropTarget,
+ getDropPlacement,
+ shouldShowGroupBoundaries
+ };
+}
diff --git a/website/src/features/toolboxes/modules/useGoalTreeController.js b/website/src/features/toolboxes/modules/useGoalTreeController.js
new file mode 100644
index 0000000..f64b7d2
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useGoalTreeController.js
@@ -0,0 +1,287 @@
+// Rôle : orchestre l'état, les mutations, les resets et le reorder de l'arbre d'objectifs.
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
+import {
+ GOAL_TREE_VIEW_SCALE,
+ KIND_ICONS,
+ getDefaultGoalTreeItem,
+ getEffectiveGoalTreeProgressMode,
+ getGoalTreeConvergenceKeepOpenIds,
+ getGoalTreeLinkedControlIds,
+ isSingleValueGoalTreeNode
+} from "./goalTreeDisplay.js";
+import {
+ applyGoalTreeDueResets,
+ evaluateGoalTreeNodes,
+ getGoalTreeChildren,
+ getGoalTreeDescendantIds,
+ getGoalTreeParentIds,
+ getGoalTreeParentMap
+} from "./goalTreeUtils.js";
+
+export function useGoalTreeController({ toolboxId, moduleId, context, editing, setEditing, openEditor }) {
+ const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
+ const textContent = context.moduleText?.goalTree || {};
+ const treeOrientation = data.orientation || "horizontal";
+ const verticalView = treeOrientation === "vertical";
+ const reorderOrientation = treeOrientation === "horizontal" ? "vertical" : "horizontal";
+ const viewScale = editing ? GOAL_TREE_VIEW_SCALE : 1;
+ const evaluatedNodes = useMemo(() => evaluateGoalTreeNodes(data.nodes), [data.nodes]);
+ const evaluatedData = useMemo(() => ({ ...data, nodes: evaluatedNodes }), [data, evaluatedNodes]);
+ const evaluatedNodeMap = useMemo(() => new Map(evaluatedNodes.map((node) => [node.id, node])), [evaluatedNodes]);
+ const [selectedNodeId, setSelectedNodeId] = useState(() => data.nodes[0]?.id || "");
+ const [activeFormTab, setActiveFormTab] = useState("base");
+ const selectedNode = evaluatedNodes.find((node) => node.id === selectedNodeId) || evaluatedNodes[0] || null;
+ const selectedNodeIsStep = selectedNode?.kind === "step";
+ const selectedNodeChildren = selectedNode ? getGoalTreeChildren(evaluatedNodes, selectedNode.id) : [];
+ const selectedProgressMode = selectedNode ? getEffectiveGoalTreeProgressMode(selectedNode, selectedNodeChildren) : "auto";
+ const formTabs = [
+ { id: "base", label: textContent.baseTab || "Base", icon: "cube" },
+ ...(!selectedNodeIsStep ? [{ id: "content", label: textContent.contentTab || "Contenu", icon: "notepad" }] : []),
+ { 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,
+ orientation: reorderOrientation,
+ getItemId: (node) => node.id,
+ getParentId: (node) => parentMap.get(node.id) || "",
+ onItemMove: (operation) => save({ ...data, nodes: moveItem(data.nodes, operation.sourceId, operation.targetId, operation.placement) }),
+ hierarchy: { enabled: true, stickyParents: true }
+ });
+
+ 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);
+ if (selectedNodeId && !data.nodes.some((node) => node.id === selectedNodeId)) setSelectedNodeId(data.nodes[0]?.id || "");
+ }, [data.nodes, selectedNodeId]);
+
+ useEffect(() => {
+ if (activeFormTab === "content" && selectedNodeIsStep) setActiveFormTab("base");
+ }, [activeFormTab, selectedNodeIsStep]);
+
+ function addNode(parentId = "") {
+ const parent = data.nodes.find((node) => node.id === parentId);
+ const node = {
+ id: context.uid("goal"),
+ parentId,
+ title: parent ? textContent.defaultChildTitle || "Nouvelle étape" : textContent.defaultRootTitle || "Nouvel objectif",
+ kind: "step",
+ icon: "cube",
+ color: "gold",
+ qtyTarget: 1,
+ qtyCurrent: 0,
+ formula: parent ? "base" : "",
+ resetType: "none",
+ resetTime: "00:00",
+ weeklyResetDay: 1,
+ items: [],
+ checkedAt: 0,
+ collapsed: false
+ };
+ save({ ...data, nodes: [...data.nodes, node] });
+ setSelectedNodeId(node.id);
+ }
+
+ function editNode(nodeId) {
+ const node = data.nodes.find((item) => item.id === nodeId);
+ if (!node) return;
+ setSelectedNodeId(nodeId);
+ setActiveFormTab(node.kind === "step" ? "base" : "content");
+ openEditor();
+ }
+
+ function addNodeContentItem(nodeId) {
+ const node = data.nodes.find((item) => item.id === nodeId);
+ if (!node || node.kind === "step") {
+ addNode(nodeId);
+ return;
+ }
+ setSelectedNodeId(nodeId);
+ setActiveFormTab("content");
+ openEditor();
+ addNodeItem(nodeId);
+ }
+
+ function updateNode(nodeId, updater) {
+ save({ ...data, nodes: data.nodes.map((node) => node.id === nodeId ? updater(node) : node) });
+ }
+
+ function toggleBranch(nodeId) {
+ const target = data.nodes.find((node) => node.id === nodeId);
+ if (!target) return;
+ const nextCollapsed = !target.collapsed;
+ const keepOpenIds = target.collapsed ? getGoalTreeConvergenceKeepOpenIds(data.nodes, nodeId) : new Set();
+ const linkedControlIds = getGoalTreeLinkedControlIds(data.nodes, nodeId);
+ const siblingIds = data.nodes
+ .filter((node) => node.parentId === target.parentId && node.id !== nodeId)
+ .map((node) => node.id);
+ const closedSiblingIds = new Set(siblingIds.flatMap((id) => [id, ...getGoalTreeDescendantIds(data.nodes, id)]));
+ save({
+ ...data,
+ nodes: data.nodes.map((node) => {
+ if (linkedControlIds.has(node.id)) return { ...node, collapsed: nextCollapsed };
+ if (!target.collapsed) return node;
+ if (keepOpenIds.has(node.id)) return { ...node, collapsed: false };
+ return closedSiblingIds.has(node.id) ? { ...node, collapsed: true } : node;
+ })
+ });
+ }
+
+ function deleteNode(nodeId) {
+ const deletedIds = new Set([nodeId, ...getGoalTreeDescendantIds(data.nodes, nodeId)]);
+ save({ ...data, nodes: data.nodes.filter((node) => !deletedIds.has(node.id)) });
+ if (deletedIds.has(selectedNodeId)) setSelectedNodeId(data.nodes.find((node) => !deletedIds.has(node.id))?.id || "");
+ }
+
+ function setStepTarget(nodeId, value) {
+ const target = String(value || "").replaceAll(",", ".").trim();
+ const numericTarget = Number(target);
+ updateNode(nodeId, (node) => {
+ if (target && Number.isFinite(numericTarget)) {
+ return { ...node, qtyTarget: Math.max(0, numericTarget), formula: "" };
+ }
+ return { ...node, formula: target, qtyTarget: target ? node.qtyTarget : 1 };
+ });
+ }
+
+ function setKind(nodeId, kind) {
+ updateNode(nodeId, (node) => ({
+ ...node,
+ kind,
+ icon: kind === "step" ? node.icon || "cube" : KIND_ICONS[kind],
+ color: kind === "step" ? node.color || "gold" : node.color || "gold",
+ qtyTarget: kind === "step" ? node.qtyTarget : 1,
+ qtyCurrent: kind === "step" ? node.qtyCurrent : 0,
+ formula: kind === "step" ? node.formula : "",
+ items: kind === "step" ? [] : (node.items || []).map((item) => kind === "checklist" ? {
+ ...item,
+ formula: item.formula || (node.parentId ? "base" : "1"),
+ qtyTarget: item.qtyTarget || 1,
+ qtyCurrent: item.qtyCurrent || 0
+ } : { id: item.id, label: item.label })
+ }));
+ }
+
+ function addNodeItem(nodeId) {
+ updateNode(nodeId, (node) => ({
+ ...node,
+ items: [...(node.items || []), getDefaultGoalTreeItem(node.kind, context, textContent, node.parentId)]
+ }));
+ }
+
+ function setStepLeafQuantity(nodeId, quantity) {
+ updateNode(nodeId, (node) => ({
+ ...node,
+ qtyCurrent: quantity,
+ checkedAt: quantity > 0 ? Date.now() : 0
+ }));
+ }
+
+ function setStepChildrenCompletion(nodeId, checked) {
+ const childIds = new Set(getGoalTreeChildren(evaluatedNodes, nodeId).filter(isSingleValueGoalTreeNode).map((node) => node.id));
+ if (!childIds.size) return;
+ save({
+ ...data,
+ nodes: data.nodes.map((node) => {
+ if (!childIds.has(node.id)) return node;
+ const evaluatedNode = evaluatedNodeMap.get(node.id) || node;
+ if (node.kind === "checklist") {
+ const evaluatedItems = new Map((evaluatedNode.items || []).map((item) => [item.id, item]));
+ return {
+ ...node,
+ items: (node.items || []).map((item) => {
+ const evaluatedItem = evaluatedItems.get(item.id) || item;
+ const target = Math.max(1, evaluatedItem.qtyTarget || item.qtyTarget || 1);
+ return { ...item, qtyCurrent: checked ? target : 0 };
+ }),
+ checkedAt: checked ? Date.now() : 0
+ };
+ }
+ const target = Math.max(1, evaluatedNode.computedQtyTarget || node.qtyTarget || 1);
+ return {
+ ...node,
+ qtyCurrent: checked ? target : 0,
+ checkedAt: checked ? Date.now() : 0
+ };
+ })
+ });
+ }
+
+ function updateNodeItem(nodeId, itemId, updater) {
+ updateNode(nodeId, (node) => ({
+ ...node,
+ items: (node.items || []).map((item) => item.id === itemId ? updater(item) : item)
+ }));
+ }
+
+ function deleteNodeItem(nodeId, itemId) {
+ updateNode(nodeId, (node) => ({
+ ...node,
+ items: (node.items || []).filter((item) => item.id !== itemId)
+ }));
+ }
+
+ function importData(nextData) {
+ save(nextData);
+ setSelectedNodeId(nextData.nodes[0]?.id || "");
+ }
+
+ function toggleTreeOrientation() {
+ save({ ...data, orientation: verticalView ? "horizontal" : "vertical" });
+ }
+
+ function toggleLinkedParent(nodeId, parentId) {
+ updateNode(nodeId, (node) => {
+ const parentIds = new Set(getGoalTreeParentIds(node));
+ if (parentIds.has(parentId)) parentIds.delete(parentId);
+ else parentIds.add(parentId);
+ if (node.parentId) parentIds.add(node.parentId);
+ return { ...node, parentIds: [...parentIds] };
+ });
+ }
+
+ return {
+ data,
+ evaluatedData,
+ evaluatedNodes,
+ textContent,
+ treeOrientation,
+ verticalView,
+ viewScale,
+ selectedNode,
+ selectedNodeIsStep,
+ selectedProgressMode,
+ formTabs,
+ activeFormTab,
+ reorder,
+ storageReady: context.storageReady !== false,
+ setEditing,
+ setSelectedNodeId,
+ setActiveFormTab,
+ addNode,
+ editNode,
+ addNodeContentItem,
+ updateNode,
+ toggleBranch,
+ deleteNode,
+ setStepTarget,
+ setKind,
+ addNodeItem,
+ setStepLeafQuantity,
+ setStepChildrenCompletion,
+ updateNodeItem,
+ deleteNodeItem,
+ importData,
+ toggleTreeOrientation,
+ toggleLinkedParent
+ };
+}
diff --git a/website/src/features/toolboxes/modules/useTableClipboard.js b/website/src/features/toolboxes/modules/useTableClipboard.js
new file mode 100644
index 0000000..ad12185
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useTableClipboard.js
@@ -0,0 +1,76 @@
+// Rôle : gère l'export TSV, la copie et le collage de blocs du tableau.
+import { useRef } from "react";
+import { cellAddress } from "./tableFormulaEngine.js";
+import {
+ getBoundsFromStartAndSize,
+ getCellBlock,
+ getSelectionBounds,
+ isMultiCellSelection,
+ parseTsvBlock,
+ pasteCellBlock
+} from "./tableUtils.js";
+
+export function useTableClipboard({ data, context, textContent, evaluatedCells, selection, setSelection, save, focusCell }) {
+ const copiedSelectionRef = useRef(null);
+
+ function getTsv(bounds = null) {
+ const rows = [];
+ const startRow = bounds?.startRow ?? 0;
+ const endRow = bounds?.endRow ?? data.rows - 1;
+ const startColumn = bounds?.startColumn ?? 0;
+ const endColumn = bounds?.endColumn ?? data.columns - 1;
+
+ for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) {
+ const columns = [];
+ for (let columnIndex = startColumn; columnIndex <= endColumn; columnIndex += 1) {
+ columns.push(evaluatedCells[cellAddress(rowIndex, columnIndex)]?.display || "");
+ }
+ rows.push(columns.join("\t"));
+ }
+ return rows.join("\n");
+ }
+
+ async function copyTsv() {
+ const bounds = isMultiCellSelection(selection) ? getSelectionBounds(selection) : null;
+ copiedSelectionRef.current = getCellBlock(data.cells, bounds || getBoundsFromStartAndSize(0, 0, data.rows, data.columns));
+ const text = getTsv(bounds);
+ if (!await context.copyText(text)) return;
+ context.notify?.(textContent.copiedTitle || "Tableau copié");
+ }
+
+ async function copySelection(event) {
+ if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
+ event.preventDefault();
+ const bounds = getSelectionBounds(selection);
+ copiedSelectionRef.current = getCellBlock(data.cells, bounds);
+ const text = getTsv(bounds);
+ await context.copyText(text);
+ }
+
+ function pasteBlock(block, rowIndex, columnIndex) {
+ const result = pasteCellBlock(data.cells, block, rowIndex, columnIndex, data.rows, data.columns);
+ if (!result) return false;
+ save({ ...data, cells: result.cells });
+ setSelection({
+ anchor: { rowIndex: result.bounds.startRow, columnIndex: result.bounds.startColumn },
+ focus: { rowIndex: result.bounds.endRow, columnIndex: result.bounds.endColumn }
+ });
+ focusCell(result.bounds.startRow, result.bounds.startColumn);
+ return true;
+ }
+
+ function pasteSelection(event, rowIndex, columnIndex, isEditing) {
+ if (isEditing) return;
+ const text = event.clipboardData?.getData("text/plain") || "";
+ const block = parseTsvBlock(text) || copiedSelectionRef.current;
+ if (!block) return;
+ event.preventDefault();
+ pasteBlock(block, rowIndex, columnIndex);
+ }
+
+ return {
+ copyTsv,
+ copySelection,
+ pasteSelection
+ };
+}
diff --git a/website/src/features/toolboxes/modules/useTableInteractions.js b/website/src/features/toolboxes/modules/useTableInteractions.js
new file mode 100644
index 0000000..7eea6c3
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useTableInteractions.js
@@ -0,0 +1,317 @@
+// Rôle : centralise les interactions du tableau entre sélection, édition, formules et presse-papiers.
+import { useEffect, useRef, useState } from "react";
+import { cellAddress, parseCellAddress } from "./tableFormulaEngine.js";
+import {
+ clampCellPosition,
+ getBoundsFromStartAndSize,
+ getFormulaRangeText,
+ getSelectionBounds,
+ isCellSelected,
+ isSameBounds,
+ moveSelectionCells
+} from "./tableUtils.js";
+import { useTableClipboard } from "./useTableClipboard.js";
+
+export function useTableInteractions({ data, context, textContent, evaluatedCells, save }) {
+ const [editingCell, setEditingCell] = useState("");
+ const [highlightedCell, setHighlightedCell] = useState("");
+ const [selection, setSelection] = useState(null);
+ const [movePreview, setMovePreview] = useState(null);
+ const draggingSelectionRef = useRef(false);
+ const movingSelectionRef = useRef(null);
+ const formulaSelectionRef = useRef(null);
+ const inputRefs = useRef(new Map());
+ const highlightTimeoutRef = useRef(0);
+ const clipboard = useTableClipboard({ data, context, textContent, evaluatedCells, selection, setSelection, save, focusCell });
+
+ function setCellValue(address, value) {
+ const nextCells = { ...data.cells };
+ if (String(value || "").trim()) {
+ nextCells[address] = value;
+ } else {
+ delete nextCells[address];
+ }
+ save({ ...data, cells: nextCells });
+ }
+
+ function clearSelection(rowIndex, columnIndex) {
+ const nextCells = { ...data.cells };
+ const bounds = getSelectionBounds(selection) || {
+ startRow: rowIndex,
+ endRow: rowIndex,
+ startColumn: columnIndex,
+ endColumn: columnIndex
+ };
+
+ for (let selectedRowIndex = bounds.startRow; selectedRowIndex <= bounds.endRow; selectedRowIndex += 1) {
+ for (let selectedColumnIndex = bounds.startColumn; selectedColumnIndex <= bounds.endColumn; selectedColumnIndex += 1) {
+ delete nextCells[cellAddress(selectedRowIndex, selectedColumnIndex)];
+ }
+ }
+
+ save({ ...data, cells: nextCells });
+ const focusedAddress = cellAddress(rowIndex, columnIndex);
+ window.requestAnimationFrame(() => inputRefs.current.get(focusedAddress)?.focus());
+ }
+
+ function startMovingSelection(rowIndex, columnIndex) {
+ const sourceBounds = getSelectionBounds(selection);
+ if (!sourceBounds || !isCellSelected(selection, rowIndex, columnIndex)) return false;
+ const height = sourceBounds.endRow - sourceBounds.startRow + 1;
+ const width = sourceBounds.endColumn - sourceBounds.startColumn + 1;
+ const targetBounds = getBoundsFromStartAndSize(sourceBounds.startRow, sourceBounds.startColumn, height, width);
+ movingSelectionRef.current = { sourceBounds, targetBounds, height, width };
+ setMovePreview(targetBounds);
+ return true;
+ }
+
+ function updateMovePreview(rowIndex, columnIndex) {
+ const movingSelection = movingSelectionRef.current;
+ if (!movingSelection) return false;
+ const targetRow = Math.min(data.rows - movingSelection.height, Math.max(0, rowIndex));
+ const targetColumn = Math.min(data.columns - movingSelection.width, Math.max(0, columnIndex));
+ const targetBounds = getBoundsFromStartAndSize(targetRow, targetColumn, movingSelection.height, movingSelection.width);
+ movingSelectionRef.current = { ...movingSelection, targetBounds };
+ setMovePreview(targetBounds);
+ return true;
+ }
+
+ function finishMovingSelection() {
+ const movingSelection = movingSelectionRef.current;
+ movingSelectionRef.current = null;
+ draggingSelectionRef.current = false;
+ setMovePreview(null);
+ if (!movingSelection || isSameBounds(movingSelection.sourceBounds, movingSelection.targetBounds)) return;
+ const nextCells = moveSelectionCells(data.cells, movingSelection.sourceBounds, movingSelection.targetBounds);
+ save({ ...data, cells: nextCells });
+ const anchor = {
+ rowIndex: movingSelection.targetBounds.startRow,
+ columnIndex: movingSelection.targetBounds.startColumn
+ };
+ const focus = {
+ rowIndex: movingSelection.targetBounds.endRow,
+ columnIndex: movingSelection.targetBounds.endColumn
+ };
+ setSelection({ anchor, focus });
+ focusCell(anchor.rowIndex, anchor.columnIndex);
+ }
+
+ function registerInput(address, element) {
+ if (element) {
+ inputRefs.current.set(address, element);
+ } else {
+ inputRefs.current.delete(address);
+ }
+ }
+
+ function getActiveFormulaInput() {
+ if (!editingCell) return null;
+ const input = inputRefs.current.get(editingCell);
+ const value = data.cells[editingCell] || "";
+ return input && value.startsWith("=") ? input : null;
+ }
+
+ function focusCell(rowIndex, columnIndex, options = {}) {
+ const position = clampCellPosition(rowIndex, columnIndex, data.rows, data.columns);
+ const address = cellAddress(position.rowIndex, position.columnIndex);
+ window.requestAnimationFrame(() => {
+ const input = inputRefs.current.get(address);
+ input?.focus();
+ if (options.select) {
+ input?.select();
+ } else {
+ input?.setSelectionRange(input.value.length, input.value.length);
+ }
+ });
+ }
+
+ function startEditing(address, initialValue = null, options = {}) {
+ const nextValue = initialValue == null ? data.cells[address] || "" : initialValue;
+ if (initialValue != null) setCellValue(address, initialValue);
+ setEditingCell(address);
+ window.requestAnimationFrame(() => {
+ const input = inputRefs.current.get(address);
+ input?.focus();
+ if (options.select) {
+ input?.select();
+ } else {
+ const position = options.caret ?? nextValue.length;
+ input?.setSelectionRange(position, position);
+ }
+ });
+ }
+
+ function insertFormulaText(text, options = {}) {
+ const input = getActiveFormulaInput();
+ if (!input || !text) return false;
+
+ const currentValue = data.cells[editingCell] || "";
+ const start = options.start ?? input.selectionStart ?? currentValue.length;
+ const end = options.end ?? input.selectionEnd ?? start;
+ const nextValue = `${currentValue.slice(0, start)}${text}${currentValue.slice(end)}`;
+ setCellValue(editingCell, nextValue);
+ setHighlightedCell(options.highlightedCell || text);
+ window.clearTimeout(highlightTimeoutRef.current);
+ highlightTimeoutRef.current = window.setTimeout(() => setHighlightedCell(""), 700);
+ window.requestAnimationFrame(() => {
+ input.focus();
+ input.setSelectionRange(start + text.length, start + text.length);
+ });
+ return true;
+ }
+
+ function insertFormulaReferenceSelection(formulaSelection) {
+ const text = getFormulaRangeText({ anchor: formulaSelection.anchor, focus: formulaSelection.focus });
+ insertFormulaText(text, {
+ start: formulaSelection.start,
+ end: formulaSelection.end,
+ highlightedCell: cellAddress(formulaSelection.focus.rowIndex, formulaSelection.focus.columnIndex)
+ });
+ }
+
+ useEffect(() => {
+ function stopSelectionDrag() {
+ if (movingSelectionRef.current) {
+ finishMovingSelection();
+ return;
+ }
+ if (formulaSelectionRef.current) {
+ const formulaSelection = formulaSelectionRef.current;
+ formulaSelectionRef.current = null;
+ draggingSelectionRef.current = false;
+ insertFormulaReferenceSelection(formulaSelection);
+ return;
+ }
+ draggingSelectionRef.current = false;
+ }
+
+ window.addEventListener("pointerup", stopSelectionDrag);
+ return () => window.removeEventListener("pointerup", stopSelectionDrag);
+ });
+
+ function selectCell(rowIndex, columnIndex, extend = false) {
+ const cell = { rowIndex, columnIndex };
+ setSelection((currentSelection) => extend && currentSelection?.anchor
+ ? { anchor: currentSelection.anchor, focus: cell }
+ : { anchor: cell, focus: cell });
+ }
+
+ function extendSelection(rowIndex, columnIndex) {
+ const cell = { rowIndex, columnIndex };
+ setSelection((currentSelection) => currentSelection?.anchor
+ ? { anchor: currentSelection.anchor, focus: cell }
+ : { anchor: cell, focus: cell });
+ }
+
+ function startFormulaSelection(rowIndex, columnIndex) {
+ const input = getActiveFormulaInput();
+ const formulaCell = parseCellAddress(editingCell);
+ if (!input || (formulaCell?.rowIndex === rowIndex && formulaCell?.columnIndex === columnIndex)) return false;
+ const cell = { rowIndex, columnIndex };
+ formulaSelectionRef.current = {
+ anchor: cell,
+ focus: cell,
+ start: input.selectionStart ?? (data.cells[editingCell] || "").length,
+ end: input.selectionEnd ?? input.selectionStart ?? (data.cells[editingCell] || "").length
+ };
+ setSelection({ anchor: cell, focus: cell });
+ draggingSelectionRef.current = true;
+ return true;
+ }
+
+ function extendFormulaSelection(rowIndex, columnIndex) {
+ if (!formulaSelectionRef.current) return false;
+ const focus = { rowIndex, columnIndex };
+ formulaSelectionRef.current = { ...formulaSelectionRef.current, focus };
+ setSelection({ anchor: formulaSelectionRef.current.anchor, focus });
+ return true;
+ }
+
+ function handleCellKeyDown(event, rowIndex, columnIndex, address, isEditing) {
+ if (!isEditing && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
+ clipboard.copySelection(event);
+ return;
+ }
+
+ if (!isEditing && event.key === "Delete") {
+ event.preventDefault();
+ clearSelection(rowIndex, columnIndex);
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ formulaSelectionRef.current = null;
+ draggingSelectionRef.current = false;
+ setEditingCell("");
+ setSelection({ anchor: { rowIndex, columnIndex }, focus: { rowIndex, columnIndex } });
+ focusCell(rowIndex, columnIndex);
+ return;
+ }
+
+ if (!isEditing && event.key === "Enter") {
+ event.preventDefault();
+ startEditing(address, null, { select: true });
+ return;
+ }
+
+ const deltas = {
+ ArrowUp: [-1, 0],
+ ArrowDown: [1, 0],
+ ArrowLeft: [0, -1],
+ ArrowRight: [0, 1],
+ Enter: [event.shiftKey ? -1 : 1, 0],
+ Tab: [0, event.shiftKey ? -1 : 1]
+ };
+ const delta = deltas[event.key];
+ if (!delta) {
+ if (!isEditing && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault();
+ startEditing(address, event.key, { caret: event.key.length });
+ }
+ return;
+ }
+
+ const input = event.currentTarget;
+ const valueLength = input.value.length;
+ const caretStart = input.selectionStart ?? valueLength;
+ const caretEnd = input.selectionEnd ?? caretStart;
+ const hasTextSelection = caretStart !== caretEnd;
+ const isHorizontalArrow = event.key === "ArrowLeft" || event.key === "ArrowRight";
+ const canMoveHorizontal = event.key === "ArrowLeft" ? caretStart === 0 : caretEnd === valueLength;
+ const shouldNavigate = !isEditing || event.key === "Tab" || event.key === "Enter" || event.shiftKey || !isHorizontalArrow || (!hasTextSelection && canMoveHorizontal);
+
+ if (!shouldNavigate) return;
+
+ event.preventDefault();
+ setEditingCell("");
+ const nextCell = clampCellPosition(rowIndex + delta[0], columnIndex + delta[1], data.rows, data.columns);
+ if (event.shiftKey && event.key.startsWith("Arrow")) {
+ extendSelection(nextCell.rowIndex, nextCell.columnIndex);
+ } else {
+ selectCell(nextCell.rowIndex, nextCell.columnIndex);
+ }
+ focusCell(nextCell.rowIndex, nextCell.columnIndex);
+ }
+
+ return {
+ editingCell,
+ highlightedCell,
+ selection,
+ movePreview,
+ draggingSelectionRef,
+ copyTsv: clipboard.copyTsv,
+ setCellValue,
+ startFormulaSelection,
+ extendFormulaSelection,
+ startMovingSelection,
+ updateMovePreview,
+ selectCell,
+ extendSelection,
+ pasteSelection: clipboard.pasteSelection,
+ handleCellKeyDown,
+ startEditing,
+ clearEditingCell: () => setEditingCell(""),
+ registerInput
+ };
+}
diff --git a/website/src/features/toolboxes/modules/useTaskPlannerReorder.js b/website/src/features/toolboxes/modules/useTaskPlannerReorder.js
new file mode 100644
index 0000000..20dae35
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useTaskPlannerReorder.js
@@ -0,0 +1,120 @@
+// Rôle : configure la réorganisation hiérarchique et par catégories du task planner.
+import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
+import {
+ getBoundaryTaskId,
+ getRootTaskIdsForCategory,
+ getTaskCategory,
+ getTaskCategoryDataset,
+ moveTask
+} from "./taskPlannerUtils.js";
+
+export function useTaskPlannerReorder({ data, parentMap, save }) {
+ const {
+ itemReorder,
+ groupReorder,
+ getItemProps,
+ getGroupProps,
+ getGroupBoundaryProps,
+ isItemDragging,
+ isItemDropTarget,
+ isGroupDragging,
+ isGroupDropTarget,
+ isBoundaryDropTarget,
+ getDropPlacement,
+ shouldShowGroupBoundaries
+ } = useGroupedReorder({
+ namespace: "task-planner",
+ items: data.tasks,
+ getItemId: (task) => task.id,
+ getParentId: (task) => parentMap.get(task.id) || "",
+ getItemGroup: (task) => getTaskCategoryDataset(task, data.tasks, parentMap),
+ getEffectiveGroup: (task) => getTaskCategoryDataset(task, data.tasks, parentMap),
+ reorderFeatures: {
+ item: {
+ groupChange: true,
+ boundaryDrop: true,
+ rootOnly: true
+ },
+ group: {
+ reorder: true,
+ boundaryDrop: true,
+ itemDrop: true,
+ rootOnly: true
+ }
+ },
+ onItemMove: (operation) => {
+ const fromTaskId = operation.sourceId;
+ const fromTask = data.tasks.find((task) => task.id === fromTaskId);
+ if (!fromTask) return;
+ function setRootTaskCategory(tasks, category) {
+ return tasks.map((task) => {
+ if (task.id !== fromTaskId) return task;
+ const nextTask = { ...task };
+ if (category) nextTask.category = category;
+ else delete nextTask.category;
+ return nextTask;
+ });
+ }
+ function moveToCategory(category, placement = operation.placement) {
+ const nextTasks = setRootTaskCategory(data.tasks, category);
+ const targetIds = category
+ ? getRootTaskIdsForCategory(data.tasks, parentMap, category).filter((taskId) => taskId !== fromTaskId)
+ : data.tasks.filter((task) => !parentMap.get(task.id) && !getTaskCategory(task) && task.id !== fromTaskId).map((task) => task.id);
+ const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement);
+ save({ ...data, tasks: boundaryTaskId ? moveTask(nextTasks, fromTaskId, boundaryTaskId, placement) : nextTasks });
+ }
+ if (operation.targetType === "boundary") {
+ const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, operation.targetGroup);
+ const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, operation.placement);
+ if (!boundaryTaskId) return;
+ const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
+ const tasks = sourceCategory ? setRootTaskCategory(data.tasks, "") : data.tasks;
+ save({ ...data, tasks: moveTask(tasks, fromTaskId, boundaryTaskId, operation.placement) });
+ return;
+ }
+ if (operation.targetType === "group") {
+ const targetCategory = operation.targetGroup;
+ const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
+ if (!sourceCategory) {
+ const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, targetCategory);
+ const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, operation.placement);
+ if (boundaryTaskId) save({ ...data, tasks: moveTask(data.tasks, fromTaskId, boundaryTaskId, operation.placement) });
+ return;
+ }
+ moveToCategory(targetCategory);
+ return;
+ }
+ const targetTask = data.tasks.find((task) => task.id === operation.targetId);
+ const targetCategory = targetTask ? getTaskCategoryDataset(targetTask, data.tasks, parentMap) : "";
+ const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
+ const shouldChangeRootCategory = !parentMap.get(fromTaskId) && targetTask && !parentMap.get(operation.targetId) && targetCategory !== sourceCategory;
+ const tasks = shouldChangeRootCategory ? setRootTaskCategory(data.tasks, targetCategory) : data.tasks;
+ save({ ...data, tasks: moveTask(tasks, fromTaskId, operation.targetId, operation.placement) });
+ },
+ onGroupMove: (operation) => {
+ save(applyGroupedReorderOperation(data, {
+ operation,
+ itemsKey: "tasks",
+ groupOrderKey: "categoryOrder",
+ getItemGroup: (task) => parentMap.get(task.id) ? "" : getTaskCategory(task),
+ setItemGroup: (task) => task
+ }));
+ },
+ hierarchy: { enabled: true, stickyParents: true }
+ });
+
+ return {
+ itemReorder,
+ groupReorder,
+ getItemProps,
+ getGroupProps,
+ getGroupBoundaryProps,
+ isItemDragging,
+ isItemDropTarget,
+ isGroupDragging,
+ isGroupDropTarget,
+ isBoundaryDropTarget,
+ getDropPlacement,
+ shouldShowGroupBoundaries
+ };
+}