sokko-g/website/src/features/toolboxes/modules/CalculatorModule.jsx
Shinuwa 1879b245fb
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
Unify toolbox drag and drop reorder behavior
2026-08-01 20:00:47 +02:00

276 lines
11 KiB
JavaScript

// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
function calculateExpression(expression) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!normalized || !/^[\d\s+\-*/().]+$/.test(normalized)) return null;
try {
const value = Function(`"use strict"; return (${normalized})`)();
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
function formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
}
function getChildren(entries, parentId) {
return entries.filter((entry) => (entry.parentId || "") === parentId);
}
function getDescendantIds(entries, parentId) {
const children = getChildren(entries, parentId);
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
}
function getEntriesInTreeOrder(entries, parentId = "") {
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
}
export function CalculatorModule({ toolboxId, moduleId, context }) {
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
const textContent = context.moduleText?.calculator || {};
const [expression, setExpression] = useState("");
const [label, setLabel] = useState("");
const [activeParentId, setActiveParentId] = useState("");
const [calculatorHeight, setCalculatorHeight] = useState(0);
const [copied, setCopied] = useState(false);
const calculatorCardRef = useRef(null);
const result = useMemo(() => calculateExpression(expression), [expression]);
const activeParent = data.entries.find((entry) => entry.id === activeParentId);
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
const reorder = useGroupedReorder({
namespace: "calculator",
items: data.entries,
getItemId: (entry) => entry.id,
getParentId: (entry) => scopeParentId(entry.parentId || ""),
onItemMove: (operation) => {
save(moveItem(data.entries, operation.sourceId, operation.targetId, operation.placement));
},
hierarchy: { enabled: true, stickyParents: true }
});
useLayoutEffect(() => {
const element = calculatorCardRef.current;
if (!element) return undefined;
function updateHeight() {
setCalculatorHeight(Math.ceil(element.getBoundingClientRect().height));
}
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(element);
return () => observer.disconnect();
}, []);
function save(entries) {
context.setModuleData(toolboxId, moduleId, { ...data, entries });
}
function setScrollResults(scrollResults) {
context.setModuleData(toolboxId, moduleId, { ...data, scrollResults });
}
function saveResult(event) {
event.preventDefault();
if (result == null) return;
const cleanLabel = label.trim();
save([
...data.entries,
{
id: context.uid("calc"),
parentId: activeParentId,
label: cleanLabel,
value: result
}
]);
setExpression("");
setLabel("");
}
function useEntry(entry) {
setExpression(formatResult(entry.value));
setActiveParentId(entry.id);
}
function deleteEntry(entryId) {
const deletedIds = new Set([entryId, ...getDescendantIds(data.entries, entryId)]);
if (deletedIds.has(activeParentId)) setActiveParentId("");
save(data.entries.filter((entry) => !deletedIds.has(entry.id)));
}
function renameEntry(entryId, label) {
const cleanLabel = label.trim();
save(data.entries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry));
}
function resetCalculator() {
setExpression("");
setLabel("");
setActiveParentId("");
}
async function copyChecklistImport() {
const text = getEntriesInTreeOrder(data.entries)
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
.join("\n");
if (!text || !await context.copyText(text)) return;
setCopied(true);
context.notify?.(textContent.copiedTitle || "Copié");
window.setTimeout(() => setCopied(false), 1400);
}
return (
<div className="calculator-module">
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<label>
<span>{textContent.expressionLabel || "Calcul"}</span>
<input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" />
</label>
<div className="calculator-result" aria-live="polite">
<span>{textContent.resultLabel || "Résultat"}</span>
<strong>{result == null ? "-" : formatResult(result)}</strong>
</div>
<label>
<span>{textContent.labelLabel || "Libellé"}</span>
<input value={label} onChange={(event) => setLabel(event.target.value)} placeholder={textContent.labelPlaceholder || "Lingots de fer"} />
</label>
<button className="primary" disabled={result == null}>{textContent.saveButton || "Enregistrer"}</button>
</div>
</form>
<div
className={`tool-split-results calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
>
<div className="tool-split-actions">
{activeParent && (
<button className="tool-split-root-button" type="button" onClick={() => setActiveParentId("")}>
{textContent.rootButton || "Revenir à la racine"}
</button>
)}
<button className="tool-split-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" />
</button>
<button className="tool-split-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<Icon name="copy" />
</button>
<button
className={`tool-split-scroll-toggle ${data.scrollResults ? "active" : ""}`}
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
title={textContent.scrollableTitle || "Liste scrollable"}
aria-label={textContent.scrollableTitle || "Liste scrollable"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
</div>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{data.entries.length ? (
<CalculatorEntries entries={data.entries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
) : (
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
)}
</div>
</div>
</div>
);
}
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onDelete }) {
const children = getChildren(entries, parentId);
const [editingId, setEditingId] = useState("");
if (!children.length) return null;
return (
<ul className="tool-split-entry-list calculator-entry-list">
{children.map((entry) => {
const className = [
entry.id === activeParentId ? "active" : "",
entry.id === editingId ? "is-editing" : "",
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li className={className} key={entry.id} {...reorder.getItemProps({ itemId: entry.id, parentId: getScopedParentId(parentId) })}>
<div className="tool-split-entry calculator-entry has-drag-handle">
<button
className="calculator-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, entry.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${entry.label || formatResult(entry.value)}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
{entry.id === editingId ? (
<>
<span className="tool-split-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
<strong>{formatResult(entry.value)}</strong>
</span>
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
</>
) : (
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<span><strong>{formatResult(entry.value)}</strong> {entry.label}</span>
</button>
)}
<button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`} title={textContent.renameTitle || "Renommer"}>
<Icon name="edit" />
</button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={onUse} onRename={onRename} onDelete={onDelete} />
</li>
);
})}
</ul>
);
}
function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
const [label, setLabel] = useState(entry.label);
const inputRef = useRef(null);
useLayoutEffect(() => {
inputRef.current?.focus();
}, [entry.id]);
function saveLabel() {
const cleanLabel = label.trim();
if (cleanLabel !== entry.label) onRename(entry.id, cleanLabel);
onDone();
}
return (
<input
ref={inputRef}
className="tool-split-entry-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
onBlur={saveLabel}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setLabel(entry.label);
onDone();
}
}}
aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
/>
);
}