u
This commit is contained in:
@@ -6,6 +6,7 @@ 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 { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
||||
import { NewArticleTile } from '../../components/article';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import {
|
||||
categoryDisplayName,
|
||||
deleteCategory,
|
||||
moveCategory,
|
||||
loadCategoryArticles,
|
||||
loadCategoryTree,
|
||||
navigateToCategory,
|
||||
@@ -57,8 +59,10 @@ class ArtikelKategorienPage extends Component {
|
||||
this.state = { drafts: {} };
|
||||
this.gridRef = createRef();
|
||||
this.sortable = null;
|
||||
this.droppable = null;
|
||||
this.checkpointOrder = null;
|
||||
this.treeDragId = null;
|
||||
this.pendingDropId = null;
|
||||
this.dropTargetEl = null;
|
||||
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
||||
this.handleDraftChange = this.handleDraftChange.bind(this);
|
||||
}
|
||||
@@ -68,6 +72,7 @@ class ArtikelKategorienPage extends Component {
|
||||
this.syncCategoryFromUrl();
|
||||
this.ensureArticlesLoaded();
|
||||
this.syncSortable();
|
||||
this.syncDroppable();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -96,11 +101,13 @@ class ArtikelKategorienPage extends Component {
|
||||
|
||||
if (this.props.renameMode !== prevProps.renameMode) {
|
||||
this.syncSortable();
|
||||
this.syncDroppable();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroySortable();
|
||||
this.destroyDroppable();
|
||||
}
|
||||
|
||||
syncSortable = () => {
|
||||
@@ -114,15 +121,117 @@ class ArtikelKategorienPage extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
syncDroppable = () => {
|
||||
if (this.props.renameMode) {
|
||||
this.destroyDroppable();
|
||||
this.activateDroppable();
|
||||
} else {
|
||||
this.destroyDroppable();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
// Capture-Listener im Grid: läuft vor SortableJS' Listener und vor Reacts
|
||||
// synthetischem mousedown — erkennt die Drag-Quelle (Tree vs. Move) zuverlässig.
|
||||
el.addEventListener('mousedown', this.handleGridMouseDownCapture, true);
|
||||
this.sortable = Sortable.create(el, {
|
||||
draggable: '.sortable-item',
|
||||
handle: '.sortable-handle, .sortable-tree',
|
||||
handle: '.sortable-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
@@ -133,10 +242,6 @@ class ArtikelKategorienPage extends Component {
|
||||
};
|
||||
|
||||
destroySortable = () => {
|
||||
const el = this.gridRef.current;
|
||||
if (el) {
|
||||
el.removeEventListener('mousedown', this.handleGridMouseDownCapture, true);
|
||||
}
|
||||
if (this.sortable) {
|
||||
this.sortable.destroy();
|
||||
this.sortable = null;
|
||||
@@ -153,50 +258,15 @@ class ArtikelKategorienPage extends Component {
|
||||
.map(Number);
|
||||
};
|
||||
|
||||
handleGridMouseDownCapture = (event) => {
|
||||
// Native Capture-Phase: läuft VOR SortableJS' Bubbling-Listener und vor
|
||||
// Reacts synthetischem Event. Das echte DOM-Ziel wird so zuverlässig erkannt.
|
||||
const target = event.target;
|
||||
if (!target || typeof target.closest !== 'function') return;
|
||||
const treeEl = target.closest('.sortable-tree');
|
||||
const isTree = treeEl != null;
|
||||
this.treeDragId = isTree
|
||||
? Number(treeEl?.closest('.sortable-item')?.dataset?.id)
|
||||
: null;
|
||||
// Beim Tree-Drag Sortable-Deaktivierung (sort=false) setzen, damit nichts umsortiert.
|
||||
this.sortable?.option('sort', !isTree);
|
||||
};
|
||||
|
||||
handleSortStart = (evt) => {
|
||||
const isTree = this.treeDragId != null;
|
||||
if (isTree) {
|
||||
// Kein Ghost/Shadow — Kacheln bleiben fest, nur das Ziel wird sichtbar.
|
||||
this.sortable?.option('ghostClass', 'sortable-tree-ghost');
|
||||
this.sortable?.option('dragClass', '');
|
||||
} else {
|
||||
this.sortable?.option('ghostClass', 'sortable-ghost');
|
||||
this.sortable?.option('dragClass', 'sortable-drag');
|
||||
}
|
||||
handleSortStart = () => {
|
||||
// Snapshot der Reihenfolge vor dem ersten Drag.
|
||||
this.checkpointOrder = this.sortableIds();
|
||||
};
|
||||
|
||||
handleSort = () => {
|
||||
// Sortable-Klassen für den nächsten Drag zurücksetzen.
|
||||
this.sortable?.option('ghostClass', 'sortable-ghost');
|
||||
this.sortable?.option('dragClass', 'sortable-drag');
|
||||
this.sortable?.option('sort', true);
|
||||
// Tree-Drag (in Unterkategorie ablegen): Geschwister-Reihenfolge nicht ändern.
|
||||
const ids = this.sortableIds();
|
||||
const before = this.checkpointOrder ?? [];
|
||||
this.checkpointOrder = null;
|
||||
if (this.treeDragId != null) {
|
||||
// Kacheln visuell an Original-Position zurückdrehen — kein Reorder-Call.
|
||||
this.treeDragId = null;
|
||||
if (before.length) this.revertDomOrder(before);
|
||||
this.clearSortableState();
|
||||
return;
|
||||
}
|
||||
const ids = this.sortableIds();
|
||||
if (
|
||||
before.length === 0 ||
|
||||
before.length !== ids.length ||
|
||||
@@ -212,24 +282,9 @@ class ArtikelKategorienPage extends Component {
|
||||
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
|
||||
// die Sortable-Instanz bleibt dadurch funktionsfähig.
|
||||
this.revertDomOrder(before);
|
||||
this.clearSortableState();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Entfernt verbliebene SortableJS-Drag/Chosen/Ghost-Klassen von allen
|
||||
* Kacheln. Nötig, weil revertDomOrder die DOM-Knoten umsortiert und
|
||||
* SortableJS sein internes Cleanup dann ggf. nicht mehr korrekt anwendet.
|
||||
*/
|
||||
clearSortableState = () => {
|
||||
const container = this.gridRef.current;
|
||||
if (!container) return;
|
||||
const classes = ['sortable-chosen', 'sortable-drag', 'sortable-ghost', 'sortable-tree-ghost'];
|
||||
for (const el of container.querySelectorAll('.sortable-item')) {
|
||||
el.classList.remove(...classes);
|
||||
}
|
||||
};
|
||||
|
||||
handleMoveCategory = (category, direction) => {
|
||||
const ids = this.sortableIds();
|
||||
const index = ids.indexOf(category.id);
|
||||
@@ -545,6 +600,7 @@ const mapDispatchToProps = {
|
||||
navigateToCategory,
|
||||
renameCategory,
|
||||
deleteCategory,
|
||||
moveCategory,
|
||||
reorderCategories,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user