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

@ -179,8 +179,49 @@ Alertes timer :
- le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ;
- un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures.
### Bloc Notes Riche
Le bloc notes utilise une edition directe en `contentEditable`, sans mode preview separe.
Regles UI :
- toolbar compacte en haut de l'outil ;
- boutons courts pour gras, italique, souligne, paragraphes, titres et listes ;
- couleurs texte et surlignage regroupees dans un dropdown unique avec icone arc-en-ciel et libelles de categories ;
- date de derniere modification affichee en pied d'outil, sans prendre le dessus sur le contenu ;
- switch de stockage des dessins en pied d'outil, avec icone sauvegarde et etat actif ;
- placeholder integre dans la surface d'edition quand la note est vide ;
- le HTML persiste doit rester nettoye via allowlist, jamais via HTML libre.
### Calque De Dessin
Les annotations dessinees utilisent un canvas transparent superpose au contenu annote.
Regles UI :
- le calque est inactif par defaut pour laisser le contenu editable ;
- en mode dessin, les interactions pointer sont capturees par le canvas ;
- les controles de dessin sont regroupes dans un panneau reutilisable vertical, colle a droite de la surface annotee ;
- seuls les controles directement lies au dessin sont affiches quand le mode dessin est actif ;
- les controles restent compacts : gommer, annuler, effacer, couleur et epaisseur via dropdowns ;
- le mode temporaire privilegie le griffonnage de session et utilise `sessionStorage` ;
- le mode permanent suit le stockage IndexedDB de l'outil ;
- pour les surfaces scrollables, le canvas suit le contenu complet plutot que le viewport visible.
- sur les annotations d'images, les traits utilisent des coordonnees en pourcentage et restent visibles dans l'aperçu reduit ; l'edition se fait uniquement en plein ecran et le switch de sauvegarde reste disponible dans la barre de dessin.
## 8. Tabs Et Switchs
### Dropdowns Compacts
Les menus flottants compacts d'icones, couleurs, epaisseurs ou actions rapides doivent reutiliser `CompactDropdown`.
Regles :
- conserver un trigger compact, idealement icone seule avec tooltip ;
- fermer au clic exterieur, a `Escape`, a la selection et quand le focus sort du menu ;
- garder le contenu interne libre pour supporter grilles d'icones, swatches, libelles de categories ou choix d'epaisseur ;
- reutiliser les classes visuelles existantes du contexte quand le menu appartient a un outil precis.
Tabs :
- utiliser le composant commun quand un outil doit alterner entre plusieurs modes ;

View file

@ -79,13 +79,36 @@ Type : `notepad`
```json
{
"text": "Notes rapides..."
"html": "<p>Notes <strong>rapides</strong>...</p>",
"text": "Notes rapides...",
"updatedAt": "2026-07-29T12:00:00.000Z",
"drawingMode": "permanent",
"drawings": {
"strokes": [
{
"id": "stroke1",
"color": "#f6c453",
"width": 4,
"points": [
{ "x": 12.5, "y": 48 },
{ "x": 24, "y": 52.5 }
]
}
]
}
}
```
Stockage compact :
- si `text` est vide, l'entrée de module peut être supprimée.
- l'ancien format `{ "text": "..." }` reste accepté et est converti en HTML riche au prochain enregistrement ;
- `html` est nettoyé avec une allowlist de balises et de couleurs ;
- `text` conserve une version texte pour les exports et les fallbacks ;
- `updatedAt` est omis si vide ou invalide ;
- `drawingMode` vaut `temporary` par défaut et est omis dans ce cas ;
- `drawings` est stocké uniquement si `drawingMode` vaut `permanent` et qu'au moins un trait valide existe ;
- les dessins temporaires du bloc-notes sont conservés dans `sessionStorage`, pas dans IndexedDB ;
- si le texte, la date et les dessins permanents sont vides, l'entrée de module peut être supprimée.
## Outil Checklist
@ -178,7 +201,20 @@ Type : `imageAnnotation`
"y": 68,
"label": "Entrée"
}
],
"drawings": {
"strokes": [
{
"id": "stroke1",
"color": "#f6c453",
"width": 4,
"points": [
{ "x": 18.5, "y": 32 },
{ "x": 24, "y": 40.5 }
]
}
]
}
}
```
@ -188,6 +224,10 @@ Stockage compact :
- `x` et `y` sont des pourcentages entre `0` et `100`.
- `label` est facultatif.
- `label` est omis si vide.
- `drawings` est facultatif et omis si aucun trait valide n'existe.
- les points des dessins sont stockés en pourcentages entre `0` et `100` pour rester alignés en aperçu réduit et en plein écran.
- les dessins sont éditables uniquement depuis la modale plein écran ; l'aperçu réduit les affiche en lecture seule.
- le mode temporaire des dessins d'image utilise `sessionStorage` et ne modifie pas `drawings` dans IndexedDB tant que l'utilisateur ne repasse pas en stockage permanent.
## Outil Timer

View file

@ -87,6 +87,31 @@ export function validateSiteContent(site) {
"toolboxes.storageHelp.title",
"toolboxes.storageHelp.text",
"toolboxes.modules.notepad.placeholder",
"toolboxes.modules.notepad.toolbarLabel",
"toolboxes.modules.notepad.textFormatLabel",
"toolboxes.modules.notepad.boldTitle",
"toolboxes.modules.notepad.italicTitle",
"toolboxes.modules.notepad.underlineTitle",
"toolboxes.modules.notepad.paragraphTitle",
"toolboxes.modules.notepad.headingTitle",
"toolboxes.modules.notepad.bulletListTitle",
"toolboxes.modules.notepad.numberListTitle",
"toolboxes.modules.notepad.colorGroupLabel",
"toolboxes.modules.notepad.textColorLabel",
"toolboxes.modules.notepad.highlightColorLabel",
"toolboxes.modules.notepad.drawingLabel",
"toolboxes.modules.notepad.drawTitle",
"toolboxes.modules.notepad.eraseTitle",
"toolboxes.modules.notepad.temporaryTitle",
"toolboxes.modules.notepad.permanentTitle",
"toolboxes.modules.notepad.undoDrawingTitle",
"toolboxes.modules.notepad.clearDrawingTitle",
"toolboxes.modules.notepad.drawingColorLabel",
"toolboxes.modules.notepad.drawingWidthLabel",
"toolboxes.modules.notepad.temporaryStatus",
"toolboxes.modules.notepad.permanentStatus",
"toolboxes.modules.notepad.updatedAtPrefix",
"toolboxes.modules.notepad.sessionStorageError",
"toolboxes.modules.checklist.itemPlaceholder",
"toolboxes.modules.checklist.sectionPlaceholder",
"toolboxes.modules.checklist.quantityLabel",
@ -246,6 +271,16 @@ export function validateSiteContent(site) {
"toolboxes.modules.imageAnnotation.emptyMarkers",
"toolboxes.modules.imageAnnotation.deleteImageTitle",
"toolboxes.modules.imageAnnotation.deleteMarkerTitle",
"toolboxes.modules.imageAnnotation.drawingLabel",
"toolboxes.modules.imageAnnotation.drawTitle",
"toolboxes.modules.imageAnnotation.eraseTitle",
"toolboxes.modules.imageAnnotation.undoDrawingTitle",
"toolboxes.modules.imageAnnotation.clearDrawingTitle",
"toolboxes.modules.imageAnnotation.drawingColorLabel",
"toolboxes.modules.imageAnnotation.drawingWidthLabel",
"toolboxes.modules.imageAnnotation.temporaryTitle",
"toolboxes.modules.imageAnnotation.permanentTitle",
"toolboxes.modules.imageAnnotation.sessionStorageError",
"toolboxes.emptyTitle",
"toolboxes.emptyText"
].forEach((path) => assertNonEmptyString(site, path));

View file

@ -114,7 +114,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /module-content/);
assert.match(moduleRegistry, /module-scroll-button/);
assert.match(moduleRegistry, /Icon name="dropdown"/);
assert.match(moduleRegistry, /aria-expanded=\{quickOpen\}/);
assert.match(moduleRegistry, /CompactDropdown/);
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
assert.match(notepadModule, /export function NotepadModule/);
assert.match(calculatorModule, /export function CalculatorModule/);

View file

@ -3,7 +3,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeNotepadData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
test("colon text import keeps urls intact after the first separator", () => {
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
@ -28,6 +28,82 @@ test("time pattern recurrence uses configured frequency instead of next remainin
assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000);
});
test("notepad data migrates text, sanitizes html and compacts drawings", () => {
const migrated = normalizeNotepadData({ text: "Boss\nPhase 2" });
assert.equal(migrated.text, "Boss\nPhase 2");
assert.match(migrated.html, /<p>Boss<\/p><p>Phase 2<\/p>/);
const normalized = normalizeNotepadData({
html: '<h3 onclick="bad()">Titre</h3><script>alert(1)</script><span style="color: rgb(246, 196, 83); position: fixed">Important</span><img src=x>',
updatedAt: "2026-07-29T12:00:00.000Z",
drawingMode: "permanent",
drawings: {
strokes: [
{
id: "stroke1",
color: "#f6c453",
width: 40,
points: [{ x: 1.123 }, { x: 4, y: 8 }, { x: 12.345, y: 16.789 }]
}
]
}
});
assert.match(normalized.html, /<h3>Titre<\/h3>/);
assert.match(normalized.html, /<span style="color: #f6c453">Important<\/span>/);
assert.doesNotMatch(normalized.html, /script|onclick|position|img/);
assert.equal(normalized.updatedAt, "2026-07-29T12:00:00.000Z");
assert.equal(normalized.drawings.strokes[0].width, 24);
assert.deepEqual(normalized.drawings.strokes[0].points, [{ x: 4, y: 8 }, { x: 12.35, y: 16.79 }]);
const compactTemporary = compactModuleDataForStorage("notepad", {
html: "<p>Note</p>",
text: "Note",
drawingMode: "temporary",
drawings: { strokes: normalized.drawings.strokes }
});
assert.equal(compactTemporary.drawings, undefined);
assert.equal(compactTemporary.drawingMode, undefined);
const compactPermanent = compactModuleDataForStorage("notepad", {
html: "<p>Note</p>",
text: "Note",
updatedAt: "not a date",
drawingMode: "permanent",
drawings: normalized.drawings
});
assert.equal(compactPermanent.updatedAt, undefined);
assert.equal(compactPermanent.drawingMode, "permanent");
assert.equal(compactPermanent.drawings.strokes.length, 1);
});
test("image annotation storage keeps drawing strokes as percent coordinates", () => {
const compact = compactModuleDataForStorage("imageAnnotation", {
image: "data:image/png;base64,aaa",
markers: [{ id: "marker1", x: 120, y: 50, label: "Boss" }],
drawings: {
strokes: [
{
id: "stroke1",
color: "#22d3ee",
width: 4,
points: [{ x: 10.123, y: 20.456 }, { x: 140, y: -4 }]
}
]
}
});
assert.equal(compact.markers[0].x, 100);
assert.deepEqual(compact.drawings.strokes[0].points, [{ x: 10.12, y: 20.46 }, { x: 100, y: 0 }]);
const exported = createToolboxExportPayload(
{ id: "toolbox1", name: "Map", modules: [{ id: "module1", type: "imageAnnotation" }], updatedAt: "2026-01-01T00:00:00.000Z" },
{ "toolbox1:module1": compact }
);
assert.equal(exported.modules.m1.markers[0].id, "k1");
assert.equal(exported.modules.m1.drawings.strokes[0].id, "d1");
});
test("task planner data normalizes invalid settings and relations", () => {
const normalized = normalizeTaskPlannerData({
weeklyResetDay: 9,

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}>
<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={() => setOpen((value) => !value)}
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>
{open && (
<div className="toolbox-icon-picker-menu" aria-label="Icônes de toolbox">
)}
>
{({ 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,73 +83,54 @@ 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>
<CompactDropdown
className="tool-add-quick-dropdown"
menuClassName="tool-quick-add"
label="Ajout rapide"
openOnHover
closeOnMouseLeave
renderTrigger={({ open: quickOpen, toggle }) => (
<button
className={`primary tool-add-quick-toggle ${quickOpen ? "active" : ""}`}
type="button"
onMouseEnter={() => setQuickOpen(true)}
onClick={() => setQuickOpen((value) => !value)}
onClick={toggle}
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">
)}
>
{({ close }) => (
<>
{modules.map(([type, module]) => (
<button
key={type}
className="tool-quick-add-button"
type="button"
onClick={() => onAdd(type)}
onClick={() => {
onAdd(type);
close();
}}
aria-label={`Ajouter ${module.label}`}
title={module.label}
>
@ -159,7 +139,9 @@ export function AddToolControls({ onAdd }) {
</span>
</button>
))}
</div>
</>
)}
</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 {