// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte. import { useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { applyGroupedReorderOperation, getGroupedEntries, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { ComboInputToken, ComboSequence, InlineTextInput } from "./CombosParts.jsx"; import { DEVICE_OPTIONS, createCombo, createInput, getComboCategory, getHoldSeconds, getPaletteColumns, getPaletteGroups, normalizeCustomKey, parseHoldSeconds, setComboItemCategory } from "./combosUtils.js"; export function CombosModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] })); const textContent = context.moduleText?.combos || {}; const [comboCategory, setComboCategory] = useState(""); const [comboName, setComboName] = useState(""); const [draftInputs, setDraftInputs] = useState([[]]); const [activeStepIndex, setActiveStepIndex] = useState(0); const [selectedInput, setSelectedInput] = useState(null); const [simultaneousMode, setSimultaneousMode] = useState(false); const [keyboardLayout, setKeyboardLayout] = useState("azerty"); const [customKey, setCustomKey] = useState(""); const [editingComboId, setEditingComboId] = useState(""); const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]); const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]); const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length); const selectedDraftInput = Number.isInteger(selectedInput?.stepIndex) && Number.isInteger(selectedInput?.inputIndex) ? draftInputs[selectedInput.stepIndex]?.[selectedInput.inputIndex] || null : null; const entries = getGroupedEntries(data.combos, { groupOrder: data.categoryOrder, getItemGroup: getComboCategory, groupIdKey: "category", groupItemKey: "combos" }); const { itemReorder, groupReorder, getItemProps, getGroupProps, getGroupBoundaryProps, isItemDragging, isItemDropTarget, isGroupDragging, isGroupDropTarget, isBoundaryDropTarget, getDropPlacement, shouldShowGroupBoundaries } = useGroupedReorder({ namespace: `combos-${moduleId}`, items: data.combos, getItemId: (combo) => combo.id, getItemGroup: getComboCategory, reorderFeatures: { item: { groupChange: true, boundaryDrop: true, ungroupedDrop: true }, group: { reorder: true, boundaryDrop: true, ungroupedDrop: true, itemDrop: true } }, onItemMove: applyComboReorder, onGroupMove: applyComboReorder, hierarchy: { enabled: false, stickyParents: false } }); function save(nextData) { context.setModuleData(toolboxId, moduleId, nextData, "combos"); } function updateDevice(device) { const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device }; save({ ...data, device, combos: data.combos.map(stampComboDevice) }); } function applyComboReorder(operation) { save(applyGroupedReorderOperation(data, { operation, itemsKey: "combos", groupOrderKey: "categoryOrder", collapsedGroupsKey: "collapsedCategories", getItemGroup: getComboCategory, setItemGroup: setComboItemCategory })); } function addInputToStep(kind, value, stepIndex) { const input = createInput(kind, value); setDraftInputs((steps) => { const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]]; const targetIndex = Math.min(Math.max(0, stepIndex), nextSteps.length - 1); nextSteps[targetIndex] = [...nextSteps[targetIndex], input]; setSelectedInput({ stepIndex: targetIndex, inputIndex: nextSteps[targetIndex].length - 1 }); return nextSteps; }); } function addDraftInput(kind, value) { const input = createInput(kind, value); if (simultaneousMode) { addInputToStep(kind, value, activeStepIndex); return; } setDraftInputs((steps) => { const filledSteps = steps.filter((step) => step.length); const nextSteps = [...filledSteps, [input]]; setActiveStepIndex(nextSteps.length - 1); setSelectedInput({ stepIndex: nextSteps.length - 1, inputIndex: 0 }); return nextSteps; }); } function dragPaletteInput(event, kind, value) { event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify(createInput(kind, value))); } function dragDraftInput(event, stepIndex, inputIndex, input) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify({ ...input, sourceStepIndex: stepIndex, sourceInputIndex: inputIndex })); } function dropInputOnStep(event, stepIndex) { const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input"); if (!rawInput) return; try { const input = JSON.parse(rawInput); if (!input?.kind || !input?.value) return; if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) { moveDraftInput(input.sourceStepIndex, input.sourceInputIndex, stepIndex); } else { addInputToStep(input.kind, input.value, stepIndex); } setActiveStepIndex(stepIndex); } catch { // Ignore invalid drag payloads from outside the app. } } function dropInputAsNewStep(event) { const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input"); if (!rawInput) return; try { const input = JSON.parse(rawInput); if (!input?.kind || !input?.value) return; if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) { moveDraftInputToNewStep(input.sourceStepIndex, input.sourceInputIndex); } else { addDraftInput(input.kind, input.value); } } catch { // Ignore invalid drag payloads from outside the app. } } function moveDraftInput(sourceStepIndex, sourceInputIndex, targetStepIndex) { setDraftInputs((steps) => { if (sourceStepIndex === targetStepIndex) return steps; const nextSteps = steps.map((step) => [...step]); const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || []; if (!input || !nextSteps[targetStepIndex]) return steps; nextSteps[targetStepIndex].push(input); setSelectedInput({ stepIndex: targetStepIndex, inputIndex: nextSteps[targetStepIndex].length - 1 }); return nextSteps.filter((step, index) => step.length || index === targetStepIndex); }); } function moveDraftInputToNewStep(sourceStepIndex, sourceInputIndex) { setDraftInputs((steps) => { const nextSteps = steps.map((step) => [...step]); const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || []; if (!input) return steps; const safeSteps = nextSteps.filter((step) => step.length); setActiveStepIndex(safeSteps.length); setSelectedInput({ stepIndex: safeSteps.length, inputIndex: 0 }); return [...safeSteps, [input]]; }); } function removeActiveStep() { setDraftInputs((steps) => { const nextSteps = steps.filter((_, index) => index !== activeStepIndex); const safeSteps = nextSteps.length ? nextSteps : [[]]; setActiveStepIndex(Math.min(activeStepIndex, safeSteps.length - 1)); setSelectedInput(null); return safeSteps; }); } function removeLastInput() { setDraftInputs((steps) => { const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]]; let targetIndex = -1; for (let index = nextSteps.length - 1; index >= 0; index -= 1) { if (nextSteps[index].length) { targetIndex = index; break; } } if (targetIndex < 0) return [[]]; nextSteps[targetIndex].pop(); const safeSteps = nextSteps.filter((step) => step.length); setActiveStepIndex(Math.max(0, safeSteps.length - 1)); setSelectedInput(null); return safeSteps.length ? safeSteps : [[]]; }); } function updateSelectedInputHold(value) { if (!selectedDraftInput) return; const holdMs = Math.round(parseHoldSeconds(value) * 1000); setDraftInputs((steps) => steps.map((step, stepIndex) => step.map((input, inputIndex) => { if (stepIndex !== selectedInput.stepIndex || inputIndex !== selectedInput.inputIndex) return input; const nextInput = { ...input }; if (holdMs > 0) { nextInput.hold = true; nextInput.holdMs = holdMs; } else delete nextInput.holdMs; return nextInput; }))); } function toggleSelectedInputHold() { if (!selectedDraftInput) return; setDraftInputs((steps) => steps.map((step, stepIndex) => step.map((input, inputIndex) => { if (stepIndex !== selectedInput.stepIndex || inputIndex !== selectedInput.inputIndex) return input; const nextInput = { ...input }; if (nextInput.hold || nextInput.holdMs) { delete nextInput.hold; delete nextInput.holdMs; } else { nextInput.hold = true; } return nextInput; }))); } function selectDraftStep(stepIndex) { setActiveStepIndex(stepIndex); setSelectedInput(null); } function addCustomKey(event) { event.preventDefault(); const value = normalizeCustomKey(customKey); if (!value || data.device !== "keyboardMouse") return; addDraftInput("key", value.toLowerCase()); setCustomKey(""); } function saveCombo(event) { event.preventDefault(); if (!comboName.trim() && !cleanDraftInputs.length) return; const combo = createCombo(context, editingComboId, comboName, comboCategory, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device); const nextCombos = data.combos.filter((item) => item.id !== editingComboId); const nextCategoryOrder = combo.category && !data.categoryOrder.includes(combo.category) ? [...data.categoryOrder, combo.category] : data.categoryOrder; save({ ...data, combos: [...nextCombos, combo], categoryOrder: nextCategoryOrder }); cancelComboEdit(); } function startComboEdit(combo) { setComboCategory(getComboCategory(combo)); setEditingComboId(combo.id); setComboName(combo.name); setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]); setActiveStepIndex(0); setSelectedInput(null); } function cancelComboEdit() { setEditingComboId(""); setComboName(""); setComboCategory(""); setDraftInputs([[]]); setActiveStepIndex(0); setSelectedInput(null); setSimultaneousMode(false); } function updateCombo(comboId, updater) { save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) }); } function deleteCombo(comboId) { updateCombo(comboId, () => ({ name: "", inputs: [] })); if (editingComboId === comboId) cancelComboEdit(); } function toggleCategoryCollapsed(category) { const collapsedCategories = data.collapsedCategories.includes(category) ? data.collapsedCategories.filter((item) => item !== category) : [...data.collapsedCategories, category]; save({ ...data, collapsedCategories }); } function renderCombo(combo) { const category = getComboCategory(combo); const comboClassName = [ "combo-card", "is-editing", isItemDragging(combo.id) ? "is-dragging" : "", isItemDropTarget(combo.id) ? "is-drop-target" : "", getDropPlacement("item", combo.id) === "after" ? "drop-after" : "" ].filter(Boolean).join(" "); return (
updateCombo(combo.id, (current) => ({ ...current, name: name || current.name }))} ariaLabel={textContent.comboNameLabel || "Nom"} />
); } function renderCategory(group, index, groupCount) { const category = group.category; const isCollapsed = data.collapsedCategories.includes(category); const isLastCategory = index === groupCount - 1; const categoryClassName = [ "combos-category checklist-section is-grouped", isCollapsed ? "is-collapsed" : "", isGroupDragging(category) ? "is-dragging" : "", isGroupDropTarget(category) ? "is-drop-target" : "", getDropPlacement("group", category) === "after" ? "drop-after" : "" ].filter(Boolean).join(" "); const showBoundaryDropZones = shouldShowGroupBoundaries(); const beforeDropZone = showBoundaryDropZones ? (
) : null; const afterDropZone = showBoundaryDropZones ? (
) : null; return [ beforeDropZone,

{category}

{group.combos.length}
{!isCollapsed && (
{group.combos.map((combo) => renderCombo(combo))}
)}
, showBoundaryDropZones && isLastCategory ? afterDropZone : null ]; } const categoryCount = entries.filter((entry) => entry.type === "group").length; let renderedCategoryIndex = -1; function renderEntry(entry) { if (entry.type !== "group") return renderCombo(entry.item); renderedCategoryIndex += 1; return renderCategory(entry.group, renderedCategoryIndex, categoryCount); } return (
{(editing || editingComboId) && (
{data.device === "keyboardMouse" && (
{[ { value: "azerty", label: "AZERTY" }, { value: "qwerty", label: "QWERTY" } ].map((layout) => ( ))}
)}
{paletteColumns.map((columnGroups, columnIndex) => (
{columnGroups.map((group) => (
{group.title}
{group.inputs.map((input, inputIndex) => { if (!input) return
))}
))}
{data.device === "keyboardMouse" && (
setCustomKey(event.target.value)} placeholder={textContent.customKeyPlaceholder || "Touche personnalisée"} />
)}
{ setActiveStepIndex(stepIndex); setSelectedInput({ stepIndex, inputIndex }); }} onDropInput={dropInputOnStep} onDropNewStep={dropInputAsNewStep} onDragInput={dragDraftInput} />
{editingComboId && ( )}
{editing && !editingComboId && ( save(nextData)} /> )}
)} {!data.combos.length && (

{textContent.emptyCombos || "Aucun combo enregistré."}

)}
{entries.map(renderEntry)}
); }