Split remaining toolbox modules by responsibility
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
43a9e791c5
commit
a0d7e80a1a
25 changed files with 2199 additions and 1992 deletions
295
website/src/features/toolboxes/modules/ToolboxModules.jsx
Normal file
295
website/src/features/toolboxes/modules/ToolboxModules.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue