Refactor calculator formula tagging workflow
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s
This commit is contained in:
parent
ae503c7260
commit
abfa23fb3e
6 changed files with 118 additions and 191 deletions
|
|
@ -1854,19 +1854,18 @@
|
|||
"labelLabel": "Libellé",
|
||||
"labelPlaceholder": "ex: Lingots de fer",
|
||||
"saveButton": "Enregistrer",
|
||||
"updateButton": "Mettre à jour",
|
||||
"newButton": "Nouveau calcul",
|
||||
"rootButton": "Revenir à la racine",
|
||||
"resetTitle": "Réinitialiser",
|
||||
"copyTitle": "Copier pour checklist",
|
||||
"copiedTitle": "Copié",
|
||||
"scrollableTitle": "Liste scrollable",
|
||||
"emptyResults": "Aucun résultat enregistré.",
|
||||
"formulaTitle": "Modifier la formule",
|
||||
"childFormulaHelp": "Utilisez base pour référencer le parent.",
|
||||
"rootFormulaHelp": "Valeur ou formule racine.",
|
||||
"baseTokenLabel": "Valeur du parent",
|
||||
"insertBaseTitle": "Ajouter la valeur du parent",
|
||||
"useEntryTitle": "Utiliser comme base",
|
||||
"renameTitle": "Renommer",
|
||||
"editTitle": "Modifier",
|
||||
"addChildTitle": "Ajouter un calcul enfant",
|
||||
"deleteTitle": "Supprimer"
|
||||
},
|
||||
"goalTree": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
|
||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
|
||||
|
|
@ -117,6 +116,23 @@ function FormattedCalculatorNumber({ value }) {
|
|||
});
|
||||
}
|
||||
|
||||
function focusExpressionInputAtEnd(element) {
|
||||
if (!element) return;
|
||||
element.focus();
|
||||
if (typeof element.setSelectionRange === "function") {
|
||||
const length = element.value?.length || 0;
|
||||
element.setSelectionRange(length, length);
|
||||
return;
|
||||
}
|
||||
if (!element.isContentEditable) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
||||
const data = context.normalizeCalculatorData(context.getModuleData(toolboxId, moduleId, { entries: [] }));
|
||||
const calculatedEntries = useMemo(() => evaluateCalculatorEntries(data.entries), [data.entries]);
|
||||
|
|
@ -125,11 +141,17 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
const [expression, setExpression] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [activeParentId, setActiveParentId] = useState("");
|
||||
const [editingEntryId, setEditingEntryId] = useState("");
|
||||
const [calculatorHeight, setCalculatorHeight] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const calculatorCardRef = useRef(null);
|
||||
const activeParent = calculatedEntries.find((entry) => entry.id === activeParentId);
|
||||
const result = useMemo(() => calculateExpression(expression, activeParent?.value), [activeParent?.value, expression]);
|
||||
const expressionInputRef = useRef(null);
|
||||
const pendingExpressionFocusRef = useRef(false);
|
||||
const editingEntry = calculatedEntries.find((entry) => entry.id === editingEntryId);
|
||||
const formulaParentId = editingEntry ? editingEntry.parentId || "" : activeParentId;
|
||||
const formulaParent = calculatedEntries.find((entry) => entry.id === formulaParentId);
|
||||
const formMode = editingEntry ? "edit" : formulaParent ? "child" : "root";
|
||||
const result = useMemo(() => calculateExpression(expression, formulaParent?.value), [formulaParent?.value, expression]);
|
||||
const scopeParentId = (parentId = "") => `${moduleId}:${parentId}`;
|
||||
const reorder = useGroupedReorder({
|
||||
namespace: "calculator",
|
||||
|
|
@ -156,6 +178,12 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingExpressionFocusRef.current || !editingEntryId) return;
|
||||
pendingExpressionFocusRef.current = false;
|
||||
window.requestAnimationFrame(() => focusExpressionInputAtEnd(expressionInputRef.current));
|
||||
}, [editingEntryId]);
|
||||
|
||||
function save(entries) {
|
||||
context.setModuleData(toolboxId, moduleId, { ...data, entries: evaluateCalculatorEntries(entries) });
|
||||
}
|
||||
|
|
@ -168,24 +196,58 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
event.preventDefault();
|
||||
if (result == null) return;
|
||||
const cleanLabel = label.trim();
|
||||
const storedFormula = createStoredFormula(expression, formulaParent?.value);
|
||||
|
||||
if (editingEntry) {
|
||||
save(calculatedEntries.map((entry) => entry.id === editingEntry.id ? {
|
||||
...entry,
|
||||
label: cleanLabel,
|
||||
value: result,
|
||||
formula: storedFormula
|
||||
} : entry));
|
||||
setLabel(cleanLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
save([
|
||||
...data.entries,
|
||||
{
|
||||
id: context.uid("calc"),
|
||||
parentId: activeParentId,
|
||||
parentId: formulaParentId,
|
||||
label: cleanLabel,
|
||||
value: result,
|
||||
formula: createStoredFormula(expression, activeParent?.value)
|
||||
formula: storedFormula
|
||||
}
|
||||
]);
|
||||
setExpression(activeParent ? formatResult(activeParent.value) : "");
|
||||
setExpression(formulaParent ? BASE_TOKEN : "");
|
||||
setLabel("");
|
||||
}
|
||||
|
||||
function useEntry(entry) {
|
||||
setActiveParentId(entry.id);
|
||||
function editEntry(entry) {
|
||||
setEditingEntryId(entry.id);
|
||||
setActiveParentId("");
|
||||
setExpression(getEditableFormula(entry));
|
||||
setLabel(entry.label || "");
|
||||
}
|
||||
|
||||
function createChildEntry(entry) {
|
||||
const childId = context.uid("calc");
|
||||
save([
|
||||
...data.entries,
|
||||
{
|
||||
id: childId,
|
||||
parentId: entry.id,
|
||||
label: "",
|
||||
value: Number.isFinite(entry.value) ? entry.value : 0,
|
||||
formula: BASE_TOKEN
|
||||
}
|
||||
]);
|
||||
setEditingEntryId("");
|
||||
pendingExpressionFocusRef.current = true;
|
||||
setEditingEntryId(childId);
|
||||
setActiveParentId("");
|
||||
setExpression(BASE_TOKEN);
|
||||
setLabel("");
|
||||
}
|
||||
|
||||
function deleteEntry(entryId) {
|
||||
|
|
@ -194,34 +256,26 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
setActiveParentId("");
|
||||
setExpression("");
|
||||
}
|
||||
if (deletedIds.has(editingEntryId)) {
|
||||
setEditingEntryId("");
|
||||
setLabel("");
|
||||
setExpression("");
|
||||
}
|
||||
save(calculatedEntries.filter((entry) => !deletedIds.has(entry.id)));
|
||||
}
|
||||
|
||||
function renameEntry(entryId, label) {
|
||||
const cleanLabel = label.trim();
|
||||
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() {
|
||||
setExpression("");
|
||||
setLabel("");
|
||||
setActiveParentId("");
|
||||
setEditingEntryId("");
|
||||
}
|
||||
|
||||
function returnToRoot() {
|
||||
setActiveParentId("");
|
||||
setEditingEntryId("");
|
||||
setExpression("");
|
||||
setLabel("");
|
||||
}
|
||||
|
||||
async function copyChecklistImport() {
|
||||
|
|
@ -245,10 +299,11 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
<div className="calculator-card" ref={calculatorCardRef}>
|
||||
<div className="calculator-field">
|
||||
<span>{textContent.expressionLabel || "Calcul"}</span>
|
||||
{activeParent ? (
|
||||
{formulaParent ? (
|
||||
<MixTagsInput
|
||||
aria-label={textContent.expressionLabel || "Calcul"}
|
||||
className="calculator-expression-input"
|
||||
inputRef={expressionInputRef}
|
||||
onChange={setExpression}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
|
|
@ -273,6 +328,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
<input
|
||||
aria-label={textContent.expressionLabel || "Calcul"}
|
||||
className="calculator-expression-input"
|
||||
ref={expressionInputRef}
|
||||
onChange={(event) => setExpression(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
|
|
@ -295,7 +351,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
<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>
|
||||
<button className="primary" disabled={result == null}>{formMode === "edit" ? textContent.updateButton || "Mettre à jour" : textContent.saveButton || "Enregistrer"}</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
|
|
@ -303,9 +359,9 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
|
||||
>
|
||||
<div className="tool-split-actions">
|
||||
{activeParent && (
|
||||
{(formulaParent || editingEntry) && (
|
||||
<button className="tool-split-root-button" type="button" onClick={returnToRoot}>
|
||||
{textContent.rootButton || "Revenir à la racine"}
|
||||
{editingEntry ? textContent.newButton || "Nouveau calcul" : 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"}>
|
||||
|
|
@ -328,7 +384,7 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
</div>
|
||||
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
|
||||
{calculatedEntries.length ? (
|
||||
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeParentId={activeParentId} textContent={textContent} reorder={reorder} onUse={useEntry} onRename={renameEntry} onUpdateFormula={updateEntryFormula} onDelete={deleteEntry} />
|
||||
<CalculatorEntries entries={calculatedEntries} parentId="" getScopedParentId={scopeParentId} activeEntryId={editingEntryId || activeParentId} textContent={textContent} reorder={reorder} onEdit={editEntry} onAddChild={createChildEntry} onDelete={deleteEntry} />
|
||||
) : (
|
||||
<p className="muted">{textContent.emptyResults || "Aucun résultat enregistré."}</p>
|
||||
)}
|
||||
|
|
@ -338,31 +394,15 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
|
|||
);
|
||||
}
|
||||
|
||||
function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentId, textContent, reorder, onUse, onRename, onUpdateFormula, onDelete }) {
|
||||
function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, 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;
|
||||
if (event.target?.closest?.("[data-calculator-edit-toggle]")) return;
|
||||
setEditingId("");
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
}, [editingId]);
|
||||
|
||||
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" : "",
|
||||
entry.parentId ? "can-edit-formula" : "",
|
||||
entry.id === activeEntryId ? "active" : "",
|
||||
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
|
||||
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
|
||||
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
|
||||
|
|
@ -380,121 +420,30 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
|
|||
>
|
||||
<Icon name="drag" />
|
||||
</button>
|
||||
{entry.id === editingId ? (
|
||||
<span className="calculator-entry-edit-fields" data-calculator-edit-fields={entry.id}>
|
||||
<EditableCalculatorFormula entry={entry} entries={entries} textContent={textContent} onUpdateFormula={onUpdateFormula} />
|
||||
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} />
|
||||
<button className="tool-split-entry-summary" type="button" onClick={() => onEdit(entry)} title={textContent.editTitle || "Modifier"}>
|
||||
<span>
|
||||
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
|
||||
<FormattedCalculatorNumber value={entry.value} />
|
||||
</strong>
|
||||
{entry.label && <em>{entry.label}</em>}
|
||||
</span>
|
||||
) : (
|
||||
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
|
||||
<span>
|
||||
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
|
||||
<FormattedCalculatorNumber value={entry.value} />
|
||||
</strong>
|
||||
{entry.label && <em>{entry.label}</em>}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={entry.id === editingId ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)}
|
||||
aria-pressed={entry.id === editingId}
|
||||
aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
|
||||
data-calculator-edit-toggle
|
||||
title={textContent.renameTitle || "Renommer"}
|
||||
onClick={() => onAddChild(entry)}
|
||||
aria-label={`${textContent.addChildTitle || "Ajouter un calcul enfant"} ${entry.label || formatResult(entry.value)}`}
|
||||
title={textContent.addChildTitle || "Ajouter un calcul enfant"}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
<Icon name="add" />
|
||||
</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} onUpdateFormula={onUpdateFormula} onDelete={onDelete} />
|
||||
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFocus = true }) {
|
||||
const inlineEdit = useInlineEdit({
|
||||
value: entry.label,
|
||||
onCommit: (label) => onRename(entry.id, label),
|
||||
onDone,
|
||||
autoFocus,
|
||||
focusKey: entry.id
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
{...inlineEdit.getInputProps({
|
||||
className: "tool-split-entry-label",
|
||||
"aria-label": `${textContent.renameTitle || "Renommer"} ${entry.label}`
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
|
||||
const parent = entries.find((candidate) => candidate.id === entry.parentId);
|
||||
const parentValue = parent?.value;
|
||||
const hasParent = Boolean(entry.parentId);
|
||||
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()
|
||||
});
|
||||
|
||||
if (!hasParent) {
|
||||
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: textContent.rootFormulaHelp || "Valeur ou formule racine."
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MixTagsInput
|
||||
aria-label={`${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`}
|
||||
className="tool-split-entry-label calculator-entry-formula"
|
||||
onBlur={() => inlineEdit.commit()}
|
||||
onChange={inlineEdit.setDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
inlineEdit.cancel(event);
|
||||
}
|
||||
}}
|
||||
formulaSpacing
|
||||
insertToken={{
|
||||
value: "base",
|
||||
label: textContent.baseTokenLabel || "Valeur du parent",
|
||||
icon: "parent",
|
||||
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
|
||||
}}
|
||||
placeholder={textContent.formulaPlaceholder || "base * 3"}
|
||||
size="small"
|
||||
title={entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."}
|
||||
tokens={[{
|
||||
value: "base",
|
||||
label: textContent.baseTokenLabel || "Valeur du parent"
|
||||
}]}
|
||||
value={inlineEdit.draft}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,27 +27,6 @@
|
|||
grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
|
||||
}
|
||||
|
||||
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle {
|
||||
grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle .calculator-drag-handle {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.calculator-entry-formula {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.calculator-entry-edit-fields {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.calculator-drag-handle {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
|
|
|
|||
|
|
@ -658,10 +658,6 @@
|
|||
min-height: 36px;
|
||||
}
|
||||
|
||||
.tool-split-entry > button.active:not(.tool-split-entry-summary, .tool-split-entry-value, .danger) {
|
||||
@include mixins.temporary-toggle-active;
|
||||
}
|
||||
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-summary,
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-value,
|
||||
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue