improve notepad features and add drawing canvas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-29 13:51:42 +02:00
parent e73d754082
commit de1b8a5638
20 changed files with 1732 additions and 141 deletions

View file

@ -0,0 +1,65 @@
// Rôle : fournit un dropdown compact réutilisable pour menus d'icônes, couleurs et actions rapides.
import { useEffect, useRef, useState } from "react";
export function CompactDropdown({
className = "",
menuClassName = "",
label,
openOnHover = false,
closeOnMouseLeave = false,
preventMouseDown = false,
renderTrigger,
children
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
useEffect(() => {
if (!open) return undefined;
function handlePointerDown(event) {
if (!rootRef.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 close() {
setOpen(false);
}
function toggle() {
setOpen((value) => !value);
}
return (
<div
className={`${className} ${open ? "is-open" : ""}`.trim()}
ref={rootRef}
onMouseEnter={openOnHover ? () => setOpen(true) : undefined}
onMouseLeave={closeOnMouseLeave ? close : undefined}
onFocusCapture={openOnHover ? () => setOpen(true) : undefined}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) close();
}}
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
aria-label={label}
>
{renderTrigger({ open, toggle, close })}
{open && (
<div className={`${menuClassName} is-open`.trim()} aria-label={label}>
{children({ close })}
</div>
)}
</div>
);
}

View file

@ -1,8 +1,13 @@
// Rôle : affiche les images en modale, avec support des annotations.
import { useEffect, useRef, useState } from "react";
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
import { Icon } from "./Icon.jsx";
const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
const DRAWING_WIDTHS = [2, 4, 8, 12];
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
@ -31,13 +36,24 @@ function openImageInNewTab(dataUrl) {
export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) {
useEffect(() => lockBodyScroll(), []);
const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers);
const canDraw = Boolean(canAnnotate && image.onChangeDrawings);
const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []);
const [viewerDrawings, setViewerDrawings] = useState(() => image.drawings?.strokes ? image.drawings : { strokes: [] });
const [drawingMode, setDrawingMode] = useState(image.drawingMode === "temporary" ? "temporary" : "permanent");
const [drawingActive, setDrawingActive] = useState(false);
const [eraseActive, setEraseActive] = useState(false);
const [drawingColor, setDrawingColor] = useState("#f6c453");
const [drawingWidth, setDrawingWidth] = useState(4);
const viewerRef = useRef(null);
const mediaRef = useRef(null);
const hasMarkers = viewerMarkers.length > 0;
useEffect(() => {
setViewerMarkers(Array.isArray(image.markers) ? image.markers : []);
setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] });
setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent");
setDrawingActive(false);
setEraseActive(false);
}, [image]);
useEffect(() => {
@ -74,8 +90,28 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
image.onChangeMarkers?.(nextMarkers);
}
function updateViewerDrawings(nextStrokes) {
const nextDrawings = { strokes: nextStrokes };
setViewerDrawings(nextDrawings);
image.onChangeDrawings?.(nextDrawings, drawingMode);
}
function toggleDrawingMode() {
const nextMode = drawingMode === "permanent" ? "temporary" : "permanent";
setDrawingMode(nextMode);
image.onChangeDrawingMode?.(nextMode, viewerDrawings);
}
function getMediaSurfaceSize() {
return {
width: mediaRef.current?.clientWidth || 0,
height: mediaRef.current?.clientHeight || 0
};
}
function addViewerMarker(event) {
if (!canAnnotate || !mediaRef.current) return;
if (drawingActive || event.target.closest(".drawing-controls-panel")) return;
const rect = mediaRef.current.getBoundingClientRect();
const nextMarkers = [
...viewerMarkers,
@ -121,6 +157,36 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined}
>
<img src={image.dataUrl} alt={image.alt || "Image"} />
<DrawingOverlay
active={drawingActive}
erase={eraseActive}
strokes={viewerDrawings.strokes}
color={drawingColor}
width={drawingWidth}
onChange={updateViewerDrawings}
getSurfaceSize={getMediaSurfaceSize}
createStrokeId={image.createStrokeId}
coordinateMode="percent"
/>
{canDraw && (
<DrawingControls
textContent={image}
active={drawingActive}
onToggleActive={() => setDrawingActive((value) => !value)}
eraseActive={eraseActive}
onToggleErase={() => setEraseActive((value) => !value)}
color={drawingColor}
colors={DRAWING_COLORS}
onColorChange={setDrawingColor}
width={drawingWidth}
widths={DRAWING_WIDTHS}
onWidthChange={setDrawingWidth}
onUndo={() => updateViewerDrawings(viewerDrawings.strokes.slice(0, -1))}
onClear={() => updateViewerDrawings([])}
storageMode={drawingMode}
onToggleStorageMode={toggleDrawingMode}
/>
)}
{viewerMarkers.map((marker, index) => (
<span
className="annotation-marker image-viewer-marker"

View file

@ -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>
);
}

View file

@ -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,

View 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>
);
}

View 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}
/>
);
}

View file

@ -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>

View file

@ -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(/&nbsp;/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>
);
}

View file

@ -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">

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function decodeBasicEntities(value) {
return String(value || "")
.replace(/&nbsp;/gi, " ")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/g, "'")
.replace(/&amp;/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") {

View file

@ -222,6 +222,11 @@
-webkit-mask-image: url("/static/icons/refresh.svg");
}
.ui-icon-back {
mask-image: url("/static/icons/back.svg");
-webkit-mask-image: url("/static/icons/back.svg");
}
.ui-icon-trash {
mask-image: url("/static/icons/trashcan.svg");
-webkit-mask-image: url("/static/icons/trashcan.svg");

View file

@ -102,6 +102,7 @@
}
.image-viewer-marker {
z-index: 3;
display: inline-grid;
width: 30px;
min-width: 30px;
@ -155,7 +156,7 @@
grid-auto-rows: max-content;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
padding: 4px 4px 4px 0;
}
.drawer[aria-hidden="true"] {

View file

@ -774,17 +774,423 @@
background: currentColor;
}
.notepad {
display: block;
width: calc(100% - 32px);
min-height: 240px;
margin: var(--space-4);
.notepad-module {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
resize: vertical;
line-height: 1.6;
}
.notepad-toolbar,
.notepad-toolbar-group,
.notepad-format-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.notepad-toolbar {
justify-content: flex-start;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.46);
}
.notepad-format-controls {
min-width: 0;
}
.notepad-toolbar-group {
padding-right: 8px;
border-right: 1px solid rgba(150, 165, 205, 0.12);
}
.notepad-toolbar-group:last-child {
padding-right: 0;
border-right: 0;
}
.notepad-toolbar-button,
.drawing-control-button,
.notepad-swatch,
.notepad-color-toggle,
.drawing-width-toggle,
.drawing-width-button {
display: inline-grid;
place-items: center;
width: 34px;
min-width: 34px;
min-height: 34px;
padding: 0;
border-color: rgba(150, 165, 205, 0.18);
border-radius: var(--radius-sm);
background: rgba(16, 20, 38, 0.74);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 900;
box-shadow: none;
}
.notepad-toolbar-button .ui-icon,
.drawing-control-button .ui-icon {
width: 17px;
height: 17px;
}
.notepad-toolbar-button:hover,
.drawing-control-button:hover,
.notepad-swatch:hover,
.notepad-color-toggle:hover,
.drawing-width-toggle:hover,
.drawing-width-button:hover,
.notepad-toolbar-button.active,
.drawing-control-button.active,
.notepad-swatch.active,
.drawing-width-button.active {
border-color: rgba(246, 196, 83, 0.48);
background: rgba(246, 196, 83, 0.1);
color: var(--color-accent-gold);
}
.notepad-color-dropdown {
position: relative;
}
.notepad-color-toggle::before,
.notepad-swatch::before {
content: "";
width: 18px;
height: 18px;
border: 1px solid rgba(245, 247, 255, 0.22);
border-radius: 50%;
background: var(--swatch-color);
box-shadow: inset 0 0 0 2px rgba(5, 7, 17, 0.36);
}
.notepad-color-toggle.is-rainbow::before {
width: 20px;
height: 20px;
border-color: rgba(245, 247, 255, 0.28);
background: conic-gradient(
from 20deg,
#f6c453,
#22d3ee,
#8b5cf6,
#d946ef,
#f5f7ff,
#f6c453
);
box-shadow:
inset 0 0 0 3px rgba(5, 7, 17, 0.34),
0 0 10px rgba(139, 92, 246, 0.16);
}
.notepad-color-menu {
position: absolute;
top: calc(100% + 8px);
left: 50%;
z-index: 50;
display: grid;
grid-template-columns: repeat(3, 34px);
gap: 6px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(10, 13, 28, 0.99);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -4px);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.notepad-color-menu.is-sectioned {
grid-template-columns: 1fr;
width: 168px;
}
.notepad-color-section {
display: grid;
gap: 6px;
}
.notepad-color-section + .notepad-color-section {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(150, 165, 205, 0.12);
}
.notepad-color-section-label {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 800;
}
.notepad-color-section-swatches {
display: grid;
grid-template-columns: repeat(4, 34px);
gap: 6px;
}
.notepad-color-dropdown:hover .notepad-color-menu,
.notepad-color-dropdown:focus-within .notepad-color-menu,
.notepad-color-dropdown.is-open .notepad-color-menu {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0);
}
.notepad-color-menu::after {
content: "";
position: absolute;
right: 0;
bottom: 100%;
left: 0;
height: 10px;
}
.notepad-color-menu::before {
content: "";
position: absolute;
bottom: 100%;
left: calc(50% - 7px);
border-right: 7px solid transparent;
border-bottom: 7px solid rgba(10, 13, 28, 0.99);
border-left: 7px solid transparent;
}
.drawing-width-toggle span,
.drawing-width-button span {
width: 20px;
height: var(--line-width);
border-radius: var(--radius-pill);
background: currentColor;
}
.drawing-width-dropdown {
position: relative;
}
.drawing-width-menu {
position: absolute;
right: auto;
bottom: calc(100% + 8px);
left: 50%;
z-index: 50;
display: grid;
grid-template-columns: repeat(2, 34px);
gap: 6px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(10, 13, 28, 0.99);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transform: translate(-50%, 4px);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.drawing-width-dropdown:hover .drawing-width-menu,
.drawing-width-dropdown:focus-within .drawing-width-menu,
.drawing-width-dropdown.is-open .drawing-width-menu {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0);
}
.drawing-width-menu::after {
content: "";
position: absolute;
top: 100%;
right: 0;
left: 0;
width: 100%;
height: 10px;
}
.drawing-width-menu::before {
content: "";
position: absolute;
top: 100%;
left: calc(50% - 7px);
border-top: 7px solid rgba(10, 13, 28, 0.99);
border-right: 7px solid transparent;
border-left: 7px solid transparent;
}
.notepad-surface {
position: relative;
min-height: 260px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.56);
}
.notepad-surface:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
}
.notepad-surface.is-drawing {
cursor: crosshair;
}
.notepad-editor {
position: relative;
z-index: 1;
min-height: 260px;
padding: var(--space-4);
color: var(--color-text-primary);
line-height: 1.6;
outline: none;
white-space: pre-wrap;
}
.notepad-editor[contenteditable="false"] {
pointer-events: none;
}
.notepad-editor.is-empty::before {
content: attr(data-placeholder);
position: absolute;
color: var(--color-text-muted);
pointer-events: none;
}
.notepad-editor h3,
.notepad-editor h4,
.notepad-editor p,
.notepad-editor ul,
.notepad-editor ol {
margin: 0 0 0.75em;
}
.notepad-editor h3 {
font-size: 1.08rem;
}
.notepad-editor h4 {
font-size: 0.98rem;
}
.notepad-editor ul,
.notepad-editor ol {
padding-left: 1.35rem;
}
.drawing-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 2;
pointer-events: none;
touch-action: none;
}
.drawing-overlay.is-active {
cursor: crosshair;
pointer-events: auto;
}
.drawing-overlay.is-erase {
cursor: crosshair;
}
.drawing-controls-panel {
position: absolute;
right: 50%;
bottom: 10px;
z-index: 6;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
width: max-content;
max-width: calc(100% - 20px);
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(10, 13, 28, 0.94);
box-shadow: var(--shadow-md);
transform: translateX(50%);
}
.drawing-controls-panel:not(.is-open) {
width: auto;
padding: 6px;
}
.drawing-controls-panel .drawing-storage-switch {
display: inline-grid;
grid-template-columns: 16px 30px;
grid-template-rows: 1fr;
width: fit-content;
min-width: 64px;
min-height: 34px;
gap: 7px;
justify-items: initial;
padding: 0 7px 0 10px;
}
.drawing-controls-panel .drawing-storage-switch .ui-icon {
justify-self: initial;
}
.drawing-controls-panel .drawing-storage-switch i {
width: 30px;
height: 16px;
}
.drawing-controls-panel .notepad-color-menu {
top: auto;
bottom: calc(100% + 8px);
right: auto;
left: 50%;
transform: translate(-50%, 4px);
}
.drawing-controls-panel .notepad-color-dropdown:hover .notepad-color-menu,
.drawing-controls-panel .notepad-color-dropdown:focus-within .notepad-color-menu,
.drawing-controls-panel .notepad-color-dropdown.is-open .notepad-color-menu {
transform: translate(-50%, 0);
}
.drawing-controls-panel .notepad-color-menu::after {
top: 100%;
right: 0;
bottom: auto;
left: 0;
width: 100%;
height: 10px;
}
.drawing-controls-panel .notepad-color-menu::before {
top: 100%;
right: auto;
left: calc(50% - 7px);
bottom: auto;
border-top: 7px solid rgba(10, 13, 28, 0.99);
border-right: 7px solid transparent;
border-bottom: 0;
border-left: 7px solid transparent;
}
.notepad-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.inline-form,
.field {
display: grid;
@ -2864,6 +3270,7 @@ textarea:focus {
.annotation-marker {
position: absolute;
z-index: 3;
display: inline-grid;
left: clamp(0px, calc(var(--marker-x, 50%) - 15px), calc(100% - 30px));
top: clamp(0px, calc(var(--marker-y, 50%) - 15px), calc(100% - 30px));
@ -2909,6 +3316,7 @@ textarea:focus {
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
padding-block: 3px;
}
.annotation-marker-list li > span {