u
This commit is contained in:
113
src/pages/artikel/CategoryDroppable.js
Normal file
113
src/pages/artikel/CategoryDroppable.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
114
src/pages/artikel/CategorySortable.js
Normal file
114
src/pages/artikel/CategorySortable.js
Normal 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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user