Learn: drag & drop sections and articles on chapter and section pages
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
/* Drag & drop для материалов: порядок и перенос между главами/разделами.
|
||||
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором
|
||||
состояния и кнопкой «Сохранить порядок» для повторной попытки. */
|
||||
Работает в трёх режимах (атрибут data-sort-scope у #content):
|
||||
- full — страница «Материалы»: главы, разделы, статьи;
|
||||
- chapter — страница главы: разделы внутри главы и статьи внутри них;
|
||||
- section — страница раздела: статьи внутри раздела.
|
||||
Изменения сохраняются в фоне (без перезагрузки страницы) с индикатором состояния. */
|
||||
(function () {
|
||||
var state = null;
|
||||
var marker = null;
|
||||
@@ -10,12 +13,20 @@
|
||||
var hoverTimer = null;
|
||||
var hoverTarget = null;
|
||||
|
||||
function contentEl() { return document.getElementById('content'); }
|
||||
function scope() { return contentEl() ? (contentEl().getAttribute('data-sort-scope') || 'full') : 'full'; }
|
||||
function csrf() { return contentEl() ? (contentEl().getAttribute('data-csrf') || '') : ''; }
|
||||
|
||||
function itemSelector(kind) {
|
||||
if (kind === 'chapter') return '[data-chapter-id]';
|
||||
if (kind === 'section') return '[data-section-id]';
|
||||
return '[data-article-id]';
|
||||
}
|
||||
|
||||
/* #content на страницах главы/раздела тоже помечен data-section-id / data-chapter-id,
|
||||
поэтому его нельзя считать перетаскиваемым элементом. */
|
||||
function isRealItem(element) { return element && element !== contentEl(); }
|
||||
|
||||
function statusBox() { return document.getElementById('sort-status'); }
|
||||
function statusText() { return document.getElementById('sort-status-text'); }
|
||||
|
||||
@@ -51,11 +62,16 @@
|
||||
return event.clientY > rect.top + rect.height / 2 ? 'drop-after' : 'drop-before';
|
||||
}
|
||||
|
||||
function listFor(kind, container) {
|
||||
if (!container) return null;
|
||||
return container.querySelector(kind === 'article' ? '[data-article-list]' : '[data-section-list]');
|
||||
}
|
||||
|
||||
function onDragStart(event) {
|
||||
var handle = event.target.closest('.drag-handle');
|
||||
if (!handle) return;
|
||||
var element = handle.closest(itemSelector(handle.dataset.dragKind));
|
||||
if (!element) return;
|
||||
if (!isRealItem(element)) return;
|
||||
state = { kind: handle.dataset.dragKind, id: handle.dataset.dragId, element: element };
|
||||
element.classList.add('dragging');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
@@ -75,23 +91,23 @@
|
||||
var cls = null;
|
||||
|
||||
if (state.kind === 'article') {
|
||||
if (article && !isDraggedOrInside(article)) {
|
||||
if (isRealItem(article) && !isDraggedOrInside(article)) {
|
||||
target = article;
|
||||
cls = placement(event, article);
|
||||
} else if (section && !isDraggedOrInside(section)) {
|
||||
var list = section.querySelector(':scope > [data-article-list]');
|
||||
} else if (isRealItem(section) && !isDraggedOrInside(section)) {
|
||||
var list = listFor('article', section);
|
||||
if (list) { target = list; cls = 'drop-into'; }
|
||||
}
|
||||
} else if (state.kind === 'section') {
|
||||
if (section && section !== state.element && !isDraggedOrInside(section)) {
|
||||
if (isRealItem(section) && section !== state.element && !isDraggedOrInside(section)) {
|
||||
target = section;
|
||||
cls = placement(event, section);
|
||||
} else if (chapter && !isDraggedOrInside(chapter)) {
|
||||
var body = chapter.querySelector(':scope > .acc-body');
|
||||
if (body) { target = body; cls = 'drop-into'; }
|
||||
} else if (isRealItem(chapter) && !isDraggedOrInside(chapter)) {
|
||||
var sectionList = listFor('section', chapter);
|
||||
if (sectionList) { target = sectionList; cls = 'drop-into'; }
|
||||
}
|
||||
} else if (state.kind === 'chapter') {
|
||||
if (chapter && chapter !== state.element && !isDraggedOrInside(chapter)) {
|
||||
if (isRealItem(chapter) && chapter !== state.element && !isDraggedOrInside(chapter)) {
|
||||
target = chapter;
|
||||
cls = placement(event, chapter);
|
||||
}
|
||||
@@ -147,26 +163,60 @@
|
||||
clearMarker();
|
||||
}
|
||||
|
||||
function collectTree() {
|
||||
var content = document.getElementById('content');
|
||||
var chapters = [];
|
||||
content.querySelectorAll(':scope > details[data-chapter-id]').forEach(function (chapter) {
|
||||
var sections = [];
|
||||
chapter.querySelectorAll(':scope > .acc-body > details[data-section-id]').forEach(function (section) {
|
||||
var articles = [];
|
||||
var list = section.querySelector(':scope > [data-article-list]');
|
||||
if (list) {
|
||||
list.querySelectorAll(':scope > [data-article-id]').forEach(function (item) {
|
||||
articles.push({ id: item.getAttribute('data-article-id') });
|
||||
});
|
||||
}
|
||||
sections.push({ id: section.getAttribute('data-section-id'), articles: articles });
|
||||
function collectArticles(container) {
|
||||
var out = [];
|
||||
var list = container ? container.querySelector('[data-article-list]') : null;
|
||||
if (list) {
|
||||
list.querySelectorAll('[data-article-id]').forEach(function (item) {
|
||||
out.push({ id: item.getAttribute('data-article-id') });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSections(container) {
|
||||
var out = [];
|
||||
if (!container) return out;
|
||||
container.querySelectorAll('[data-section-id]').forEach(function (section) {
|
||||
if (!isRealItem(section)) return;
|
||||
out.push({ id: section.getAttribute('data-section-id'), articles: collectArticles(section) });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectPayload() {
|
||||
var content = contentEl();
|
||||
if (!content) return { chapters: [] };
|
||||
|
||||
if (scope() === 'chapter') {
|
||||
return { sections: collectSections(content.querySelector('[data-section-list]')) };
|
||||
}
|
||||
if (scope() === 'section') {
|
||||
return { articles: collectArticles(content) };
|
||||
}
|
||||
|
||||
var chapters = [];
|
||||
content.querySelectorAll('[data-chapter-id]').forEach(function (chapter) {
|
||||
if (!isRealItem(chapter)) return;
|
||||
chapters.push({
|
||||
id: chapter.getAttribute('data-chapter-id'),
|
||||
sections: collectSections(chapter.querySelector('[data-section-list]'))
|
||||
});
|
||||
chapters.push({ id: chapter.getAttribute('data-chapter-id'), sections: sections });
|
||||
});
|
||||
return { chapters: chapters };
|
||||
}
|
||||
|
||||
function saveUrl() {
|
||||
var content = contentEl();
|
||||
if (scope() === 'chapter' && content) {
|
||||
return '/admin/content/chapters/' + encodeURIComponent(content.getAttribute('data-chapter-id')) + '/reorder-sections';
|
||||
}
|
||||
if (scope() === 'section' && content) {
|
||||
return '/admin/content/sections/' + encodeURIComponent(content.getAttribute('data-section-id')) + '/reorder-articles';
|
||||
}
|
||||
return '/admin/content/reorder';
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
pending = true;
|
||||
showStatus('Изменения не сохранены…');
|
||||
@@ -178,13 +228,11 @@
|
||||
if (!pending || saving) return;
|
||||
saving = true;
|
||||
clearTimeout(timer);
|
||||
var content = document.getElementById('content');
|
||||
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
|
||||
showStatus('Сохранение…');
|
||||
fetch('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), {
|
||||
fetch(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(collectTree())
|
||||
body: JSON.stringify(collectPayload())
|
||||
}).then(function (response) {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
pending = false;
|
||||
@@ -208,10 +256,8 @@
|
||||
window.addEventListener('beforeunload', function () {
|
||||
if (!pending || saving) return;
|
||||
try {
|
||||
var content = document.getElementById('content');
|
||||
var csrf = content ? (content.getAttribute('data-csrf') || '') : '';
|
||||
var blob = new Blob([JSON.stringify(collectTree())], { type: 'application/json' });
|
||||
navigator.sendBeacon('/admin/content/reorder?_csrf=' + encodeURIComponent(csrf), blob);
|
||||
var blob = new Blob([JSON.stringify(collectPayload())], { type: 'application/json' });
|
||||
navigator.sendBeacon(saveUrl() + '?_csrf=' + encodeURIComponent(csrf()), blob);
|
||||
} catch (e) { /* игнорируем */ }
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user