diff --git a/website/public/data/site.json b/website/public/data/site.json index 9a2373d..0698883 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -227,6 +227,8 @@ "labelPlaceholder": "Libellé de l'image", "labelAriaLabel": "Libellé de l'image", "previewAriaLabel": "Agrandir l'image", + "reorderAriaLabel": "Déplacer l'image", + "reorderTitle": "Déplacer", "annotateAriaLabel": "Annoter l'image", "annotateTitle": "Annoter", "deleteAriaLabel": "Supprimer l'image", diff --git a/website/public/static/icons/clock.svg b/website/public/static/icons/clock.svg new file mode 100644 index 0000000..8734376 --- /dev/null +++ b/website/public/static/icons/clock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/website/src/features/toolboxes/modules/ImagesModule.jsx b/website/src/features/toolboxes/modules/ImagesModule.jsx index 9e2c41c..0d01765 100644 --- a/website/src/features/toolboxes/modules/ImagesModule.jsx +++ b/website/src/features/toolboxes/modules/ImagesModule.jsx @@ -1,12 +1,33 @@ // Rôle : fournit l'outil images avec import, collage, libellés et annotation rapide. import { useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; export function ImagesModule({ toolboxId, moduleId, context, editing }) { const data = context.getModuleData(toolboxId, moduleId, { images: [] }); const textContent = context.moduleText?.images || {}; const [dragOver, setDragOver] = useState(false); const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici"; + const { + draggingId: draggingImageId, + dropTarget, + startDrag + } = usePointerReorder({ + targetSelector: "[data-image-id]", + getTargetId: (target) => target.dataset.imageId, + canDropOn: (target) => target.dataset.imageModuleId === moduleId, + getPlacement: (event, target) => { + const rect = target.getBoundingClientRect(); + return event.clientX > rect.left + rect.width / 2 ? "after" : "before"; + }, + onMove: (draggingId, targetId, placement) => { + const nextImages = data.images.filter((image) => image.id !== draggingId); + const targetIndex = nextImages.findIndex((image) => image.id === targetId); + if (targetIndex === -1) return; + nextImages.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, data.images.find((image) => image.id === draggingId)); + context.setModuleData(toolboxId, moduleId, { images: nextImages.filter(Boolean) }); + } + }); async function addFiles(files) { if (await context.addImageFiles(toolboxId, moduleId, files)) { @@ -68,40 +89,59 @@ export function ImagesModule({ toolboxId, moduleId, context, editing }) { )}
- {data.images.map((image) => ( -
- - {editing ? ( - updateImage(image.id, { label: event.target.value })} - aria-label={textContent.labelAriaLabel || "Libellé de l'image"} - /> - ) : image.label ? ( -
{image.label}
- ) : null} -
+ {data.images.map((image) => { + const isDragging = draggingImageId === image.id; + const isDropTarget = dropTarget.id === image.id; + const className = [ + isDragging ? "is-dragging" : "", + isDropTarget ? "is-drop-target" : "", + isDropTarget && dropTarget.placement === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + + return ( +
- -
-
- ))} + {editing ? ( + updateImage(image.id, { label: event.target.value })} + aria-label={textContent.labelAriaLabel || "Libellé de l'image"} + /> + ) : image.label ? ( +
{image.label}
+ ) : null} +
+ + +
+ + ); + })}
); diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index a19e456..a45769e 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -1720,6 +1720,60 @@ textarea:focus { background: rgba(5, 7, 17, 0.38); } +.images figure.is-dragging { + opacity: 0.42; +} + +.images figure.is-drop-target { + border-color: rgba(246, 196, 83, 0.72); + box-shadow: 0 0 0 1px rgba(246, 196, 83, 0.26), 0 0 18px rgba(246, 196, 83, 0.12); +} + +.images figure.is-drop-target.drop-after { + border-color: rgba(112, 89, 255, 0.82); + box-shadow: 0 0 0 1px rgba(164, 124, 255, 0.3), 0 0 18px rgba(112, 89, 255, 0.16); +} + +.image-drag-handle { + position: absolute; + z-index: 4; + top: 12px; + left: 12px; + display: grid; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 1px solid rgba(138, 150, 197, 0.22); + border-radius: var(--radius-md); + background: rgba(9, 13, 29, 0.76); + color: rgba(229, 235, 255, 0.78); + cursor: grab; + opacity: 0; + transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast), box-shadow var(--transition-fast); +} + +.image-drag-handle .ui-icon { + width: 16px; + height: 16px; +} + +.images figure:hover .image-drag-handle, +.images figure:focus-within .image-drag-handle { + opacity: 1; +} + +.image-drag-handle:hover { + border-color: rgba(246, 196, 83, 0.56); + background: rgba(20, 28, 55, 0.86); + color: var(--color-accent-gold); + box-shadow: 0 0 14px rgba(246, 196, 83, 0.12); +} + +.image-drag-handle:active { + cursor: grabbing; +} + .image-preview { display: block; width: 100%;