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

@ -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
};
}