improve notepad features and add drawing canvas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
e73d754082
commit
de1b8a5638
20 changed files with 1732 additions and 141 deletions
|
|
@ -1,5 +1,6 @@
|
|||
// Rôle : affiche une toolbox dans la liste, avec actions rapides et icône personnalisable.
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import { CompactDropdown } from "../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../components/Icon.jsx";
|
||||
import {
|
||||
DEFAULT_TOOLBOX_ICON,
|
||||
|
|
@ -73,54 +74,37 @@ export function ToolboxCard({ toolbox, game, actions, draggingToolboxId, dropTar
|
|||
}
|
||||
|
||||
export function ToolboxIconPicker({ toolbox, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pickerRef = useRef(null);
|
||||
const icon = normalizeToolboxIcon(toolbox.icon);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (!pickerRef.current?.contains(event.target)) setOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function selectIcon(nextIcon) {
|
||||
onChange(nextIcon);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="toolbox-icon-picker" ref={pickerRef}>
|
||||
<button
|
||||
className="toolbox-icon-picker-button"
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-label="Changer l'icône de la toolbox"
|
||||
aria-expanded={open}
|
||||
title="Changer l'icône"
|
||||
>
|
||||
<img src={icon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="toolbox-icon-picker-menu" aria-label="Icônes de toolbox">
|
||||
<CompactDropdown
|
||||
className="toolbox-icon-picker"
|
||||
menuClassName="toolbox-icon-picker-menu"
|
||||
label="Icônes de toolbox"
|
||||
renderTrigger={({ open, toggle }) => (
|
||||
<button
|
||||
className="toolbox-icon-picker-button"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label="Changer l'icône de la toolbox"
|
||||
aria-expanded={open}
|
||||
title="Changer l'icône"
|
||||
>
|
||||
<img src={icon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{TOOLBOX_ICONS.map((path) => (
|
||||
<button
|
||||
key={path}
|
||||
className={path === icon ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => selectIcon(path)}
|
||||
onClick={() => {
|
||||
onChange(path);
|
||||
close();
|
||||
}}
|
||||
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
|
||||
aria-pressed={path === icon}
|
||||
title={path.split("/").pop().replace(".png", "")}
|
||||
|
|
@ -128,9 +112,9 @@ export function ToolboxIconPicker({ toolbox, onChange }) {
|
|||
<img src={path} alt="" aria-hidden="true" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CompactDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
normalizeCountersData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeLinksData,
|
||||
normalizeNotepadData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeTimerData,
|
||||
normalizeUrl,
|
||||
|
|
@ -146,6 +147,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
normalizeCountersData,
|
||||
normalizeCalculatorData,
|
||||
normalizeImageAnnotationData,
|
||||
normalizeNotepadData,
|
||||
normalizeTimerData,
|
||||
normalizeTaskPlannerData,
|
||||
normalizeUrl,
|
||||
|
|
|
|||
180
website/src/features/toolboxes/modules/DrawingControls.jsx
Normal file
180
website/src/features/toolboxes/modules/DrawingControls.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// Rôle : rend les contrôles compacts des calques de dessin réutilisables.
|
||||
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
|
||||
const DEFAULT_WIDTHS = [2, 4, 8, 12];
|
||||
|
||||
export function DrawingControls({ textContent = {}, active, onToggleActive, eraseActive, onToggleErase, color, colors, onColorChange, width, widths = DEFAULT_WIDTHS, onWidthChange, onUndo, onClear, storageMode, onToggleStorageMode }) {
|
||||
return (
|
||||
<div className={`drawing-controls-panel ${active ? "is-open" : ""}`} aria-label={textContent.drawingLabel || "Dessin"}>
|
||||
<button className={`drawing-control-button ${active ? "active" : ""}`} type="button" aria-pressed={active} title={textContent.drawTitle || "Dessiner"} onClick={onToggleActive}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
{active && (
|
||||
<>
|
||||
<button
|
||||
className={`drawing-control-button ${eraseActive ? "active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={eraseActive}
|
||||
title={textContent.eraseTitle || "Gommer"}
|
||||
onClick={onToggleErase}
|
||||
>
|
||||
<Icon name="rubber" />
|
||||
</button>
|
||||
<DrawingColorDropdown
|
||||
label={textContent.drawingColorLabel || "Couleur du dessin"}
|
||||
colors={colors}
|
||||
value={color}
|
||||
onSelect={onColorChange}
|
||||
/>
|
||||
<DrawingWidthDropdown
|
||||
label={textContent.drawingWidthLabel || "Épaisseur du trait"}
|
||||
widths={widths}
|
||||
value={width}
|
||||
onSelect={onWidthChange}
|
||||
/>
|
||||
<button className="drawing-control-button" type="button" title={textContent.undoDrawingTitle || "Annuler le dernier trait"} onClick={onUndo}>
|
||||
<Icon name="back" />
|
||||
</button>
|
||||
<button className="drawing-control-button danger" type="button" title={textContent.clearDrawingTitle || "Effacer les dessins"} onClick={onClear}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
{onToggleStorageMode && (
|
||||
<button
|
||||
className={`module-scroll-button drawing-storage-switch ${storageMode === "permanent" ? "active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={storageMode === "permanent"}
|
||||
aria-label={storageMode === "permanent" ? textContent.permanentTitle || "Annotations permanentes" : textContent.temporaryTitle || "Annotations temporaires"}
|
||||
title={storageMode === "permanent" ? textContent.permanentTitle || "Annotations permanentes" : textContent.temporaryTitle || "Annotations temporaires"}
|
||||
onClick={onToggleStorageMode}
|
||||
>
|
||||
<Icon name="save" />
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormattingColorDropdown({ label, sections }) {
|
||||
return (
|
||||
<CompactDropdown
|
||||
className="notepad-color-dropdown"
|
||||
menuClassName="notepad-color-menu is-sectioned"
|
||||
label={label}
|
||||
closeOnMouseLeave
|
||||
preventMouseDown
|
||||
renderTrigger={({ toggle }) => (
|
||||
<button className="notepad-color-toggle is-rainbow" type="button" title={label} aria-label={label} onClick={toggle} />
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<div className="notepad-color-section" key={section.label}>
|
||||
<span className="notepad-color-section-label">{section.label}</span>
|
||||
<div className="notepad-color-section-swatches">
|
||||
{section.colors.map((color) => (
|
||||
<button
|
||||
key={`${section.label}-${color}`}
|
||||
className={`notepad-swatch ${section.value === color ? "active" : ""}`}
|
||||
type="button"
|
||||
title={`${section.label} ${color}`}
|
||||
aria-label={`${section.label} ${color}`}
|
||||
aria-pressed={section.value === color}
|
||||
style={{ "--swatch-color": color }}
|
||||
onClick={() => {
|
||||
section.onSelect(color);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawingColorDropdown({ label, colors, value, onSelect }) {
|
||||
return (
|
||||
<CompactDropdown
|
||||
className="notepad-color-dropdown"
|
||||
menuClassName="notepad-color-menu"
|
||||
label={label}
|
||||
closeOnMouseLeave
|
||||
preventMouseDown
|
||||
renderTrigger={({ toggle }) => (
|
||||
<button
|
||||
className="notepad-color-toggle is-rainbow"
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{ "--swatch-color": value }}
|
||||
onClick={toggle}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`notepad-swatch ${value === color ? "active" : ""}`}
|
||||
type="button"
|
||||
title={`${label} ${color}`}
|
||||
aria-label={`${label} ${color}`}
|
||||
aria-pressed={value === color}
|
||||
style={{ "--swatch-color": color }}
|
||||
onClick={() => {
|
||||
onSelect(color);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawingWidthDropdown({ label, widths, value, onSelect }) {
|
||||
return (
|
||||
<CompactDropdown
|
||||
className="drawing-width-dropdown"
|
||||
menuClassName="drawing-width-menu"
|
||||
label={label}
|
||||
closeOnMouseLeave
|
||||
preventMouseDown
|
||||
renderTrigger={({ toggle }) => (
|
||||
<button className="drawing-width-toggle" type="button" title={label} aria-label={label} onClick={toggle}>
|
||||
<span style={{ "--line-width": `${value}px` }} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{widths.map((lineWidth) => (
|
||||
<button
|
||||
key={lineWidth}
|
||||
className={`drawing-width-button ${value === lineWidth ? "active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={value === lineWidth}
|
||||
title={`${label} ${lineWidth}`}
|
||||
onClick={() => {
|
||||
onSelect(lineWidth);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span style={{ "--line-width": `${lineWidth}px` }} />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CompactDropdown>
|
||||
);
|
||||
}
|
||||
142
website/src/features/toolboxes/modules/DrawingOverlay.jsx
Normal file
142
website/src/features/toolboxes/modules/DrawingOverlay.jsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Rôle : fournit un calque canvas réutilisable pour les annotations dessinées.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
function getCanvasPoint(event, canvas, coordinateMode = "pixel") {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
if (coordinateMode === "percent") return { x: rect.width ? (x / rect.width) * 100 : 0, y: rect.height ? (y / rect.height) * 100 : 0 };
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function pointDistance(a, b) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
function toCanvasPoint(point, size, coordinateMode) {
|
||||
if (coordinateMode === "percent") return { x: (point.x / 100) * size.width, y: (point.y / 100) * size.height };
|
||||
return point;
|
||||
}
|
||||
|
||||
function strokeHitsPoint(stroke, point, size, coordinateMode) {
|
||||
const canvasPoint = toCanvasPoint(point, size, coordinateMode);
|
||||
const threshold = Math.max(10, (Number(stroke.width) || 4) * 2.2);
|
||||
return stroke.points.some((strokePoint) => pointDistance(toCanvasPoint(strokePoint, size, coordinateMode), canvasPoint) <= threshold);
|
||||
}
|
||||
|
||||
function drawStroke(context, stroke, size, coordinateMode) {
|
||||
if (!stroke.points.length) return;
|
||||
context.strokeStyle = stroke.color;
|
||||
context.lineWidth = stroke.width;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
stroke.points.forEach((point, index) => {
|
||||
const canvasPoint = toCanvasPoint(point, size, coordinateMode);
|
||||
if (index === 0) context.moveTo(canvasPoint.x, canvasPoint.y);
|
||||
else context.lineTo(canvasPoint.x, canvasPoint.y);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
export function DrawingOverlay({ active, erase, strokes, color, width, onChange, getSurfaceSize, createStrokeId, coordinateMode = "pixel", className = "" }) {
|
||||
const canvasRef = useRef(null);
|
||||
const drawingRef = useRef(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
function syncSize() {
|
||||
const nextSize = getSurfaceSize?.() || { width: 0, height: 0 };
|
||||
setSize({
|
||||
width: Math.max(0, Math.ceil(nextSize.width || 0)),
|
||||
height: Math.max(0, Math.ceil(nextSize.height || 0))
|
||||
});
|
||||
}
|
||||
|
||||
syncSize();
|
||||
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncSize) : null;
|
||||
const canvas = canvasRef.current;
|
||||
if (observer && canvas?.parentElement) observer.observe(canvas.parentElement);
|
||||
window.addEventListener("resize", syncSize);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", syncSize);
|
||||
};
|
||||
}, [getSurfaceSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !size.width || !size.height) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.floor(size.width * ratio));
|
||||
canvas.height = Math.max(1, Math.floor(size.height * ratio));
|
||||
canvas.style.width = `${size.width}px`;
|
||||
canvas.style.height = `${size.height}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, size.width, size.height);
|
||||
strokes.forEach((stroke) => drawStroke(context, stroke, size, coordinateMode));
|
||||
}, [coordinateMode, size, strokes]);
|
||||
|
||||
function commitStroke(points) {
|
||||
if (points.length < 2) return;
|
||||
onChange([
|
||||
...strokes,
|
||||
{
|
||||
id: createStrokeId?.() || `stroke_${Date.now().toString(36)}`,
|
||||
color,
|
||||
width,
|
||||
points
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (!active || event.button > 0) return;
|
||||
event.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
const point = getCanvasPoint(event, canvas, coordinateMode);
|
||||
if (erase) {
|
||||
const nextStrokes = strokes.filter((stroke) => !strokeHitsPoint(stroke, point, size, coordinateMode));
|
||||
if (nextStrokes.length !== strokes.length) onChange(nextStrokes);
|
||||
return;
|
||||
}
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
drawingRef.current = { pointerId: event.pointerId, points: [point] };
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
const drawing = drawingRef.current;
|
||||
if (!drawing || drawing.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const point = getCanvasPoint(event, canvasRef.current, coordinateMode);
|
||||
const lastPoint = drawing.points[drawing.points.length - 1];
|
||||
if (pointDistance(toCanvasPoint(lastPoint, size, coordinateMode), toCanvasPoint(point, size, coordinateMode)) < 1.5) return;
|
||||
drawing.points.push(point);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext("2d");
|
||||
drawStroke(context, { color, width, points: [lastPoint, point] }, size, coordinateMode);
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
const drawing = drawingRef.current;
|
||||
if (!drawing || drawing.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
canvasRef.current.releasePointerCapture?.(event.pointerId);
|
||||
drawingRef.current = null;
|
||||
commitStroke(drawing.points);
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`drawing-overlay ${active ? "is-active" : ""} ${erase ? "is-erase" : ""} ${className}`.trim()}
|
||||
aria-hidden="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Rôle : fournit l'outil annotation d'images avec marqueurs éditables.
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||
|
||||
function markerLabel(index, marker, textContent) {
|
||||
return marker.label || `${textContent.markerPrefix || "Marqueur"} ${index + 1}`;
|
||||
|
|
@ -9,9 +10,36 @@ function markerLabel(index, marker, textContent) {
|
|||
export function ImageAnnotationModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeImageAnnotationData(context.getModuleData(toolboxId, moduleId, { image: "", markers: [] }));
|
||||
const textContent = context.moduleText?.imageAnnotation || {};
|
||||
const previewMediaRef = useRef(null);
|
||||
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(() => {
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "{\"strokes\":[]}");
|
||||
setTemporaryDrawings(stored?.strokes ? stored : { strokes: [] });
|
||||
} catch {
|
||||
setTemporaryDrawings({ strokes: [] });
|
||||
}
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawingMode !== "temporary") return;
|
||||
try {
|
||||
sessionStorage.setItem(sessionKey, JSON.stringify(temporaryDrawings));
|
||||
} catch {
|
||||
context.notify?.(textContent.sessionStorageError || "Les annotations temporaires n'ont pas pu être sauvegardées.");
|
||||
}
|
||||
}, [context, drawingMode, sessionKey, temporaryDrawings, textContent.sessionStorageError]);
|
||||
|
||||
function save(nextData) {
|
||||
context.setModuleData(toolboxId, moduleId, nextData);
|
||||
|
|
@ -20,12 +48,14 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
async function setImageFromFiles(files) {
|
||||
const file = [...files].find((item) => item?.type?.startsWith("image/"));
|
||||
if (!file) return;
|
||||
save({ image: await context.compressImageFile(file), markers: [] });
|
||||
save({ image: await context.compressImageFile(file), markers: [], drawings: { strokes: [] } });
|
||||
setTemporaryDrawings({ strokes: [] });
|
||||
setDragOver(false);
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
save({ image: "", markers: [] });
|
||||
save({ image: "", markers: [], drawings: { strokes: [] } });
|
||||
setTemporaryDrawings({ strokes: [] });
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -79,7 +109,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
{data.image ? (
|
||||
<div className="annotation-workspace">
|
||||
<div className="annotation-stage annotation-stage-preview" aria-label={textContent.stageAriaLabel || "Image annotée"}>
|
||||
<div className="annotation-media">
|
||||
<div className="annotation-media" ref={previewMediaRef}>
|
||||
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
|
||||
<div className="annotation-preview-actions">
|
||||
<button
|
||||
|
|
@ -88,14 +118,35 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
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",
|
||||
onChangeMarkers: (nextMarkers) => save({ ...data, markers: nextMarkers })
|
||||
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 setTemporaryDrawings(nextDrawings);
|
||||
},
|
||||
onChangeDrawingMode: (nextMode, currentDrawings) => {
|
||||
setDrawingMode(nextMode);
|
||||
if (nextMode === "permanent") save({ ...data, drawings: currentDrawings });
|
||||
else setTemporaryDrawings(currentDrawings);
|
||||
}
|
||||
})}
|
||||
aria-label={textContent.previewAriaLabel || "Agrandir l'image annotée"}
|
||||
title={textContent.previewTitle || "Agrandir"}
|
||||
|
|
@ -118,6 +169,16 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
|
|||
{index + 1}
|
||||
</span>
|
||||
))}
|
||||
<DrawingOverlay
|
||||
active={false}
|
||||
erase={false}
|
||||
strokes={drawings.strokes}
|
||||
color="#f6c453"
|
||||
width={4}
|
||||
onChange={() => {}}
|
||||
coordinateMode="percent"
|
||||
getSurfaceSize={() => getPreviewSurfaceSize(previewMediaRef.current)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,328 @@
|
|||
// Rôle : fournit l'outil bloc-notes libre.
|
||||
import { useState } from "react";
|
||||
// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
|
||||
import { DrawingOverlay } from "./DrawingOverlay.jsx";
|
||||
|
||||
export function NotepadModule({ toolboxId, moduleId, context }) {
|
||||
const data = context.getModuleData(toolboxId, moduleId, { text: "" });
|
||||
const [text, setText] = useState(data.text || "");
|
||||
const textContent = context.moduleText?.notepad || {};
|
||||
const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
||||
const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
|
||||
const DRAWING_WIDTHS = [2, 4, 8, 12];
|
||||
|
||||
function getPlainText(element) {
|
||||
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
function isHtmlEmpty(html, text) {
|
||||
return !text && !String(html || "").replace(/<br\s*\/?>/gi, "").replace(/<[^>]*>/g, "").replace(/ /gi, " ").trim();
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function queryCommandState(command) {
|
||||
try {
|
||||
return document.queryCommandState(command);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function queryBlockFormat() {
|
||||
try {
|
||||
return String(document.queryCommandValue("formatBlock") || "").replace(/[<>]/g, "").toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function ToolbarButton({ active, title, children, onClick }) {
|
||||
return (
|
||||
<textarea
|
||||
className="notepad"
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
context.setModuleData(toolboxId, moduleId, { text: event.target.value });
|
||||
}}
|
||||
placeholder={textContent.placeholder || "Notes rapides..."}
|
||||
/>
|
||||
<button
|
||||
className={`notepad-toolbar-button ${active ? "active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
title={title}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotepadModule({ toolboxId, moduleId, context }) {
|
||||
const storedData = context.getModuleData(toolboxId, moduleId, { text: "" });
|
||||
const normalizedData = useMemo(() => context.normalizeNotepadData(storedData), [context, storedData]);
|
||||
const textContent = context.moduleText?.notepad || {};
|
||||
const editorRef = useRef(null);
|
||||
const surfaceRef = useRef(null);
|
||||
const [html, setHtml] = useState(normalizedData.html);
|
||||
const [text, setText] = useState(normalizedData.text);
|
||||
const [updatedAt, setUpdatedAt] = useState(normalizedData.updatedAt);
|
||||
const [drawingMode, setDrawingMode] = useState(normalizedData.drawingMode);
|
||||
const [drawingActive, setDrawingActive] = useState(false);
|
||||
const [eraseActive, setEraseActive] = useState(false);
|
||||
const [drawingColor, setDrawingColor] = useState("#f6c453");
|
||||
const [drawingWidth, setDrawingWidth] = useState(4);
|
||||
const [textColor, setTextColor] = useState(TEXT_COLORS[0]);
|
||||
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
|
||||
const [activeFormats, setActiveFormats] = useState({
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
paragraph: false,
|
||||
heading: false,
|
||||
bulletList: false,
|
||||
numberList: false
|
||||
});
|
||||
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}`;
|
||||
const [temporaryStrokes, setTemporaryStrokes] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setHtml(normalizedData.html);
|
||||
setText(normalizedData.text);
|
||||
setUpdatedAt(normalizedData.updatedAt);
|
||||
setDrawingMode(normalizedData.drawingMode);
|
||||
setDrawingActive(false);
|
||||
setEraseActive(false);
|
||||
}, [toolboxId, moduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || editorRef.current.innerHTML === html) return;
|
||||
editorRef.current.innerHTML = html;
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
function updateActiveFormats() {
|
||||
const editor = editorRef.current;
|
||||
const selection = document.getSelection();
|
||||
if (!editor || !selection?.rangeCount || !editor.contains(selection.anchorNode)) {
|
||||
setActiveFormats((current) => Object.values(current).some(Boolean)
|
||||
? { bold: false, italic: false, underline: false, paragraph: false, heading: false, bulletList: false, numberList: false }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
const block = queryBlockFormat();
|
||||
setActiveFormats({
|
||||
bold: queryCommandState("bold"),
|
||||
italic: queryCommandState("italic"),
|
||||
underline: queryCommandState("underline"),
|
||||
paragraph: block === "p" || block === "div",
|
||||
heading: block === "h3" || block === "heading 3",
|
||||
bulletList: queryCommandState("insertUnorderedList"),
|
||||
numberList: queryCommandState("insertOrderedList")
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("selectionchange", updateActiveFormats);
|
||||
return () => document.removeEventListener("selectionchange", updateActiveFormats);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(sessionKey) || "[]");
|
||||
setTemporaryStrokes(Array.isArray(stored) ? stored : []);
|
||||
} catch {
|
||||
setTemporaryStrokes([]);
|
||||
}
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawingMode !== "temporary") return;
|
||||
try {
|
||||
sessionStorage.setItem(sessionKey, JSON.stringify(temporaryStrokes));
|
||||
} catch {
|
||||
context.notify?.(textContent.sessionStorageError || "Les annotations temporaires n'ont pas pu être sauvegardées.");
|
||||
}
|
||||
}, [context, drawingMode, sessionKey, temporaryStrokes, textContent.sessionStorageError]);
|
||||
|
||||
const activeStrokes = drawingMode === "permanent" ? normalizedData.drawings.strokes : temporaryStrokes;
|
||||
const formattedUpdatedAt = formatUpdatedAt(updatedAt);
|
||||
|
||||
function saveContent(nextHtml, nextText) {
|
||||
const nextUpdatedAt = new Date().toISOString();
|
||||
setHtml(nextHtml);
|
||||
setText(nextText);
|
||||
setUpdatedAt(nextUpdatedAt);
|
||||
context.setModuleData(toolboxId, moduleId, {
|
||||
...normalizedData,
|
||||
html: nextHtml,
|
||||
text: nextText,
|
||||
updatedAt: nextUpdatedAt,
|
||||
drawingMode,
|
||||
drawings: drawingMode === "permanent" ? { strokes: activeStrokes } : { strokes: [] }
|
||||
});
|
||||
}
|
||||
|
||||
function saveDrawingMode(nextMode, nextStrokes) {
|
||||
setDrawingMode(nextMode);
|
||||
if (nextMode === "temporary") setTemporaryStrokes(nextStrokes);
|
||||
context.setModuleData(toolboxId, moduleId, {
|
||||
...normalizedData,
|
||||
html,
|
||||
text,
|
||||
updatedAt,
|
||||
drawingMode: nextMode,
|
||||
drawings: nextMode === "permanent" ? { strokes: nextStrokes } : { strokes: [] }
|
||||
});
|
||||
}
|
||||
|
||||
function saveStrokes(nextStrokes) {
|
||||
if (drawingMode === "permanent") {
|
||||
context.setModuleData(toolboxId, moduleId, {
|
||||
...normalizedData,
|
||||
html,
|
||||
text,
|
||||
updatedAt,
|
||||
drawingMode,
|
||||
drawings: { strokes: nextStrokes }
|
||||
});
|
||||
} else {
|
||||
setTemporaryStrokes(nextStrokes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorInput() {
|
||||
const nextHtml = editorRef.current?.innerHTML || "";
|
||||
const nextText = getPlainText(editorRef.current);
|
||||
saveContent(nextHtml, nextText);
|
||||
requestAnimationFrame(() => document.dispatchEvent(new Event("selectionchange")));
|
||||
}
|
||||
|
||||
function runCommand(command, value = null) {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand("styleWithCSS", false, true);
|
||||
document.execCommand(command, false, value);
|
||||
handleEditorInput();
|
||||
}
|
||||
|
||||
function applyBlock(tag) {
|
||||
runCommand("formatBlock", tag);
|
||||
}
|
||||
|
||||
function applyTextColor(color) {
|
||||
setTextColor(color);
|
||||
runCommand("foreColor", color);
|
||||
}
|
||||
|
||||
function applyHighlightColor(color) {
|
||||
setHighlightColor(color);
|
||||
runCommand("hiliteColor", color);
|
||||
}
|
||||
|
||||
function handlePaste(event) {
|
||||
const pastedText = event.clipboardData?.getData("text/plain");
|
||||
if (!pastedText) return;
|
||||
event.preventDefault();
|
||||
document.execCommand("insertText", false, pastedText);
|
||||
handleEditorInput();
|
||||
}
|
||||
|
||||
const getSurfaceSize = useCallback(() => {
|
||||
const element = surfaceRef.current;
|
||||
return {
|
||||
width: element?.clientWidth || 0,
|
||||
height: Math.max(element?.scrollHeight || 0, element?.clientHeight || 0)
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toggleDrawingMode() {
|
||||
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
|
||||
saveDrawingMode(nextMode, activeStrokes);
|
||||
}
|
||||
|
||||
function undoStroke() {
|
||||
saveStrokes(activeStrokes.slice(0, -1));
|
||||
}
|
||||
|
||||
function clearStrokes() {
|
||||
saveStrokes([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notepad-module">
|
||||
<div className="notepad-toolbar" aria-label={textContent.toolbarLabel || "Mise en page du bloc notes"}>
|
||||
<div className="notepad-format-controls">
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.textFormatLabel || "Style du texte"}>
|
||||
<ToolbarButton active={activeFormats.bold} title={textContent.boldTitle || "Gras"} onClick={() => runCommand("bold")}>B</ToolbarButton>
|
||||
<ToolbarButton active={activeFormats.italic} title={textContent.italicTitle || "Italique"} onClick={() => runCommand("italic")}><em>I</em></ToolbarButton>
|
||||
<ToolbarButton active={activeFormats.underline} title={textContent.underlineTitle || "Souligné"} onClick={() => runCommand("underline")}><u>U</u></ToolbarButton>
|
||||
<ToolbarButton active={activeFormats.paragraph} title={textContent.paragraphTitle || "Paragraphe"} onClick={() => applyBlock("p")}>P</ToolbarButton>
|
||||
<ToolbarButton active={activeFormats.heading} title={textContent.headingTitle || "Titre"} onClick={() => applyBlock("h3")}>H</ToolbarButton>
|
||||
<ToolbarButton active={activeFormats.bulletList} title={textContent.bulletListTitle || "Liste à puces"} onClick={() => runCommand("insertUnorderedList")}>•</ToolbarButton>
|
||||
<ToolbarButton active={activeFormats.numberList} title={textContent.numberListTitle || "Liste numérotée"} onClick={() => runCommand("insertOrderedList")}>1.</ToolbarButton>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.colorGroupLabel || "Couleurs"}>
|
||||
<FormattingColorDropdown
|
||||
label={textContent.colorGroupLabel || "Couleurs"}
|
||||
sections={[
|
||||
{
|
||||
label: textContent.textColorLabel || "Couleur du texte",
|
||||
colors: TEXT_COLORS,
|
||||
value: textColor,
|
||||
onSelect: applyTextColor
|
||||
},
|
||||
{
|
||||
label: textContent.highlightColorLabel || "Surlignage",
|
||||
colors: HIGHLIGHT_COLORS,
|
||||
value: highlightColor,
|
||||
onSelect: applyHighlightColor
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`notepad-surface ${drawingActive ? "is-drawing" : ""}`} ref={surfaceRef}>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={`notepad-editor ${isHtmlEmpty(html, text) ? "is-empty" : ""}`}
|
||||
contentEditable={!drawingActive}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder={textContent.placeholder || "Notes rapides..."}
|
||||
onInput={handleEditorInput}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<DrawingOverlay
|
||||
active={drawingActive}
|
||||
erase={eraseActive}
|
||||
strokes={activeStrokes}
|
||||
color={drawingColor}
|
||||
width={drawingWidth}
|
||||
onChange={saveStrokes}
|
||||
getSurfaceSize={getSurfaceSize}
|
||||
createStrokeId={() => context.uid("stroke")}
|
||||
/>
|
||||
<DrawingControls
|
||||
textContent={textContent}
|
||||
active={drawingActive}
|
||||
onToggleActive={() => setDrawingActive((value) => !value)}
|
||||
eraseActive={eraseActive}
|
||||
onToggleErase={() => setEraseActive((value) => !value)}
|
||||
color={drawingColor}
|
||||
colors={TEXT_COLORS}
|
||||
onColorChange={setDrawingColor}
|
||||
width={drawingWidth}
|
||||
widths={DRAWING_WIDTHS}
|
||||
onWidthChange={setDrawingWidth}
|
||||
onUndo={undoStroke}
|
||||
onClear={clearStrokes}
|
||||
storageMode={drawingMode}
|
||||
onToggleStorageMode={toggleDrawingMode}
|
||||
/>
|
||||
</div>
|
||||
<footer className="notepad-footer">
|
||||
{formattedUpdatedAt && <span>{`${textContent.updatedAtPrefix || "Modifié le"} ${formattedUpdatedAt}`}</span>}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// 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";
|
||||
|
|
@ -75,8 +76,6 @@ function chooseMeasuredSplitIndex(modules, moduleHeights, fallbackSplitIndex) {
|
|||
|
||||
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(() => {
|
||||
|
|
@ -84,82 +83,65 @@ export function AddToolControls({ onAdd }) {
|
|||
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 (
|
||||
<>
|
||||
<div
|
||||
className="tool-add-controls"
|
||||
ref={controlsRef}
|
||||
onMouseLeave={() => 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"
|
||||
>
|
||||
<button
|
||||
className="primary tool-add-modal-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuickOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Ajouter un outil
|
||||
</button>
|
||||
<button
|
||||
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onMouseEnter={() => setQuickOpen(true)}
|
||||
onClick={() => setQuickOpen((value) => !value)}
|
||||
aria-label="Afficher l'ajout rapide"
|
||||
aria-expanded={quickOpen}
|
||||
title="Ajout rapide"
|
||||
>
|
||||
<Icon name="dropdown" />
|
||||
</button>
|
||||
<div className={`tool-quick-add ${quickOpen ? "is-open" : ""}`} aria-label="Ajout rapide">
|
||||
{modules.map(([type, module]) => (
|
||||
<CompactDropdown
|
||||
className="tool-add-quick-dropdown"
|
||||
menuClassName="tool-quick-add"
|
||||
label="Ajout rapide"
|
||||
openOnHover
|
||||
closeOnMouseLeave
|
||||
renderTrigger={({ open: quickOpen, toggle }) => (
|
||||
<button
|
||||
key={type}
|
||||
className="tool-quick-add-button"
|
||||
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onAdd(type)}
|
||||
aria-label={`Ajouter ${module.label}`}
|
||||
title={module.label}
|
||||
onClick={toggle}
|
||||
aria-label="Afficher l'ajout rapide"
|
||||
aria-expanded={quickOpen}
|
||||
title="Ajout rapide"
|
||||
>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${module.icon}`} />
|
||||
</span>
|
||||
<Icon name="dropdown" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ 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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
|
||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e" };
|
||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
|
||||
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
|
||||
const NOTEPAD_RGB_COLOR_MAP = {
|
||||
"rgb(245, 247, 255)": "#f5f7ff",
|
||||
"rgb(180, 189, 211)": "#b4bdd3",
|
||||
"rgb(246, 196, 83)": "#f6c453",
|
||||
"rgb(34, 211, 238)": "#22d3ee",
|
||||
"rgb(139, 92, 246)": "#8b5cf6",
|
||||
"rgb(217, 70, 239)": "#d946ef",
|
||||
"rgb(16, 20, 38)": "#101426",
|
||||
"rgb(32, 39, 69)": "#202745"
|
||||
};
|
||||
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
|
||||
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
|
||||
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
|
||||
|
|
@ -119,6 +131,136 @@ function parsePositiveInt(value, fallback = 1) {
|
|||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function decodeBasicEntities(value) {
|
||||
return String(value || "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/gi, "&");
|
||||
}
|
||||
|
||||
function normalizeStyleAttribute(value) {
|
||||
const declarations = String(value || "")
|
||||
.split(";")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const allowed = [];
|
||||
declarations.forEach((declaration) => {
|
||||
const [property, rawValue] = declaration.split(":").map((item) => item?.trim().toLowerCase());
|
||||
if (!["color", "background-color"].includes(property)) return;
|
||||
const color = NOTEPAD_RGB_COLOR_MAP[rawValue] || rawValue;
|
||||
if (!NOTEPAD_ALLOWED_COLORS.has(color)) return;
|
||||
allowed.push(`${property}: ${color}`);
|
||||
});
|
||||
return allowed.join("; ");
|
||||
}
|
||||
|
||||
export function sanitizeNotepadHtml(value) {
|
||||
const source = String(value || "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "");
|
||||
let html = "";
|
||||
let cursor = 0;
|
||||
source.replace(/<\/?([a-z0-9]+)([^>]*)>/gi, (match, rawTag, rawAttributes, offset) => {
|
||||
html += escapeHtml(source.slice(cursor, offset));
|
||||
cursor = offset + match.length;
|
||||
const tag = rawTag.toLowerCase();
|
||||
if (!NOTEPAD_ALLOWED_TAGS.has(tag)) return "";
|
||||
if (match.startsWith("</")) {
|
||||
if (tag !== "br") html += `</${tag}>`;
|
||||
return "";
|
||||
}
|
||||
if (tag === "br") {
|
||||
html += "<br>";
|
||||
return "";
|
||||
}
|
||||
const styleMatch = String(rawAttributes || "").match(/\sstyle=(?:"([^"]*)"|'([^']*)')/i);
|
||||
const style = normalizeStyleAttribute(styleMatch?.[1] || styleMatch?.[2] || "");
|
||||
html += style ? `<${tag} style="${style}">` : `<${tag}>`;
|
||||
return "";
|
||||
});
|
||||
html += escapeHtml(source.slice(cursor));
|
||||
return html.trim();
|
||||
}
|
||||
|
||||
function textToNotepadHtml(value) {
|
||||
return String(value || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function notepadHtmlToText(value) {
|
||||
const withBreaks = String(value || "")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(p|div|h3|h4|li)>/gi, "\n")
|
||||
.replace(/<li[^>]*>/gi, "- ");
|
||||
return decodeBasicEntities(withBreaks.replace(/<[^>]*>/g, ""))
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeIsoDate(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
const timestamp = Date.parse(text);
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "";
|
||||
}
|
||||
|
||||
function normalizeDrawingPoint(point, options = {}) {
|
||||
const x = Number(point?.x);
|
||||
const y = Number(point?.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
const max = Number(options.max);
|
||||
return {
|
||||
x: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(x * 100) / 100)),
|
||||
y: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(y * 100) / 100))
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDrawingStroke(stroke, options = {}) {
|
||||
const points = (Array.isArray(stroke?.points) ? stroke.points : []).map((point) => normalizeDrawingPoint(point, options)).filter(Boolean);
|
||||
if (points.length < 2) return null;
|
||||
const color = NOTEPAD_ALLOWED_COLORS.has(String(stroke?.color || "").toLowerCase()) ? String(stroke.color).toLowerCase() : "#f6c453";
|
||||
const width = Math.min(24, Math.max(1, Number(stroke?.width) || 4));
|
||||
return {
|
||||
id: stroke?.id || uid("stroke"),
|
||||
color,
|
||||
width: Math.round(width * 10) / 10,
|
||||
points
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDrawingData(data, options = {}) {
|
||||
return {
|
||||
strokes: (Array.isArray(data?.strokes) ? data.strokes : []).map((stroke) => normalizeDrawingStroke(stroke, options)).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNotepadData(data) {
|
||||
const source = data && typeof data === "object" ? data : {};
|
||||
const legacyText = String(source.text || "");
|
||||
const html = sanitizeNotepadHtml(source.html ? source.html : textToNotepadHtml(legacyText));
|
||||
const text = notepadHtmlToText(html) || legacyText.trim();
|
||||
const updatedAt = normalizeIsoDate(source.updatedAt);
|
||||
const drawingMode = source.drawingMode === "permanent" ? "permanent" : "temporary";
|
||||
const drawings = normalizeDrawingData(source.drawings);
|
||||
return { html, text, updatedAt, drawingMode, drawings };
|
||||
}
|
||||
|
||||
export function clampQty(value, target) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
const safeValue = Number.isFinite(parsed) ? parsed : 0;
|
||||
|
|
@ -413,7 +555,7 @@ export function normalizeImageAnnotationData(data) {
|
|||
y: clampPercent(marker?.y),
|
||||
label: String(marker?.label || "").trim()
|
||||
}));
|
||||
return { image, markers };
|
||||
return { image, markers, drawings: normalizeDrawingData(data?.drawings, { max: 100 }) };
|
||||
}
|
||||
|
||||
function compactChecklistItemForStorage(item) {
|
||||
|
|
@ -438,8 +580,14 @@ function compactChecklistSectionForStorage(section) {
|
|||
|
||||
export function compactModuleDataForStorage(type, value) {
|
||||
if (type === "notepad") {
|
||||
const text = String(value?.text || "");
|
||||
return text ? { text } : null;
|
||||
const normalized = normalizeNotepadData(value);
|
||||
const compact = {};
|
||||
if (normalized.html && normalized.text) compact.html = normalized.html;
|
||||
if (normalized.text) compact.text = normalized.text;
|
||||
if (normalized.updatedAt) compact.updatedAt = normalized.updatedAt;
|
||||
if (normalized.drawingMode === "permanent") compact.drawingMode = "permanent";
|
||||
if (normalized.drawingMode === "permanent" && normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
|
||||
return Object.keys(compact).length ? compact : null;
|
||||
}
|
||||
if (type === "checklist") {
|
||||
const normalized = normalizeChecklistData(value);
|
||||
|
|
@ -465,7 +613,7 @@ export function compactModuleDataForStorage(type, value) {
|
|||
if (type === "imageAnnotation") {
|
||||
const normalized = normalizeImageAnnotationData(value);
|
||||
if (!normalized.image) return null;
|
||||
return {
|
||||
const compact = {
|
||||
image: normalized.image,
|
||||
markers: normalized.markers.map((marker) => {
|
||||
const compact = { id: marker.id, x: marker.x, y: marker.y };
|
||||
|
|
@ -473,6 +621,8 @@ export function compactModuleDataForStorage(type, value) {
|
|||
return compact;
|
||||
})
|
||||
};
|
||||
if (normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
|
||||
return compact;
|
||||
}
|
||||
if (type === "links") {
|
||||
const links = normalizeLinksData(value).links.map((link) => {
|
||||
|
|
@ -604,10 +754,16 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
}
|
||||
|
||||
if (type === "imageAnnotation") {
|
||||
return {
|
||||
const remapped = {
|
||||
...compact,
|
||||
markers: compact.markers.map((marker) => ({ ...marker, id: nextId("marker") }))
|
||||
};
|
||||
if (compact.drawings?.strokes) {
|
||||
remapped.drawings = {
|
||||
strokes: compact.drawings.strokes.map((stroke) => ({ ...stroke, id: nextId("stroke") }))
|
||||
};
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
if (type === "links") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue