Fix notepad cursor and calculator linked formulas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
This commit is contained in:
parent
992665f4ea
commit
49ba0c4cb2
10 changed files with 223 additions and 45 deletions
|
|
@ -1,18 +1,21 @@
|
|||
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
|
||||
const BASE_TOKEN = "base";
|
||||
|
||||
function calculateExpression(expression) {
|
||||
function calculateExpression(expression, baseValue = null) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
if (!normalized || !/^[\d\s+\-*/().]+$/.test(normalized)) return null;
|
||||
if (!normalized) return null;
|
||||
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
|
||||
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
|
||||
|
||||
try {
|
||||
const value = Function(`"use strict"; return (${normalized})`)();
|
||||
const value = Function(`"use strict"; return (${withBase})`)();
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -56,6 +59,48 @@ function getEntriesInTreeOrder(entries, parentId = "") {
|
|||
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
|
||||
}
|
||||
|
||||
function getEntryFormula(entry) {
|
||||
return String(entry?.formula || "").trim();
|
||||
}
|
||||
|
||||
function getEditableFormula(entry) {
|
||||
return getEntryFormula(entry) || formatResult(entry.value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function createStoredFormula(expression, parentValue) {
|
||||
const normalized = String(expression || "").replaceAll(",", ".").trim();
|
||||
if (!Number.isFinite(parentValue)) return normalized;
|
||||
const parentText = formatResult(parentValue);
|
||||
if (/\bbase\b/i.test(normalized)) return normalized;
|
||||
const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
|
||||
if (withBase !== normalized) return withBase;
|
||||
if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function evaluateCalculatorEntries(entries) {
|
||||
const byParent = new Map();
|
||||
entries.forEach((entry) => {
|
||||
const parentId = entry.parentId || "";
|
||||
byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
|
||||
});
|
||||
|
||||
function visit(parentId = "", parentValue = null) {
|
||||
return (byParent.get(parentId) || []).flatMap((entry) => {
|
||||
const formula = getEntryFormula(entry);
|
||||
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
|
||||
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
|
||||
return [nextEntry, ...visit(entry.id, nextEntry.value)];
|
||||
});
|
||||
}
|
||||
|
||||
return visit();
|
||||
}
|
||||
|
||||
function FormattedCalculatorNumber({ value }) {
|
||||
if (!Number.isFinite(value)) return "-";
|
||||
let integerGroupIndex = 0;
|
||||
|
|
@ -73,6 +118,8 @@ function FormattedCalculatorNumber({ value }) {
|
|||
|
||||
export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
||||
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
|
||||
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
|
||||
const calculatedData = useMemo(() => ({ ...data, entries: calculatedEntries }), [data, calculatedEntries]);
|
||||
const textContent = context.moduleText?.calculator || {};
|
||||
const [expression, setExpression] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
|
|
@ -80,16 +127,16 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
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 activeParent = calculatedEntries.find((entry) => entry.id === activeParentId);
|
||||
const result = useMemo(() => calculateExpression(expression, activeParent?.value), [activeParent?.value, expression]);
|
||||
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
|
||||
const reorder = useGroupedReorder({
|
||||
namespace: "calculator",
|
||||
items: data.entries,
|
||||
items: calculatedEntries,
|
||||
getItemId: (entry) => entry.id,
|
||||
getParentId: (entry) => scopeParentId(entry.parentId || ""),
|
||||
onItemMove: (operation) => {
|
||||
save(moveItem(data.entries, operation.sourceId, operation.targetId, operation.placement));
|
||||
save(moveItem(calculatedEntries, operation.sourceId, operation.targetId, operation.placement));
|
||||
},
|
||||
hierarchy: { enabled: true, stickyParents: true }
|
||||
});
|
||||
|
|
@ -109,7 +156,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
}, []);
|
||||
|
||||
function save(entries) {
|
||||
context.setModuleData(toolboxId, moduleId, { ...data, entries });
|
||||
context.setModuleData(toolboxId, moduleId, { ...data, entries: evaluateCalculatorEntries(entries) });
|
||||
}
|
||||
|
||||
function setScrollResults(scrollResults) {
|
||||
|
|
@ -127,7 +174,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
id: context.uid("calc"),
|
||||
parentId: activeParentId,
|
||||
label: cleanLabel,
|
||||
value: result
|
||||
value: result,
|
||||
formula: createStoredFormula(expression, activeParent?.value)
|
||||
}
|
||||
]);
|
||||
setExpression(activeParent ? formatResult(activeParent.value) : "");
|
||||
|
|
@ -140,17 +188,28 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
}
|
||||
|
||||
function deleteEntry(entryId) {
|
||||
const deletedIds = new Set([entryId, ...getDescendantIds(data.entries, entryId)]);
|
||||
const deletedIds = new Set([entryId, ...getDescendantIds(calculatedEntries, entryId)]);
|
||||
if (deletedIds.has(activeParentId)) {
|
||||
setActiveParentId("");
|
||||
setExpression("");
|
||||
}
|
||||
save(data.entries.filter((entry) => !deletedIds.has(entry.id)));
|
||||
save(calculatedEntries.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));
|
||||
save(calculatedEntries.map((entry) => entry.id === entryId ? { ...entry, label: cleanLabel } : entry));
|
||||
}
|
||||
|
||||
function updateEntryFormula(entryId, formula) {
|
||||
const nextEntries = calculatedEntries.map((entry) => {
|
||||
if (entry.id !== entryId) return entry;
|
||||
const parent = calculatedEntries.find((candidate) => candidate.id === entry.parentId);
|
||||
const nextFormula = createStoredFormula(formula, parent?.value);
|
||||
const nextValue = calculateExpression(nextFormula, parent?.value) ?? entry.value;
|
||||
return { ...entry, formula: nextFormula, value: nextValue };
|
||||
});
|
||||
save(nextEntries);
|
||||
}
|
||||
|
||||
function resetCalculator() {
|
||||
|
|
@ -165,7 +224,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
}
|
||||
|
||||
async function copyChecklistImport() {
|
||||
const text = getEntriesInTreeOrder(data.entries)
|
||||
const text = getEntriesInTreeOrder(calculatedEntries)
|
||||
.map((entry) => `${entry.label || formatResult(entry.value)}:${formatResult(entry.value)}`)
|
||||
.join("\n");
|
||||
if (!text || !await context.copyText(text)) return;
|
||||
|
|
@ -178,7 +237,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
<div className="calculator-module">
|
||||
{editing && (
|
||||
<div className="module-add-panel calculator-exchange-panel">
|
||||
<TextExchangeActions type="calculator" data={data} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
|
||||
<TextExchangeActions type="calculator" data={calculatedData} context={context} textContent={textContent} onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "calculator")} />
|
||||
</div>
|
||||
)}
|
||||
<form className="calculator-form" onSubmit={saveResult}>
|
||||
|
|
@ -229,8 +288,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
</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} />
|
||||
{calculatedEntries.length ? (
|
||||
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
|
||||
) : (
|
||||
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
|
||||
)}
|
||||
|
|
@ -240,9 +299,21 @@ export function CalculatorModule({ toolboxId, moduleId, context, editing }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onDelete }) {
|
||||
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, editing, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
|
||||
const children = getChildren(entries, parentId);
|
||||
const [editingId, setEditingId] = useState("");
|
||||
useEffect(() => {
|
||||
if (!editingId) return undefined;
|
||||
|
||||
function closeOnOutsidePointer(event) {
|
||||
if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return;
|
||||
setEditingId("");
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
}, [editingId]);
|
||||
|
||||
if (!children.length) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -251,6 +322,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
const className = [
|
||||
entry.id === activeParentId ? "active" : "",
|
||||
entry.id === editingId ? "is-editing" : "",
|
||||
"can-edit-formula",
|
||||
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
||||
|
|
@ -269,14 +341,10 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
<Icon name="drag" />
|
||||
</button>
|
||||
{entry.id === editingId ? (
|
||||
<>
|
||||
<span className="tool-split-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
|
||||
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
|
||||
<FormattedCalculatorNumber value={entry.value} />
|
||||
</strong>
|
||||
</span>
|
||||
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
|
||||
</>
|
||||
<span className="calculator-entry-edit-fields" data-calculator-edit-fields={entry.id}>
|
||||
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} autoFocus={editing} />
|
||||
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} autoFocus={!editing} />
|
||||
</span>
|
||||
) : (
|
||||
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
|
||||
<span>
|
||||
|
|
@ -294,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
<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} />
|
||||
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeParentId={activeParentId} textContent={textContent} editing={editing} reorder={reorder} onUse={onUse} onRename={onRename} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
@ -302,12 +370,12 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
);
|
||||
}
|
||||
|
||||
function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
|
||||
function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFocus = true }) {
|
||||
const inlineEdit = useInlineEdit({
|
||||
value: entry.label,
|
||||
onCommit: (label) => onRename(entry.id, label),
|
||||
onDone,
|
||||
autoFocus: true,
|
||||
autoFocus,
|
||||
focusKey: entry.id
|
||||
});
|
||||
|
||||
|
|
@ -320,3 +388,28 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
|
||||
const parent = entries.find((candidate) => candidate.id === entry.parentId);
|
||||
const parentValue = parent?.value;
|
||||
const inlineEdit = useInlineEdit({
|
||||
value: getEditableFormula(entry),
|
||||
onCommit: (formula) => {
|
||||
const storedFormula = createStoredFormula(formula, parentValue);
|
||||
if (calculateExpression(storedFormula, parentValue) != null) onUpdateFormula(entry.id, storedFormula);
|
||||
},
|
||||
autoFocus,
|
||||
focusKey: `${entry.id}:formula`,
|
||||
transform: (draft) => String(draft || "").replaceAll(",", ".").trim()
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
{...inlineEdit.getInputProps({
|
||||
className: "tool-split-entry-label calculator-entry-formula",
|
||||
"aria-label": `${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`,
|
||||
title: entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue