// 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} />
); }