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 combosEditor = await readFile("website/src/features/toolboxes/modules/CombosEditor.jsx", "utf8");
|
||||||
const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.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 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 combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
|
||||||
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "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");
|
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 tableGrid = await readFile("website/src/features/toolboxes/modules/TableGrid.jsx", "utf8");
|
||||||
const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.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 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 tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
|
||||||
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.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 timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
|
||||||
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.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 timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
|
||||||
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "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 calendarDraft = await readFile("website/src/features/toolboxes/modules/calendarDraft.js", "utf8");
|
||||||
const calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "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 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 calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
|
||||||
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "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 taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
|
||||||
const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.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 taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
|
||||||
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "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 goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
|
||||||
const goalTreeParts = await readFile("website/src/features/toolboxes/modules/GoalTreeParts.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 goalTreeDisplay = await readFile("website/src/features/toolboxes/modules/goalTreeDisplay.js", "utf8");
|
||||||
const goalTreeUtils = await readFile("website/src/features/toolboxes/modules/goalTreeUtils.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");
|
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(combosUtils, /Nintendo 64/);
|
||||||
assert.match(combosParts, /export function ComboInputToken/);
|
assert.match(combosParts, /export function ComboInputToken/);
|
||||||
assert.match(combosParts, /export function ComboSequence/);
|
assert.match(combosParts, /export function ComboSequence/);
|
||||||
assert.match(combosModule, /keyboardLayout/);
|
assert.match(combosBundle, /keyboardLayout/);
|
||||||
assert.match(combosModule, /simultaneousMode/);
|
assert.match(combosBundle, /simultaneousMode/);
|
||||||
assert.match(combosUtils, /SPECIAL_INPUT_GROUP/);
|
assert.match(combosUtils, /SPECIAL_INPUT_GROUP/);
|
||||||
assert.match(combosUtils, /grab/);
|
assert.match(combosUtils, /grab/);
|
||||||
assert.match(combosUtils, /protect/);
|
assert.match(combosUtils, /protect/);
|
||||||
assert.match(combosUtils, /shoot/);
|
assert.match(combosUtils, /shoot/);
|
||||||
assert.match(combosBundle, /holdMs/);
|
assert.match(combosBundle, /holdMs/);
|
||||||
assert.match(combosModule, /toggleSelectedInputHold/);
|
assert.match(combosBundle, /toggleSelectedInputHold/);
|
||||||
assert.match(combosEditor, /combo-hold-field/);
|
assert.match(combosEditor, /combo-hold-field/);
|
||||||
assert.match(combosParts, /useInlineEdit/);
|
assert.match(combosParts, /useInlineEdit/);
|
||||||
assert.match(combosModule, /useGroupedReorder/);
|
assert.match(combosBundle, /useGroupedReorder/);
|
||||||
assert.match(combosModule, /reorderFeatures/);
|
assert.match(combosBundle, /reorderFeatures/);
|
||||||
assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
|
assert.match(combosBundle, /namespace: `combos-\$\{moduleId\}`/);
|
||||||
assert.match(combosBundle, /getGroupBoundaryProps/);
|
assert.match(combosBundle, /getGroupBoundaryProps/);
|
||||||
assert.match(combosBundle, /combos-category-boundary-drop-zone/);
|
assert.match(combosBundle, /combos-category-boundary-drop-zone/);
|
||||||
assert.match(combosUtils, /function setComboItemCategory/);
|
assert.match(combosUtils, /function setComboItemCategory/);
|
||||||
assert.match(combosModule, /setItemGroup: setComboItemCategory/);
|
assert.match(combosBundle, /setItemGroup: setComboItemCategory/);
|
||||||
assert.doesNotMatch(combosModule, /canMoveItem:/);
|
assert.doesNotMatch(combosModule, /canMoveItem:/);
|
||||||
assert.doesNotMatch(combosModule, /canMoveGroup:/);
|
assert.doesNotMatch(combosModule, /canMoveGroup:/);
|
||||||
assert.match(calculatorModule, /export function CalculatorModule/);
|
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="add-column"/);
|
||||||
assert.match(tableBundle, /Icon name="remove-row"/);
|
assert.match(tableBundle, /Icon name="remove-row"/);
|
||||||
assert.match(tableBundle, /Icon name="remove-column"/);
|
assert.match(tableBundle, /Icon name="remove-column"/);
|
||||||
assert.match(tableModule, /insertFormulaReferenceSelection/);
|
assert.match(tableBundle, /insertFormulaReferenceSelection/);
|
||||||
assert.match(tableModule, /startFormulaSelection/);
|
assert.match(tableBundle, /startFormulaSelection/);
|
||||||
assert.match(tableModule, /copyTsv/);
|
assert.match(tableBundle, /copyTsv/);
|
||||||
assert.match(tableBundle, /notepad-toolbar table-toolbar/);
|
assert.match(tableBundle, /notepad-toolbar table-toolbar/);
|
||||||
assert.match(tableBundle, /notepad-toolbar-group/);
|
assert.match(tableBundle, /notepad-toolbar-group/);
|
||||||
assert.match(tableBundle, /notepad-toolbar-button/);
|
assert.match(tableBundle, /notepad-toolbar-button/);
|
||||||
assert.match(tableModule, /selection/);
|
assert.match(tableBundle, /selection/);
|
||||||
assert.match(tableModule, /draggingSelectionRef/);
|
assert.match(tableBundle, /draggingSelectionRef/);
|
||||||
assert.match(tableModule, /movingSelectionRef/);
|
assert.match(tableBundle, /movingSelectionRef/);
|
||||||
assert.match(tableModule, /copiedSelectionRef/);
|
assert.match(tableBundle, /copiedSelectionRef/);
|
||||||
assert.match(tableUtils, /moveSelectionCells/);
|
assert.match(tableUtils, /moveSelectionCells/);
|
||||||
assert.match(tableUtils, /pasteCellBlock/);
|
assert.match(tableUtils, /pasteCellBlock/);
|
||||||
assert.match(tableUtils, /parseTsvBlock/);
|
assert.match(tableUtils, /parseTsvBlock/);
|
||||||
assert.match(tableGridParts, /is-move-target/);
|
assert.match(tableGridParts, /is-move-target/);
|
||||||
assert.match(tableUtils, /isMultiCellSelection/);
|
assert.match(tableUtils, /isMultiCellSelection/);
|
||||||
assert.match(tableGrid, /export function TableGrid/);
|
assert.match(tableGrid, /export function TableGrid/);
|
||||||
assert.match(tableModule, /handleCellKeyDown/);
|
assert.match(tableBundle, /handleCellKeyDown/);
|
||||||
assert.match(tableModule, /clearSelection/);
|
assert.match(tableBundle, /clearSelection/);
|
||||||
assert.match(tableModule, /event\.key === "Delete"/);
|
assert.match(tableBundle, /event\.key === "Delete"/);
|
||||||
assert.match(tableModule, /ArrowUp/);
|
assert.match(tableBundle, /ArrowUp/);
|
||||||
assert.match(tableModule, /ArrowDown/);
|
assert.match(tableBundle, /ArrowDown/);
|
||||||
assert.match(tableModule, /ArrowLeft/);
|
assert.match(tableBundle, /ArrowLeft/);
|
||||||
assert.match(tableModule, /ArrowRight/);
|
assert.match(tableBundle, /ArrowRight/);
|
||||||
assert.match(tableGridParts, /export function HeaderLabelInput/);
|
assert.match(tableGridParts, /export function HeaderLabelInput/);
|
||||||
assert.match(tableGridParts, /useInlineEdit/);
|
assert.match(tableGridParts, /useInlineEdit/);
|
||||||
assert.match(tableModule, /TextExchangeActions/);
|
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, /durationMinutes < 120/);
|
||||||
assert.match(calendarBundle, /is-compact/);
|
assert.match(calendarBundle, /is-compact/);
|
||||||
assert.match(calendarModule, /handleKeyDown/);
|
assert.match(calendarModule, /handleKeyDown/);
|
||||||
assert.match(calendarModule, /TextExchangeActions/);
|
assert.match(calendarContent, /TextExchangeActions/);
|
||||||
assert.match(calendarModule, /type="calendar"/);
|
assert.match(calendarContent, /type="calendar"/);
|
||||||
assert.match(calendarBundle, /repeatLabel/);
|
assert.match(calendarBundle, /repeatLabel/);
|
||||||
assert.match(calendarBundle, /CalendarAlertModeSwitch/);
|
assert.match(calendarBundle, /CalendarAlertModeSwitch/);
|
||||||
assert.match(calendarDraft, /export function createDefaultDraft/);
|
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, /normalizeTaskPlannerData/);
|
||||||
assert.match(taskPlannerModule, /applyDueResets/);
|
assert.match(taskPlannerModule, /applyDueResets/);
|
||||||
assert.match(taskPlannerModule, /TaskPlannerBranch/);
|
assert.match(taskPlannerModule, /TaskPlannerBranch/);
|
||||||
assert.match(taskPlannerModule, /useGroupedReorder/);
|
assert.match(taskPlannerBundle, /useGroupedReorder/);
|
||||||
assert.match(taskPlannerModule, /useDraftForm/);
|
assert.match(taskPlannerModule, /useDraftForm/);
|
||||||
assert.match(taskPlannerModule, /TextExchangeActions/);
|
assert.match(taskPlannerBundle, /TextExchangeActions/);
|
||||||
assert.match(taskPlannerModule, /reorderFeatures/);
|
assert.match(taskPlannerBundle, /reorderFeatures/);
|
||||||
assert.match(taskPlannerItems, /export function TaskPlannerBranch/);
|
assert.match(taskPlannerItems, /export function TaskPlannerBranch/);
|
||||||
assert.match(taskPlannerItems, /function TaskPlannerItem/);
|
assert.match(taskPlannerItems, /function TaskPlannerItem/);
|
||||||
assert.match(taskPlannerBundle, /task-planner-warning/);
|
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, /canMoveItem:/);
|
||||||
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
|
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
|
||||||
assert.match(goalTreeModule, /export function GoalTreeModule/);
|
assert.match(goalTreeModule, /export function GoalTreeModule/);
|
||||||
assert.match(goalTreeModule, /normalizeGoalTreeData/);
|
assert.match(goalTreeBundle, /normalizeGoalTreeData/);
|
||||||
assert.match(goalTreeModule, /applyGoalTreeDueResets/);
|
assert.match(goalTreeBundle, /applyGoalTreeDueResets/);
|
||||||
assert.match(goalTreeModule, /TextExchangeActions/);
|
assert.match(goalTreeModule, /TextExchangeActions/);
|
||||||
assert.match(goalTreeModule, /DiagramConnectors/);
|
assert.match(goalTreeBundle, /DiagramConnectors/);
|
||||||
assert.match(goalTreeEditor, /export function GoalTreeEditor/);
|
assert.match(goalTreeEditor, /export function GoalTreeEditor/);
|
||||||
assert.match(goalTreeEditor, /Tabs/);
|
assert.match(goalTreeEditor, /Tabs/);
|
||||||
assert.match(goalTreeEditor, /GoalTreeFormulaInput/);
|
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, /GoalTreeChecklistInlineControl/);
|
||||||
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
|
assert.match(goalTreeParts, /GoalTreeStepLeafControl/);
|
||||||
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
|
assert.match(goalTreeParts, /export function GoalTreePrerequisitePicker/);
|
||||||
assert.match(goalTreeModule, /function toggleBranch/);
|
assert.match(goalTreeBundle, /function toggleBranch/);
|
||||||
assert.match(goalTreeModule, /function editNode/);
|
assert.match(goalTreeBundle, /function editNode/);
|
||||||
assert.match(goalTreeModule, /function addNodeContentItem/);
|
assert.match(goalTreeBundle, /function addNodeContentItem/);
|
||||||
assert.match(goalTreeModule, /closedSiblingIds/);
|
assert.match(goalTreeBundle, /closedSiblingIds/);
|
||||||
assert.match(goalTreeModule, /useGroupedReorder/);
|
assert.match(goalTreeBundle, /useGroupedReorder/);
|
||||||
assert.match(goalTreeModule, /const treeOrientation = data\.orientation \|\| "horizontal"/);
|
assert.match(goalTreeBundle, /const treeOrientation = data\.orientation \|\| "horizontal"/);
|
||||||
assert.match(goalTreeModule, /const reorderOrientation = treeOrientation === "horizontal" \? "vertical" : "horizontal"/);
|
assert.match(goalTreeBundle, /const reorderOrientation = treeOrientation === "horizontal" \? "vertical" : "horizontal"/);
|
||||||
assert.match(goalTreeModule, /orientation: reorderOrientation/);
|
assert.match(goalTreeBundle, /orientation: reorderOrientation/);
|
||||||
assert.match(goalTreeModule, /orientation={treeOrientation}/);
|
assert.match(goalTreeView, /orientation={treeOrientation}/);
|
||||||
assert.match(goalTreeModule, /module-scroll-button goal-tree-orientation-switch/);
|
assert.match(goalTreeView, /module-scroll-button goal-tree-orientation-switch/);
|
||||||
assert.match(goalTreeModule, /Icon name="rotate"/);
|
assert.match(goalTreeView, /Icon name="rotate"/);
|
||||||
assert.match(goalTreeModule, /function toggleLinkedParent/);
|
assert.match(goalTreeBundle, /function toggleLinkedParent/);
|
||||||
assert.match(goalTreeDisplay, /grouped: false/);
|
assert.match(goalTreeDisplay, /grouped: false/);
|
||||||
assert.match(goalTreeModule, /getGoalTreeConvergenceKeepOpenIds/);
|
assert.match(goalTreeBundle, /getGoalTreeConvergenceKeepOpenIds/);
|
||||||
assert.match(goalTreeDisplay, /progress\.current/);
|
assert.match(goalTreeDisplay, /progress\.current/);
|
||||||
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
assert.match(goalTreeDisplay, /label: textContent\.itemLabelPlaceholder \|\| "Élément"/);
|
||||||
assert.doesNotMatch(goalTreeUtils, /"calculator"/);
|
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.
|
// 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 { Icon } from "../../../components/Icon.jsx";
|
||||||
import {
|
import {
|
||||||
CALENDAR_ALERT_OFFSETS,
|
WEEK_TIME_INPUT_STEP_MINUTES
|
||||||
CALENDAR_COLORS,
|
|
||||||
WEEK_TIME_INPUT_STEP_MINUTES,
|
|
||||||
addMonths,
|
|
||||||
formatLocalDateKey,
|
|
||||||
getLocalDayIndex,
|
|
||||||
getMonthGridDays,
|
|
||||||
getMonthStart,
|
|
||||||
pad2,
|
|
||||||
parseLocalDateKey
|
|
||||||
} from "./calendarUtils.js";
|
} from "./calendarUtils.js";
|
||||||
import { WEEKDAY_LABELS } from "./calendarLayout.js";
|
import { WEEKDAY_LABELS } from "./calendarLayout.js";
|
||||||
import { ALERT_MODES } from "./timerOptions.js";
|
import { CalendarAlertModeSwitch, CalendarAlertOffsets, CalendarColorPicker, MonthEventRepeatSwitch, MonthlyRepeatSwitch } from "./CalendarEventControls.jsx";
|
||||||
|
import { MonthDateDropdown } from "./CalendarMonthDatePicker.jsx";
|
||||||
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;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarEventForm({
|
export function CalendarEventForm({
|
||||||
data,
|
data,
|
||||||
|
|
@ -189,204 +139,3 @@ export function CalendarEventForm({
|
||||||
</form>
|
</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.
|
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { CalendarContent } from "./CalendarContent.jsx";
|
||||||
import { CalendarEventForm } from "./CalendarEventForm.jsx";
|
|
||||||
import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
|
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
import {
|
import {
|
||||||
CALENDAR_ALERT_MODES,
|
CALENDAR_ALERT_MODES,
|
||||||
CALENDAR_COLORS,
|
CALENDAR_COLORS,
|
||||||
WEEK_SLOT_MINUTES,
|
WEEK_SLOT_MINUTES,
|
||||||
addDays,
|
addDays,
|
||||||
addMonths,
|
|
||||||
formatLocalDateKey,
|
formatLocalDateKey,
|
||||||
getCalendarOccurrences,
|
getCalendarOccurrences,
|
||||||
getLocalDayIndex,
|
getLocalDayIndex,
|
||||||
|
|
@ -391,19 +387,30 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedAlertOffsets = draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="calendar-module" onKeyDown={handleKeyDown}>
|
<CalendarContent
|
||||||
{formOpen && (
|
|
||||||
<CalendarEventForm
|
|
||||||
data={data}
|
data={data}
|
||||||
draft={draft}
|
context={context}
|
||||||
|
draft={{ ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) }}
|
||||||
error={error}
|
error={error}
|
||||||
activeOccurrenceDate={activeOccurrenceDate}
|
activeOccurrenceDate={activeOccurrenceDate}
|
||||||
monthStart={monthStart}
|
monthStart={monthStart}
|
||||||
selectedAlertOffsets={selectedAlertOffsets}
|
monthGridDays={monthGridDays}
|
||||||
|
occurrences={occurrences}
|
||||||
textContent={textContent}
|
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}
|
onSubmit={submitEvent}
|
||||||
onDraftChange={updateDraft}
|
onDraftChange={updateDraft}
|
||||||
onAlertModeChange={updateAlertMode}
|
onAlertModeChange={updateAlertMode}
|
||||||
|
|
@ -412,67 +419,15 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
|
||||||
onDeleteOccurrence={deleteOccurrence}
|
onDeleteOccurrence={deleteOccurrence}
|
||||||
onClose={closeForm}
|
onClose={closeForm}
|
||||||
onDisplayedMonthChange={setDisplayedMonth}
|
onDisplayedMonthChange={setDisplayedMonth}
|
||||||
/>
|
onImport={(nextData) => save(nextData)}
|
||||||
)}
|
onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
|
||||||
{textExchangeOpen && (
|
onMonthPointerDown={handleMonthPointerDown}
|
||||||
<div className="module-text-exchange-panel">
|
onMonthPointerEnter={handleMonthPointerEnter}
|
||||||
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
|
onMonthPointerUp={() => setMonthSelection(null)}
|
||||||
</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}
|
onSlotPointerDown={handleSlotPointerDown}
|
||||||
onSlotPointerEnter={handleSlotPointerEnter}
|
onSlotPointerEnter={handleSlotPointerEnter}
|
||||||
onSlotPointerUp={() => setSelection(null)}
|
onSlotPointerUp={() => setSelection(null)}
|
||||||
onEditEvent={editEvent}
|
onEditEvent={editEvent}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
|
||||||
import { useMemo, useState } from "react";
|
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
|
||||||
import {
|
|
||||||
applyGroupedReorderOperation,
|
|
||||||
getGroupedEntries,
|
|
||||||
useGroupedReorder
|
|
||||||
} from "../../../hooks/useGroupedReorder.js";
|
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import { CombosEditor } from "./CombosEditor.jsx";
|
import { CombosEditor } from "./CombosEditor.jsx";
|
||||||
import { CombosList } from "./CombosList.jsx";
|
import { CombosList } from "./CombosList.jsx";
|
||||||
import {
|
import {
|
||||||
createCombo,
|
getComboCategory
|
||||||
createInput,
|
|
||||||
getComboCategory,
|
|
||||||
getPaletteColumns,
|
|
||||||
getPaletteGroups,
|
|
||||||
normalizeCustomKey,
|
|
||||||
parseHoldSeconds,
|
|
||||||
setComboItemCategory
|
|
||||||
} from "./combosUtils.js";
|
} from "./combosUtils.js";
|
||||||
|
import { useCombosEditorState } from "./useCombosEditorState.js";
|
||||||
|
import { useCombosReorder } from "./useCombosReorder.js";
|
||||||
|
|
||||||
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
export function CombosModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||||
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
|
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
|
||||||
const textContent = context.moduleText?.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, {
|
const entries = getGroupedEntries(data.combos, {
|
||||||
groupOrder: data.categoryOrder,
|
groupOrder: data.categoryOrder,
|
||||||
getItemGroup: getComboCategory,
|
getItemGroup: getComboCategory,
|
||||||
groupIdKey: "category",
|
groupIdKey: "category",
|
||||||
groupItemKey: "combos"
|
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) {
|
function save(nextData) {
|
||||||
context.setModuleData(toolboxId, moduleId, nextData, "combos");
|
context.setModuleData(toolboxId, moduleId, nextData, "combos");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDevice(device) {
|
const editorState = useCombosEditorState({ data, context, textContent, save });
|
||||||
const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device };
|
const reorder = useCombosReorder({ data, moduleId, save });
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCombo(comboId, updater) {
|
function updateCombo(comboId, updater) {
|
||||||
save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) });
|
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) {
|
function deleteCombo(comboId) {
|
||||||
updateCombo(comboId, () => ({ name: "", inputs: [] }));
|
updateCombo(comboId, () => ({ name: "", inputs: [] }));
|
||||||
if (editingComboId === comboId) cancelComboEdit();
|
if (editorState.editingComboId === comboId) editorState.cancelComboEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategoryCollapsed(category) {
|
function toggleCategoryCollapsed(category) {
|
||||||
|
|
@ -322,45 +44,42 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="combos-module">
|
<div className="combos-module">
|
||||||
{(editing || editingComboId) && (
|
{(editing || editorState.editingComboId) && (
|
||||||
<CombosEditor
|
<CombosEditor
|
||||||
data={data}
|
data={data}
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
comboCategory={comboCategory}
|
comboCategory={editorState.comboCategory}
|
||||||
comboName={comboName}
|
comboName={editorState.comboName}
|
||||||
customKey={customKey}
|
customKey={editorState.customKey}
|
||||||
keyboardLayout={keyboardLayout}
|
keyboardLayout={editorState.keyboardLayout}
|
||||||
paletteColumns={paletteColumns}
|
paletteColumns={editorState.paletteColumns}
|
||||||
draftInputs={draftInputs}
|
draftInputs={editorState.draftInputs}
|
||||||
cleanDraftInputs={cleanDraftInputs}
|
cleanDraftInputs={editorState.cleanDraftInputs}
|
||||||
activeStepIndex={activeStepIndex}
|
activeStepIndex={editorState.activeStepIndex}
|
||||||
selectedInput={selectedInput}
|
selectedInput={editorState.selectedInput}
|
||||||
selectedDraftInput={selectedDraftInput}
|
selectedDraftInput={editorState.selectedDraftInput}
|
||||||
simultaneousMode={simultaneousMode}
|
simultaneousMode={editorState.simultaneousMode}
|
||||||
editingComboId={editingComboId}
|
editingComboId={editorState.editingComboId}
|
||||||
onDeviceChange={updateDevice}
|
onDeviceChange={editorState.updateDevice}
|
||||||
onKeyboardLayoutChange={setKeyboardLayout}
|
onKeyboardLayoutChange={editorState.setKeyboardLayout}
|
||||||
onComboCategoryChange={setComboCategory}
|
onComboCategoryChange={editorState.setComboCategory}
|
||||||
onComboNameChange={setComboName}
|
onComboNameChange={editorState.setComboName}
|
||||||
onCustomKeyChange={setCustomKey}
|
onCustomKeyChange={editorState.setCustomKey}
|
||||||
onCustomKeyAdd={addCustomKey}
|
onCustomKeyAdd={editorState.addCustomKey}
|
||||||
onComboSave={saveCombo}
|
onComboSave={editorState.saveCombo}
|
||||||
onDraftInputAdd={addDraftInput}
|
onDraftInputAdd={editorState.addDraftInput}
|
||||||
onPaletteInputDrag={dragPaletteInput}
|
onPaletteInputDrag={editorState.dragPaletteInput}
|
||||||
onDraftInputDrag={dragDraftInput}
|
onDraftInputDrag={editorState.dragDraftInput}
|
||||||
onInputDrop={dropInputOnStep}
|
onInputDrop={editorState.dropInputOnStep}
|
||||||
onNewStepDrop={dropInputAsNewStep}
|
onNewStepDrop={editorState.dropInputAsNewStep}
|
||||||
onStepSelect={selectDraftStep}
|
onStepSelect={editorState.selectDraftStep}
|
||||||
onInputSelect={(stepIndex, inputIndex) => {
|
onInputSelect={editorState.selectDraftInput}
|
||||||
setActiveStepIndex(stepIndex);
|
onSelectedInputHoldToggle={editorState.toggleSelectedInputHold}
|
||||||
setSelectedInput({ stepIndex, inputIndex });
|
onSelectedInputHoldChange={editorState.updateSelectedInputHold}
|
||||||
}}
|
onSimultaneousModeToggle={editorState.toggleSimultaneousMode}
|
||||||
onSelectedInputHoldToggle={toggleSelectedInputHold}
|
onLastInputRemove={editorState.removeLastInput}
|
||||||
onSelectedInputHoldChange={updateSelectedInputHold}
|
onActiveStepRemove={editorState.removeActiveStep}
|
||||||
onSimultaneousModeToggle={() => setSimultaneousMode((enabled) => !enabled)}
|
onComboEditCancel={editorState.cancelComboEdit}
|
||||||
onLastInputRemove={removeLastInput}
|
|
||||||
onActiveStepRemove={removeActiveStep}
|
|
||||||
onComboEditCancel={cancelComboEdit}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{textExchangeOpen && (
|
{textExchangeOpen && (
|
||||||
|
|
@ -378,21 +97,10 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
|
||||||
data={data}
|
data={data}
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
reorder={{
|
reorder={{
|
||||||
itemReorder,
|
...reorder
|
||||||
groupReorder,
|
|
||||||
getItemProps,
|
|
||||||
getGroupProps,
|
|
||||||
getGroupBoundaryProps,
|
|
||||||
isItemDragging,
|
|
||||||
isItemDropTarget,
|
|
||||||
isGroupDragging,
|
|
||||||
isGroupDropTarget,
|
|
||||||
isBoundaryDropTarget,
|
|
||||||
getDropPlacement,
|
|
||||||
shouldShowGroupBoundaries
|
|
||||||
}}
|
}}
|
||||||
onComboUpdate={updateCombo}
|
onComboUpdate={updateCombo}
|
||||||
onComboEdit={startComboEdit}
|
onComboEdit={editorState.startComboEdit}
|
||||||
onComboDelete={deleteCombo}
|
onComboDelete={deleteCombo}
|
||||||
onCategoryToggle={toggleCategoryCollapsed}
|
onCategoryToggle={toggleCategoryCollapsed}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,404 +1,63 @@
|
||||||
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
|
// Rôle : fournit l'outil arbre d'objectifs avec quantités, formules, resets et échange texte.
|
||||||
import { 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 { GoalTreeEditor } from "./GoalTreeEditor.jsx";
|
||||||
|
import { GoalTreeView } from "./GoalTreeView.jsx";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import { GoalTreeBranch } from "./GoalTreeParts.jsx";
|
import { useGoalTreeController } from "./useGoalTreeController.js";
|
||||||
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";
|
|
||||||
|
|
||||||
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
|
export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditing = () => {}, openEditor = () => {}, textExchangeOpen }) {
|
||||||
const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
|
const controller = useGoalTreeController({ toolboxId, moduleId, context, editing, setEditing, openEditor });
|
||||||
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] };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`goal-tree-module ${editing ? "" : "is-view-only"}`}>
|
<div className={`goal-tree-module ${editing ? "" : "is-view-only"}`}>
|
||||||
{editing && (
|
{editing && (
|
||||||
<GoalTreeEditor
|
<GoalTreeEditor
|
||||||
selectedNode={selectedNode}
|
selectedNode={controller.selectedNode}
|
||||||
selectedNodeIsStep={selectedNodeIsStep}
|
selectedNodeIsStep={controller.selectedNodeIsStep}
|
||||||
selectedProgressMode={selectedProgressMode}
|
selectedProgressMode={controller.selectedProgressMode}
|
||||||
formTabs={formTabs}
|
formTabs={controller.formTabs}
|
||||||
activeFormTab={activeFormTab}
|
activeFormTab={controller.activeFormTab}
|
||||||
evaluatedNodes={evaluatedNodes}
|
evaluatedNodes={controller.evaluatedNodes}
|
||||||
textContent={textContent}
|
textContent={controller.textContent}
|
||||||
onTabChange={setActiveFormTab}
|
onTabChange={controller.setActiveFormTab}
|
||||||
onClose={() => setEditing(false)}
|
onClose={() => controller.setEditing(false)}
|
||||||
onDeleteNode={deleteNode}
|
onDeleteNode={controller.deleteNode}
|
||||||
onSetKind={setKind}
|
onSetKind={controller.setKind}
|
||||||
onUpdateNode={updateNode}
|
onUpdateNode={controller.updateNode}
|
||||||
onSetStepTarget={setStepTarget}
|
onSetStepTarget={controller.setStepTarget}
|
||||||
onAddNodeItem={addNodeItem}
|
onAddNodeItem={controller.addNodeItem}
|
||||||
onUpdateNodeItem={updateNodeItem}
|
onUpdateNodeItem={controller.updateNodeItem}
|
||||||
onDeleteNodeItem={deleteNodeItem}
|
onDeleteNodeItem={controller.deleteNodeItem}
|
||||||
onToggleLinkedParent={toggleLinkedParent}
|
onToggleLinkedParent={controller.toggleLinkedParent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{textExchangeOpen && (
|
{textExchangeOpen && (
|
||||||
<div className="module-text-exchange-panel">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className={`goal-tree-view tool-split-results ${verticalView ? "is-vertical" : "is-horizontal"} ${editing ? "is-editing" : ""}`} aria-label={textContent.treeTitle || "Arbre d'objectifs"}>
|
<GoalTreeView
|
||||||
<button
|
evaluatedNodes={controller.evaluatedNodes}
|
||||||
className={`module-scroll-button goal-tree-orientation-switch ${verticalView ? "active" : ""}`}
|
selectedNode={controller.selectedNode}
|
||||||
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}
|
editing={editing}
|
||||||
reorder={reorder}
|
reorder={controller.reorder}
|
||||||
textContent={textContent}
|
textContent={controller.textContent}
|
||||||
onSelect={setSelectedNodeId}
|
treeOrientation={controller.treeOrientation}
|
||||||
onAdd={addNode}
|
verticalView={controller.verticalView}
|
||||||
onAddContentItem={addNodeContentItem}
|
viewScale={controller.viewScale}
|
||||||
onSetStepLeafQuantity={setStepLeafQuantity}
|
storageReady={controller.storageReady}
|
||||||
onSetStepChildrenCompletion={setStepChildrenCompletion}
|
textExchangeOpen={textExchangeOpen}
|
||||||
onUpdateNodeItem={updateNodeItem}
|
onSelect={controller.setSelectedNodeId}
|
||||||
onEditNode={editNode}
|
onAdd={controller.addNode}
|
||||||
onToggleBranch={toggleBranch}
|
onAddContentItem={controller.addNodeContentItem}
|
||||||
onDeleteNode={deleteNode}
|
onSetStepLeafQuantity={controller.setStepLeafQuantity}
|
||||||
|
onSetStepChildrenCompletion={controller.setStepChildrenCompletion}
|
||||||
|
onUpdateNodeItem={controller.updateNodeItem}
|
||||||
|
onEditNode={controller.editNode}
|
||||||
|
onToggleBranch={controller.toggleBranch}
|
||||||
|
onDeleteNode={controller.deleteNode}
|
||||||
|
onToggleOrientation={controller.toggleTreeOrientation}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</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.
|
// 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 { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
import { columnIndexToName, evaluateTable } from "./tableFormulaEngine.js";
|
||||||
import { TableGrid } from "./TableGrid.jsx";
|
import { TableGrid } from "./TableGrid.jsx";
|
||||||
import { TableToolbar } from "./TableToolbar.jsx";
|
import { TableToolbar } from "./TableToolbar.jsx";
|
||||||
import {
|
import {
|
||||||
DEFAULT_COLUMNS,
|
DEFAULT_COLUMNS,
|
||||||
DEFAULT_ROWS,
|
DEFAULT_ROWS,
|
||||||
clampCellPosition,
|
|
||||||
cleanCells,
|
cleanCells,
|
||||||
cleanLabels,
|
cleanLabels,
|
||||||
getBoundsFromStartAndSize,
|
|
||||||
getCellBlock,
|
|
||||||
getColumnWidths,
|
|
||||||
getFormulaRangeText,
|
|
||||||
getRowHeaderWidth,
|
getRowHeaderWidth,
|
||||||
getSelectionBounds,
|
getColumnWidths
|
||||||
isCellSelected,
|
|
||||||
isMultiCellSelection,
|
|
||||||
isSameBounds,
|
|
||||||
moveSelectionCells,
|
|
||||||
parseTsvBlock,
|
|
||||||
pasteCellBlock
|
|
||||||
} from "./tableUtils.js";
|
} from "./tableUtils.js";
|
||||||
|
import { useTableInteractions } from "./useTableInteractions.js";
|
||||||
|
|
||||||
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
||||||
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
||||||
const textContent = context.moduleText?.table || {};
|
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 evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]);
|
||||||
const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]);
|
const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]);
|
||||||
const rowHeaderWidth = useMemo(() => getRowHeaderWidth(data), [data]);
|
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) {
|
function save(nextData) {
|
||||||
context.setModuleData(toolboxId, moduleId, nextData, "table");
|
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) {
|
function resize(rows, columns) {
|
||||||
save({
|
save({
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -168,239 +56,7 @@ export function TableModule({ toolboxId, moduleId, context, textExchangeOpen })
|
||||||
}
|
}
|
||||||
save({ ...data, rowLabels: nextLabels });
|
save({ ...data, rowLabels: nextLabels });
|
||||||
}
|
}
|
||||||
|
const tableInteractions = useTableInteractions({ data, context, textContent, evaluatedCells, save });
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-module">
|
<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")} />
|
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
|
||||||
</div>
|
</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
|
<TableGrid
|
||||||
data={data}
|
data={data}
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
evaluatedCells={evaluatedCells}
|
evaluatedCells={evaluatedCells}
|
||||||
editingCell={editingCell}
|
editingCell={tableInteractions.editingCell}
|
||||||
highlightedCell={highlightedCell}
|
highlightedCell={tableInteractions.highlightedCell}
|
||||||
selection={selection}
|
selection={tableInteractions.selection}
|
||||||
movePreview={movePreview}
|
movePreview={tableInteractions.movePreview}
|
||||||
columnWidths={columnWidths}
|
columnWidths={columnWidths}
|
||||||
rowHeaderWidth={rowHeaderWidth}
|
rowHeaderWidth={rowHeaderWidth}
|
||||||
draggingSelectionRef={draggingSelectionRef}
|
draggingSelectionRef={tableInteractions.draggingSelectionRef}
|
||||||
onColumnLabelChange={setColumnLabel}
|
onColumnLabelChange={setColumnLabel}
|
||||||
onRowLabelChange={setRowLabel}
|
onRowLabelChange={setRowLabel}
|
||||||
onCellChange={setCellValue}
|
onCellChange={tableInteractions.setCellValue}
|
||||||
onFormulaSelectionStart={startFormulaSelection}
|
onFormulaSelectionStart={tableInteractions.startFormulaSelection}
|
||||||
onFormulaSelectionExtend={extendFormulaSelection}
|
onFormulaSelectionExtend={tableInteractions.extendFormulaSelection}
|
||||||
onStartMoveSelection={startMovingSelection}
|
onStartMoveSelection={tableInteractions.startMovingSelection}
|
||||||
onMoveSelection={updateMovePreview}
|
onMoveSelection={tableInteractions.updateMovePreview}
|
||||||
onSelectCell={selectCell}
|
onSelectCell={tableInteractions.selectCell}
|
||||||
onExtendSelection={extendSelection}
|
onExtendSelection={tableInteractions.extendSelection}
|
||||||
onPasteSelection={pasteSelection}
|
onPasteSelection={tableInteractions.pasteSelection}
|
||||||
onCellKeyDown={handleCellKeyDown}
|
onCellKeyDown={tableInteractions.handleCellKeyDown}
|
||||||
onStartEditing={startEditing}
|
onStartEditing={tableInteractions.startEditing}
|
||||||
onEditingCellClear={() => setEditingCell("")}
|
onEditingCellClear={tableInteractions.clearEditingCell}
|
||||||
registerInput={registerInput}
|
registerInput={tableInteractions.registerInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
// 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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
||||||
import { TaskPlannerBranch } from "./TaskPlannerItems.jsx";
|
import { TaskPlannerBranch } from "./TaskPlannerItems.jsx";
|
||||||
|
import { TaskPlannerSettings } from "./TaskPlannerSettings.jsx";
|
||||||
|
import { TaskPlannerTopbar } from "./TaskPlannerTopbar.jsx";
|
||||||
import {
|
import {
|
||||||
TASK_TYPES,
|
|
||||||
WEEK_DAYS,
|
|
||||||
applyDueResets,
|
applyDueResets,
|
||||||
getBoundaryTaskId,
|
getTaskParentMap
|
||||||
getRootTaskIdsForCategory,
|
|
||||||
getTaskCategory,
|
|
||||||
getTaskCategoryDataset,
|
|
||||||
getTaskParentMap,
|
|
||||||
getTaskTypeLabel,
|
|
||||||
moveTask
|
|
||||||
} from "./taskPlannerUtils.js";
|
} from "./taskPlannerUtils.js";
|
||||||
|
import { useTaskPlannerReorder } from "./useTaskPlannerReorder.js";
|
||||||
|
|
||||||
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
||||||
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
|
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 [prerequisiteNoticeTaskIds, setPrerequisiteNoticeTaskIds] = useState(() => new Set());
|
||||||
const prerequisiteNoticeTimeouts = useRef(new Map());
|
const prerequisiteNoticeTimeouts = useRef(new Map());
|
||||||
const parentMap = useMemo(() => getTaskParentMap(data.tasks, data.relations), [data.tasks, data.relations]);
|
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) {
|
function save(nextData) {
|
||||||
context.setModuleData(toolboxId, moduleId, nextData, "taskPlanner");
|
context.setModuleData(toolboxId, moduleId, nextData, "taskPlanner");
|
||||||
}
|
}
|
||||||
|
const reorder = useTaskPlannerReorder({ data, parentMap, save });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
|
const intervalId = window.setInterval(() => setNowMs(Date.now()), 60000);
|
||||||
|
|
@ -280,75 +166,28 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task-planner-module">
|
<div className="task-planner-module">
|
||||||
<div className="task-planner-topbar">
|
<TaskPlannerTopbar
|
||||||
<div className="task-planner-global-controls">
|
data={data}
|
||||||
<button
|
context={context}
|
||||||
className={`tool-split-scroll-toggle task-planner-hide-completed-toggle ${data.hideCompleted ? "active" : ""}`}
|
editing={editing}
|
||||||
type="button"
|
settingsOpen={settingsOpen}
|
||||||
onClick={() => save({ ...data, hideCompleted: !data.hideCompleted })}
|
taskDraft={taskDraft}
|
||||||
aria-pressed={data.hideCompleted}
|
textContent={textContent}
|
||||||
aria-label={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
|
textExchangeOpen={textExchangeOpen}
|
||||||
title={textContent.hideCompletedTitle || "Masquer les tâches effectuées"}
|
onAddTask={addTask}
|
||||||
>
|
onHideCompletedToggle={() => save({ ...data, hideCompleted: !data.hideCompleted })}
|
||||||
<Icon name={data.hideCompleted ? "eye-closed" : "eye-open"} />
|
onSettingsToggle={() => setSettingsOpen((value) => !value)}
|
||||||
<i aria-hidden="true" />
|
onImport={(nextData) => save(nextData)}
|
||||||
</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>
|
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<section className="task-planner-settings" aria-label={textContent.settingsTitle || "Réglages du planificateur"}>
|
<TaskPlannerSettings
|
||||||
<label>
|
data={data}
|
||||||
<span>{textContent.globalWeeklyResetDayLabel || "Reset hebdo"}</span>
|
textContent={textContent}
|
||||||
<select value={data.weeklyResetDay} onChange={(event) => save({ ...data, weeklyResetDay: Number(event.target.value) })}>
|
onWeeklyResetDayChange={(weeklyResetDay) => save({ ...data, weeklyResetDay })}
|
||||||
{WEEK_DAYS.map((day) => <option key={day.value} value={day.value}>{day.label}</option>)}
|
onResetTimeChange={(resetTime) => save({ ...data, resetTime })}
|
||||||
</select>
|
onClearCompletedUniqueTasks={clearCompletedUniqueTasks}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="task-planner-list">
|
<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