sokko-g/website/src/features/toolboxes/modules/CombosModule.jsx

607 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (
<article className={comboClassName} key={combo.id} {...getItemProps({ itemId: combo.id, groupId: category })}>
<button
className="task-planner-drag-handle combo-drag-handle"
type="button"
onPointerDown={(event) => itemReorder.startDrag(event, combo.id)}
aria-label={`${textContent.reorderComboTitle || "Déplacer le combo"} ${combo.name}`}
title={textContent.reorderComboTitle || "Déplacer le combo"}
>
<Icon name="drag" />
</button>
<div className="combo-card-main">
<InlineTextInput
className="tool-split-entry-label combo-title-input"
value={combo.name}
onCommit={(name) => updateCombo(combo.id, (current) => ({ ...current, name: name || current.name }))}
ariaLabel={textContent.comboNameLabel || "Nom"}
/>
<ComboSequence inputs={combo.inputs} device={combo.device || data.device} emptyLabel={textContent.emptySequence || "Combo sans touches"} />
</div>
<div className="combo-card-actions">
<button type="button" className="checklist-delete-button" onClick={() => startComboEdit(combo)} aria-label={`${textContent.editComboTitle || "Modifier le combo"} ${combo.name}`} title={textContent.editComboTitle || "Modifier le combo"}>
<Icon name="edit" />
</button>
<button type="button" className="checklist-delete-button danger" onClick={() => deleteCombo(combo.id)} aria-label={`${textContent.deleteComboTitle || "Supprimer le combo"} ${combo.name}`} title={textContent.deleteComboTitle || "Supprimer le combo"}>
<Icon name="trash" />
</button>
</div>
</article>
);
}
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 ? (
<div
className={`combos-category-boundary-drop-zone ${isBoundaryDropTarget({ groupId: category, placement: "before" }) ? "is-drop-target" : ""}`}
key={`${category}:before-drop`}
{...getGroupBoundaryProps({ groupId: category, placement: "before" })}
/>
) : null;
const afterDropZone = showBoundaryDropZones ? (
<div
className={`combos-category-boundary-drop-zone ${isBoundaryDropTarget({ groupId: category, placement: "after" }) ? "is-drop-target" : ""}`}
key={`${category}:after-drop`}
{...getGroupBoundaryProps({ groupId: category, placement: "after" })}
/>
) : null;
return [
beforeDropZone,
<section className={categoryClassName} key={category} {...getGroupProps({ groupId: category })}>
<div className="checklist-section-header combos-category-header">
<div className="combos-category-title">
<button
className="task-planner-category-drag-handle"
type="button"
onPointerDown={(event) => groupReorder.startDrag(event, { groupId: category })}
aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category}`}
title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}
>
<Icon name="drag" />
</button>
<h3>{category}</h3>
</div>
<div className="checklist-section-actions">
<span>{group.combos.length}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={() => toggleCategoryCollapsed(category)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
title={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</div>
{!isCollapsed && (
<div className="combos-list">
{group.combos.map((combo) => renderCombo(combo))}
</div>
)}
</section>,
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 (
<div className="combos-module">
{(editing || editingComboId) && (
<div className="module-add-panel combos-editor">
{editing && !editingComboId && (
<TextExchangeActions type="combos" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
)}
<div className="combos-device-row">
<label>
<span>{textContent.deviceLabel || "Périphérique"}</span>
<select value={data.device} onChange={(event) => updateDevice(event.target.value)}>
{DEVICE_OPTIONS.map((device) => (
<option key={device.value} value={device.value}>{device.label}</option>
))}
</select>
</label>
{data.device === "keyboardMouse" && (
<div className="combos-keyboard-layout-switch" role="group" aria-label={textContent.keyboardLayoutLabel || "Disposition clavier"}>
{[
{ value: "azerty", label: "AZERTY" },
{ value: "qwerty", label: "QWERTY" }
].map((layout) => (
<button
key={layout.value}
type="button"
className={keyboardLayout === layout.value ? "active" : ""}
onClick={() => setKeyboardLayout(layout.value)}
aria-pressed={keyboardLayout === layout.value}
>
{layout.label}
</button>
))}
</div>
)}
</div>
<form className="combos-add-form" onSubmit={saveCombo}>
<div className="combos-form-grid">
<label>
<span>{textContent.targetCategoryLabel || "Catégorie"}</span>
<input value={comboCategory} onChange={(event) => setComboCategory(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} />
</label>
<label>
<span>{textContent.comboNameLabel || "Nom"}</span>
<input value={comboName} onChange={(event) => setComboName(event.target.value)} placeholder={textContent.comboNamePlaceholder || "Nom du combo"} />
</label>
</div>
<div className="combos-palette" aria-label={textContent.paletteLabel || "Palette de touches"}>
{paletteColumns.map((columnGroups, columnIndex) => (
<div className="combos-palette-column" key={columnIndex}>
{columnGroups.map((group) => (
<div className={`combos-palette-group ${group.kind === "directions" ? "is-direction-grid" : ""}`} key={group.title}>
<span>{group.title}</span>
<div>
{group.inputs.map((input, inputIndex) => {
if (!input) return <span className="combo-palette-empty-cell" key={`empty-${inputIndex}`} aria-hidden="true" />;
const [kind, value] = input;
return (
<ComboInputToken
key={`${kind}-${value}`}
input={createInput(kind, value)}
device={data.device}
palette
onClick={() => addDraftInput(kind, value)}
onDragStart={(event) => dragPaletteInput(event, kind, value)}
/>
);
})}
</div>
</div>
))}
</div>
))}
</div>
{data.device === "keyboardMouse" && (
<div className="inline-form combos-custom-key-form">
<input value={customKey} onChange={(event) => setCustomKey(event.target.value)} placeholder={textContent.customKeyPlaceholder || "Touche personnalisée"} />
<button type="button" onClick={addCustomKey}>{textContent.addCustomKeyButton || "Ajouter touche"}</button>
</div>
)}
<div className="combo-draft-panel">
<ComboSequence
inputs={draftInputs}
device={data.device}
emptyLabel={textContent.emptyDraft || "Aucune touche sélectionnée."}
activeStepIndex={activeStepIndex}
selectedInput={selectedInput}
onSelectStep={selectDraftStep}
onSelectInput={(stepIndex, inputIndex) => {
setActiveStepIndex(stepIndex);
setSelectedInput({ stepIndex, inputIndex });
}}
onDropInput={dropInputOnStep}
onDropNewStep={dropInputAsNewStep}
onDragInput={dragDraftInput}
/>
<div className="combo-draft-actions">
<label className="combo-hold-field">
<span className="combo-hold-controls">
<button
type="button"
className={selectedDraftInput?.hold || selectedDraftInput?.holdMs ? "active" : ""}
onClick={toggleSelectedInputHold}
disabled={!selectedDraftInput}
aria-pressed={Boolean(selectedDraftInput?.hold || selectedDraftInput?.holdMs)}
>
{textContent.holdToggleLabel || "Hold"}
</button>
<input
type="number"
min="0"
max="99"
step="1"
inputMode="numeric"
value={selectedDraftInput ? getHoldSeconds(selectedDraftInput) || "" : ""}
onChange={(event) => updateSelectedInputHold(event.target.value)}
disabled={!selectedDraftInput}
placeholder="0"
aria-label={textContent.holdLabel || "Maintien en secondes"}
/>
</span>
</label>
<button
type="button"
className={`combos-simultaneous-toggle ${simultaneousMode ? "active" : ""}`}
onClick={() => setSimultaneousMode((enabled) => !enabled)}
aria-pressed={simultaneousMode}
title={textContent.simultaneousModeTitle || "Ajout simultané"}
aria-label={textContent.simultaneousModeTitle || "Ajout simultané"}
>
<span className={!simultaneousMode ? "active" : ""}>{textContent.successiveModeLabel || "A › B"}</span>
<span className={simultaneousMode ? "active" : ""}>{textContent.simultaneousModeLabel || "A + B"}</span>
</button>
<button type="button" className="notepad-toolbar-button" onClick={removeLastInput} disabled={!cleanDraftInputs.length} title={textContent.removeLastInputTitle || "Retirer la dernière touche"} aria-label={textContent.removeLastInputTitle || "Retirer la dernière touche"}>
<Icon name="back" />
</button>
<button type="button" className="notepad-toolbar-button danger" onClick={removeActiveStep} disabled={draftInputs.length <= 1} title={textContent.removeStepTitle || "Supprimer l'étape"} aria-label={textContent.removeStepTitle || "Supprimer l'étape"}>
<Icon name="trash" />
</button>
<div className="combo-save-actions">
{editingComboId && (
<button type="button" className="combo-cancel-button" onClick={cancelComboEdit}>
{textContent.cancelEditButton || "Annuler"}
</button>
)}
<button className="primary" disabled={!comboName.trim() && !cleanDraftInputs.length}>
{editingComboId ? textContent.saveComboButton || "Enregistrer" : textContent.addComboButton || "Ajouter le combo"}
</button>
</div>
</div>
</div>
</form>
</div>
)}
{!data.combos.length && (
<p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p>
)}
<div className="combos-category-list">
{entries.map(renderEntry)}
</div>
</div>
);
}