Refactor large toolbox modules
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
This commit is contained in:
parent
c2e37ad3f2
commit
6dd64f87c3
19 changed files with 2042 additions and 1659 deletions
|
|
@ -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"/);
|
||||
|
|
|
|||
144
website/src/features/toolboxes/modules/CalendarContent.jsx
Normal file
144
website/src/features/toolboxes/modules/CalendarContent.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
website/src/features/toolboxes/modules/CalendarEventControls.jsx
Normal file
145
website/src/features/toolboxes/modules/CalendarEventControls.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
147
website/src/features/toolboxes/modules/GoalTreeView.jsx
Normal file
147
website/src/features/toolboxes/modules/GoalTreeView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
66
website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx
Normal file
66
website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
website/src/features/toolboxes/modules/useCombosEditorState.js
Normal file
280
website/src/features/toolboxes/modules/useCombosEditorState.js
Normal 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
|
||||
};
|
||||
}
|
||||
73
website/src/features/toolboxes/modules/useCombosReorder.js
Normal file
73
website/src/features/toolboxes/modules/useCombosReorder.js
Normal 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
|
||||
};
|
||||
}
|
||||
287
website/src/features/toolboxes/modules/useGoalTreeController.js
Normal file
287
website/src/features/toolboxes/modules/useGoalTreeController.js
Normal 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
|
||||
};
|
||||
}
|
||||
76
website/src/features/toolboxes/modules/useTableClipboard.js
Normal file
76
website/src/features/toolboxes/modules/useTableClipboard.js
Normal 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
|
||||
};
|
||||
}
|
||||
317
website/src/features/toolboxes/modules/useTableInteractions.js
Normal file
317
website/src/features/toolboxes/modules/useTableInteractions.js
Normal 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
|
||||
};
|
||||
}
|
||||
120
website/src/features/toolboxes/modules/useTaskPlannerReorder.js
Normal file
120
website/src/features/toolboxes/modules/useTaskPlannerReorder.js
Normal 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
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue