445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
// 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(/<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 (
|
|
<button
|
|
className={`notepad-toolbar-button ${active ? "active" : ""}`}
|
|
type="button"
|
|
aria-pressed={active}
|
|
title={title}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ListTypeDropdown({ textContent, activeFormats, onCommand }) {
|
|
return (
|
|
<CompactDropdown
|
|
className="notepad-list-dropdown"
|
|
menuClassName="notepad-list-menu"
|
|
label={textContent.listGroupLabel || "Listes"}
|
|
closeOnMouseLeave
|
|
preventMouseDown
|
|
renderTrigger={({ toggle }) => (
|
|
<button
|
|
className={`notepad-toolbar-button ${activeFormats.bulletList || activeFormats.numberList ? "active" : ""}`}
|
|
type="button"
|
|
aria-pressed={activeFormats.bulletList || activeFormats.numberList}
|
|
title={textContent.listGroupLabel || "Listes"}
|
|
onClick={toggle}
|
|
>
|
|
<span className="notepad-list-icon" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
>
|
|
{({ close }) => (
|
|
<>
|
|
<button
|
|
className={`notepad-list-option ${activeFormats.bulletList ? "active" : ""}`}
|
|
type="button"
|
|
aria-pressed={activeFormats.bulletList}
|
|
onClick={() => {
|
|
onCommand("insertUnorderedList");
|
|
close();
|
|
}}
|
|
>
|
|
•
|
|
</button>
|
|
<button
|
|
className={`notepad-list-option ${activeFormats.numberList ? "active" : ""}`}
|
|
type="button"
|
|
aria-pressed={activeFormats.numberList}
|
|
onClick={() => {
|
|
onCommand("insertOrderedList");
|
|
close();
|
|
}}
|
|
>
|
|
1.
|
|
</button>
|
|
</>
|
|
)}
|
|
</CompactDropdown>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="notepad-module">
|
|
{textExchangeOpen && (
|
|
<div className="module-text-exchange-panel">
|
|
<TextExchangeActions type="notepad" data={normalizedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "notepad")} />
|
|
</div>
|
|
)}
|
|
<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.strikeThrough} title={textContent.strikeTitle || "Barré"} onClick={() => runCommand("strikeThrough")}><s>S</s></ToolbarButton>
|
|
<ToolbarButton active={activeFormats.heading} title={textContent.headingTitle || "Titre"} onClick={toggleHeading}>T</ToolbarButton>
|
|
</div>
|
|
<div className="notepad-toolbar-group" role="group" aria-label={textContent.colorGroupLabel || "Couleurs"}>
|
|
<ListTypeDropdown textContent={textContent} activeFormats={activeFormats} onCommand={runCommand} />
|
|
<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>
|
|
);
|
|
}
|