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

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

View file

@ -0,0 +1,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">