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