221 lines
10 KiB
JavaScript
221 lines
10 KiB
JavaScript
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables et échange texte.
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Icon } from "../../../components/Icon.jsx";
|
|
import { Tooltip } from "../../../components/Tooltip.jsx";
|
|
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
|
|
function markerLabel(index, marker, textContent) {
|
|
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
|
}
|
|
|
|
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing, textExchangeOpen }) {
|
|
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
|
|
const textContent = context.moduleText?.imageAnnotation || {};
|
|
const notify = context.notify;
|
|
const previewMediaRef = useRef(null);
|
|
const temporaryDrawingsDirtyRef = useRef(false);
|
|
const sessionStorageFailureRef = useRef("");
|
|
const sessionKey = `sokkog:image-annotation-drawings:${toolboxId}:${moduleId}`;
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [drawingMode, setDrawingMode] = useState("permanent");
|
|
const [temporaryDrawings, setTemporaryDrawings] = useState({ strokes: [] });
|
|
const markers = data.markers;
|
|
const drawings = drawingMode === "permanent" ? data.drawings || { strokes: [] } : temporaryDrawings;
|
|
const pastePlaceholder = textContent.pastePlaceholder || "Coller une image ici";
|
|
const getPreviewSurfaceSize = useCallback((element) => ({
|
|
width: element?.clientWidth || 0,
|
|
height: element?.clientHeight || 0
|
|
}), []);
|
|
|
|
useEffect(() => {
|
|
temporaryDrawingsDirtyRef.current = false;
|
|
sessionStorageFailureRef.current = "";
|
|
try {
|
|
const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "{\"strokes\":[]}");
|
|
setTemporaryDrawings(stored?.strokes ? stored : { strokes: [] });
|
|
} catch {
|
|
setTemporaryDrawings({ strokes: [] });
|
|
}
|
|
}, [sessionKey]);
|
|
|
|
useEffect(() => {
|
|
if (drawingMode !== "temporary") return;
|
|
if (!temporaryDrawingsDirtyRef.current) return;
|
|
const serializedDrawings = JSON.stringify(temporaryDrawings);
|
|
try {
|
|
sessionStorage.setItem(sessionKey, serializedDrawings);
|
|
temporaryDrawingsDirtyRef.current = false;
|
|
sessionStorageFailureRef.current = "";
|
|
} catch {
|
|
const failureKey = `${sessionKey}:${serializedDrawings}`;
|
|
if (sessionStorageFailureRef.current === failureKey) return;
|
|
sessionStorageFailureRef.current = failureKey;
|
|
notify?.(textContent.sessionStorageError || "Les annotations temporaires n'ont pas pu être sauvegardées.");
|
|
}
|
|
}, [drawingMode, notify, sessionKey, temporaryDrawings, textContent.sessionStorageError]);
|
|
|
|
function save(nextData) {
|
|
context.setModuleData(toolboxId, moduleId, nextData);
|
|
}
|
|
|
|
async function setImageFromFiles(files) {
|
|
const file = [...files].find((item) => item?.type?.startsWith("image/"));
|
|
if (!file) return;
|
|
save({ image: await context.compressImageFile(file), markers: [], drawings: { strokes: [] } });
|
|
setTemporaryDrawings({ strokes: [] });
|
|
setDragOver(false);
|
|
}
|
|
|
|
function removeImage() {
|
|
save({ image: "", markers: [], drawings: { strokes: [] } });
|
|
setTemporaryDrawings({ strokes: [] });
|
|
}
|
|
|
|
return (
|
|
<div className="image-annotation-module">
|
|
{editing && (
|
|
<div className="module-add-panel">
|
|
<label
|
|
className={`dropzone annotation-dropzone ${dragOver ? "is-drag-over" : ""}`}
|
|
onDragOver={(event) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
setImageFromFiles(event.dataTransfer.files);
|
|
}}
|
|
>
|
|
<input type="file" accept="image/*" hidden onChange={(event) => setImageFromFiles(event.target.files)} />
|
|
{data.image ? textContent.replaceImage || "Remplacer l'image" : textContent.addImage || "Ajouter une image"}
|
|
</label>
|
|
<div
|
|
className="paste-target"
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
role="textbox"
|
|
aria-label={textContent.pasteAriaLabel || "Coller une image à annoter depuis le presse-papiers"}
|
|
onFocus={(event) => {
|
|
if (event.currentTarget.textContent.trim() === pastePlaceholder) event.currentTarget.textContent = "";
|
|
}}
|
|
onBlur={(event) => {
|
|
if (!event.currentTarget.textContent.trim()) event.currentTarget.textContent = pastePlaceholder;
|
|
}}
|
|
onPaste={(event) => {
|
|
const files = [...(event.clipboardData?.items || [])]
|
|
.filter((item) => item.type.startsWith("image/"))
|
|
.map((item) => item.getAsFile())
|
|
.filter(Boolean);
|
|
if (!files.length) return;
|
|
event.preventDefault();
|
|
event.currentTarget.textContent = pastePlaceholder;
|
|
setImageFromFiles(files);
|
|
}}
|
|
>
|
|
{pastePlaceholder}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{textExchangeOpen && (
|
|
<div className="module-text-exchange-panel">
|
|
<TextExchangeActions type="imageAnnotation" data={data} context={context} textContent={textContent} onImport={(nextData) => {
|
|
save(nextData);
|
|
setTemporaryDrawings({ strokes: [] });
|
|
}} />
|
|
</div>
|
|
)}
|
|
|
|
{data.image ? (
|
|
<div className="annotation-workspace">
|
|
<div className="annotation-stage annotation-stage-preview" aria-label={textContent.stageAriaLabel || "Image annotée"}>
|
|
<div className="annotation-media" ref={previewMediaRef}>
|
|
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
|
|
<div className="annotation-preview-actions">
|
|
<button
|
|
className="tool-split-action-button nds-icon-button"
|
|
data-size="sm"
|
|
type="button"
|
|
onClick={() => context.setImage({
|
|
dataUrl: data.image,
|
|
markers,
|
|
drawings,
|
|
alt: textContent.imageAlt || "Image annotée",
|
|
canAnnotate: true,
|
|
createMarkerId: () => context.uid("marker"),
|
|
createStrokeId: () => context.uid("stroke"),
|
|
drawingMode,
|
|
markerPrefix: textContent.markerPrefix || "Marqueur",
|
|
addMarkerAriaLabel: textContent.addMarkerAriaLabel || "Ajouter un marqueur sur l'image",
|
|
markerPlaceholder: textContent.markerPlaceholder || "Libellé du marqueur",
|
|
deleteMarkerTitle: textContent.deleteMarkerTitle || "Supprimer",
|
|
drawingLabel: textContent.drawingLabel || "Dessin",
|
|
drawTitle: textContent.drawTitle || "Dessiner",
|
|
eraseTitle: textContent.eraseTitle || "Gommer",
|
|
undoDrawingTitle: textContent.undoDrawingTitle || "Annuler le dernier trait",
|
|
clearDrawingTitle: textContent.clearDrawingTitle || "Effacer les dessins",
|
|
drawingColorLabel: textContent.drawingColorLabel || "Couleur du dessin",
|
|
drawingWidthLabel: textContent.drawingWidthLabel || "Épaisseur du trait",
|
|
temporaryTitle: textContent.temporaryTitle || "Annotations temporaires",
|
|
permanentTitle: textContent.permanentTitle || "Annotations permanentes",
|
|
onChangeMarkers: (nextMarkers) => save({ ...data, markers: nextMarkers }),
|
|
onChangeDrawings: (nextDrawings, nextMode = drawingMode) => {
|
|
if (nextMode === "permanent") save({ ...data, drawings: nextDrawings });
|
|
else {
|
|
temporaryDrawingsDirtyRef.current = true;
|
|
setTemporaryDrawings(nextDrawings);
|
|
}
|
|
},
|
|
onChangeDrawingMode: (nextMode, currentDrawings) => {
|
|
setDrawingMode(nextMode);
|
|
if (nextMode === "permanent") save({ ...data, drawings: currentDrawings });
|
|
else {
|
|
temporaryDrawingsDirtyRef.current = true;
|
|
setTemporaryDrawings(currentDrawings);
|
|
}
|
|
}
|
|
})}
|
|
aria-label={textContent.previewAriaLabel || "Agrandir l'image annotée"}
|
|
title={textContent.previewTitle || "Agrandir"}
|
|
>
|
|
<Icon name="zoom" />
|
|
</button>
|
|
{editing && (
|
|
<button className="tool-split-action-button nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
|
|
<Icon name="trash" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{markers.map((marker, index) => (
|
|
<Tooltip
|
|
as="span"
|
|
key={marker.id}
|
|
className="annotation-marker"
|
|
content={markerLabel(index, marker, textContent)}
|
|
position="top"
|
|
style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }}
|
|
>
|
|
{index + 1}
|
|
</Tooltip>
|
|
))}
|
|
<DrawingOverlay
|
|
active={false}
|
|
erase={false}
|
|
strokes={drawings.strokes}
|
|
color="#f6c453"
|
|
width={4}
|
|
onChange={() => {}}
|
|
coordinateMode="percent"
|
|
getSurfaceSize={() => getPreviewSurfaceSize(previewMediaRef.current)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="annotation-empty muted">{textContent.emptyImage || "Ajoutez une image pour commencer l'annotation."}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|