This commit is contained in:
seb
2026-08-03 05:11:50 +02:00
parent 5b5c77f870
commit 4db4948090
14 changed files with 1410 additions and 1178 deletions

View File

@@ -0,0 +1,113 @@
import { Droppable } from '@shopify/draggable';
import { notifyError } from '../../utils/notify';
/**
* Encapsulates @shopify/draggable Droppable logic for moving a category
* into another category (tree drag-and-drop).
*
* Usage:
* this.droppable = new CategoryDroppable(containerEl, { onMove });
* this.droppable.activate();
* this.droppable.destroy();
*/
export default class CategoryDroppable {
#instance = null;
#container = null;
#onMove = null;
#pendingDropId = null;
#dropTargetEl = null;
constructor(container, { onMove }) {
this.#container = container;
this.#onMove = onMove;
}
activate() {
const el = this.#container;
if (!el || this.#instance) return;
this.#pendingDropId = null;
this.#instance = new Droppable(el, {
draggable: '.sortable-item',
dropzone: '.sortable-item',
handle: '.droppable-handle',
delay: 0,
mirror: { constrainDimensions: true },
classes: { 'droppable:active': '', 'droppable:occupied': '' },
});
this.#instance.on('drag:start', this.#handleTreeDragStart);
this.#instance.on('drag:over', this.#handleDropOver);
this.#instance.on('drag:out', this.#handleDropOut);
this.#instance.on('droppable:dropped', this.#handleDropped);
this.#instance.on('droppable:returned', this.#handleDroppedReturned);
this.#instance.on('drag:stop', this.#handleDragStop);
}
destroy() {
if (this.#instance) {
this.#instance.destroy();
this.#instance = null;
}
this.#pendingDropId = null;
this.#clearDropTarget();
document.body.classList.remove('tree-dragging');
}
// --- private ---
#handleTreeDragStart = () => {
document.body.classList.add('tree-dragging');
};
#handleDropped = (event) => {
event.cancel();
};
#handleDropOver = (event) => {
const tile =
event?.over && typeof event.over.closest === 'function'
? event.over.closest('.sortable-item')
: null;
if (!tile) return;
const sourceId = Number(event?.source?.dataset?.id);
const targetId = Number(tile.dataset.id);
if (sourceId && targetId === sourceId) {
this.#pendingDropId = null;
return;
}
this.#clearDropTarget();
this.#pendingDropId = targetId;
this.#dropTargetEl = tile;
tile.classList.add('tree-drop-target');
};
#handleDropOut = () => {
this.#pendingDropId = null;
this.#clearDropTarget();
};
#handleDroppedReturned = (event) => {
this.#pendingDropId = null;
this.#clearDropTarget();
event.cancel();
};
#handleDragStop = (event) => {
document.body.classList.remove('tree-dragging');
const categoryId = Number(event?.source?.dataset?.id);
const targetId = this.#pendingDropId;
this.#pendingDropId = null;
this.#clearDropTarget();
if (!categoryId || !targetId || categoryId === targetId) return;
void this.#onMove({ categoryId, parentId: targetId })
.unwrap()
.catch(notifyError);
};
#clearDropTarget = () => {
if (this.#dropTargetEl) {
this.#dropTargetEl.classList.remove('tree-drop-target');
this.#dropTargetEl = null;
}
};
}

View File

@@ -0,0 +1,114 @@
import Sortable from 'sortablejs';
import { notifyError } from '../../utils/notify';
/**
* Encapsulates SortableJS logic for reordering category tiles inside a grid.
*
* Usage:
* this.sortable = new CategorySortable(containerEl, { onReorder });
* this.sortable.activate();
* this.sortable.destroy();
* this.sortable.sortableIds(); // current DOM order
* this.sortable.handleMoveCategory(id, direction); // +1 / -1
*/
export default class CategorySortable {
#instance = null;
#container = null;
#onReorder = null;
#checkpointOrder = null;
constructor(container, { onReorder }) {
this.#container = container;
this.#onReorder = onReorder;
}
activate() {
const el = this.#container;
if (!el || this.#instance) return;
this.#instance = Sortable.create(el, {
draggable: '.sortable-item:not(.not-sortable)',
handle: '.sortable-handle',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
filter: '.not-sortable, .sortable-handle-btn',
forceFallback: true,
fallbackOnBody: true,
onStart: this.#handleSortStart,
onEnd: this.#handleSort,
});
}
destroy() {
if (this.#instance) {
this.#instance.destroy();
this.#instance = null;
}
this.#checkpointOrder = null;
document.body.classList.remove('tree-dragging');
}
sortableIds() {
const container = this.#container;
if (!container) return [];
return [...container.querySelectorAll('.sortable-item:not(.not-sortable)')]
.map((el) => el.dataset.id)
.filter(Boolean)
.map(Number);
}
handleMoveCategory = (categoryId, direction) => {
const ids = this.sortableIds();
const index = ids.indexOf(categoryId);
if (index === -1) return;
const target = index + direction;
if (target < 0 || target >= ids.length) return;
const next = [...ids];
next[index] = ids[target];
next[target] = categoryId;
void this.#onReorder({ categoryIds: next }).unwrap().catch(notifyError);
};
revertDomOrder = (order) => {
const container = this.#container;
if (!container) return;
const items = [...container.querySelectorAll('.sortable-item')];
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
const anchor =
[...container.children].find((el) => !el.classList.contains('sortable-item')) ?? null;
for (const id of order) {
const el = byId.get(id);
if (!el) continue;
if (anchor) container.insertBefore(el, anchor);
else container.appendChild(el);
}
};
// --- private ---
#handleSortStart = () => {
this.#checkpointOrder = this.sortableIds();
document.body.classList.add('tree-dragging');
};
#handleSort = () => {
document.body.classList.remove('tree-dragging');
const ids = this.sortableIds();
const before = this.#checkpointOrder ?? [];
this.#checkpointOrder = null;
if (
before.length === 0 ||
before.length !== ids.length ||
before.every((id, index) => id === ids[index])
) {
return;
}
void this.#onReorder({ categoryIds: ids })
.unwrap()
.catch((err) => {
notifyError(err);
this.revertDomOrder(before);
});
};
}

View File

@@ -5,9 +5,9 @@ import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import Sortable from 'sortablejs';
import { Droppable } from '@shopify/draggable';
import BackendError from '../../components/BackendError';
import CategorySortable from './CategorySortable';
import CategoryDroppable from './CategoryDroppable';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
import { notifyError } from '../../utils/notify';
@@ -58,11 +58,8 @@ class ArtikelKategorienPage extends Component {
super(props);
this.state = { drafts: {} };
this.gridRef = createRef();
this.sortable = null;
this.droppable = null;
this.checkpointOrder = null;
this.pendingDropId = null;
this.dropTargetEl = null;
this.sortableModule = null;
this.droppableModule = null;
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
@@ -106,219 +103,47 @@ class ArtikelKategorienPage extends Component {
}
componentWillUnmount() {
this.destroySortable();
this.destroyDroppable();
this.sortableModule?.destroy();
this.droppableModule?.destroy();
}
#ensureModules = () => {
const el = this.gridRef.current;
if (!el) return;
if (!this.sortableModule) {
this.sortableModule = new CategorySortable(el, {
onReorder: this.props.reorderCategories,
});
}
if (!this.droppableModule) {
this.droppableModule = new CategoryDroppable(el, {
onMove: this.props.moveCategory,
});
}
};
syncSortable = () => {
this.#ensureModules();
if (this.props.renameMode) {
// Erst alte Instanz sicher los, falls irgendwann noch vorhanden,
// damit Sortable die aktuelle (Edit-)DOM-Struktur bindet.
this.destroySortable();
this.activateSortable();
this.sortableModule?.destroy();
this.sortableModule?.activate();
} else {
this.destroySortable();
this.sortableModule?.destroy();
}
};
syncDroppable = () => {
this.#ensureModules();
if (this.props.renameMode) {
this.destroyDroppable();
this.activateDroppable();
this.droppableModule?.destroy();
this.droppableModule?.activate();
} else {
this.destroyDroppable();
this.droppableModule?.destroy();
}
};
activateDroppable = () => {
const el = this.gridRef.current;
if (!el || this.droppable) return;
this.pendingDropId = null;
this.droppable = new Droppable(el, {
draggable: '.sortable-item',
dropzone: '.sortable-item',
handle: '.droppable-handle',
delay: 0,
mirror: { constrainDimensions: true },
// Droppable markiert sonst alle Dropzones gleichzeitig mit der
// --active-Klasse. Das deaktivieren wir und heben stattdessen nur das
// aktuell anvisierte Ziel über tree-drop-target hervor.
classes: { 'droppable:active': '', 'droppable:occupied': '' },
});
this.droppable.on('drag:start', this.handleTreeDragStart);
this.droppable.on('drag:over', this.handleDropOver);
this.droppable.on('drag:out', this.handleDropOut);
this.droppable.on('droppable:dropped', this.handleDropped);
this.droppable.on('droppable:returned', this.handleDroppedReturned);
this.droppable.on('drag:stop', this.handleDragStop);
};
handleTreeDragStart = () => {
// Während des Tree-Draggings native Textselektion unterbinden, damit der
// Cursor kein Text-I-Cursor wird und Textareas keine Hover-Selektion zeigen.
document.body.classList.add('tree-dragging');
};
destroyDroppable = () => {
if (this.droppable) {
this.droppable.destroy();
this.droppable = null;
}
this.pendingDropId = null;
this.clearDropTarget();
document.body.classList.remove('tree-dragging');
};
handleDropped = (event) => {
// Reiner DOM-Schutz: verhindert, dass Droppable das Quell-Element per
// appendChild in die Dropzone verschiebt. Das Ziel-Tracking läuft über
// drag:over/drag:out (pendingDropId), nicht über dieses Event.
event.cancel();
};
handleDropOver = (event) => {
const tile =
event?.over && typeof event.over.closest === 'function'
? event.over.closest('.sortable-item')
: null;
if (!tile) return;
// Die gezogene Kachel selbst ist kein gültiges Drop-Ziel.
const sourceId = Number(event?.source?.dataset?.id);
const targetId = Number(tile.dataset.id);
if (sourceId && targetId === sourceId) {
this.pendingDropId = null;
return;
}
this.clearDropTarget();
this.pendingDropId = targetId;
this.dropTargetEl = tile;
tile.classList.add('tree-drop-target');
};
handleDropOut = () => {
this.pendingDropId = null;
this.clearDropTarget();
};
clearDropTarget = () => {
if (this.dropTargetEl) {
this.dropTargetEl.classList.remove('tree-drop-target');
this.dropTargetEl = null;
}
};
handleDroppedReturned = (event) => {
this.pendingDropId = null;
this.clearDropTarget();
event.cancel();
};
handleDragStop = (event) => {
document.body.classList.remove('tree-dragging');
const categoryId = Number(event?.source?.dataset?.id);
const targetId = this.pendingDropId;
this.pendingDropId = null;
this.clearDropTarget();
if (!categoryId || !targetId || categoryId === targetId) return;
void this.props
.moveCategory({ categoryId, parentId: targetId })
.unwrap()
.catch(notifyError);
};
activateSortable = () => {
const el = this.gridRef.current;
if (!el || this.sortable) return;
this.sortable = Sortable.create(el, {
draggable: '.sortable-item',
handle: '.sortable-handle',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
filter: '.not-sortable, .sortable-handle-btn',
// Eigener Fallback-Drag statt nativer HTML5-DnD: sonst zeigt der Browser
// über Button/Input den Text-I-Cursor bzw. native Hover-Stile.
forceFallback: true,
fallbackOnBody: true,
onStart: this.handleSortStart,
onEnd: this.handleSort,
});
};
destroySortable = () => {
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
}
this.checkpointOrder = null;
document.body.classList.remove('tree-dragging');
};
sortableIds = () => {
const container = this.gridRef.current;
if (!container) return [];
return [...container.querySelectorAll('.sortable-item')]
.map((el) => el.dataset.id)
.filter(Boolean)
.map(Number);
};
handleSortStart = () => {
// Snapshot der Reihenfolge vor dem ersten Drag.
this.checkpointOrder = this.sortableIds();
document.body.classList.add('tree-dragging');
};
handleSort = () => {
document.body.classList.remove('tree-dragging');
const ids = this.sortableIds();
const before = this.checkpointOrder ?? [];
this.checkpointOrder = null;
if (
before.length === 0 ||
before.length !== ids.length ||
before.every((id, index) => id === ids[index])
) {
return; // nichts geändert
}
void this.props
.reorderCategories({ categoryIds: ids })
.unwrap()
.catch((err) => {
notifyError(err);
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
// die Sortable-Instanz bleibt dadurch funktionsfähig.
this.revertDomOrder(before);
});
};
handleMoveCategory = (category, direction) => {
const ids = this.sortableIds();
const index = ids.indexOf(category.id);
if (index === -1) return;
const target = index + direction;
if (target < 0 || target >= ids.length) return; // nicht verschiebbar
const next = [...ids];
next[index] = ids[target];
next[target] = category.id;
void this.props.reorderCategories({ categoryIds: next }).unwrap().catch(notifyError);
};
revertDomOrder = (order) => {
const container = this.gridRef.current;
if (!container) return;
const items = [...container.querySelectorAll('.sortable-item')];
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
// Erste nicht-sortierbare Kachel (= BackTile o. Ä.), davor werden die Items eingefügt.
const anchor =
[...container.children].find((el) => !el.classList.contains('sortable-item')) ??
null;
for (const id of order) {
const el = byId.get(id);
if (!el) continue;
if (anchor) container.insertBefore(el, anchor);
else container.appendChild(el);
}
this.sortableModule?.handleMoveCategory(category.id, direction);
};
ensureTreeLoaded = () => {
@@ -495,7 +320,11 @@ class ArtikelKategorienPage extends Component {
<Box ref={this.gridRef} sx={gridSx}>
{canGoBack && (
<BackTile onClick={this.handleNavigateBack} />
<BackTile
onClick={this.handleNavigateBack}
data-id={String(breadcrumbs[breadcrumbs.length - 2].id)}
className="sortable-item not-sortable"
/>
)}
{childCategories.map((item, index) => {
@@ -524,7 +353,7 @@ class ArtikelKategorienPage extends Component {
}}
variant="standard"
size="small"
slotProps={{ inputLabel: { shrink: true } }}
slotProps={{ inputLabel: { shrink: true }, input: { spellCheck: false } }}
sx={{
width: '100%',
'& .MuiInput-underline:before, & .MuiInput-underline:after': {