sokko-g/website/src/features/toolboxes/modules/index.jsx
Shinuwa de1b8a5638
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
improve notepad features and add drawing canvas
2026-07-29 13:51:42 +02:00

396 lines
15 KiB
JavaScript

// 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 { CompactDropdown } from "../../../components/CompactDropdown.jsx";
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 modules = Object.entries(TOOLBOX_MODULES);
useEffect(() => {
if (!open) return undefined;
return lockBodyScroll();
}, [open]);
function addTool(type) {
onAdd(type);
setOpen(false);
}
return (
<>
<div
className="tool-add-controls"
aria-label="Ajouter un outil"
>
<button
className="primary tool-add-modal-button"
type="button"
onClick={() => setOpen(true)}
>
Ajouter un outil
</button>
<CompactDropdown
className="tool-add-quick-dropdown"
menuClassName="tool-quick-add"
label="Ajout rapide"
openOnHover
closeOnMouseLeave
renderTrigger={({ open: quickOpen, toggle }) => (
<button
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label="Afficher l'ajout rapide"
aria-expanded={quickOpen}
title="Ajout rapide"
>
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<>
{modules.map(([type, module]) => (
<button
key={type}
className="tool-quick-add-button"
type="button"
onClick={() => {
onAdd(type);
close();
}}
aria-label={`Ajouter ${module.label}`}
title={module.label}
>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
</button>
))}
</>
)}
</CompactDropdown>
</div>
{open && createPortal(
<div className="tool-add-modal-root" role="dialog" aria-modal="true" aria-labelledby="tool-add-modal-title">
<div className="confirm-backdrop" onClick={() => setOpen(false)} />
<section className="confirm-modal tool-add-modal">
<header>
<h2 id="tool-add-modal-title">Ajouter un outil</h2>
<button className="drawer-close-button" type="button" onClick={() => setOpen(false)} aria-label="Fermer" title="Fermer">
<Icon name="close" />
</button>
</header>
<div className="tool-add-grid">
{modules.map(([type, module]) => (
<button key={type} className="tool-add-card" type="button" onClick={() => addTool(type)}>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${module.icon}`} />
</span>
<span>{module.label}</span>
</button>
))}
</div>
</section>
</div>,
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 (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{toolbox.modules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
draggingModuleId={draggingModuleId}
dropTarget={dropTarget}
onDragStart={startModuleDrag}
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);
return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => (
<div className="module-column" key={index}>
{modules.map((module) => (
<ModuleShell
key={module.id}
toolbox={toolbox}
module={module}
context={context}
draggingModuleId={draggingModuleId}
dropTarget={dropTarget}
onDragStart={startModuleDrag}
onRename={onRename}
onUpdateModule={onUpdateModule}
onDelete={onDelete}
onMove={onMove}
registerModuleElement={registerModuleElement}
/>
))}
</div>
))}
</section>
);
}
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 (
<article
className={className}
data-toolbox-id={toolbox.id}
data-module-id={module.id}
ref={(element) => registerModuleElement?.(module.id, element)}
>
<header>
<div>
<button
className="module-drag-handle"
type="button"
onPointerDown={(event) => onDragStart(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} 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>
)}
{definition.editable && (
<button
className={`module-edit-button ${editing ? "active" : ""}`}
onClick={() => setEditing((value) => !value)}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</button>
)}
<button className="module-delete-button danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Component toolboxId={toolbox.id} moduleId={module.id} context={context} editing={editing} />
</div>
</article>
);
}
function EditableModuleTitle({ value, fallback, onSave }) {
return (
<h2
className="module-title"
contentEditable
suppressContentEditableWarning
spellCheck="false"
title="Cliquer pour renommer"
onFocus={(event) => { event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }}
onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value;
event.currentTarget.blur();
}
}}
>
{value}
</h2>
);
}