Add internal mix tag input for calculator and goal tree formulas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 13:24:44 +02:00
parent 5c8148c905
commit ae503c7260
13 changed files with 929 additions and 23 deletions

View file

@ -158,6 +158,18 @@ Regles :
- edition inline des libelles plutot que boutons ou alertes navigateur ; - edition inline des libelles plutot que boutons ou alertes navigateur ;
- styles specifiques autorises uniquement pour la logique propre a l'outil. - styles specifiques autorises uniquement pour la logique propre a l'outil.
### Champs Mixtes A Badges
Les champs texte qui remplacent une valeur brute par un badge visuel doivent utiliser le pattern `MixTagsInput`.
Regles :
- le champ reprend le principe du mode mix Tagify : zone `contentEditable` et badges `contentEditable=false` ;
- la valeur sauvegardee reste une string normale, le badge est une representation atomique de cette valeur ;
- un badge est atomique : le curseur peut se placer avant ou apres, et la suppression retire toute la valeur associee ;
- la copie d'une selection contenant un badge doit copier la valeur brute, pas seulement le libelle visible.
- utiliser `size="small"` pour les champs compacts de 36 px, plutot que de deduire la taille depuis une classe metier.
### Timer ### Timer
Le timer combine chronometre a etapes et comptes a rebours multiples. Le timer combine chronometre a etapes et comptes a rebours multiples.

View file

@ -266,6 +266,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.calculator.formulaTitle", "toolboxes.modules.calculator.formulaTitle",
"toolboxes.modules.calculator.childFormulaHelp", "toolboxes.modules.calculator.childFormulaHelp",
"toolboxes.modules.calculator.rootFormulaHelp", "toolboxes.modules.calculator.rootFormulaHelp",
"toolboxes.modules.calculator.baseTokenLabel",
"toolboxes.modules.calculator.insertBaseTitle",
"toolboxes.modules.calculator.useEntryTitle", "toolboxes.modules.calculator.useEntryTitle",
"toolboxes.modules.calculator.renameTitle", "toolboxes.modules.calculator.renameTitle",
"toolboxes.modules.calculator.deleteTitle", "toolboxes.modules.calculator.deleteTitle",
@ -318,6 +320,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.goalTree.addContentItemButton", "toolboxes.modules.goalTree.addContentItemButton",
"toolboxes.modules.goalTree.formulaPlaceholder", "toolboxes.modules.goalTree.formulaPlaceholder",
"toolboxes.modules.goalTree.formulaHelp", "toolboxes.modules.goalTree.formulaHelp",
"toolboxes.modules.goalTree.baseTokenLabel",
"toolboxes.modules.goalTree.insertBaseTitle",
"toolboxes.modules.goalTree.resetTypeLabel", "toolboxes.modules.goalTree.resetTypeLabel",
"toolboxes.modules.goalTree.noneReset", "toolboxes.modules.goalTree.noneReset",
"toolboxes.modules.goalTree.dailyReset", "toolboxes.modules.goalTree.dailyReset",

View file

@ -3,6 +3,7 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js";
import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js"; import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
@ -41,6 +42,35 @@ test("colon text import accepts labels without value", () => {
]); ]);
}); });
test("mix tags input parses isolated token values only", () => {
const tokens = [{ value: "base", label: "Valeur du parent" }, { value: "loot", label: "Butin" }];
assert.deepEqual(tokenizeMixTagValue("base * 3", tokens).map((segment) => segment.type), ["token", "text"]);
assert.deepEqual(tokenizeMixTagValue("X - Base", tokens).map((segment) => segment.type), ["text", "token"]);
assert.deepEqual(tokenizeMixTagValue("database", tokens).map((segment) => ({ type: segment.type, text: segment.text })), [{ type: "text", text: "database" }]);
assert.deepEqual(tokenizeMixTagValue("base + loot", tokens).map((segment) => segment.type), ["token", "text", "token"]);
assert.deepEqual(tokenizeMixTagValue("", tokens), []);
assert.deepEqual(tokenizeMixTagValue("base * 2,5", tokens).map((segment) => segment.type), ["token", "text"]);
assert.equal(normalizeMixTagText("X - Base", tokens), "X - base");
assert.equal(normalizeMixTagText("database", tokens), "database");
assert.equal(normalizeMixTagSpacing(" 1 base + 2 "), "1 base + 2");
});
test("mix tags input insertion preserves formula spacing when requested", () => {
assert.deepEqual(buildInsertedMixTagValue("* 3", 0, 0, "base", { formulaSpacing: true }), { value: "base* 3", cursor: 4 });
assert.deepEqual(buildInsertedMixTagValue("2", 1, 1, "base", { formulaSpacing: true }), { value: "2 base", cursor: 6 });
assert.deepEqual(buildInsertedMixTagValue("2+3", 2, 2, "base", { formulaSpacing: true }), { value: "2+base 3", cursor: 7 });
assert.deepEqual(buildInsertedMixTagValue("2+old*3", 2, 5, "base", { formulaSpacing: true }), { value: "2+base*3", cursor: 6 });
assert.deepEqual(buildInsertedMixTagValue("route", 5, 5, "@item"), { value: "route@item", cursor: 10 });
});
test("mix tags input deletion removes the whole token", () => {
const tokens = [{ value: "base", label: "Valeur du parent" }];
assert.deepEqual(removeMixTagAtOffset("base * 3", 4, 4, tokens, "backward"), { value: " * 3", cursor: 0 });
assert.deepEqual(removeMixTagAtOffset("2 + base", 4, 4, tokens, "forward"), { value: "2 + ", cursor: 4 });
assert.deepEqual(removeMixTagAtOffset("2 + base * 3", 6, 8, tokens, "backward"), { value: "2 + * 3", cursor: 4 });
assert.equal(removeMixTagAtOffset("2 + 3", 5, 5, tokens, "backward"), null);
});
test("text exchange round trips simple toolbox modules and resets checklist progress", () => { test("text exchange round trips simple toolbox modules and resets checklist progress", () => {
const context = createTextImportContext(); const context = createTextImportContext();
const notepad = normalizeNotepadData({ const notepad = normalizeNotepadData({

View file

@ -1863,6 +1863,8 @@
"formulaTitle": "Modifier la formule", "formulaTitle": "Modifier la formule",
"childFormulaHelp": "Utilisez base pour référencer le parent.", "childFormulaHelp": "Utilisez base pour référencer le parent.",
"rootFormulaHelp": "Valeur ou formule racine.", "rootFormulaHelp": "Valeur ou formule racine.",
"baseTokenLabel": "Valeur du parent",
"insertBaseTitle": "Ajouter la valeur du parent",
"useEntryTitle": "Utiliser comme base", "useEntryTitle": "Utiliser comme base",
"renameTitle": "Renommer", "renameTitle": "Renommer",
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
@ -1949,6 +1951,8 @@
"addContentItemButton": "Ajouter une ligne", "addContentItemButton": "Ajouter une ligne",
"formulaPlaceholder": "base * 3", "formulaPlaceholder": "base * 3",
"formulaHelp": "Utilisez base pour référencer la cible du parent.", "formulaHelp": "Utilisez base pour référencer la cible du parent.",
"baseTokenLabel": "Valeur du parent",
"insertBaseTitle": "Ajouter la valeur du parent",
"resetTypeLabel": "Type de reset", "resetTypeLabel": "Type de reset",
"noneReset": "Aucun", "noneReset": "Aucun",
"dailyReset": "Quotidien", "dailyReset": "Quotidien",

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M7 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm0 2a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm10.5 2a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 2a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm2.5 6v-.5a2.5 2.5 0 1 0-5 0v.5h-2v-.5a4.5 4.5 0 1 1 9 0v.5h-2zm-10 0v-4a3 3 0 0 0-6 0v4H2v-4a5 5 0 0 1 10 0v4h-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 566 B

View file

@ -0,0 +1,397 @@
// Rôle : fournit un champ contentEditable mixte inspiré du mode mix Tagify.
// Affiche des tags atomiques dans le texte tout en exposant une valeur string brute.
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { buildInsertedMixTagValue, normalizeMixTagDefinitions, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../utils/mixTagsInputUtils.js";
const ZERO_WIDTH_SPACE = "\u200B";
const WORD_CHAR_PATTERN = /[\p{L}\p{N}_]/u;
function assignRef(ref, node) {
if (!ref) return;
if (typeof ref === "function") ref(node);
else ref.current = node;
}
function createInputLikeEvent(value, element = null) {
return {
target: { value },
currentTarget: { value, textContent: value, isContentEditable: true, blur: () => element?.blur?.() }
};
}
function cleanTextValue(value) {
return String(value || "").replaceAll(ZERO_WIDTH_SPACE, "");
}
function getTokenElement(node, root) {
let current = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
while (current && current !== root) {
if (current.dataset?.mixTagValue != null) return current;
current = current.parentElement;
}
return null;
}
function readNodeValue(node) {
if (!node) return "";
if (node.nodeType === Node.TEXT_NODE) return cleanTextValue(node.nodeValue);
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (node.dataset?.mixTagValue != null) return node.dataset.mixTagValue;
return Array.from(node.childNodes).map(readNodeValue).join("");
}
function valueNeedsSeparator(left, right) {
if (!left || !right) return false;
return WORD_CHAR_PATTERN.test(left[left.length - 1]) && WORD_CHAR_PATTERN.test(right[0]);
}
function readEditorValue(editor) {
let value = "";
Array.from(editor?.childNodes || []).forEach((node) => {
const nextValue = readNodeValue(node);
if (!nextValue) return;
value += `${valueNeedsSeparator(value, nextValue) ? " " : ""}${nextValue}`;
});
return value;
}
function getNodeRawLength(node) {
return readNodeValue(node).length;
}
function getOffsetFromPoint(root, targetNode, targetOffset) {
const tokenElement = getTokenElement(targetNode, root);
let offset = 0;
let found = false;
function visit(node) {
if (found) return;
if (node === targetNode) {
if (node.nodeType === Node.TEXT_NODE) {
offset += cleanTextValue((node.nodeValue || "").slice(0, targetOffset)).length;
} else {
Array.from(node.childNodes).slice(0, targetOffset).forEach((child) => {
offset += getNodeRawLength(child);
});
}
found = true;
return;
}
if (node === tokenElement) {
const tokenLength = node.dataset.mixTagValue.length;
const tokenTextLength = node.textContent?.length || 1;
const visualOffset = targetNode === node ? targetOffset : Math.min(targetOffset, tokenTextLength);
offset += visualOffset < tokenTextLength / 2 ? 0 : tokenLength;
found = true;
return;
}
if (node.nodeType === Node.TEXT_NODE || node.dataset?.mixTagValue != null) {
offset += getNodeRawLength(node);
return;
}
Array.from(node.childNodes || []).forEach(visit);
}
visit(root);
return offset;
}
function getSelectionOffsets(root) {
const selection = window.getSelection();
if (!selection?.rangeCount) return { start: 0, end: 0 };
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) {
const length = readEditorValue(root).length;
return { start: length, end: length };
}
const start = getOffsetFromPoint(root, range.startContainer, range.startOffset);
const end = getOffsetFromPoint(root, range.endContainer, range.endOffset);
return { start: Math.min(start, end), end: Math.max(start, end) };
}
function findPointFromOffset(root, requestedOffset) {
let remaining = Math.max(0, requestedOffset);
let fallback = { node: root, offset: root.childNodes.length };
function visit(node) {
if (node.nodeType === Node.TEXT_NODE) {
const raw = cleanTextValue(node.nodeValue);
if (remaining <= raw.length) {
let domOffset = 0;
let rawOffset = 0;
const text = node.nodeValue || "";
while (domOffset < text.length && rawOffset < remaining) {
if (text[domOffset] !== ZERO_WIDTH_SPACE) rawOffset += 1;
domOffset += 1;
}
return { node, offset: domOffset };
}
remaining -= raw.length;
return null;
}
if (node.nodeType === Node.ELEMENT_NODE && node.dataset?.mixTagValue != null) {
const length = node.dataset.mixTagValue.length;
const parent = node.parentNode || root;
const index = Array.from(parent.childNodes).indexOf(node);
if (remaining <= length) return { node: parent, offset: remaining <= length / 2 ? index : index + 1 };
remaining -= length;
fallback = { node: parent, offset: index + 1 };
return null;
}
const children = Array.from(node.childNodes || []);
for (const child of children) {
const point = visit(child);
if (point) return point;
}
fallback = { node, offset: children.length };
return null;
}
return visit(root) || fallback;
}
function setCaretOffset(root, offset) {
const selection = window.getSelection();
if (!selection) return;
const point = findPointFromOffset(root, offset);
const range = document.createRange();
range.setStart(point.node, point.offset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function getRawSelectionText(root) {
const selection = window.getSelection();
if (!selection?.rangeCount) return "";
const range = selection.getRangeAt(0);
if (!root.contains(range.commonAncestorContainer)) return "";
const fragment = range.cloneContents();
return Array.from(fragment.childNodes).map(readNodeValue).join("");
}
export function MixTagsInput({
value = "",
onChange,
onBlur,
onFocus,
onKeyDown,
tokens = [],
placeholder = "",
disabled = false,
className = "",
wrapperClassName = "",
size = "default",
id,
title = "",
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
inputRef,
inputMode,
spellCheck = "false",
insertToken = null,
formulaSpacing = false
}) {
const editorRef = useRef(null);
const valueRef = useRef(String(value ?? ""));
const pendingCaretRef = useRef(null);
const latestRef = useRef({});
const composingRef = useRef(false);
const [isFocused, setIsFocused] = useState(false);
const tokenDefinitions = useMemo(() => normalizeMixTagDefinitions(tokens), [tokens]);
const normalizedValue = normalizeMixTagText(String(value ?? ""), tokenDefinitions);
const segments = useMemo(() => tokenizeMixTagValue(normalizedValue, tokenDefinitions), [normalizedValue, tokenDefinitions]);
useEffect(() => {
latestRef.current = { onChange, onBlur, onFocus, onKeyDown, tokenDefinitions };
});
useEffect(() => {
valueRef.current = normalizedValue;
}, [normalizedValue]);
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor || pendingCaretRef.current == null) return;
editor.focus();
setCaretOffset(editor, pendingCaretRef.current);
pendingCaretRef.current = null;
}, [normalizedValue]);
const emitValue = useCallback((nextValue, nextCursor = null) => {
const latest = latestRef.current;
const nextNormalizedValue = normalizeMixTagText(nextValue, latest.tokenDefinitions);
valueRef.current = nextNormalizedValue;
pendingCaretRef.current = nextCursor;
latest.onChange?.(nextNormalizedValue);
}, []);
function removeTokenFromSelection(event, direction) {
const editor = editorRef.current;
if (!editor) return false;
const { start, end } = getSelectionOffsets(editor);
const next = removeMixTagAtOffset(valueRef.current, start, end, tokenDefinitions, direction);
if (!next) return false;
event.preventDefault();
emitValue(next.value, next.cursor);
return true;
}
function snapSelectionToTokenBoundary() {
const editor = editorRef.current;
if (!editor) return;
const selection = window.getSelection();
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return;
const token = getTokenElement(selection.anchorNode, editor);
if (!token) return;
const { start } = getSelectionOffsets(editor);
setCaretOffset(editor, start);
}
function handleInput() {
const editor = editorRef.current;
if (!editor) return;
const { start } = getSelectionOffsets(editor);
if (composingRef.current) {
valueRef.current = readEditorValue(editor);
pendingCaretRef.current = start;
return;
}
emitValue(readEditorValue(editor), start);
}
function handleKeyDown(event) {
latestRef.current.onKeyDown?.(event);
if (event.defaultPrevented || (event.key !== "Backspace" && event.key !== "Delete")) return;
removeTokenFromSelection(event, event.key === "Backspace" ? "backward" : "forward");
}
function handleBeforeInput(event) {
if (event.defaultPrevented) return;
if (event.nativeEvent.inputType === "deleteContentBackward") removeTokenFromSelection(event, "backward");
if (event.nativeEvent.inputType === "deleteContentForward") removeTokenFromSelection(event, "forward");
}
function handleCompositionStart() {
composingRef.current = true;
}
function handleCompositionEnd() {
composingRef.current = false;
const editor = editorRef.current;
if (!editor) return;
const { start } = getSelectionOffsets(editor);
emitValue(readEditorValue(editor), start);
}
function handleCopy(event) {
const editor = editorRef.current;
const selectedText = editor ? getRawSelectionText(editor) : "";
if (!selectedText) return;
event.preventDefault();
event.clipboardData.setData("text/plain", selectedText);
}
function handlePaste(event) {
const editor = editorRef.current;
if (!editor) return;
event.preventDefault();
const { start, end } = getSelectionOffsets(editor);
const pastedText = event.clipboardData.getData("text/plain");
const nextValue = `${valueRef.current.slice(0, start)}${pastedText}${valueRef.current.slice(end)}`;
emitValue(nextValue, start + pastedText.length);
}
function handleFocus() {
setIsFocused(true);
latestRef.current.onFocus?.(createInputLikeEvent(valueRef.current, editorRef.current));
}
function handleBlur() {
setIsFocused(false);
latestRef.current.onBlur?.(createInputLikeEvent(valueRef.current, editorRef.current));
}
function handleInsertToken() {
const editor = editorRef.current;
const tokenValue = insertToken?.value || tokenDefinitions[0]?.value;
if (!editor || !tokenValue) return;
editor.focus();
const { start, end } = getSelectionOffsets(editor);
const next = buildInsertedMixTagValue(valueRef.current, start, end, tokenValue, { formulaSpacing });
emitValue(next.value, next.cursor);
}
const rootClassName = [
"mix-tags-input-wrap",
size === "small" ? "mix-tags-input-wrap--small" : "",
wrapperClassName
].filter(Boolean).join(" ");
return (
<span className={rootClassName}>
<span
aria-disabled={disabled || undefined}
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
aria-multiline="true"
className={`mix-tags-input ${className} ${isFocused ? "is-focused" : ""}`.trim()}
contentEditable={!disabled}
data-placeholder={placeholder}
data-value={normalizedValue}
id={id}
key={normalizedValue}
inputMode={inputMode}
onBeforeInput={handleBeforeInput}
onBlur={handleBlur}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onCopy={handleCopy}
onFocus={handleFocus}
onInput={handleInput}
onKeyDown={handleKeyDown}
onKeyUp={snapSelectionToTokenBoundary}
onMouseUp={snapSelectionToTokenBoundary}
onPaste={handlePaste}
ref={(node) => {
editorRef.current = node;
assignRef(inputRef, node);
}}
role="textbox"
spellCheck={spellCheck}
suppressContentEditableWarning
tabIndex={disabled ? -1 : 0}
title={title}
>
{segments.length ? segments.map((segment) => (
segment.type === "text"
? <Fragment key={`${segment.start}:${segment.end}:text`}>{segment.text}</Fragment>
: (
<Fragment key={`${segment.start}:${segment.end}:${segment.token.value}`}>
{ZERO_WIDTH_SPACE}
<span className="mix-tags-input-tag" contentEditable={false} data-mix-tag-value={segment.token.value} tabIndex={-1} title={segment.token.title || segment.token.value}>
<span className="mix-tags-input-tag-text">{segment.token.label || segment.token.value}</span>
</span>
{ZERO_WIDTH_SPACE}
</Fragment>
)
)) : null}
</span>
{insertToken && (
<button
className="mix-tags-input-insert-button"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={handleInsertToken}
aria-label={insertToken.title || insertToken.label || insertToken.value}
title={insertToken.title || insertToken.label || insertToken.value}
disabled={disabled}
>
{insertToken.icon ? <span className={`ui-icon ui-icon-${insertToken.icon}`} aria-hidden="true" /> : <span>{insertToken.label || insertToken.value}</span>}
</button>
)}
</span>
);
}

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte. // 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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
@ -183,8 +184,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
} }
function useEntry(entry) { function useEntry(entry) {
setExpression(formatResult(entry.value));
setActiveParentId(entry.id); setActiveParentId(entry.id);
setExpression(BASE_TOKEN);
} }
function deleteEntry(entryId) { function deleteEntry(entryId) {
@ -242,10 +243,48 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
)} )}
<form className="calculator-form" onSubmit={saveResult}> <form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}> <div className="calculator-card" ref={calculatorCardRef}>
<label> <div className="calculator-field">
<span>{textContent.expressionLabel || "Calcul"}</span> <span>{textContent.expressionLabel || "Calcul"}</span>
<input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" /> {activeParent ? (
</label> <MixTagsInput
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
onChange={setExpression}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
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"}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={expression}
/>
) : (
<input
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
inputMode="decimal"
placeholder={textContent.expressionPlaceholder || "10*10"}
value={expression}
/>
)}
</div>
<div className={`calculator-result ${result == null ? "" : getResultLengthClass(result)}`} aria-live="polite"> <div className={`calculator-result ${result == null ? "" : getResultLengthClass(result)}`} aria-live="polite">
<span>{textContent.resultLabel || "Résultat"}</span> <span>{textContent.resultLabel || "Résultat"}</span>
<strong aria-label={result == null ? "-" : formatDisplayResult(result)}> <strong aria-label={result == null ? "-" : formatDisplayResult(result)}>
@ -307,6 +346,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
function closeOnOutsidePointer(event) { function closeOnOutsidePointer(event) {
if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return; if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return;
if (event.target?.closest?.("[data-calculator-edit-toggle]")) return;
setEditingId(""); setEditingId("");
} }
@ -322,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
const className = [ const className = [
entry.id === activeParentId ? "active" : "", entry.id === activeParentId ? "active" : "",
entry.id === editingId ? "is-editing" : "", entry.id === editingId ? "is-editing" : "",
"can-edit-formula", entry.parentId ? "can-edit-formula" : "",
reorder.isItemDragging(entry.id) ? "is-dragging" : "", reorder.isItemDragging(entry.id) ? "is-dragging" : "",
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "", reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : "" reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
@ -355,7 +395,15 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
</span> </span>
</button> </button>
)} )}
<button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`} title={textContent.renameTitle || "Renommer"}> <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"}
>
<Icon name="edit" /> <Icon name="edit" />
</button> </button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}> <button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
@ -392,6 +440,7 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFoc
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) { function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
const parent = entries.find((candidate) => candidate.id === entry.parentId); const parent = entries.find((candidate) => candidate.id === entry.parentId);
const parentValue = parent?.value; const parentValue = parent?.value;
const hasParent = Boolean(entry.parentId);
const inlineEdit = useInlineEdit({ const inlineEdit = useInlineEdit({
value: getEditableFormula(entry), value: getEditableFormula(entry),
onCommit: (formula) => { onCommit: (formula) => {
@ -403,13 +452,49 @@ function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormul
transform: (draft) => String(draft || "").replaceAll(",", ".").trim() transform: (draft) => String(draft || "").replaceAll(",", ".").trim()
}); });
if (!hasParent) {
return ( return (
<input <input
{...inlineEdit.getInputProps({ {...inlineEdit.getInputProps({
className: "tool-split-entry-label calculator-entry-formula", className: "tool-split-entry-label calculator-entry-formula",
"aria-label": `${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`, "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." 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}
/>
);
} }

View file

@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx"; import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { Tabs } from "../../../components/Tabs.jsx"; import { Tabs } from "../../../components/Tabs.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx"; import { Tooltip } from "../../../components/Tooltip.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
@ -42,6 +43,7 @@ const KIND_ICONS = {
}; };
const GOAL_TREE_VIEW_SCALE = 0.7; const GOAL_TREE_VIEW_SCALE = 0.7;
const BASE_TOKEN = "base";
function getGoalTreeKindIcon(kind) { function getGoalTreeKindIcon(kind) {
return KIND_ICONS[kind] || "diagram"; return KIND_ICONS[kind] || "diagram";
@ -528,10 +530,13 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
<div className="goal-tree-form-section-title">{textContent.quantitySectionTitle || "Quantité"}</div> <div className="goal-tree-form-section-title">{textContent.quantitySectionTitle || "Quantité"}</div>
<label> <label>
<span>{textContent.targetQuantityLabel || "Cible"}</span> <span>{textContent.targetQuantityLabel || "Cible"}</span>
<input <GoalTreeFormulaInput
aria-label={textContent.targetQuantityLabel || "Cible"}
hasParent={Boolean(selectedNode.parentId)}
onChange={(value) => setStepTarget(selectedNode.id, value)}
value={selectedNode.formula || selectedNode.qtyTarget} value={selectedNode.formula || selectedNode.qtyTarget}
placeholder={textContent.formulaPlaceholder || "base * 3"} placeholder={textContent.formulaPlaceholder || "base * 3"}
onChange={(event) => setStepTarget(selectedNode.id, event.target.value)} textContent={textContent}
title={textContent.formulaHelp || "Utilisez base pour référencer la cible du parent."} title={textContent.formulaHelp || "Utilisez base pour référencer la cible du parent."}
/> />
</label> </label>
@ -852,6 +857,46 @@ function GoalTreeTitleInput({ value, onChange }) {
); );
} }
function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
const normalizedValue = String(value ?? "");
if (!hasParent) {
return (
<input
aria-label={ariaLabel}
className={className}
inputMode="decimal"
onChange={(event) => onChange(event.target.value.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
value={normalizedValue}
/>
);
}
return (
<MixTagsInput
aria-label={ariaLabel}
className={className}
formulaSpacing
inputMode="decimal"
insertToken={{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
tokens={[{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={normalizedValue}
/>
);
}
function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) { function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
return ( return (
<div className="goal-tree-content-editor"> <div className="goal-tree-content-editor">
@ -859,17 +904,21 @@ function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete })
{(node.items || []).map((item) => ( {(node.items || []).map((item) => (
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}> <div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
<input <input
className="goal-tree-content-label-input"
value={item.label} value={item.label}
placeholder={textContent.itemLabelPlaceholder || "Élément"} placeholder={textContent.itemLabelPlaceholder || "Élément"}
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))} onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))}
aria-label={textContent.itemLabelLabel || "Libellé"} aria-label={textContent.itemLabelLabel || "Libellé"}
/> />
{node.kind === "checklist" && ( {node.kind === "checklist" && (
<input <GoalTreeFormulaInput
aria-label={textContent.itemTotalLabel || "Total"}
className="goal-tree-content-formula-input"
hasParent={Boolean(node.parentId)}
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
value={item.formula || "1"} value={item.formula || "1"}
placeholder={textContent.itemTotalPlaceholder || "base"} placeholder={textContent.itemTotalPlaceholder || "base"}
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, formula: event.target.value.replaceAll(",", ".") }))} textContent={textContent}
aria-label={textContent.itemTotalLabel || "Total"}
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."} title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
/> />
)} )}

View file

@ -357,6 +357,11 @@
-webkit-mask-image: url("/static/icons/refresh.svg"); -webkit-mask-image: url("/static/icons/refresh.svg");
} }
.ui-icon-parent {
mask-image: url("/static/icons/parent.svg");
-webkit-mask-image: url("/static/icons/parent.svg");
}
.ui-icon-back { .ui-icon-back {
mask-image: url("/static/icons/back.svg"); mask-image: url("/static/icons/back.svg");
-webkit-mask-image: url("/static/icons/back.svg"); -webkit-mask-image: url("/static/icons/back.svg");

View file

@ -8,6 +8,7 @@
} }
.calculator-form label, .calculator-form label,
.calculator-field,
.calculator-result { .calculator-result {
display: grid; display: grid;
width: 100%; width: 100%;
@ -27,11 +28,12 @@
} }
.tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle { .tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle {
grid-template-columns: 30px auto minmax(0, 1fr) 34px 34px; grid-template-columns: 30px minmax(0, 1fr) 34px 34px;
align-items: start;
} }
.tool-split-entry-list li.is-editing.can-edit-formula > .calculator-entry.has-drag-handle { .tool-split-entry-list li.is-editing > .calculator-entry.has-drag-handle .calculator-drag-handle {
grid-template-columns: 30px minmax(92px, 0.9fr) minmax(80px, 1fr) 34px 34px; grid-row: 1 / span 2;
} }
.calculator-entry-formula { .calculator-entry-formula {
@ -39,7 +41,11 @@
} }
.calculator-entry-edit-fields { .calculator-entry-edit-fields {
display: contents; display: grid;
min-width: 0;
gap: 6px;
grid-column: 2;
grid-row: 1 / span 2;
} }
.calculator-drag-handle { .calculator-drag-handle {
@ -108,6 +114,7 @@
} }
.calculator-form label span, .calculator-form label span,
.calculator-field > span,
.calculator-result span, .calculator-result span,
.tool-split-root-button { .tool-split-root-button {
color: var(--color-text-secondary); color: var(--color-text-secondary);

View file

@ -128,11 +128,12 @@
"label delete"; "label delete";
} }
.goal-tree-content-editor-row.is-checklist input:first-child { .goal-tree-content-label-input {
grid-area: label; grid-area: label;
} }
.goal-tree-content-editor-row.is-checklist input:nth-child(2) { .goal-tree-content-formula-input,
.goal-tree-content-editor-row.is-checklist .mix-tags-input-wrap {
grid-area: total; grid-area: total;
} }

View file

@ -346,6 +346,144 @@
padding-right: 8px; padding-right: 8px;
} }
.mix-tags-input-wrap {
position: relative;
display: flex;
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 44px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(21, 26, 48, 0.92);
color: var(--color-text-primary);
padding-right: 34px;
transition:
transform var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.mix-tags-input {
position: relative;
display: block;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
min-width: 0;
max-width: 100%;
height: 42px;
min-height: 42px;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 4px 4px 7px;
border: 0;
background: transparent;
color: inherit;
caret-color: var(--color-text-primary);
font: inherit;
font-variant-numeric: tabular-nums;
line-height: 20px;
outline: none;
scrollbar-width: none;
white-space: nowrap;
cursor: text;
}
.mix-tags-input-wrap:hover,
.mix-tags-input-wrap:has(.mix-tags-input.is-focused) {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
}
.mix-tags-input-wrap:has(.mix-tags-input:focus-visible) {
outline: 2px solid var(--color-accent-gold);
outline-offset: 3px;
}
.mix-tags-input-wrap:has(.mix-tags-input.is-focused) {
color: var(--color-text-primary);
}
.mix-tags-input::-webkit-scrollbar {
display: none;
}
.mix-tags-input:empty::before {
content: attr(data-placeholder);
color: var(--color-text-muted);
opacity: 0.58;
}
.mix-tags-input::selection {
background: rgba(139, 92, 246, 0.42);
}
.mix-tags-input-tag {
position: relative;
top: -1px;
display: inline-flex;
width: 68px;
min-width: 68px;
height: 24px;
align-items: center;
justify-content: center;
margin: 0 4px;
padding: 1px 5px;
border: 1px solid rgba(150, 165, 205, 0.18);
border-radius: var(--radius-sm);
background: rgba(150, 165, 205, 0.07);
color: var(--color-text-secondary);
font-size: 0.62rem;
font-weight: 700;
line-height: 11px;
text-align: center;
white-space: normal;
vertical-align: middle;
}
.mix-tags-input-tag-text {
overflow: hidden;
line-height: 11px;
text-overflow: clip;
}
.mix-tags-input-insert-button {
position: absolute;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
display: inline-flex;
box-sizing: border-box;
width: 34px;
min-width: 34px;
min-height: 0;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-left: 1px solid rgba(165, 180, 252, 0.12);
border-radius: 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0;
background: transparent;
color: var(--color-text-muted);
}
.mix-tags-input-insert-button:hover,
.mix-tags-input-insert-button:focus-visible {
background: rgba(196, 181, 253, 0.08);
color: var(--color-text-primary);
}
.mix-tags-input-insert-button .ui-icon {
display: block;
width: 15px;
height: 15px;
}
.tool-split-entry-list { .tool-split-entry-list {
display: grid; display: grid;
gap: 6px; gap: 6px;
@ -471,6 +609,45 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.mix-tags-input-wrap--small .mix-tags-input {
display: block;
height: 34px;
min-height: 34px;
padding: 6px 4px 6px 9px;
white-space: nowrap;
text-overflow: clip;
}
.mix-tags-input-wrap--small .mix-tags-input-tag {
top: 0;
width: 62px;
min-width: 62px;
height: 22px;
}
.mix-tags-input-wrap--small {
height: 36px;
min-height: 36px;
max-height: 36px;
}
.mix-tags-input-wrap--small .mix-tags-input-insert-button {
top: 0;
right: 0;
bottom: 0;
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
border-radius: 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0;
}
.mix-tags-input-wrap--small .mix-tags-input-insert-button .ui-icon {
display: block;
width: 14px;
height: 14px;
}
.tool-split-entry .ui-icon { .tool-split-entry .ui-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -481,6 +658,10 @@
min-height: 36px; 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-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-value,
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label { .tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label {

View file

@ -0,0 +1,123 @@
// Rôle : fournit les helpers purs pour les champs mix-tags à valeur string.
const TOKEN_WORD_CHAR_PATTERN = /[\p{L}\p{N}_]/u;
function normalizeText(value) {
return String(value ?? "");
}
export function normalizeMixTagSpacing(value) {
return normalizeText(value).replace(/\s+/g, " ").trim();
}
export function normalizeMixTagDefinitions(tokens = []) {
const seen = new Set();
return tokens
.map((token) => ({
...token,
value: normalizeText(token.value),
label: normalizeText(token.label || token.value)
}))
.filter((token) => {
if (!token.value || seen.has(token.value)) return false;
seen.add(token.value);
return true;
})
.sort((left, right) => right.value.length - left.value.length);
}
export function isIsolatedMixTagValue(text, start, length) {
const before = start > 0 ? text[start - 1] : "";
const after = start + length < text.length ? text[start + length] : "";
return (!before || !TOKEN_WORD_CHAR_PATTERN.test(before)) && (!after || !TOKEN_WORD_CHAR_PATTERN.test(after));
}
export function getMixTagRanges(value, tokens = []) {
const text = normalizeText(value);
const definitions = normalizeMixTagDefinitions(tokens);
const ranges = [];
let index = 0;
while (index < text.length) {
const token = definitions.find((candidate) => (
text.slice(index, index + candidate.value.length).toLowerCase() === candidate.value.toLowerCase() && isIsolatedMixTagValue(text, index, candidate.value.length)
));
if (token) {
ranges.push({ start: index, end: index + token.value.length, token });
index += token.value.length;
continue;
}
index += 1;
}
return ranges;
}
export function normalizeMixTagText(value, tokens = []) {
const text = normalizeMixTagSpacing(value);
const ranges = getMixTagRanges(text, tokens);
if (!ranges.length) return text;
let cursor = 0;
let normalized = "";
ranges.forEach((range) => {
normalized += text.slice(cursor, range.start);
normalized += range.token.value;
cursor = range.end;
});
return `${normalized}${text.slice(cursor)}`;
}
export function tokenizeMixTagValue(value, tokens = []) {
const text = normalizeText(value);
const ranges = getMixTagRanges(text, tokens);
const segments = [];
let cursor = 0;
ranges.forEach((range) => {
if (range.start > cursor) segments.push({ type: "text", text: text.slice(cursor, range.start), start: cursor, end: range.start });
segments.push({ type: "token", token: range.token, text: text.slice(range.start, range.end), start: range.start, end: range.end });
cursor = range.end;
});
if (cursor < text.length) segments.push({ type: "text", text: text.slice(cursor), start: cursor, end: text.length });
return segments;
}
export function buildInsertedMixTagValue(value, start, end, tokenValue, { formulaSpacing = false } = {}) {
const text = normalizeText(value);
const selectionStart = Math.max(0, Math.min(start, end));
const selectionEnd = Math.max(selectionStart, Math.max(start, end));
const before = text.slice(0, selectionStart);
const after = text.slice(selectionEnd);
const normalizedTokenValue = normalizeText(tokenValue);
const needsSpaceBefore = formulaSpacing && before && !/[\s(+\-*/]$/.test(before);
const needsSpaceAfter = formulaSpacing && after && !/^[\s)+\-*/]/.test(after);
const inserted = `${needsSpaceBefore ? " " : ""}${normalizedTokenValue}${needsSpaceAfter ? " " : ""}`;
return {
value: `${before}${inserted}${after}`,
cursor: before.length + inserted.length
};
}
export function removeMixTagAtOffset(value, start, end, tokens = [], direction = "backward") {
const text = normalizeText(value);
const selectionStart = Math.max(0, Math.min(start, end));
const selectionEnd = Math.max(selectionStart, Math.max(start, end));
const token = getMixTagRanges(text, tokens).find((range) => {
if (selectionStart !== selectionEnd) return selectionStart < range.end && selectionEnd > range.start;
return direction === "backward"
? selectionStart > range.start && selectionStart <= range.end
: selectionStart >= range.start && selectionStart < range.end;
});
if (!token) return null;
const removeStart = selectionStart === selectionEnd ? token.start : Math.min(selectionStart, token.start);
const removeEnd = selectionStart === selectionEnd ? token.end : Math.max(selectionEnd, token.end);
return {
value: `${text.slice(0, removeStart)}${text.slice(removeEnd)}`,
cursor: removeStart
};
}