// Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation. import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Icon } from "../../../components/Icon.jsx"; import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; import { lockBodyScroll } from "../../../utils/bodyScrollLock.js"; import { CalculatorModule } from "./CalculatorModule.jsx"; import { ChecklistModule } from "./ChecklistModule.jsx"; import { CountersModule } from "./CountersModule.jsx"; import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx"; import { LinksModule } from "./LinksModule.jsx"; import { NotepadModule } from "./NotepadModule.jsx"; import { ImagesModule } from "./ImagesModule.jsx"; import { TaskPlannerModule } from "./TaskPlannerModule.jsx"; import { TimerModule } from "./TimerModule.jsx"; const MODULE_COMPONENTS = { notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false }, checklist: { label: "Checklist", icon: "checklist", Component: ChecklistModule, editable: true, scrollable: true }, images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true }, links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true }, counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true }, calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false }, timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false }, taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true }, imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true } }; const MODULE_COLUMN_GAP_PX = 16; 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 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; } } return bestLeftDominantSplitIndex || bestSplitIndex; } export function AddToolControls({ onAdd }) { const [open, setOpen] = useState(false); const [quickOpen, setQuickOpen] = useState(false); const controlsRef = useRef(null); const modules = Object.entries(TOOLBOX_MODULES); useEffect(() => { if (!open) return undefined; return lockBodyScroll(); }, [open]); useEffect(() => { if (!quickOpen) return undefined; function handlePointerDown(event) { if (!controlsRef.current?.contains(event.target)) setQuickOpen(false); } function handleKeyDown(event) { if (event.key === "Escape") setQuickOpen(false); } document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [quickOpen]); function addTool(type) { onAdd(type); setOpen(false); setQuickOpen(false); } return ( <>
setQuickOpen(false)} onFocusCapture={(event) => { if (event.target.closest(".tool-add-quick-toggle")) setQuickOpen(true); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setQuickOpen(false); }} aria-label="Ajouter un outil" >
{modules.map(([type, module]) => ( ))}
{open && createPortal(
setOpen(false)} />

Ajouter un outil

{modules.map(([type, module]) => ( ))}
, document.body )} ); } 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()); const moduleIdSignature = useMemo(() => toolbox.modules.map((module) => module.id).join("|"), [toolbox.modules]); const { draggingId: draggingModuleId, dropTarget, startDrag: startModuleDrag } = usePointerReorder({ targetSelector: ".module", getTargetId: (target) => target.dataset.moduleId, canDropOn: (target) => target.dataset.toolboxId === toolbox.id, onMove }); 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 (
{toolbox.modules.map((module) => ( ))}
); } const splitIndex = Math.min(Math.max(measuredSplitIndex, 1), Math.max(1, toolbox.modules.length - 1)); const columns = getSequentialModuleColumns(toolbox.modules, splitIndex); return (
{columns.map((modules, index) => (
{modules.map((module) => ( ))}
))}
); } function ModuleShell({ toolbox, module, context, draggingModuleId, dropTarget, onDragStart, onRename, onUpdateModule, onDelete, registerModuleElement }) { 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 scrollable = definition.scrollable && module.scrollable === true; const isDragging = draggingModuleId === module.id; const isDropTarget = dropTarget.id === module.id; const className = [ "module", isDragging ? "is-dragging" : "", isDropTarget ? "is-drop-target" : "", isDropTarget && dropTarget.placement === "after" ? "drop-after" : "", scrollable ? "is-scrollable" : "" ].filter(Boolean).join(" "); return (
registerModuleElement?.(module.id, element)} >
{definition.scrollable && (