Split remaining toolbox modules by responsibility
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s

This commit is contained in:
Shinuwa 2026-08-25 09:33:15 +02:00
parent 43a9e791c5
commit a0d7e80a1a
25 changed files with 2199 additions and 1992 deletions

View file

@ -0,0 +1,295 @@
// Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation.
import { Suspense, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js";
import { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
const MODULE_COLUMN_IDS = ["left", "right"];
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;
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 { ...order, two: columns };
}
export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
const columns = useMemo(() => [
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
getModulesByIds(toolbox.modules, twoColumnOrder[1])
], [toolbox.modules, twoColumnOrder]);
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,
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 }
});
if (moduleColumns === 1) {
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{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}
/>
))}
</section>
);
}
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 ${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}
toolbox={toolbox}
module={module}
context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
itemGroup={MODULE_COLUMN_IDS[index]}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
/>
))}
<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, itemGroup = "", onRename, onUpdateModule, onDelete }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true;
const className = [
"module",
reorder.isItemDragging(module.id) ? "is-dragging" : "",
reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
scrollable ? "is-scrollable" : ""
].filter(Boolean).join(" ");
const componentProps = {
toolboxId: toolbox.id,
moduleId: module.id,
context,
editing,
setEditing,
openEditor: () => {
setTextExchangeOpen(false);
setEditing(true);
},
textExchangeOpen
};
return (
<article
className={className}
data-module-type={module.type}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header>
<div>
<button
className="module-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, module.id)}
aria-label={`Déplacer ${module.title || label}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
</span>
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "notepad" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div>
{definition.scrollable && (
<button
className={`module-scroll-button ${scrollable ? "active" : ""}`}
onClick={() => onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
if (scrollable) {
delete nextModule.scrollable;
} else {
nextModule.scrollable = true;
}
return nextModule;
})}
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
aria-pressed={scrollable}
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
)}
{hasAddPanel && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setTextExchangeOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</button>
)}
{hasTextExchange && (
<button
className={`module-exchange-button ${textExchangeOpen ? "active" : ""}`}
onClick={() => setTextExchangeOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={`${textExchangeOpen ? "Masquer" : "Afficher"} l'import/export de ${module.title || label}`}
aria-pressed={textExchangeOpen}
title={textExchangeOpen ? "Masquer import/export" : "Import / export"}
>
<Icon name="import" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
{LAZY_MODULE_TYPES.has(module.type) ? (
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<Component {...componentProps} />
</Suspense>
) : (
<Component {...componentProps} />
)}
</div>
</article>
);
}
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
blurOnEscape: true
});
return (
<h2
{...inlineEdit.getContentEditableProps({
className: "module-title",
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer"
})}
>
{value}
</h2>
);
}