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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue