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

@ -204,7 +204,32 @@
},
"modules": {
"notepad": {
"placeholder": "Notes rapides..."
"placeholder": "Notes rapides...",
"toolbarLabel": "Mise en page du bloc notes",
"textFormatLabel": "Style du texte",
"boldTitle": "Gras",
"italicTitle": "Italique",
"underlineTitle": "Souligné",
"paragraphTitle": "Paragraphe",
"headingTitle": "Titre",
"bulletListTitle": "Liste à puces",
"numberListTitle": "Liste numérotée",
"colorGroupLabel": "Couleurs",
"textColorLabel": "Couleur du texte",
"highlightColorLabel": "Surlignage",
"drawingLabel": "Dessin",
"drawTitle": "Dessiner",
"eraseTitle": "Gommer",
"temporaryTitle": "Annotations temporaires",
"permanentTitle": "Annotations permanentes",
"undoDrawingTitle": "Annuler le dernier trait",
"clearDrawingTitle": "Effacer les dessins",
"drawingColorLabel": "Couleur du dessin",
"drawingWidthLabel": "Épaisseur du trait",
"temporaryStatus": "Dessins temporaires",
"permanentStatus": "Dessins permanents",
"updatedAtPrefix": "Modifié le",
"sessionStorageError": "Les annotations temporaires n'ont pas pu être sauvegardées."
},
"checklist": {
"itemPlaceholder": "Nouvel item",
@ -381,7 +406,17 @@
"emptyImage": "Ajoutez une image pour commencer l'annotation.",
"emptyMarkers": "Agrandissez l'image pour ajouter un marqueur.",
"deleteImageTitle": "Supprimer l'image",
"deleteMarkerTitle": "Supprimer"
"deleteMarkerTitle": "Supprimer",
"drawingLabel": "Dessin",
"drawTitle": "Dessiner",
"eraseTitle": "Gommer",
"undoDrawingTitle": "Annuler le dernier trait",
"clearDrawingTitle": "Effacer les dessins",
"drawingColorLabel": "Couleur du dessin",
"drawingWidthLabel": "Épaisseur du trait",
"temporaryTitle": "Annotations temporaires",
"permanentTitle": "Annotations permanentes",
"sessionStorageError": "Les annotations temporaires n'ont pas pu être sauvegardées."
}
},
"emptyTitle": "Aucune toolbox",

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 10L3.29289 10.7071L2.58579 10L3.29289 9.29289L4 10ZM21 18C21 18.5523 20.5523 19 20 19C19.4477 19 19 18.5523 19 18L21 18ZM8.29289 15.7071L3.29289 10.7071L4.70711 9.29289L9.70711 14.2929L8.29289 15.7071ZM3.29289 9.29289L8.29289 4.29289L9.70711 5.70711L4.70711 10.7071L3.29289 9.29289ZM4 9L14 9L14 11L4 11L4 9ZM21 16L21 18L19 18L19 16L21 16ZM14 9C17.866 9 21 12.134 21 16L19 16C19 13.2386 16.7614 11 14 11L14 9Z" fill="#33363F"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

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 {