Add internal mix tag input for calculator and goal tree formulas
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
5c8148c905
commit
ae503c7260
13 changed files with 929 additions and 23 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
8
website/public/static/icons/parent.svg
Normal file
8
website/public/static/icons/parent.svg
Normal 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 |
397
website/src/components/MixTagsInput.jsx
Normal file
397
website/src/components/MixTagsInput.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
123
website/src/utils/mixTagsInputUtils.js
Normal file
123
website/src/utils/mixTagsInputUtils.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue