// Rôle : fournit l'outil bloc-notes riche avec annotations dessinées.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DrawingControls, FormattingColorDropdown } from "./DrawingControls.jsx";
import { DrawingOverlay } from "./DrawingOverlay.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
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 getSelectionTextOffset(container) {
const selection = document.getSelection();
if (!container || !selection?.rangeCount || !container.contains(selection.anchorNode)) return null;
const range = selection.getRangeAt(0);
const prefix = range.cloneRange();
prefix.selectNodeContents(container);
prefix.setEnd(range.startContainer, range.startOffset);
return prefix.toString().length;
}
function restoreSelectionTextOffset(container, offset) {
if (!container || offset == null) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = offset;
let node = walker.nextNode();
while (node) {
const length = node.textContent.length;
if (remaining <= length) {
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return;
}
remaining -= length;
node = walker.nextNode();
}
const range = document.createRange();
range.selectNodeContents(container);
range.collapse(false);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function isHtmlEmpty(html, text) {
return !text && !String(html || "").replace(/
/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 (
);
}
function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
return (
(
)}
>
{({ close }) => (
<>
>
)}
);
}
export function NotepadModule({ toolboxId, moduleId, context, textExchangeOpen }) {
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 selectionOffsetRef = useRef(null);
const localSaveUpdatedAtRef = useRef("");
const moduleKeyRef = useRef(`${toolboxId}:${moduleId}`);
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,
strikeThrough: false,
heading: false,
bulletList: false,
numberList: false
});
const sessionKey = `sokkog:notepad-drawings:${toolboxId}:${moduleId}`;
const [temporaryStrokes, setTemporaryStrokes] = useState([]);
useEffect(() => {
const moduleKey = `${toolboxId}:${moduleId}`;
if (moduleKeyRef.current !== moduleKey) {
moduleKeyRef.current = moduleKey;
localSaveUpdatedAtRef.current = "";
}
if (normalizedData.updatedAt && normalizedData.updatedAt === localSaveUpdatedAtRef.current) return;
const editor = editorRef.current;
if (document.activeElement === editor && getPlainText(editor) === normalizedData.text) {
setText(normalizedData.text);
setUpdatedAt(normalizedData.updatedAt);
setDrawingMode(normalizedData.drawingMode);
return;
}
setHtml(normalizedData.html);
setText(normalizedData.text);
setUpdatedAt(normalizedData.updatedAt);
setDrawingMode(normalizedData.drawingMode);
setDrawingActive(false);
setEraseActive(false);
}, [toolboxId, moduleId, normalizedData.html, normalizedData.text, normalizedData.updatedAt, normalizedData.drawingMode]);
useLayoutEffect(() => {
if (!editorRef.current || editorRef.current.innerHTML === html) return;
if (document.activeElement === editorRef.current && getPlainText(editorRef.current) === text) return;
editorRef.current.innerHTML = html;
if (document.activeElement === editorRef.current) restoreSelectionTextOffset(editorRef.current, selectionOffsetRef.current);
}, [html, text]);
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, strikeThrough: false, heading: false, bulletList: false, numberList: false }
: current);
return;
}
const block = queryBlockFormat();
setActiveFormats({
bold: queryCommandState("bold"),
italic: queryCommandState("italic"),
underline: queryCommandState("underline"),
strikeThrough: queryCommandState("strikeThrough"),
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();
localSaveUpdatedAtRef.current = nextUpdatedAt;
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() {
selectionOffsetRef.current = getSelectionTextOffset(editorRef.current);
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 toggleHeading() {
applyBlock(activeFormats.heading ? "p" : "h3");
}
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 (
{textExchangeOpen && (
context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
)}
runCommand("bold")}>B
runCommand("italic")}>I
runCommand("underline")}>U
runCommand("strikeThrough")}>S
T
context.uid("stroke")}
/>
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}
/>
);
}