Refactor large toolbox modules
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s

This commit is contained in:
Shinuwa 2026-08-25 08:20:56 +02:00
parent c2e37ad3f2
commit 6dd64f87c3
19 changed files with 2042 additions and 1659 deletions

View file

@ -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"/);

View file

@ -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 (
<div className="calendar-module" onKeyDown={onKeyDown}>
{formOpen && (
<CalendarEventForm
data={data}
draft={draft}
error={error}
activeOccurrenceDate={activeOccurrenceDate}
monthStart={monthStart}
selectedAlertOffsets={selectedAlertOffsets}
textContent={textContent}
onSubmit={onSubmit}
onDraftChange={onDraftChange}
onAlertModeChange={onAlertModeChange}
onAlertOffsetToggle={onAlertOffsetToggle}
onDeleteEvent={onDeleteEvent}
onDeleteOccurrence={onDeleteOccurrence}
onClose={onClose}
onDisplayedMonthChange={onDisplayedMonthChange}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={onImport} />
</div>
)}
{data.viewMode === "week" && (
<CalendarWeekOptions textContent={textContent} hideEmptyHours={hideEmptyHours} onToggle={onHideEmptyHoursToggle} />
)}
{data.viewMode === "month" ? (
<CalendarMonthView
monthStart={monthStart}
monthGridDays={monthGridDays}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
selectedRange={formOpen ? { startDate: draft.startDate, endDate: draft.endDate || draft.startDate } : null}
dragSelection={monthSelection}
todayDate={formatLocalDateKey(now)}
nowLabel={minutesToTimeValue(nowMinutes)}
onPreviousMonth={() => onDisplayedMonthChange(getMonthStart(addMonths(monthStart, -1)))}
onCurrentMonth={() => onDisplayedMonthChange(getMonthStart(new Date()))}
onNextMonth={() => onDisplayedMonthChange(getMonthStart(addMonths(monthStart, 1)))}
onPointerDown={onMonthPointerDown}
onPointerEnter={onMonthPointerEnter}
onPointerUp={onMonthPointerUp}
onEditEvent={onEditEvent}
/>
) : (
<CalendarWeekGrid
days={days}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
formOpen={formOpen}
draftSelection={draftSelection}
selection={selection}
weekGridLayout={weekGridLayout}
nowDayIndex={nowDayIndex}
nowMinutes={nowMinutes}
onSlotPointerDown={onSlotPointerDown}
onSlotPointerEnter={onSlotPointerEnter}
onSlotPointerUp={onSlotPointerUp}
onEditEvent={onEditEvent}
/>
)}
</div>
);
}
function CalendarWeekOptions({ textContent, hideEmptyHours, onToggle }) {
return (
<div className="calendar-week-options">
<span>{textContent.hideEmptyHoursLabel || "Masquer les heures vides"}</span>
<button
className={`tool-split-scroll-toggle calendar-hide-hours-switch ${hideEmptyHours ? "active" : ""}`}
type="button"
onClick={onToggle}
aria-pressed={hideEmptyHours}
title={textContent.hideEmptyHoursLabel || "Masquer les heures vides"}
>
<Icon name={hideEmptyHours ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
</div>
);
}

View file

@ -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 (
<div className="layout-switch calendar-month-repeat-switch" role="group" aria-label={textContent.monthlyRepeatLabel || "Règle mensuelle"}>
{options.map((option) => (
<button
key={option.value}
className={value === option.value ? "active" : ""}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<div className="calendar-month-event-repeat-switch" role="group" aria-label={textContent.repeatLabel || "Répétition"}>
{options.map((option) => (
<button
key={option.value}
className={safeValue === option.value ? "active" : ""}
type="button"
disabled={option.disabled}
onClick={() => onChange(option.value)}
aria-pressed={safeValue === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<fieldset className="calendar-alert-offsets">
<legend>{textContent.alertOffsetLabel || "Alerte"}</legend>
{CALENDAR_ALERT_OFFSETS.map((offset) => (
<label key={offset}>
<input type="checkbox" checked={selectedOffsets.includes(offset)} onChange={() => onToggle(offset)} />
<span>{labels[offset]}</span>
</label>
))}
</fieldset>
);
}
export function CalendarColorPicker({ value, textContent, onChange }) {
const label = textContent.colorLabel || "Couleur";
return (
<div className="calendar-color-picker notepad-color-dropdown">
<span>{label}</span>
<button
className="calendar-color-toggle notepad-color-toggle"
type="button"
title={label}
aria-label={label}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[value] || CALENDAR_COLOR_VALUES.violet }}
/>
<div className="calendar-color-menu notepad-color-menu" role="menu" aria-label={label}>
{CALENDAR_COLORS.map((color) => (
<button
key={color}
className={`notepad-swatch ${value === color ? "active" : ""}`}
type="button"
title={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-label={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-pressed={value === color}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[color] }}
onClick={() => onChange(color)}
/>
))}
</div>
</div>
);
}
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 (
<div className="timer-alert-mode-switch calendar-alert-mode-switch" role="group" aria-label={textContent.alertModeTitle || "Mode d'alerte"}>
{ALERT_MODES.map((option) => (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
aria-pressed={mode === option.mode}
aria-label={labels[option.mode]}
title={labels[option.mode]}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}

View file

@ -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({
</form>
);
}
function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
return (
<div className={`calendar-month-date-dropdown ${align === "end" ? "is-aligned-end" : ""}`}>
<button className="calendar-month-date-trigger" type="button" aria-label={label}>
<Icon name={mode === "weekday" ? "day-name" : "day-number"} />
<span>{formatMonthDateLabel(safeValue, mode)}</span>
<Icon name="chevron-down" />
</button>
<MonthDatePicker value={safeValue} displayedMonth={displayedMonth} minDate={minDate} mode={mode} textContent={textContent} onChange={onChange} onMonthChange={onMonthChange} />
</div>
);
}
function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
const days = getMonthGridDays(displayedMonth);
const weekdayRows = getWeekdayPickerRows(displayedMonth);
const minDateKey = normalizeDraftDateKey(minDate);
return (
<div className={`calendar-month-date-picker ${mode === "weekday" ? "is-weekday-mode" : ""}`} role="group" aria-label={textContent.monthDatePickerLabel || "Choisir une date"}>
{mode === "weekday" ? (
<>
{weekdayRows.flatMap((row) => [
<span key={`label-${row.ordinal}`} className="calendar-month-date-picker-week">{`S${pad2(row.ordinal)}`}</span>,
...row.days.map((day, dayIndex) => {
const dateKey = day ? formatLocalDateKey(day.date) : "";
const disabled = !day || (minDateKey && dateKey < minDateKey);
return (
<button
key={`${row.ordinal}-${dayIndex}`}
className={`${value === dateKey ? "active" : ""} ${!day ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{WEEKDAY_LABELS[dayIndex]}
</button>
);
})
])}
</>
) : (
<>
<div className="calendar-month-date-picker-header">
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
<strong>{formatMonthTitle(displayedMonth)}</strong>
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
</div>
{WEEKDAY_LABELS.map((day) => <span key={day} className="calendar-month-date-picker-day">{day.slice(0, 3)}</span>)}
{days.map((day) => {
const dateKey = formatLocalDateKey(day.date);
const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
return (
<button
key={dateKey}
className={`${value === dateKey ? "active" : ""} ${!day.inMonth ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{day.dayNumber}
</button>
);
})}
</>
)}
</div>
);
}
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 (
<div className="layout-switch calendar-month-repeat-switch" role="group" aria-label={textContent.monthlyRepeatLabel || "Règle mensuelle"}>
{options.map((option) => (
<button
key={option.value}
className={value === option.value ? "active" : ""}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<div className="calendar-month-event-repeat-switch" role="group" aria-label={textContent.repeatLabel || "Répétition"}>
{options.map((option) => (
<button
key={option.value}
className={safeValue === option.value ? "active" : ""}
type="button"
disabled={option.disabled}
onClick={() => onChange(option.value)}
aria-pressed={safeValue === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<fieldset className="calendar-alert-offsets">
<legend>{textContent.alertOffsetLabel || "Alerte"}</legend>
{CALENDAR_ALERT_OFFSETS.map((offset) => (
<label key={offset}>
<input type="checkbox" checked={selectedOffsets.includes(offset)} onChange={() => onToggle(offset)} />
<span>{labels[offset]}</span>
</label>
))}
</fieldset>
);
}
function CalendarColorPicker({ value, textContent, onChange }) {
const label = textContent.colorLabel || "Couleur";
return (
<div className="calendar-color-picker notepad-color-dropdown">
<span>{label}</span>
<button
className="calendar-color-toggle notepad-color-toggle"
type="button"
title={label}
aria-label={label}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[value] || CALENDAR_COLOR_VALUES.violet }}
/>
<div className="calendar-color-menu notepad-color-menu" role="menu" aria-label={label}>
{CALENDAR_COLORS.map((color) => (
<button
key={color}
className={`notepad-swatch ${value === color ? "active" : ""}`}
type="button"
title={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-label={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-pressed={value === color}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[color] }}
onClick={() => onChange(color)}
/>
))}
</div>
</div>
);
}
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 (
<div className="timer-alert-mode-switch calendar-alert-mode-switch" role="group" aria-label={textContent.alertModeTitle || "Mode d'alerte"}>
{ALERT_MODES.map((option) => (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
aria-pressed={mode === option.mode}
aria-label={labels[option.mode]}
title={labels[option.mode]}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}

View file

@ -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 (
<div className="calendar-module" onKeyDown={handleKeyDown}>
{formOpen && (
<CalendarEventForm
data={data}
draft={draft}
error={error}
activeOccurrenceDate={activeOccurrenceDate}
monthStart={monthStart}
selectedAlertOffsets={selectedAlertOffsets}
textContent={textContent}
onSubmit={submitEvent}
onDraftChange={updateDraft}
onAlertModeChange={updateAlertMode}
onAlertOffsetToggle={toggleAlertOffset}
onDeleteEvent={deleteEvent}
onDeleteOccurrence={deleteOccurrence}
onClose={closeForm}
onDisplayedMonthChange={setDisplayedMonth}
/>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
{data.viewMode === "week" && (
<div className="calendar-week-options">
<span>{textContent.hideEmptyHoursLabel || "Masquer les heures vides"}</span>
<button
className={`tool-split-scroll-toggle calendar-hide-hours-switch ${hideEmptyHours ? "active" : ""}`}
type="button"
onClick={() => setHideEmptyHours((current) => !current)}
aria-pressed={hideEmptyHours}
title={textContent.hideEmptyHoursLabel || "Masquer les heures vides"}
>
<Icon name={hideEmptyHours ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
</div>
)}
{data.viewMode === "month" ? (
<CalendarMonthView
monthStart={monthStart}
monthGridDays={monthGridDays}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
selectedRange={formOpen ? { startDate: draft.startDate, endDate: draft.endDate || draft.startDate } : null}
dragSelection={monthSelection}
todayDate={formatLocalDateKey(now)}
nowLabel={minutesToTimeValue(nowMinutes)}
onPreviousMonth={() => 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}
/>
) : (
<CalendarWeekGrid
days={days}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
formOpen={formOpen}
draftSelection={draftSelection}
selection={selection}
weekGridLayout={weekGridLayout}
nowDayIndex={nowDayIndex}
nowMinutes={nowMinutes}
onSlotPointerDown={handleSlotPointerDown}
onSlotPointerEnter={handleSlotPointerEnter}
onSlotPointerUp={() => setSelection(null)}
onEditEvent={editEvent}
/>
)}
</div>
<CalendarContent
data={data}
context={context}
draft={{ ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) }}
error={error}
activeOccurrenceDate={activeOccurrenceDate}
monthStart={monthStart}
monthGridDays={monthGridDays}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
formOpen={formOpen}
draftSelection={draftSelection}
selection={selection}
monthSelection={monthSelection}
days={days}
weekGridLayout={weekGridLayout}
now={now}
nowDayIndex={nowDayIndex}
nowMinutes={nowMinutes}
hideEmptyHours={hideEmptyHours}
textExchangeOpen={textExchangeOpen}
onKeyDown={handleKeyDown}
onSubmit={submitEvent}
onDraftChange={updateDraft}
onAlertModeChange={updateAlertMode}
onAlertOffsetToggle={toggleAlertOffset}
onDeleteEvent={deleteEvent}
onDeleteOccurrence={deleteOccurrence}
onClose={closeForm}
onDisplayedMonthChange={setDisplayedMonth}
onImport={(nextData) => save(nextData)}
onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
onMonthPointerDown={handleMonthPointerDown}
onMonthPointerEnter={handleMonthPointerEnter}
onMonthPointerUp={() => setMonthSelection(null)}
onSlotPointerDown={handleSlotPointerDown}
onSlotPointerEnter={handleSlotPointerEnter}
onSlotPointerUp={() => setSelection(null)}
onEditEvent={editEvent}
/>
);
}

View file

@ -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 (
<div className={`calendar-month-date-dropdown ${align === "end" ? "is-aligned-end" : ""}`}>
<button className="calendar-month-date-trigger" type="button" aria-label={label}>
<Icon name={mode === "weekday" ? "day-name" : "day-number"} />
<span>{formatMonthDateLabel(safeValue, mode)}</span>
<Icon name="chevron-down" />
</button>
<MonthDatePicker value={safeValue} displayedMonth={displayedMonth} minDate={minDate} mode={mode} textContent={textContent} onChange={onChange} onMonthChange={onMonthChange} />
</div>
);
}
function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
const days = getMonthGridDays(displayedMonth);
const weekdayRows = getWeekdayPickerRows(displayedMonth);
const minDateKey = normalizeDraftDateKey(minDate);
return (
<div className={`calendar-month-date-picker ${mode === "weekday" ? "is-weekday-mode" : ""}`} role="group" aria-label={textContent.monthDatePickerLabel || "Choisir une date"}>
{mode === "weekday" ? (
<>
{weekdayRows.flatMap((row) => [
<span key={`label-${row.ordinal}`} className="calendar-month-date-picker-week">{`S${pad2(row.ordinal)}`}</span>,
...row.days.map((day, dayIndex) => {
const dateKey = day ? formatLocalDateKey(day.date) : "";
const disabled = !day || (minDateKey && dateKey < minDateKey);
return (
<button
key={`${row.ordinal}-${dayIndex}`}
className={`${value === dateKey ? "active" : ""} ${!day ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{WEEKDAY_LABELS[dayIndex]}
</button>
);
})
])}
</>
) : (
<>
<div className="calendar-month-date-picker-header">
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
<strong>{formatMonthTitle(displayedMonth)}</strong>
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
</div>
{WEEKDAY_LABELS.map((day) => <span key={day} className="calendar-month-date-picker-day">{day.slice(0, 3)}</span>)}
{days.map((day) => {
const dateKey = formatLocalDateKey(day.date);
const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
return (
<button
key={dateKey}
className={`${value === dateKey ? "active" : ""} ${!day.inMonth ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{day.dayNumber}
</button>
);
})}
</>
)}
</div>
);
}

View file

@ -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 (
<div className="combos-module">
{(editing || editingComboId) && (
{(editing || editorState.editingComboId) && (
<CombosEditor
data={data}
textContent={textContent}
comboCategory={comboCategory}
comboName={comboName}
customKey={customKey}
keyboardLayout={keyboardLayout}
paletteColumns={paletteColumns}
draftInputs={draftInputs}
cleanDraftInputs={cleanDraftInputs}
activeStepIndex={activeStepIndex}
selectedInput={selectedInput}
selectedDraftInput={selectedDraftInput}
simultaneousMode={simultaneousMode}
editingComboId={editingComboId}
onDeviceChange={updateDevice}
onKeyboardLayoutChange={setKeyboardLayout}
onComboCategoryChange={setComboCategory}
onComboNameChange={setComboName}
onCustomKeyChange={setCustomKey}
onCustomKeyAdd={addCustomKey}
onComboSave={saveCombo}
onDraftInputAdd={addDraftInput}
onPaletteInputDrag={dragPaletteInput}
onDraftInputDrag={dragDraftInput}
onInputDrop={dropInputOnStep}
onNewStepDrop={dropInputAsNewStep}
onStepSelect={selectDraftStep}
onInputSelect={(stepIndex, inputIndex) => {
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}
/>

View file

@ -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 (
<div className={`goal-tree-module ${editing ? "" : "is-view-only"}`}>
{editing && (
<GoalTreeEditor
selectedNode={selectedNode}
selectedNodeIsStep={selectedNodeIsStep}
selectedProgressMode={selectedProgressMode}
formTabs={formTabs}
activeFormTab={activeFormTab}
evaluatedNodes={evaluatedNodes}
textContent={textContent}
onTabChange={setActiveFormTab}
onClose={() => 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 && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="goalTree" data={evaluatedData} context={context} textContent={textContent} onImport={importData} />
<TextExchangeActions type="goalTree" data={controller.evaluatedData} context={context} textContent={controller.textContent} onImport={controller.importData} />
</div>
)}
<section className={`goal-tree-view tool-split-results ${verticalView ? "is-vertical" : "is-horizontal"} ${editing ? "is-editing" : ""}`} aria-label={textContent.treeTitle || "Arbre d'objectifs"}>
<button
className={`module-scroll-button goal-tree-orientation-switch ${verticalView ? "active" : ""}`}
type="button"
onClick={toggleTreeOrientation}
aria-label={verticalView ? textContent.horizontalViewTitle || "Afficher horizontalement" : textContent.verticalViewTitle || "Afficher verticalement"}
aria-pressed={verticalView}
title={verticalView ? textContent.horizontalViewTitle || "Afficher horizontalement" : textContent.verticalViewTitle || "Afficher verticalement"}
>
<Icon name="rotate" />
<i aria-hidden="true" />
</button>
<div className="goal-tree-scroll-area themed-scrollbar">
<div className="goal-tree-view-controls">
{editing && (
<button className="goal-tree-add-root-button" type="button" onClick={() => addNode("")}>
<Icon name="add" />
<span>{textContent.addRootSchemaButton || "Ajouter un objectif a la racine"}</span>
</button>
)}
</div>
{evaluatedNodes.length ? (
<div
className="goal-tree-canvas-frame"
style={{
"--goal-tree-view-scale": viewScale,
"--goal-tree-canvas-width": `${Math.max(1, canvasSize.width) * viewScale}px`,
"--goal-tree-canvas-height": `${Math.max(1, canvasSize.height) * viewScale}px`
}}
>
<div className={`goal-tree-canvas ${verticalView ? "is-vertical" : "is-horizontal"}`} ref={treeViewRef}>
<GoalTreeBranch
nodes={evaluatedNodes}
parentId=""
selectedNodeId={selectedNode?.id || ""}
editing={editing}
reorder={reorder}
textContent={textContent}
onSelect={setSelectedNodeId}
onAdd={addNode}
onAddContentItem={addNodeContentItem}
onSetStepLeafQuantity={setStepLeafQuantity}
onSetStepChildrenCompletion={setStepChildrenCompletion}
onUpdateNodeItem={updateNodeItem}
onEditNode={editNode}
onToggleBranch={toggleBranch}
onDeleteNode={deleteNode}
/>
<DiagramConnectors containerRef={treeViewRef} edges={treeEdges} refreshKey={connectorRefreshKey} className="goal-tree-connectors" stroke="#947436" scale={viewScale} orientation={treeOrientation} />
</div>
</div>
) : (
<div className="goal-tree-empty">
<p>{textContent.emptyTree || "Aucun objectif."}</p>
</div>
)}
</div>
</section>
<GoalTreeView
evaluatedNodes={controller.evaluatedNodes}
selectedNode={controller.selectedNode}
editing={editing}
reorder={controller.reorder}
textContent={controller.textContent}
treeOrientation={controller.treeOrientation}
verticalView={controller.verticalView}
viewScale={controller.viewScale}
storageReady={controller.storageReady}
textExchangeOpen={textExchangeOpen}
onSelect={controller.setSelectedNodeId}
onAdd={controller.addNode}
onAddContentItem={controller.addNodeContentItem}
onSetStepLeafQuantity={controller.setStepLeafQuantity}
onSetStepChildrenCompletion={controller.setStepChildrenCompletion}
onUpdateNodeItem={controller.updateNodeItem}
onEditNode={controller.editNode}
onToggleBranch={controller.toggleBranch}
onDeleteNode={controller.deleteNode}
onToggleOrientation={controller.toggleTreeOrientation}
/>
</div>
);
}

View file

@ -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 (
<section className={`goal-tree-view tool-split-results ${verticalView ? "is-vertical" : "is-horizontal"} ${editing ? "is-editing" : ""}`} aria-label={textContent.treeTitle || "Arbre d'objectifs"}>
<button
className={`module-scroll-button goal-tree-orientation-switch ${verticalView ? "active" : ""}`}
type="button"
onClick={onToggleOrientation}
aria-label={verticalView ? textContent.horizontalViewTitle || "Afficher horizontalement" : textContent.verticalViewTitle || "Afficher verticalement"}
aria-pressed={verticalView}
title={verticalView ? textContent.horizontalViewTitle || "Afficher horizontalement" : textContent.verticalViewTitle || "Afficher verticalement"}
>
<Icon name="rotate" />
<i aria-hidden="true" />
</button>
<div className="goal-tree-scroll-area themed-scrollbar">
<div className="goal-tree-view-controls">
{editing && (
<button className="goal-tree-add-root-button" type="button" onClick={() => onAdd("")}>
<Icon name="add" />
<span>{textContent.addRootSchemaButton || "Ajouter un objectif a la racine"}</span>
</button>
)}
</div>
{evaluatedNodes.length ? (
<div
className="goal-tree-canvas-frame"
style={{
"--goal-tree-view-scale": viewScale,
"--goal-tree-canvas-width": `${Math.max(1, canvasSize.width) * viewScale}px`,
"--goal-tree-canvas-height": `${Math.max(1, canvasSize.height) * viewScale}px`
}}
>
<div className={`goal-tree-canvas ${verticalView ? "is-vertical" : "is-horizontal"}`} ref={treeViewRef}>
<GoalTreeBranch
nodes={evaluatedNodes}
parentId=""
selectedNodeId={selectedNode?.id || ""}
editing={editing}
reorder={reorder}
textContent={textContent}
onSelect={onSelect}
onAdd={onAdd}
onAddContentItem={onAddContentItem}
onSetStepLeafQuantity={onSetStepLeafQuantity}
onSetStepChildrenCompletion={onSetStepChildrenCompletion}
onUpdateNodeItem={onUpdateNodeItem}
onEditNode={onEditNode}
onToggleBranch={onToggleBranch}
onDeleteNode={onDeleteNode}
/>
<DiagramConnectors containerRef={treeViewRef} edges={treeEdges} refreshKey={connectorRefreshKey} className="goal-tree-connectors" stroke="#947436" scale={viewScale} orientation={treeOrientation} />
</div>
</div>
) : (
<div className="goal-tree-empty">
<p>{textContent.emptyTree || "Aucun objectif."}</p>
</div>
)}
</div>
</section>
);
}

View file

@ -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 (
<div className="table-module">
@ -409,32 +65,32 @@ export function TableModule({ toolboxId, moduleId, context, textExchangeOpen })
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
</div>
)}
<TableToolbar rows={data.rows} columns={data.columns} textContent={textContent} onResize={resize} onCopy={copyTsv} />
<TableToolbar rows={data.rows} columns={data.columns} textContent={textContent} onResize={resize} onCopy={tableInteractions.copyTsv} />
<TableGrid
data={data}
textContent={textContent}
evaluatedCells={evaluatedCells}
editingCell={editingCell}
highlightedCell={highlightedCell}
selection={selection}
movePreview={movePreview}
editingCell={tableInteractions.editingCell}
highlightedCell={tableInteractions.highlightedCell}
selection={tableInteractions.selection}
movePreview={tableInteractions.movePreview}
columnWidths={columnWidths}
rowHeaderWidth={rowHeaderWidth}
draggingSelectionRef={draggingSelectionRef}
draggingSelectionRef={tableInteractions.draggingSelectionRef}
onColumnLabelChange={setColumnLabel}
onRowLabelChange={setRowLabel}
onCellChange={setCellValue}
onFormulaSelectionStart={startFormulaSelection}
onFormulaSelectionExtend={extendFormulaSelection}
onStartMoveSelection={startMovingSelection}
onMoveSelection={updateMovePreview}
onSelectCell={selectCell}
onExtendSelection={extendSelection}
onPasteSelection={pasteSelection}
onCellKeyDown={handleCellKeyDown}
onStartEditing={startEditing}
onEditingCellClear={() => 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}
/>
</div>
);

View file

@ -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 (
<div className="task-planner-module">
<div className="task-planner-topbar">
<div className="task-planner-global-controls">
<button
className={`tool-split-scroll-toggle task-planner-hide-completed-toggle ${data.hideCompleted ? "active" : ""}`}
type="button"
onClick={() => save({ ...data, hideCompleted: !data.hideCompleted })}
aria-pressed={data.hideCompleted}
aria-label={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
title={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
>
<Icon name={data.hideCompleted ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
<button
className={`task-planner-settings-toggle ${settingsOpen ? "active" : ""}`}
type="button"
onClick={() => setSettingsOpen((value) => !value)}
aria-expanded={settingsOpen}
aria-label={textContent.settingsTitle || "Réglages du planificateur"}
title={textContent.settingsTitle || "Réglages du planificateur"}
>
<Icon name="settings" />
<span>{textContent.settingsButtonLabel || "Paramètres des réinitialisations"}</span>
</button>
</div>
{editing && (
<div className="task-planner-add-stack">
<form className="inline-form task-planner-add-form" onSubmit={addTask}>
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
</div>
)}
</div>
<TaskPlannerTopbar
data={data}
context={context}
editing={editing}
settingsOpen={settingsOpen}
taskDraft={taskDraft}
textContent={textContent}
textExchangeOpen={textExchangeOpen}
onAddTask={addTask}
onHideCompletedToggle={() => save({ ...data, hideCompleted: !data.hideCompleted })}
onSettingsToggle={() => setSettingsOpen((value) => !value)}
onImport={(nextData) => save(nextData)}
/>
{settingsOpen && (
<section className="task-planner-settings" aria-label={textContent.settingsTitle || "Réglages du planificateur"}>
<label>
<span>{textContent.globalWeeklyResetDayLabel || "Reset hebdo"}</span>
<select value={data.weeklyResetDay} onChange={(event) => save({ ...data, weeklyResetDay: Number(event.target.value) })}>
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
<label>
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input type="time" value={data.resetTime} onChange={(event) => save({ ...data, resetTime: event.target.value || "00:00" })} />
</label>
<div className="task-planner-settings-actions">
<button
className="task-planner-clear-completed-button danger"
type="button"
onClick={clearCompletedUniqueTasks}
disabled={!data.tasks.some((task) => task.type === "unique" && task.checked)}
title={textContent.clearCompletedUniqueTitle || "Nettoyer les tâches ponctuelles terminées"}
aria-label={textContent.clearCompletedUniqueTitle || "Nettoyer les tâches ponctuelles terminées"}
>
<Icon name="trash" />
<span>{textContent.clearCompletedUniqueButton || "Nettoyer les tâches ponctuelles terminées"}</span>
</button>
</div>
</section>
<TaskPlannerSettings
data={data}
textContent={textContent}
onWeeklyResetDayChange={(weeklyResetDay) => save({ ...data, weeklyResetDay })}
onResetTimeChange={(resetTime) => save({ ...data, resetTime })}
onClearCompletedUniqueTasks={clearCompletedUniqueTasks}
/>
)}
<ul className="task-planner-list">

View file

@ -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 (
<section className="task-planner-settings" aria-label={textContent.settingsTitle || "Réglages du planificateur"}>
<label>
<span>{textContent.globalWeeklyResetDayLabel || "Reset hebdo"}</span>
<select value={data.weeklyResetDay} onChange={(event) => onWeeklyResetDayChange(Number(event.target.value))}>
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
</select>
</label>
<label>
<span>{textContent.resetTimeLabel || "Heure de reset"}</span>
<input type="time" value={data.resetTime} onChange={(event) => onResetTimeChange(event.target.value || "00:00")} />
</label>
<div className="task-planner-settings-actions">
<button
className="task-planner-clear-completed-button danger"
type="button"
onClick={onClearCompletedUniqueTasks}
disabled={!data.tasks.some((task) => task.type === "unique" && task.checked)}
title={textContent.clearCompletedUniqueTitle || "Nettoyer les tâches ponctuelles terminées"}
aria-label={textContent.clearCompletedUniqueTitle || "Nettoyer les tâches ponctuelles terminées"}
>
<Icon name="trash" />
<span>{textContent.clearCompletedUniqueButton || "Nettoyer les tâches ponctuelles terminées"}</span>
</button>
</div>
</section>
);
}

View file

@ -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 (
<div className="task-planner-topbar">
<div className="task-planner-global-controls">
<button
className={`tool-split-scroll-toggle task-planner-hide-completed-toggle ${data.hideCompleted ? "active" : ""}`}
type="button"
onClick={onHideCompletedToggle}
aria-pressed={data.hideCompleted}
aria-label={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
title={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
>
<Icon name={data.hideCompleted ? "eye-closed" : "eye-open"} />
<i aria-hidden="true" />
</button>
<button
className={`task-planner-settings-toggle ${settingsOpen ? "active" : ""}`}
type="button"
onClick={onSettingsToggle}
aria-expanded={settingsOpen}
aria-label={textContent.settingsTitle || "Réglages du planificateur"}
title={textContent.settingsTitle || "Réglages du planificateur"}
>
<Icon name="settings" />
<span>{textContent.settingsButtonLabel || "Paramètres des réinitialisations"}</span>
</button>
</div>
{editing && (
<div className="task-planner-add-stack">
<form className="inline-form task-planner-add-form" onSubmit={onAddTask}>
<input {...taskDraft.getFieldProps("title", { placeholder: textContent.titlePlaceholder || "Nouvelle tâche" })} />
<select {...taskDraft.getFieldProps("type", { "aria-label": textContent.typeLabel || "Type" })}>
{TASK_TYPES.map((taskType) => <option key={taskType} value={taskType}>{getTaskTypeLabel(taskType, textContent)}</option>)}
</select>
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
</div>
)}
{textExchangeOpen && (
<div className="module-text-exchange-panel">
<TextExchangeActions type="taskPlanner" data={data} context={context} textContent={textContent} onImport={onImport} />
</div>
)}
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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