Add toolbox spaces and recover from stale asset bundles
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
0959c6740b
commit
0acf5ecf21
30 changed files with 1252 additions and 179 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
|
||||
import { Icon } from "../../components/Icon.jsx";
|
||||
import { ImportButton } from "../../components/ImportButton.jsx";
|
||||
|
|
@ -11,6 +11,16 @@ import { useInlineEdit } from "../../hooks/useInlineEdit.js";
|
|||
import { compressImage } from "../../utils/imageCompression.js";
|
||||
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
|
||||
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
|
||||
import { ToolboxSpaces } from "./ToolboxSpaces.jsx";
|
||||
import {
|
||||
addModuleToToolboxSpace,
|
||||
decodeSpaceColumn,
|
||||
encodeSpaceColumn,
|
||||
getActiveToolboxSpace,
|
||||
getModuleSpace,
|
||||
moveToolboxModule,
|
||||
removeModuleFromToolboxSpaces
|
||||
} from "./toolboxSpaceUtils.js";
|
||||
import {
|
||||
clampQty,
|
||||
hostnameFromUrl,
|
||||
|
|
@ -24,7 +34,6 @@ import {
|
|||
normalizeImageAnnotationData,
|
||||
normalizeLinksData,
|
||||
normalizeMemosData,
|
||||
normalizeModuleOrder,
|
||||
normalizeNotepadData,
|
||||
normalizeRandomizerData,
|
||||
normalizeRngSimulatorData,
|
||||
|
|
@ -42,31 +51,6 @@ function getDrawerMaxWidth() {
|
|||
return Math.max(DRAWER_INITIAL_WIDTH, Math.floor(window.innerWidth * 0.94));
|
||||
}
|
||||
|
||||
function addModuleToOrder(moduleOrder, modules, moduleId) {
|
||||
const order = normalizeModuleOrder(moduleOrder, modules);
|
||||
const two = [
|
||||
order.two[0].filter((id) => id !== moduleId),
|
||||
order.two[1].filter((id) => id !== moduleId)
|
||||
];
|
||||
const targetColumnIndex = two[0].length <= two[1].length ? 0 : 1;
|
||||
two[targetColumnIndex].push(moduleId);
|
||||
return {
|
||||
one: [...order.one.filter((id) => id !== moduleId), moduleId],
|
||||
two
|
||||
};
|
||||
}
|
||||
|
||||
function removeModuleFromOrder(moduleOrder, modules, moduleId) {
|
||||
const order = normalizeModuleOrder(moduleOrder, modules);
|
||||
return {
|
||||
one: order.one.filter((id) => id !== moduleId),
|
||||
two: [
|
||||
order.two[0].filter((id) => id !== moduleId),
|
||||
order.two[1].filter((id) => id !== moduleId)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
|
|
@ -187,10 +171,16 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
|
|||
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
|
||||
const toolboxId = toolbox.id;
|
||||
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
|
||||
const activeSpace = getActiveToolboxSpace(toolbox);
|
||||
const spacesText = siteContent.toolboxes.spaces;
|
||||
const toolboxGameCover = getGameCardCover(toolboxGame);
|
||||
const moduleText = siteContent.toolboxes.modules;
|
||||
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
|
||||
const [pendingModuleId, setPendingModuleId] = useState("");
|
||||
const dragOriginSpaceRef = useRef("");
|
||||
const dragSucceededRef = useRef(false);
|
||||
const hoverActivatedRef = useRef(false);
|
||||
const previousDraggingIdRef = useRef("");
|
||||
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
|
||||
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
|
||||
// Consommer la demande de lancement ne doit pas interrompre la visite en cours.
|
||||
|
|
@ -237,15 +227,99 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
refresh: () => {}
|
||||
};
|
||||
|
||||
const moduleLocations = useMemo(() => {
|
||||
const locations = new Map();
|
||||
toolbox.spaces.forEach((space) => {
|
||||
space.moduleOrder.one.forEach((moduleId) => {
|
||||
const column = space.moduleOrder.two[1].includes(moduleId) ? "right" : "left";
|
||||
locations.set(moduleId, { spaceId: space.id, column });
|
||||
});
|
||||
});
|
||||
return locations;
|
||||
}, [toolbox.spaces]);
|
||||
|
||||
const rawModuleReorder = useGroupedReorder({
|
||||
namespace: `toolbox-modules-${toolbox.id}`,
|
||||
items: toolbox.modules,
|
||||
getItemId: (module) => module.id,
|
||||
getItemGroup: (module) => {
|
||||
const location = moduleLocations.get(module.id);
|
||||
return encodeSpaceColumn(location?.spaceId || toolbox.defaultSpaceId, moduleColumns === 1 ? "one" : location?.column || "left");
|
||||
},
|
||||
getEffectiveGroup: (module) => {
|
||||
const location = moduleLocations.get(module.id);
|
||||
return encodeSpaceColumn(location?.spaceId || toolbox.defaultSpaceId, moduleColumns === 1 ? "one" : location?.column || "left");
|
||||
},
|
||||
getParentId: () => toolbox.id,
|
||||
reorderFeatures: { item: { groupChange: true, boundaryDrop: true } },
|
||||
canMoveItem: (operation) => operation.targetType !== "group" || decodeSpaceColumn(operation.sourceGroup).spaceId !== operation.targetGroup,
|
||||
onItemMove: (operation) => {
|
||||
let targetSpaceId = "";
|
||||
let targetColumn = "";
|
||||
let targetModuleId = "";
|
||||
if (operation.targetType === "group") {
|
||||
targetSpaceId = operation.targetGroup;
|
||||
} else if (operation.targetType === "item") {
|
||||
targetModuleId = operation.targetId;
|
||||
targetSpaceId = getModuleSpace(toolbox, targetModuleId)?.id || "";
|
||||
} else if (operation.targetType === "boundary") {
|
||||
const target = decodeSpaceColumn(operation.targetGroup);
|
||||
targetSpaceId = target.spaceId;
|
||||
targetColumn = target.column;
|
||||
}
|
||||
if (!targetSpaceId) return;
|
||||
const sourceSpace = getModuleSpace(toolbox, operation.sourceId);
|
||||
dragSucceededRef.current = true;
|
||||
updateToolbox(moveToolboxModule(toolbox, operation.sourceId, {
|
||||
targetSpaceId,
|
||||
targetModuleId,
|
||||
targetColumn,
|
||||
placement: operation.placement,
|
||||
moduleColumns
|
||||
}));
|
||||
if (sourceSpace?.id !== targetSpaceId) {
|
||||
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
|
||||
actions.notify(spacesText.moveSuccess.replace("{space}", destination?.name || ""));
|
||||
}
|
||||
},
|
||||
hierarchy: { enabled: true, stickyParents: true }
|
||||
});
|
||||
const moduleReorder = {
|
||||
...rawModuleReorder,
|
||||
itemReorder: {
|
||||
...rawModuleReorder.itemReorder,
|
||||
startDrag: (event, moduleId) => {
|
||||
dragOriginSpaceRef.current = getModuleSpace(toolbox, moduleId)?.id || activeSpace.id;
|
||||
dragSucceededRef.current = false;
|
||||
hoverActivatedRef.current = false;
|
||||
rawModuleReorder.itemReorder.startDrag(event, moduleId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const activateHoveredSpace = useCallback((spaceId) => {
|
||||
if (spaceId === toolbox.activeSpaceId) return;
|
||||
hoverActivatedRef.current = true;
|
||||
updateToolbox({ ...toolbox, activeSpaceId: spaceId });
|
||||
}, [toolbox, updateToolbox]);
|
||||
|
||||
useEffect(() => {
|
||||
const draggingId = rawModuleReorder.itemReorder.draggingId;
|
||||
if (previousDraggingIdRef.current && !draggingId) {
|
||||
if (!dragSucceededRef.current && hoverActivatedRef.current && toolbox.spaces.some((space) => space.id === dragOriginSpaceRef.current)) {
|
||||
updateToolbox({ ...toolbox, activeSpaceId: dragOriginSpaceRef.current });
|
||||
}
|
||||
dragOriginSpaceRef.current = "";
|
||||
dragSucceededRef.current = false;
|
||||
hoverActivatedRef.current = false;
|
||||
}
|
||||
previousDraggingIdRef.current = draggingId;
|
||||
}, [rawModuleReorder.itemReorder.draggingId, toolbox, updateToolbox]);
|
||||
|
||||
function addModule(type, initialData = null) {
|
||||
if (!type) return;
|
||||
const module = { id: uid("mod"), type };
|
||||
const modules = [...toolbox.modules, module];
|
||||
updateToolbox({
|
||||
...toolbox,
|
||||
modules,
|
||||
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
|
||||
});
|
||||
updateToolbox(addModuleToToolboxSpace(toolbox, module, activeSpace.id));
|
||||
setPendingModuleId(module.id);
|
||||
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
|
||||
}
|
||||
|
|
@ -253,12 +327,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
function createImageAnnotationModule(dataUrl) {
|
||||
if (!dataUrl) return;
|
||||
const moduleId = uid("mod");
|
||||
const modules = [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }];
|
||||
updateToolbox({
|
||||
...toolbox,
|
||||
modules,
|
||||
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
|
||||
});
|
||||
updateToolbox(addModuleToToolboxSpace(toolbox, { id: moduleId, type: "imageAnnotation" }, activeSpace.id));
|
||||
setPendingModuleId(moduleId);
|
||||
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
|
||||
}
|
||||
|
|
@ -291,6 +360,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
<AddToolControls onAdd={addModule} />
|
||||
</div>
|
||||
</section>
|
||||
<ToolboxSpaces
|
||||
toolbox={toolbox}
|
||||
text={spacesText}
|
||||
moduleReorder={moduleReorder}
|
||||
onUpdateToolbox={updateToolbox}
|
||||
onHoverActivate={activateHoveredSpace}
|
||||
onConfirm={actions.setConfirmModal}
|
||||
/>
|
||||
{!embedded && showTutorial && (
|
||||
<GuidedTooltipTour
|
||||
key={toolboxId}
|
||||
|
|
@ -321,13 +398,20 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
)}
|
||||
<ToolboxModules
|
||||
toolbox={toolbox}
|
||||
space={activeSpace}
|
||||
moduleColumns={moduleColumns}
|
||||
context={moduleContext}
|
||||
reorder={moduleReorder}
|
||||
spacesText={spacesText}
|
||||
scrollToModuleId={pendingModuleId}
|
||||
onModuleScrolled={() => setPendingModuleId("")}
|
||||
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })}
|
||||
onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })}
|
||||
onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })}
|
||||
onMoveToSpace={(moduleId, targetSpaceId) => {
|
||||
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
|
||||
updateToolbox(moveToolboxModule(toolbox, moduleId, { targetSpaceId, moduleColumns }));
|
||||
actions.notify(spacesText.moveSuccess.replace("{space}", destination?.name || ""));
|
||||
}}
|
||||
onDelete={(moduleId) => actions.setConfirmModal({
|
||||
title: "Retirer l'outil",
|
||||
message: "Retirer cet outil de la toolbox ?",
|
||||
|
|
@ -336,12 +420,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
|||
onResolve: (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
actions.removeModuleData(toolbox.id, moduleId);
|
||||
const modules = toolbox.modules.filter((module) => module.id !== moduleId);
|
||||
updateToolbox({
|
||||
...toolbox,
|
||||
modules,
|
||||
moduleOrder: removeModuleFromOrder(toolbox.moduleOrder, modules, moduleId)
|
||||
});
|
||||
updateToolbox(removeModuleFromToolboxSpaces(toolbox, moduleId));
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
134
website/src/features/toolboxes/ToolboxSpaces.jsx
Normal file
134
website/src/features/toolboxes/ToolboxSpaces.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Rôle : affiche et gère les onglets d'espaces persistants d'une toolbox.
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "../../components/Icon.jsx";
|
||||
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
|
||||
import { lockBodyScroll } from "../../utils/bodyScrollLock.js";
|
||||
import { MAX_TOOLBOX_SPACES } from "./storage/toolboxStorage.js";
|
||||
import { createToolboxSpace, deleteToolboxSpace, reorderToolboxSpaces } from "./toolboxSpaceUtils.js";
|
||||
|
||||
const SPACE_HOVER_DELAY_MS = 600;
|
||||
|
||||
function SpaceNameModal({ initialName = "", title, text, onClose }) {
|
||||
const [name, setName] = useState(initialName);
|
||||
useEffect(() => lockBodyScroll(), []);
|
||||
return (
|
||||
<div className="confirm-modal-root">
|
||||
<div className="confirm-backdrop nds-modal-backdrop" onClick={() => onClose("")} />
|
||||
<section className="confirm-modal nds-modal toolbox-space-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-space-modal-title">
|
||||
<header><h2 id="toolbox-space-modal-title">{title}</h2></header>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (name.trim()) onClose(name.trim());
|
||||
}}>
|
||||
<label className="field"><span>{text.nameLabel}</span><input className="nds-input" data-size="sm" value={name} onChange={(event) => setName(event.target.value)} autoComplete="off" autoFocus required /></label>
|
||||
<footer className="nds-modal-footer">
|
||||
<button className="nds-button" data-size="sm" type="button" onClick={() => onClose("")}>{text.cancelLabel}</button>
|
||||
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{text.saveLabel}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolboxSpaces({ toolbox, text, moduleReorder, onUpdateToolbox, onHoverActivate, onConfirm }) {
|
||||
const [nameModal, setNameModal] = useState(null);
|
||||
const activeSpace = toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId) || toolbox.spaces[0];
|
||||
const atLimit = toolbox.spaces.length >= MAX_TOOLBOX_SPACES;
|
||||
const hoveredSpaceId = toolbox.spaces.find((space) => space.id !== activeSpace.id && moduleReorder.isGroupDropTarget(space.id, toolbox.id))?.id || "";
|
||||
const spaceReorder = useGroupedReorder({
|
||||
namespace: `toolbox-spaces-${toolbox.id}`,
|
||||
items: toolbox.spaces,
|
||||
getItemId: (space) => space.id,
|
||||
orientation: "horizontal",
|
||||
canMoveItem: (operation) => operation.sourceId !== toolbox.defaultSpaceId,
|
||||
onItemMove: (operation) => onUpdateToolbox(reorderToolboxSpaces(toolbox, operation.sourceId, operation.targetId, operation.placement))
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!hoveredSpaceId || !moduleReorder.itemReorder.draggingId) return undefined;
|
||||
const timeoutId = window.setTimeout(() => onHoverActivate(hoveredSpaceId), SPACE_HOVER_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [hoveredSpaceId, moduleReorder.itemReorder.draggingId, onHoverActivate]);
|
||||
|
||||
function saveName(name) {
|
||||
if (!name || !nameModal) return setNameModal(null);
|
||||
if (nameModal.mode === "create") onUpdateToolbox(createToolboxSpace(toolbox, name));
|
||||
else onUpdateToolbox({
|
||||
...toolbox,
|
||||
spaces: toolbox.spaces.map((space) => space.id === nameModal.spaceId ? { ...space, name } : space)
|
||||
});
|
||||
setNameModal(null);
|
||||
}
|
||||
|
||||
function requestDelete() {
|
||||
const count = activeSpace.moduleOrder.one.length;
|
||||
onConfirm({
|
||||
title: text.deleteTitle,
|
||||
message: count ? text.deleteWithTools.replace("{count}", count) : text.deleteEmpty,
|
||||
confirmLabel: text.deleteLabel,
|
||||
danger: true,
|
||||
onResolve: (confirmed) => {
|
||||
if (confirmed) onUpdateToolbox(deleteToolboxSpace(toolbox, activeSpace.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={`toolbox-spaces-toolbar ${toolbox.spaces.length === 1 ? "is-single" : ""}`} aria-label={text.toolbarLabel}>
|
||||
{toolbox.spaces.length > 1 && (
|
||||
<div className="toolbox-space-tabs-row legacy-scrollbar">
|
||||
<div className="toolbox-space-tabs" role="tablist" aria-label={text.tabsLabel}>
|
||||
{toolbox.spaces.map((space) => {
|
||||
const active = space.id === activeSpace.id;
|
||||
const isDefault = space.id === toolbox.defaultSpaceId;
|
||||
const isModuleDropTarget = moduleReorder.isGroupDropTarget(space.id, toolbox.id);
|
||||
const dropAfter = spaceReorder.getDropPlacement("item", space.id) === "after";
|
||||
return (
|
||||
<div
|
||||
className={`toolbox-space-tab-wrap ${active ? "active" : ""} ${isModuleDropTarget ? "is-module-drop-target" : ""} ${spaceReorder.isItemDragging(space.id) ? "is-dragging" : ""} ${spaceReorder.isItemDropTarget(space.id) ? "is-drop-target" : ""} ${dropAfter ? "drop-after" : ""}`}
|
||||
key={space.id}
|
||||
role="presentation"
|
||||
{...spaceReorder.getItemProps({ itemId: space.id })}
|
||||
>
|
||||
{!isDefault && <button className="toolbox-space-drag-handle nds-icon-button" data-size="xs" type="button" onPointerDown={(event) => spaceReorder.itemReorder.startDrag(event, space.id)} aria-label={`${text.reorderLabel} ${space.name}`} title={text.reorderLabel}><Icon name="drag" /></button>}
|
||||
<button
|
||||
className={`toolbox-space-tab ${active ? "active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onUpdateToolbox({ ...toolbox, activeSpaceId: space.id })}
|
||||
onKeyDown={(event) => {
|
||||
const index = toolbox.spaces.findIndex((item) => item.id === space.id);
|
||||
let nextIndex;
|
||||
if (event.key === "ArrowRight") nextIndex = (index + 1) % toolbox.spaces.length;
|
||||
else if (event.key === "ArrowLeft") nextIndex = (index - 1 + toolbox.spaces.length) % toolbox.spaces.length;
|
||||
else if (event.key === "Home") nextIndex = 0;
|
||||
else if (event.key === "End") nextIndex = toolbox.spaces.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
const nextSpace = toolbox.spaces[nextIndex];
|
||||
onUpdateToolbox({ ...toolbox, activeSpaceId: nextSpace.id });
|
||||
event.currentTarget.parentElement?.parentElement?.querySelectorAll('[role="tab"]')[nextIndex]?.focus();
|
||||
}}
|
||||
{...moduleReorder.getGroupProps({ groupId: space.id, parentId: toolbox.id })}
|
||||
>{space.name}</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button className="toolbox-space-add-compact nds-icon-button" data-size="sm" type="button" disabled={atLimit} onClick={() => !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}><Icon name="add" /></button>
|
||||
</div>
|
||||
)}
|
||||
<div className="toolbox-space-actions nds-toolbar">
|
||||
{toolbox.spaces.length > 1 && <button className="nds-icon-button" data-size="sm" type="button" onClick={() => setNameModal({ mode: "rename", spaceId: activeSpace.id })} aria-label={text.renameLabel} title={text.renameLabel}><Icon name="edit" /></button>}
|
||||
{toolbox.spaces.length > 1 && activeSpace.id !== toolbox.defaultSpaceId && <button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={requestDelete} aria-label={text.deleteLabel} title={text.deleteLabel}><span className="ui-icon ui-icon-trash" aria-hidden="true" /></button>}
|
||||
{toolbox.spaces.length === 1 && <button className="nds-button toolbox-space-add" data-size="sm" data-variant="dashed" type="button" disabled={atLimit} onClick={() => !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}><Icon name="add" />{text.addLabel}</button>}
|
||||
</div>
|
||||
</section>
|
||||
{nameModal && <SpaceNameModal initialName={nameModal.mode === "rename" ? activeSpace.name : ""} title={nameModal.mode === "create" ? text.createTitle : text.renameTitle} text={text} onClose={saveName} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
|
|||
import { supportsTextExchange } from "./textImport.js";
|
||||
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
|
||||
|
||||
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete }) {
|
||||
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
|
||||
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
|
||||
const Component = definition.Component;
|
||||
const label = definition.label || module.type;
|
||||
|
|
@ -93,6 +93,19 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
|
|||
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
|
||||
</div>
|
||||
<div>
|
||||
{spaces.length > 1 && (
|
||||
<select
|
||||
className="module-space-select nds-select"
|
||||
data-size="sm"
|
||||
value=""
|
||||
aria-label={`${moveText} — ${module.title || label}`}
|
||||
title={moveText}
|
||||
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
|
||||
>
|
||||
<option value="" disabled>{moveText}</option>
|
||||
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{definition.scrollable && (
|
||||
<button
|
||||
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Rôle : répartit et réorganise les modules d'une toolbox dans leurs colonnes.
|
||||
// Rôle : répartit les modules de l'espace actif dans leurs colonnes et cibles de dépôt.
|
||||
import { useMemo } from "react";
|
||||
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { encodeSpaceColumn } from "../toolboxSpaceUtils.js";
|
||||
import { ModuleShell } from "./ModuleShell.jsx";
|
||||
|
||||
const MODULE_COLUMN_IDS = ["left", "right"];
|
||||
|
|
@ -10,97 +10,29 @@ function getModulesByIds(modules, ids) {
|
|||
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function getModuleColumnId(moduleId, twoColumnOrder) {
|
||||
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0];
|
||||
}
|
||||
|
||||
function moveId(ids, sourceId, targetId, placement = "before") {
|
||||
const nextIds = ids.filter((id) => id !== sourceId);
|
||||
const targetIndex = nextIds.indexOf(targetId);
|
||||
if (targetIndex < 0) return ids;
|
||||
nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId);
|
||||
return nextIds;
|
||||
}
|
||||
|
||||
function moveModuleInOneColumnOrder(order, operation) {
|
||||
if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order;
|
||||
return {
|
||||
...order,
|
||||
one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement)
|
||||
};
|
||||
}
|
||||
|
||||
function moveModuleInTwoColumnOrder(order, operation) {
|
||||
const columns = [
|
||||
[...(order.two?.[0] || [])],
|
||||
[...(order.two?.[1] || [])]
|
||||
];
|
||||
const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId));
|
||||
if (sourceColumnIndex < 0) return order;
|
||||
const targetColumnIndex = operation.targetType === "item"
|
||||
? columns.findIndex((column) => column.includes(operation.targetId))
|
||||
: MODULE_COLUMN_IDS.indexOf(operation.targetGroup);
|
||||
if (targetColumnIndex < 0) return order;
|
||||
|
||||
const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1);
|
||||
if (operation.targetType === "item") {
|
||||
const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId);
|
||||
if (targetIndex < 0) return order;
|
||||
columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId);
|
||||
} else {
|
||||
columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId);
|
||||
}
|
||||
return { ...order, two: columns };
|
||||
}
|
||||
|
||||
export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
|
||||
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
|
||||
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
|
||||
export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
|
||||
const twoColumnOrder = useMemo(() => space.moduleOrder?.two || [[], []], [space.moduleOrder?.two]);
|
||||
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, space.moduleOrder?.one || []), [toolbox.modules, space.moduleOrder?.one]);
|
||||
const columns = useMemo(() => [
|
||||
getModulesByIds(toolbox.modules, twoColumnOrder[0]),
|
||||
getModulesByIds(toolbox.modules, twoColumnOrder[1])
|
||||
], [toolbox.modules, twoColumnOrder]);
|
||||
const {
|
||||
itemReorder,
|
||||
getItemProps,
|
||||
getGroupBoundaryProps,
|
||||
isItemDragging,
|
||||
isItemDropTarget,
|
||||
isBoundaryDropTarget,
|
||||
getDropPlacement
|
||||
} = useGroupedReorder({
|
||||
namespace: "toolbox-modules",
|
||||
items: toolbox.modules,
|
||||
getItemId: (module) => module.id,
|
||||
getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
|
||||
getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
|
||||
getParentId: () => toolbox.id,
|
||||
reorderFeatures: {
|
||||
item: {
|
||||
groupChange: moduleColumns === 2,
|
||||
boundaryDrop: moduleColumns === 2
|
||||
}
|
||||
},
|
||||
onItemMove: (operation) => {
|
||||
const nextOrder = moduleColumns === 2
|
||||
? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
|
||||
: moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
|
||||
onModuleOrderChange(nextOrder);
|
||||
},
|
||||
hierarchy: { enabled: true, stickyParents: true }
|
||||
});
|
||||
|
||||
if (moduleColumns === 1) {
|
||||
return (
|
||||
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||
{!orderedModules.length && <div className="empty toolbox-space-empty nds-empty-state"><p>{spacesText.emptyText}</p></div>}
|
||||
{orderedModules.map((module) => (
|
||||
<ModuleShell
|
||||
key={module.id}
|
||||
toolbox={toolbox}
|
||||
module={module}
|
||||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup=""
|
||||
reorder={reorder}
|
||||
itemGroup={encodeSpaceColumn(space.id, "one")}
|
||||
spaces={toolbox.spaces}
|
||||
activeSpaceId={space.id}
|
||||
moveText={spacesText.moveLabel}
|
||||
onMoveToSpace={onMoveToSpace}
|
||||
scrollIntoView={module.id === scrollToModuleId}
|
||||
onScrolledIntoView={onModuleScrolled}
|
||||
onRename={onRename}
|
||||
|
|
@ -112,15 +44,15 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
|
|||
);
|
||||
}
|
||||
|
||||
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
|
||||
const hasEmptyColumn = orderedModules.length > 1 && columns.some((modules) => modules.length === 0);
|
||||
|
||||
return (
|
||||
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
|
||||
{columns.map((modules, index) => (
|
||||
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
|
||||
<div
|
||||
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })}
|
||||
className={`module-column-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
|
||||
{...reorder.getGroupBoundaryProps({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "before" })}
|
||||
/>
|
||||
{modules.map((module) => (
|
||||
<ModuleShell
|
||||
|
|
@ -128,8 +60,12 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
|
|||
toolbox={toolbox}
|
||||
module={module}
|
||||
context={context}
|
||||
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }}
|
||||
itemGroup={MODULE_COLUMN_IDS[index]}
|
||||
reorder={reorder}
|
||||
itemGroup={encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index])}
|
||||
spaces={toolbox.spaces}
|
||||
activeSpaceId={space.id}
|
||||
moveText={spacesText.moveLabel}
|
||||
onMoveToSpace={onMoveToSpace}
|
||||
scrollIntoView={module.id === scrollToModuleId}
|
||||
onScrolledIntoView={onModuleScrolled}
|
||||
onRename={onRename}
|
||||
|
|
@ -138,11 +74,12 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
|
|||
/>
|
||||
))}
|
||||
<div
|
||||
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })}
|
||||
className={`module-column-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
|
||||
{...reorder.getGroupBoundaryProps({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "after" })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!orderedModules.length && <div className="empty toolbox-space-empty nds-empty-state"><p>{spacesText.emptyText}</p></div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Rôle : fournit les primitives communes de normalisation et d'identifiants du stockage toolbox.
|
||||
export const ID_PREFIXES = {
|
||||
tbx: "t",
|
||||
space: "sp",
|
||||
mod: "m",
|
||||
item: "i",
|
||||
section: "g",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const TOOLBOX_ICON_FILES = [
|
|||
];
|
||||
export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`);
|
||||
export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
|
||||
export const MAX_TOOLBOX_SPACES = 5;
|
||||
|
||||
export function normalizeToolboxIcon(icon) {
|
||||
const value = String(icon || "").trim();
|
||||
|
|
@ -123,9 +124,74 @@ export function remapModuleOrderIds(moduleOrder, moduleIdMap) {
|
|||
};
|
||||
}
|
||||
|
||||
function getSpaceModuleIds(space, knownModuleIds) {
|
||||
const candidates = [
|
||||
...(Array.isArray(space?.moduleOrder?.one) ? space.moduleOrder.one : []),
|
||||
...(Array.isArray(space?.moduleOrder?.two?.[0]) ? space.moduleOrder.two[0] : []),
|
||||
...(Array.isArray(space?.moduleOrder?.two?.[1]) ? space.moduleOrder.two[1] : [])
|
||||
];
|
||||
return uniqueKnownIds(candidates, knownModuleIds);
|
||||
}
|
||||
|
||||
export function normalizeToolboxSpaces(toolbox, modules) {
|
||||
const moduleIds = modules.map((module) => module.id);
|
||||
const rawSpaces = Array.isArray(toolbox?.spaces) ? toolbox.spaces.filter((space) => space && typeof space === "object") : [];
|
||||
if (!rawSpaces.length) {
|
||||
const id = uid("space");
|
||||
return {
|
||||
spaces: [{ id, name: "Défaut", moduleOrder: normalizeModuleOrder(toolbox?.moduleOrder, modules) }],
|
||||
defaultSpaceId: id,
|
||||
activeSpaceId: id
|
||||
};
|
||||
}
|
||||
|
||||
const seenSpaceIds = new Set();
|
||||
const prepared = rawSpaces.slice(0, MAX_TOOLBOX_SPACES).map((space, index) => {
|
||||
let id = String(space.id || "").trim() || uid("space");
|
||||
while (seenSpaceIds.has(id)) id = uid("space");
|
||||
seenSpaceIds.add(id);
|
||||
return {
|
||||
id,
|
||||
name: String(space.name || "").trim() || (index === 0 ? "Défaut" : `Espace ${index + 1}`),
|
||||
source: space
|
||||
};
|
||||
});
|
||||
const requestedDefaultId = String(toolbox?.defaultSpaceId || "");
|
||||
const defaultIndex = prepared.findIndex((space) => space.id === requestedDefaultId);
|
||||
if (defaultIndex > 0) prepared.unshift(prepared.splice(defaultIndex, 1)[0]);
|
||||
|
||||
const assigned = new Set();
|
||||
const memberships = new Map();
|
||||
prepared.forEach((space) => {
|
||||
const ids = getSpaceModuleIds(space.source, moduleIds).filter((id) => {
|
||||
if (assigned.has(id)) return false;
|
||||
assigned.add(id);
|
||||
return true;
|
||||
});
|
||||
memberships.set(space.id, ids);
|
||||
});
|
||||
const defaultId = prepared[0].id;
|
||||
memberships.set(defaultId, [...memberships.get(defaultId), ...moduleIds.filter((id) => !assigned.has(id))]);
|
||||
|
||||
const spaces = prepared.map((space) => {
|
||||
const memberIds = memberships.get(space.id);
|
||||
const memberSet = new Set(memberIds);
|
||||
const spaceModules = modules.filter((module) => memberSet.has(module.id));
|
||||
return {
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
moduleOrder: normalizeModuleOrder(space.source.moduleOrder, spaceModules)
|
||||
};
|
||||
});
|
||||
const requestedActiveId = String(toolbox?.activeSpaceId || "");
|
||||
const activeSpaceId = spaces.some((space) => space.id === requestedActiveId) ? requestedActiveId : defaultId;
|
||||
return { spaces, defaultSpaceId: defaultId, activeSpaceId };
|
||||
}
|
||||
|
||||
export function normalizeToolbox(toolbox) {
|
||||
if (!toolbox || typeof toolbox !== "object") return null;
|
||||
const modules = (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean);
|
||||
const spaceState = normalizeToolboxSpaces(toolbox, modules);
|
||||
const normalized = {
|
||||
id: toolbox.id || uid("tbx"),
|
||||
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
|
||||
|
|
@ -133,7 +199,7 @@ export function normalizeToolbox(toolbox) {
|
|||
customCover: normalizeToolboxCustomCover(toolbox.customCover),
|
||||
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
|
||||
modules,
|
||||
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules),
|
||||
...spaceState,
|
||||
updatedAt: toolbox.updatedAt || new Date().toISOString()
|
||||
};
|
||||
if (toolbox.tutorial === true) normalized.tutorial = true;
|
||||
|
|
@ -147,7 +213,9 @@ export function compactToolboxForStorage(toolbox) {
|
|||
id: normalized.id,
|
||||
name: normalized.name,
|
||||
modules: normalized.modules,
|
||||
moduleOrder: normalized.moduleOrder,
|
||||
spaces: normalized.spaces,
|
||||
defaultSpaceId: normalized.defaultSpaceId,
|
||||
activeSpaceId: normalized.activeSpaceId,
|
||||
updatedAt: normalized.updatedAt
|
||||
};
|
||||
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
|
||||
|
|
@ -402,6 +470,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
|
|||
const source = normalizeToolbox(toolbox);
|
||||
const nextId = createExportIdFactory();
|
||||
const moduleIdMap = new Map();
|
||||
const spaceIdMap = new Map(source.spaces.map((space) => [space.id, nextId("space")]));
|
||||
const exportedToolbox = compactToolboxForStorage({
|
||||
...source,
|
||||
id: nextId("tbx"),
|
||||
|
|
@ -410,7 +479,13 @@ export function createToolboxExportPayload(toolbox, moduleData) {
|
|||
moduleIdMap.set(module.id, id);
|
||||
return { ...module, id };
|
||||
}),
|
||||
moduleOrder: remapModuleOrderIds(source.moduleOrder, moduleIdMap)
|
||||
spaces: source.spaces.map((space) => ({
|
||||
...space,
|
||||
id: spaceIdMap.get(space.id),
|
||||
moduleOrder: remapModuleOrderIds(space.moduleOrder, moduleIdMap)
|
||||
})),
|
||||
defaultSpaceId: spaceIdMap.get(source.defaultSpaceId),
|
||||
activeSpaceId: spaceIdMap.get(source.activeSpaceId)
|
||||
});
|
||||
const modules = Object.fromEntries(source.modules
|
||||
.map((module) => [
|
||||
|
|
@ -418,7 +493,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
|
|||
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
|
||||
])
|
||||
.filter(([, data]) => data != null));
|
||||
return { toolbox: exportedToolbox, modules };
|
||||
return { version: 2, toolbox: exportedToolbox, modules };
|
||||
}
|
||||
|
||||
export function createGlobalExportPayload(toolboxes, links, moduleData) {
|
||||
|
|
@ -430,7 +505,7 @@ export function createGlobalExportPayload(toolboxes, links, moduleData) {
|
|||
});
|
||||
});
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
toolboxes: compactToolboxesForStorage(toolboxes),
|
||||
modules,
|
||||
|
|
|
|||
163
website/src/features/toolboxes/toolboxSpaceUtils.js
Normal file
163
website/src/features/toolboxes/toolboxSpaceUtils.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// Rôle : applique les créations, suppressions et déplacements d'outils entre espaces toolbox.
|
||||
import { MAX_TOOLBOX_SPACES, normalizeModuleOrder, uid } from "./storage/toolboxStorage.js";
|
||||
|
||||
export const SPACE_GROUP_SEPARATOR = "::";
|
||||
|
||||
export function getActiveToolboxSpace(toolbox) {
|
||||
return toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId) || toolbox.spaces[0];
|
||||
}
|
||||
|
||||
export function getModuleSpace(toolbox, moduleId) {
|
||||
return toolbox.spaces.find((space) => space.moduleOrder.one.includes(moduleId)) || toolbox.spaces[0];
|
||||
}
|
||||
|
||||
export function encodeSpaceColumn(spaceId, column = "one") {
|
||||
return `${spaceId}${SPACE_GROUP_SEPARATOR}${column}`;
|
||||
}
|
||||
|
||||
export function decodeSpaceColumn(value) {
|
||||
const [spaceId = "", column = "one"] = String(value || "").split(SPACE_GROUP_SEPARATOR);
|
||||
return { spaceId, column };
|
||||
}
|
||||
|
||||
function removeId(order, moduleId) {
|
||||
return {
|
||||
one: order.one.filter((id) => id !== moduleId),
|
||||
two: order.two.map((column) => column.filter((id) => id !== moduleId))
|
||||
};
|
||||
}
|
||||
|
||||
function insertAround(ids, moduleId, targetId, placement) {
|
||||
const next = ids.filter((id) => id !== moduleId);
|
||||
const targetIndex = next.indexOf(targetId);
|
||||
if (targetIndex < 0) return [...next, moduleId];
|
||||
next.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moduleId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addModuleToToolboxSpace(toolbox, module, spaceId = toolbox.activeSpaceId) {
|
||||
const modules = [...toolbox.modules, module];
|
||||
const targetId = toolbox.spaces.some((space) => space.id === spaceId) ? spaceId : toolbox.defaultSpaceId;
|
||||
return {
|
||||
...toolbox,
|
||||
modules,
|
||||
spaces: toolbox.spaces.map((space) => {
|
||||
if (space.id !== targetId) return space;
|
||||
const order = normalizeModuleOrder(space.moduleOrder, modules.filter((item) => space.moduleOrder.one.includes(item.id)));
|
||||
const two = order.two.map((column) => column.filter((id) => id !== module.id));
|
||||
const targetColumn = two[0].length <= two[1].length ? 0 : 1;
|
||||
two[targetColumn].push(module.id);
|
||||
return { ...space, moduleOrder: { one: [...order.one.filter((id) => id !== module.id), module.id], two } };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function removeModuleFromToolboxSpaces(toolbox, moduleId) {
|
||||
return {
|
||||
...toolbox,
|
||||
modules: toolbox.modules.filter((module) => module.id !== moduleId),
|
||||
spaces: toolbox.spaces.map((space) => ({ ...space, moduleOrder: removeId(space.moduleOrder, moduleId) }))
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolboxSpace(toolbox, name) {
|
||||
if (toolbox.spaces.length >= MAX_TOOLBOX_SPACES || !String(name || "").trim()) return toolbox;
|
||||
const id = uid("space");
|
||||
return {
|
||||
...toolbox,
|
||||
spaces: [...toolbox.spaces, { id, name: String(name || "").trim(), moduleOrder: { one: [], two: [[], []] } }],
|
||||
activeSpaceId: id
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteToolboxSpace(toolbox, spaceId) {
|
||||
if (spaceId === toolbox.defaultSpaceId) return toolbox;
|
||||
const removed = toolbox.spaces.find((space) => space.id === spaceId);
|
||||
const fallback = toolbox.spaces.find((space) => space.id === toolbox.defaultSpaceId);
|
||||
if (!removed || !fallback) return toolbox;
|
||||
const mergedOrder = {
|
||||
one: [...fallback.moduleOrder.one, ...removed.moduleOrder.one],
|
||||
two: [
|
||||
[...fallback.moduleOrder.two[0], ...removed.moduleOrder.two[0]],
|
||||
[...fallback.moduleOrder.two[1], ...removed.moduleOrder.two[1]]
|
||||
]
|
||||
};
|
||||
return {
|
||||
...toolbox,
|
||||
spaces: toolbox.spaces.filter((space) => space.id !== spaceId).map((space) => (
|
||||
space.id === fallback.id ? { ...space, moduleOrder: mergedOrder } : space
|
||||
)),
|
||||
activeSpaceId: fallback.id
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderToolboxSpaces(toolbox, sourceId, targetId, placement = "before") {
|
||||
if (sourceId === toolbox.defaultSpaceId || sourceId === targetId) return toolbox;
|
||||
const secondary = toolbox.spaces.filter((space) => space.id !== toolbox.defaultSpaceId && space.id !== sourceId);
|
||||
const moving = toolbox.spaces.find((space) => space.id === sourceId);
|
||||
if (!moving) return toolbox;
|
||||
let targetIndex = secondary.findIndex((space) => space.id === targetId);
|
||||
if (targetId === toolbox.defaultSpaceId) targetIndex = 0;
|
||||
else if (placement === "after" && targetIndex >= 0) targetIndex += 1;
|
||||
if (targetIndex < 0) targetIndex = secondary.length;
|
||||
secondary.splice(targetIndex, 0, moving);
|
||||
const fallback = toolbox.spaces.find((space) => space.id === toolbox.defaultSpaceId);
|
||||
return { ...toolbox, spaces: [fallback, ...secondary] };
|
||||
}
|
||||
|
||||
export function moveToolboxModule(toolbox, moduleId, {
|
||||
targetSpaceId,
|
||||
targetModuleId = "",
|
||||
targetColumn = "",
|
||||
placement = "after",
|
||||
moduleColumns = toolbox.moduleColumns
|
||||
}) {
|
||||
const sourceSpace = getModuleSpace(toolbox, moduleId);
|
||||
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
|
||||
if (!sourceSpace || !destination) return toolbox;
|
||||
const sourceColumnIndex = sourceSpace.moduleOrder.two[1].includes(moduleId) ? 1 : 0;
|
||||
|
||||
if (sourceSpace.id === destination.id) {
|
||||
return {
|
||||
...toolbox,
|
||||
spaces: toolbox.spaces.map((space) => {
|
||||
if (space.id !== destination.id) return space;
|
||||
const next = { one: [...space.moduleOrder.one], two: space.moduleOrder.two.map((column) => [...column]) };
|
||||
if (Number(moduleColumns) === 1) {
|
||||
next.one = insertAround(next.one, moduleId, targetModuleId, placement);
|
||||
} else {
|
||||
next.two = next.two.map((column) => column.filter((id) => id !== moduleId));
|
||||
const foundColumnIndex = next.two.findIndex((column) => column.includes(targetModuleId));
|
||||
const columnIndex = targetColumn === "right" ? 1 : targetColumn === "left" ? 0 : foundColumnIndex >= 0 ? foundColumnIndex : sourceColumnIndex;
|
||||
next.two[columnIndex] = targetModuleId
|
||||
? insertAround(next.two[columnIndex], moduleId, targetModuleId, placement)
|
||||
: placement === "before" ? [moduleId, ...next.two[columnIndex]] : [...next.two[columnIndex], moduleId];
|
||||
}
|
||||
return { ...space, moduleOrder: next };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const spaces = toolbox.spaces.map((space) => ({ ...space, moduleOrder: removeId(space.moduleOrder, moduleId) }));
|
||||
return {
|
||||
...toolbox,
|
||||
spaces: spaces.map((space) => {
|
||||
if (space.id !== destination.id) return space;
|
||||
const next = { one: [...space.moduleOrder.one], two: space.moduleOrder.two.map((column) => [...column]) };
|
||||
const targetColumnIndex = targetColumn === "right"
|
||||
? 1
|
||||
: targetColumn === "left"
|
||||
? 0
|
||||
: Number(moduleColumns) === 2 ? next.two.findIndex((column) => column.includes(targetModuleId)) : -1;
|
||||
|
||||
next.one = targetModuleId && Number(moduleColumns) === 1
|
||||
? insertAround(next.one, moduleId, targetModuleId, placement)
|
||||
: [...next.one, moduleId];
|
||||
const columnIndex = targetColumnIndex >= 0 ? targetColumnIndex : sourceColumnIndex;
|
||||
next.two[columnIndex] = targetModuleId && Number(moduleColumns) === 2
|
||||
? insertAround(next.two[columnIndex], moduleId, targetModuleId, placement)
|
||||
: targetColumn && placement === "before" ? [moduleId, ...next.two[columnIndex]] : [...next.two[columnIndex], moduleId];
|
||||
return { ...space, moduleOrder: next };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
|
@ -5,12 +5,14 @@ import {
|
|||
createToolboxExportPayload,
|
||||
globalModuleKey,
|
||||
labelFromFileName,
|
||||
MAX_TOOLBOX_SPACES,
|
||||
normalizeToolbox,
|
||||
remapModuleOrderIds,
|
||||
uid
|
||||
} from "./storage/toolboxStorage.js";
|
||||
import { compressImage } from "../../utils/imageCompression.js";
|
||||
import { getBackupFileName } from "./storage/autoBackup.js";
|
||||
import { addModuleToToolboxSpace } from "./toolboxSpaceUtils.js";
|
||||
|
||||
function downloadJson(payload, filename) {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
|
|
@ -38,7 +40,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
title: creationTemplate?.links?.title || "Librairie des outils",
|
||||
url: getLibraryUrl()
|
||||
};
|
||||
const toolbox = {
|
||||
const toolbox = normalizeToolbox({
|
||||
id: uid("tbx"),
|
||||
name: name.trim() || "Nouvelle toolbox",
|
||||
updatedAt: now,
|
||||
|
|
@ -46,7 +48,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
{ id: memosModuleId, type: "memos", title: creationTemplate?.memos?.title || "Mémos" },
|
||||
{ id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" }
|
||||
]
|
||||
};
|
||||
});
|
||||
if (options.tutorial === true) toolbox.tutorial = true;
|
||||
const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] });
|
||||
const links = compactModuleDataForStorage("links", { links: [libraryLink] });
|
||||
|
|
@ -141,7 +143,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
if (!confirmed) return;
|
||||
const updatedAt = new Date().toISOString();
|
||||
store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId
|
||||
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt }
|
||||
? { ...addModuleToToolboxSpace(item, { id: moduleId, type: "checklist", title: list.title || "Checklist" }, item.activeSpaceId), updatedAt }
|
||||
: item));
|
||||
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
|
||||
notify("Checklist créée dans la toolbox associée.");
|
||||
|
|
@ -153,6 +155,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
async function importToolboxPayload(file, gameId = "") {
|
||||
const payload = JSON.parse(await file.text());
|
||||
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
|
||||
if (Array.isArray(payload.toolbox.spaces) && payload.toolbox.spaces.length > MAX_TOOLBOX_SPACES) {
|
||||
throw new Error(`Une toolbox ne peut pas contenir plus de ${MAX_TOOLBOX_SPACES} espaces.`);
|
||||
}
|
||||
const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() });
|
||||
const moduleIdMap = new Map();
|
||||
imported.modules = imported.modules.map((module) => {
|
||||
|
|
@ -160,7 +165,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
moduleIdMap.set(module.id, nextId);
|
||||
return { ...module, id: nextId };
|
||||
});
|
||||
imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap);
|
||||
const spaceIdMap = new Map(imported.spaces.map((space) => [space.id, uid("space")]));
|
||||
imported.spaces = imported.spaces.map((space) => ({
|
||||
...space,
|
||||
id: spaceIdMap.get(space.id),
|
||||
moduleOrder: remapModuleOrderIds(space.moduleOrder, moduleIdMap)
|
||||
}));
|
||||
imported.defaultSpaceId = spaceIdMap.get(imported.defaultSpaceId);
|
||||
imported.activeSpaceId = spaceIdMap.get(imported.activeSpaceId);
|
||||
const normalizedImport = normalizeToolbox(imported);
|
||||
const nextModuleData = {};
|
||||
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
|
||||
|
|
@ -185,6 +197,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") {
|
||||
throw new Error("Format d'import global invalide");
|
||||
}
|
||||
if (payload.toolboxes.some((toolbox) => Array.isArray(toolbox.spaces) && toolbox.spaces.length > MAX_TOOLBOX_SPACES)) {
|
||||
throw new Error(`Une toolbox ne peut pas contenir plus de ${MAX_TOOLBOX_SPACES} espaces.`);
|
||||
}
|
||||
const toolboxIdMap = new Map();
|
||||
const moduleIdMap = new Map();
|
||||
const importedToolboxes = payload.toolboxes.map((toolbox) => {
|
||||
|
|
@ -197,8 +212,23 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
|||
localModuleIdMap.set(module.id, nextModuleId);
|
||||
return { ...module, id: nextModuleId };
|
||||
});
|
||||
const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap);
|
||||
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() });
|
||||
const normalizedSource = normalizeToolbox({ ...toolbox, modules: toolbox.modules || [] });
|
||||
const spaceIdMap = new Map(normalizedSource.spaces.map((space) => [space.id, uid("space")]));
|
||||
const spaces = normalizedSource.spaces.map((space) => ({
|
||||
...space,
|
||||
id: spaceIdMap.get(space.id),
|
||||
moduleOrder: remapModuleOrderIds(space.moduleOrder, localModuleIdMap)
|
||||
}));
|
||||
return normalizeToolbox({
|
||||
...toolbox,
|
||||
id: nextToolboxId,
|
||||
name: `${toolbox.name || "Toolbox"} (import)`,
|
||||
modules,
|
||||
spaces,
|
||||
defaultSpaceId: spaceIdMap.get(normalizedSource.defaultSpaceId),
|
||||
activeSpaceId: spaceIdMap.get(normalizedSource.activeSpaceId),
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
const nextLinks = {};
|
||||
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/
|
|||
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
|
||||
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
|
||||
|
||||
const assetRecoveryUrl = new URL(window.location.href);
|
||||
if (assetRecoveryUrl.searchParams.has("_asset-recovery")) {
|
||||
assetRecoveryUrl.searchParams.delete("_asset-recovery");
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`${assetRecoveryUrl.pathname}${assetRecoveryUrl.search}${assetRecoveryUrl.hash}`
|
||||
);
|
||||
}
|
||||
|
||||
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
|
||||
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
||||
const SHORT_NOTIFICATION_DURATION_MS = 5000;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
--font-size-3xl: 2rem;
|
||||
--font-size-4xl: 2.5rem;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@
|
|||
@use "layout/add-tools";
|
||||
@use "layout/preferences";
|
||||
@use "layout/modules";
|
||||
@use "layout/spaces";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
font-size: 17px;
|
||||
}
|
||||
|
||||
.module-space-select {
|
||||
width: auto;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.module-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
192
website/src/styles/toolboxes/layout/_spaces.scss
Normal file
192
website/src/styles/toolboxes/layout/_spaces.scss
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Rôle : stylise les onglets, actions et états de dépôt des espaces toolbox.
|
||||
.toolbox-spaces-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(16, 20, 38, 0.58);
|
||||
}
|
||||
|
||||
.toolbox-spaces-toolbar.is-single {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toolbox-space-tabs-row {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.toolbox-space-tabs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(165, 180, 252, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(7, 10, 24, 0.45);
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-standard),
|
||||
background-color var(--duration-fast) var(--ease-standard),
|
||||
box-shadow var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap:hover:not(.active) {
|
||||
border-color: rgba(196, 181, 253, 0.34);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
box-shadow:
|
||||
0 0 14px rgba(139, 92, 246, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap:focus-within {
|
||||
outline: 2px solid var(--color-accent-gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.active {
|
||||
border-color: rgba(246, 196, 83, 0.48);
|
||||
background: rgba(246, 196, 83, 0.09);
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.active:hover {
|
||||
border-color: rgba(246, 196, 83, 0.62);
|
||||
background: rgba(246, 196, 83, 0.13);
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.is-drop-target {
|
||||
border-color: rgba(246, 196, 83, 0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.is-drop-target::before {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(246, 196, 83, 0.9);
|
||||
box-shadow: 0 0 12px rgba(246, 196, 83, 0.24);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.is-drop-target.drop-after::before {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.is-module-drop-target {
|
||||
border-color: rgba(246, 196, 83, 0.72);
|
||||
background: rgba(246, 196, 83, 0.14);
|
||||
box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.9);
|
||||
}
|
||||
|
||||
.toolbox-space-tab,
|
||||
.toolbox-space-drag-handle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.toolbox-space-tab {
|
||||
min-height: 38px;
|
||||
padding: 0 var(--space-3) 0 var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.toolbox-space-tab:first-child {
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.toolbox-space-tab.active {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap.is-module-drop-target .toolbox-space-tab {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toolbox-space-drag-handle,
|
||||
.toolbox-space-drag-handle.nds-icon-button {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
margin-left: var(--space-1);
|
||||
border-right: 1px solid rgba(165, 180, 252, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: grab;
|
||||
opacity: 0.65;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap > .toolbox-space-tab:hover:not(:disabled),
|
||||
.toolbox-space-tab-wrap > .toolbox-space-drag-handle.nds-icon-button:hover {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap > .toolbox-space-tab:focus-visible,
|
||||
.toolbox-space-tab-wrap > .toolbox-space-drag-handle.nds-icon-button:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbox-space-tab-wrap:hover .toolbox-space-drag-handle,
|
||||
.toolbox-space-tab-wrap:focus-within .toolbox-space-drag-handle {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.toolbox-space-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toolbox-space-add-compact {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbox-space-empty {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbox-space-modal {
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbox-spaces-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbox-space-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toolbox-space-add {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue