396 lines
15 KiB
JavaScript
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>
|
|
);
|
|
}
|