Add manual toolbox module ordering
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-09 20:51:51 +02:00
parent 7140012a98
commit e043d31fa8
10 changed files with 321 additions and 128 deletions

View file

@ -1,5 +1,5 @@
// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
@ -34,51 +34,63 @@ const MODULE_COMPONENTS = {
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
};
const MODULE_COLUMN_GAP_PX = 16;
const MODULE_COLUMN_IDS = ["left", "right"];
export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(MODULE_COMPONENTS).map(([type, module]) => [
type,
{ label: module.label, icon: module.icon }
]));
function getSequentialModuleColumns(modules, splitIndex) {
return [
modules.slice(0, splitIndex),
modules.slice(splitIndex)
function getModuleMap(modules) {
return new Map(modules.map((module) => [module.id, module]));
}
function getModulesByIds(modules, ids) {
const moduleMap = getModuleMap(modules);
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
function getModuleColumnId(moduleId, twoColumnOrder) {
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
}
function moveId(ids, sourceId, targetId, placement = "before") {
const nextIds = ids.filter((id) => id !== sourceId);
const targetIndex = nextIds.indexOf(targetId);
if (targetIndex < 0) return ids;
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
return nextIds;
}
function moveModuleInOneColumnOrder(order, operation) {
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
return {
...order,
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
};
}
function moveModuleInTwoColumnOrder(order, operation) {
const columns = [
[...(order.two?.[0] || [])],
[...(order.two?.[1] || [])]
];
}
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
if (sourceColumnIndex < 0) return order;
const targetColumnIndex = operation.targetType === "item"
? columns.findIndex((column) => column.includes(operation.targetId))
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
if (targetColumnIndex < 0) return order;
function getColumnHeight(heights) {
if (!heights.length) return 0;
return heights.reduce((total, height) => total + height, 0) + MODULE_COLUMN_GAP_PX * (heights.length - 1);
}
function chooseMeasuredSplitIndex(modules, moduleHeights, fallbackSplitIndex) {
if (modules.length <= 1) return modules.length;
if (modules.some((module) => !moduleHeights.get(module.id))) return fallbackSplitIndex;
let bestSplitIndex = fallbackSplitIndex;
let bestDiff = Number.POSITIVE_INFINITY;
let bestLeftDominantSplitIndex = 0;
let bestLeftDominantDiff = Number.POSITIVE_INFINITY;
for (let splitIndex = 1; splitIndex < modules.length; splitIndex += 1) {
const leftHeight = getColumnHeight(modules.slice(0, splitIndex).map((module) => moduleHeights.get(module.id)));
const rightHeight = getColumnHeight(modules.slice(splitIndex).map((module) => moduleHeights.get(module.id)));
const diff = Math.abs(leftHeight - rightHeight);
if (leftHeight >= rightHeight && diff < bestLeftDominantDiff) {
bestLeftDominantDiff = diff;
bestLeftDominantSplitIndex = splitIndex;
}
if (diff < bestDiff) {
bestDiff = diff;
bestSplitIndex = splitIndex;
}
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
if (operation.targetType === "item") {
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
if (targetIndex < 0) return order;
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
} else {
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
}
return bestLeftDominantSplitIndex || bestSplitIndex;
return { ...order, two: columns };
}
export function AddToolControls({ onAdd }) {
@ -178,100 +190,74 @@ export function AddToolControls({ onAdd }) {
);
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onMove }) {
const [measuredSplitIndex, setMeasuredSplitIndex] = useState(() => Math.ceil(toolbox.modules.length / 2));
const moduleElementsRef = useRef(new Map());
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const moduleIdSignature = useMemo(() => toolbox.modules.map((module) => module.id).join("|"), [toolbox.modules]);
const twoColumnOrder = toolbox.moduleOrder?.two || [[], []];
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one, moduleIdSignature]);
const columns = useMemo(() => [
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
getModulesByIds(toolbox.modules, twoColumnOrder[1])
], [toolbox.modules, twoColumnOrder, moduleIdSignature]);
const {
itemReorder,
getItemProps,
getGroupBoundaryProps,
isItemDragging,
isItemDropTarget,
isBoundaryDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: "toolbox-modules",
items: toolbox.modules,
getItemId: (module) => module.id,
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
getParentId: () => toolbox.id,
onItemMove: (operation) => onMove(operation.sourceId, operation.targetId, operation.placement),
reorderFeatures: {
item: {
groupChange: moduleColumns === 2,
boundaryDrop: moduleColumns === 2
}
},
onItemMove: (operation) => {
const nextOrder = moduleColumns === 2
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
onModuleOrderChange(nextOrder);
},
hierarchy: { enabled: true, stickyParents: true }
});
useEffect(() => {
setMeasuredSplitIndex(Math.ceil(toolbox.modules.length / 2));
}, [toolbox.id, moduleIdSignature, toolbox.modules.length]);
useLayoutEffect(() => {
if (moduleColumns !== 2 || toolbox.modules.length <= 1) return undefined;
let frameId = 0;
function rebalanceColumns() {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
const moduleHeights = new Map();
toolbox.modules.forEach((module) => {
const element = moduleElementsRef.current.get(module.id);
if (element) moduleHeights.set(module.id, element.getBoundingClientRect().height);
});
setMeasuredSplitIndex((currentSplitIndex) => {
const fallbackSplitIndex = Math.min(Math.max(currentSplitIndex, 1), toolbox.modules.length - 1);
const nextSplitIndex = chooseMeasuredSplitIndex(toolbox.modules, moduleHeights, fallbackSplitIndex);
return nextSplitIndex === currentSplitIndex ? currentSplitIndex : nextSplitIndex;
});
});
}
const observer = new ResizeObserver(rebalanceColumns);
toolbox.modules.forEach((module) => {
const element = moduleElementsRef.current.get(module.id);
if (element) observer.observe(element);
});
rebalanceColumns();
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [moduleColumns, toolbox.id, moduleIdSignature, toolbox.modules.length]);
function registerModuleElement(moduleId, element) {
if (element) {
moduleElementsRef.current.set(moduleId, element);
} else {
moduleElementsRef.current.delete(moduleId);
}
}
if (moduleColumns === 1) {
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{toolbox.modules.map((module) => (
{orderedModules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup=""
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
onMove={onMove}
registerModuleElement={registerModuleElement}
/>
))}
</section>
);
}
const splitIndex = Math.min(Math.max(measuredSplitIndex, 1), Math.max(1, toolbox.modules.length - 1));
const columns = getSequentialModuleColumns(toolbox.modules, splitIndex);
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => (
<div className="module-column" key={index}>
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
/>
{modules.map((module) => (
<ModuleShell
key={module.id}
@ -279,20 +265,23 @@ export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUp
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
onMove={onMove}
registerModuleElement={registerModuleElement}
/>
))}
<div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
/>
</div>
))}
</section>
);
}
function ModuleShell({ toolbox, module, context, reorder, onRename, onUpdateModule, onDelete, registerModuleElement }) {
function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
@ -309,8 +298,7 @@ function ModuleShell({ toolbox, module, context, reorder, onRename, onUpdateModu
return (
<article
className={className}
{...reorder.getItemProps({ itemId: module.id, parentId: toolbox.id })}
ref={(element) => registerModuleElement?.(module.id, element)}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
<div>