From 5a61df26056dcaaf630acb0315672caccc713018 Mon Sep 17 00:00:00 2001
From: Ron Lucke <lucke@elan-ev.de>
Date: Fri, 16 Dec 2022 09:53:11 +0000
Subject: [PATCH] Courseware: Umsortieren und Verschieben von Seiten im
 Inhaltsverzeichnis

Closes #1646

Merge request studip/studip!1203
---
 .../assets/stylesheets/scss/courseware.scss   | 256 ++++++++-------
 .../courseware/CoursewareRibbonToolbar.vue    |   1 -
 .../CoursewareStructuralElement.vue           |   2 +-
 .../components/courseware/CoursewareTree.vue  | 193 +++++++++++-
 .../courseware/CoursewareTreeItem.vue         | 293 ++++++++++++++++--
 .../vue/store/courseware/courseware.module.js |  11 +
 6 files changed, 611 insertions(+), 145 deletions(-)

diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss
index 536c5c85108..11816754397 100644
--- a/resources/assets/stylesheets/scss/courseware.scss
+++ b/resources/assets/stylesheets/scss/courseware.scss
@@ -1293,7 +1293,18 @@ label[for="cw-keypoint-color"] {
     display: block;
 }
 
-
+.cw-tree-item-wrapper {
+    .cw-sortable-handle {
+        display: inline-block;
+        cursor: grab;
+        background-image: url("#{$image-path}/anfasser_24.png");
+        background-repeat: no-repeat;
+        width: 7px;
+        height: 24px;
+        padding-right: 4px;
+        vertical-align: middle;
+    }
+}
 /* * * * * * * * * * *
  sortable handle end
  * * * * * * * * * * */
@@ -1303,137 +1314,143 @@ label[for="cw-keypoint-color"] {
  * * * * */
 
 .cw-tree {
-    ul {
+    ol {
         list-style: none;
         padding-left: 1.25em;
         margin-bottom: 20px;
 
-        &.cw-tree-subchapter-list,
-        &.cw-tree-chapter-list,
         &.cw-tree-root-list {
             padding-left: 0;
-        }
-
-        .cw-tree-item-is-root,
-        .cw-tree-item-first-level {
-            font-size: 16px;
-            border-bottom: solid thin $content-color-40;
-            .cw-tree-item-link {
-                padding-left: 3px;
-            }
-        }
-        .cw-tree-item-is-root {
-            display: block;
-            font-size: 18px;
-            .cw-tree-item-link {
-                padding-left: 26px;
-
-                @include background-icon(courseware, clickable, 18);
-                background-repeat: no-repeat;
-                background-position: 3px 3px;
-                &:hover {
-                    @include background-icon(courseware, attention, 18);
-                }
-                &.cw-tree-item-link-current {
-                    @include background-icon(courseware, info, 18);
+            > li.cw-tree-item {
+                > .cw-tree-item-wrapper {
+                    border-bottom: solid thin $content-color-40;
+                    display: block;
+                    > .cw-sortable-handle {
+                        margin-bottom: 4px;
+                    }
+                    > a.cw-tree-item-link {
+                        display: block;
+                        font-size: 18px;
+                        padding-left: 26px;
+                        @include background-icon(courseware, clickable, 18);
+                        background-repeat: no-repeat;
+                        background-position: 3px 3px;
+                    }
                 }
-            }
-        }
-        .cw-tree-item-first-level {
-            margin: 28px 0 12px 0;
-            &:hover {
-                background-color: $content-color-20;
-            }
-            .cw-tree-item-link,
-            .cw-tree-item-link:hover,
-            .cw-tree-item-link.cw-tree-item-link-current {
-                background-image: none;
-            }
 
-            @each $type, $icon in $element-icons {
-                &.cw-tree-item-#{$type} .cw-tree-item-link {
-                    background-repeat: no-repeat;
-                    background-position: 3px 3px;
-                    padding-left: 26px;
-                    @include background-icon(#{$icon}, clickable, 18);
-                    &:hover {
-                        @include background-icon(#{$icon}, attention, 18);
-                    }
-                    &.cw-tree-item-link-current {
-                        @include background-icon(#{$icon}, info, 18);
+                ol {
+                    padding-left: 0;
+                    > li.cw-tree-item {
+                        margin: 28px 0 0 0;
+                        > .cw-tree-item-wrapper {
+                            display: block;
+                            border-bottom: solid thin $content-color-40;
+                            margin-bottom: 12px;
+                            > a.cw-tree-item-link {
+                                display: inline-block;
+                                width: calc(100% - 4px);
+                                padding-left: 4px;
+                                font-size: 16px;
+                                &.cw-tree-item-link-edit {
+                                    width: calc(100% - 19px);
+                                }
+                            }
+                        }
+                        @each $type, $icon in $element-icons {
+                            &.cw-tree-item-#{$type} .cw-tree-item-wrapper a.cw-tree-item-link {
+                                 background-position: 0px 2px;
+                                 background-repeat: no-repeat;
+                                 padding-left: 20px;
+                                 @include background-icon(#{$icon}, clickable, 16);
+                                 &:hover {
+                                     @include background-icon(#{$icon}, attention, 16);
+                                 }
+                                 &.cw-tree-item-link-current {
+                                     @include background-icon(#{$icon}, info, 16);
+                                 }
+                             }
+                         }
+                        ol {
+                            padding-left: 0.25em;
+                            &.cw-tree-draggable-list {
+                                padding-left: 1em;
+                            }
+                            > li.cw-tree-item {
+                                margin: 4px 0; 
+                                > .cw-tree-item-wrapper {
+                                    border: none;
+                                    > a.cw-tree-item-link {
+                                        display: inline-block;
+                                        border-bottom: none;
+                                        font-size: 14px;
+                                        width: calc(100% - 14px);
+                                        background-repeat: no-repeat;
+                                        padding-left: 18px;
+                                        margin-left: 4px;
+                                        margin-bottom: 0;
+
+                                        &.cw-tree-item-link-edit {
+                                            width: calc(100% - 38px);
+                                        }
+
+                                        @include background-icon(bullet-dot, clickable, 18);
+                                        &:hover {
+                                            @include background-icon(bullet-dot, attention, 18);
+                                        }
+                                        &.cw-tree-item-link-current {
+                                            @include background-icon(bullet-dot, info, 18);
+                                        }
+                                    }
+                                }
+                                ol {
+                                    padding-left: 1em;
+                                }
+                            }
+                        }
                     }
                 }
             }
-        }
-
-        .cw-tree-item-link {
-            display: inline-block;
-            width: calc(100% - 14px);
-            text-align: justify;
-            background-repeat: no-repeat;
-            padding-left: 20px;
-            background-position: 4px 1px;
+        }    
+    }
 
-            @include background-icon(bullet-dot, clickable, 18);
-            &:hover {
-                @include background-icon(bullet-dot, attention, 18);
-            }
-            &.cw-tree-item-link-current {
-                @include background-icon(bullet-dot, info, 18);
+    .cw-tree-item-link {
+        &:hover {
+            background-color: $light-gray-color-20;
+            color: $active-color;
+        }
+        &.cw-tree-item-link-current {
+            color: $black;
+            font-weight: 600;
+            cursor: default;
+            &::before {
+                color: $black;
             }
+        }
+        &.cw-tree-item-link-selected {
+            font-style: italic;
+            font-weight: 600;
+        }
 
-            &:hover {
-                background-color: $light-gray-color-20;
-                color: $active-color;
-            }
 
-            &.cw-tree-item-link-current {
-                color: $black;
-                font-weight: 600;
-                &::before {
-                    color: $black;
-                }
+        @each $type, $icon in $tree-item-flag-icons {
+            .cw-tree-item-flag-#{$type} {
+                display: inline-block;
+                width: 16px;
+                height: 16px;
+                vertical-align: top;
+                @include background-icon(#{$icon}, clickable, 16);
             }
-            @each $type, $icon in $tree-item-flag-icons {
-                .cw-tree-item-flag-#{$type} {
-                    display: inline-block;
-                    width: 16px;
-                    height: 16px;
-                    vertical-align: top;
-                    @include background-icon(#{$icon}, clickable, 16);
-                }
-                &:hover .cw-tree-item-flag-#{$type} {
-                    @include background-icon(#{$icon}, attention, 16);
-                }
-                &.cw-tree-item-link-current .cw-tree-item-flag-#{$type} {
-                    @include background-icon(#{$icon}, info, 16);
-                }
+            &:hover .cw-tree-item-flag-#{$type} {
+                @include background-icon(#{$icon}, attention, 16);
             }
-        }
-        @each $type, $icon in $element-icons {
-            .cw-tree-item-#{$type} .cw-tree-item-link {
-                background-position: 0px 2px;
-                @include background-icon(#{$icon}, clickable, 16);
-                &:hover {
-                    @include background-icon(#{$icon}, attention, 16);
-                }
-                &.cw-tree-item-link-current {
-                    @include background-icon(#{$icon}, info, 16);
-                }
+            &.cw-tree-item-link-current .cw-tree-item-flag-#{$type} {
+                @include background-icon(#{$icon}, info, 16);
             }
         }
-
     }
 
-    .cw-tree-item {
-        margin-bottom: 5px;
-        div {
-            display: inline;
-            &.cw-tree-item-is-root,
-            &.cw-tree-item-first-level{
-                display: block;
-            }
-        }
+    .cw-tree-item-ghost {
+        opacity: 0.6;
     }
 }
 
@@ -5367,3 +5384,20 @@ p u b l i c  l i n k s
 /* * * * * * * * * * * * * * *
 e n d  p u b l i c  l i n k s
 * * * * * * * * * * * * * * */
+
+/* * * * * * * * * * * *
+a s s i s t i v e
+* * * * * * * * * * * */
+.assistive-text {
+    position: absolute;
+    margin: -1px;
+    border: 0;
+    padding: 0;
+    width: 1px;
+    height: 1px;
+    overflow: hidden;
+    clip: rect(0 0 0 0);
+  }
+/* * * * * * * * * * * * * * *
+e n d  a s s i s t i v e
+* * * * * * * * * * * * * * */
diff --git a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue
index f5f2a22b399..7d6a779dada 100644
--- a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue
+++ b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue
@@ -3,7 +3,6 @@
         <div
             class="cw-ribbon-tools"
             :class="{ unfold: toolsActive, 'cw-ribbon-tools-consume': consumeMode }"
-            @keydown.esc="$emit('deactivate')"
         >
             <div class="cw-ribbon-tool-content">
                 <div class="cw-ribbon-tool-content-nav">
diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue
index 96b175d3d8c..76a949d04ee 100644
--- a/resources/vue/components/courseware/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue
@@ -573,7 +573,7 @@
                     v-if="showDeleteDialog"
                     :title="textDelete.title"
                     :question="textDelete.alert"
-                    height="180"
+                    height="200"
                     @confirm="deleteCurrentElement"
                     @close="closeDeleteDialog"
                 ></studip-dialog>
diff --git a/resources/vue/components/courseware/CoursewareTree.vue b/resources/vue/components/courseware/CoursewareTree.vue
index eba9f775bc4..57e8a0488d1 100644
--- a/resources/vue/components/courseware/CoursewareTree.vue
+++ b/resources/vue/components/courseware/CoursewareTree.vue
@@ -1,28 +1,60 @@
 <template>
-    <div class="cw-tree">
-        <ul class="cw-tree-root-list">
+    <div class="cw-tree" ref="tree">
+        <template v-if="editMode">
+                <span aria-live="assertive" class="assistive-text">{{ assistiveLive }}</span>
+                <span id="operation" class="assistive-text">
+                    {{$gettext('Drücken Sie die Leertaste, um neu anzuordnen')}}
+                </span>
+        </template>
+        <ol v-if="!processing" class="cw-tree-root-list" role="listbox">
             <courseware-tree-item
                 class="cw-tree-item"
-                :element="rootElement"
+                :element="rootElementWithNestedChildren"
                 :currentElement="currentElement"
+                @sort="sort"
+                @moveItemUp="moveItemUp"
+                @moveItemDown="moveItemDown"
+                @moveItemPrevLevel="moveItemPrevLevel"
+                @moveItemNextLevel="moveItemNextLevel"
+                @childrenUpdated="updateNestedChildren"
             ></courseware-tree-item>
-        </ul>
+        </ol>
+        <studip-progress-indicator
+            v-else 
+            :description="$gettext('Vorgang wird bearbeitet...')"
+        />
     </div>
 </template>
 
 <script>
 import CoursewareTreeItem from './CoursewareTreeItem.vue';
-import { mapGetters } from 'vuex';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+
+import { mapActions, mapGetters } from 'vuex';
 
 export default {
-    components: { CoursewareTreeItem },
+    components: { 
+        CoursewareTreeItem,
+        StudipProgressIndicator
+    },
     name: 'courseware-tree',
+    data() {
+        return {
+            nestedChildren: [],
+            processing: false,
+            rootElementWithNestedChildren: {},
+        }
+    },
     computed: {
         ...mapGetters({
             context: 'context',
             courseware: 'courseware',
             relatedStructuralElement: 'courseware-structural-elements/related',
             structuralElementById: 'courseware-structural-elements/byId',
+            childrenById: 'courseware-structure/children',
+            viewMode: 'viewMode',   
+            structuralElements: 'courseware-structural-elements/all',
+            assistiveLive: 'assistiveLiveContents'
         }),
         currentElement() {
             const id = this.$route?.params?.id;
@@ -46,6 +78,155 @@ export default {
             }
 
         },
+        editMode() {
+            return this.viewMode === 'edit';
+        },
+    },
+    methods: {
+         ...mapActions({
+            updateStructuralElement: 'updateStructuralElement',
+            lockObject: 'lockObject',
+            unlockObject: 'unlockObject',
+            sortChildrenInStructualElements: 'sortChildrenInStructualElements',
+            loadStructuralElement: 'loadStructuralElement',
+            setAssistiveLiveContents: 'setAssistiveLiveContents'
+         }),
+        updateNestedChildren() {
+            this.nestedChildren = this.getNestedChildren(this.rootElement);
+            this.setRootElementWithNestedChildren();
+        },
+        setRootElementWithNestedChildren() {
+            let element = { id: this.rootElement.id, attributes: this.rootElement.attributes };
+            element.nestedChildren = this.nestedChildren;
+
+            this.rootElementWithNestedChildren = element;
+        },
+        getNestedChildren(structuralElement) {
+            let children = _.cloneDeep(this.structuralElements
+                .filter(
+                    element => element.relationships.parent?.data?.id === structuralElement.id
+                )).sort((a,b) => a.attributes.position - b.attributes.position);
+
+            let nestedChildren = [];
+            for (let child of children) {
+                child.nestedChildren = this.getNestedChildren(child);
+                nestedChildren.push(child);
+            }
+
+            return nestedChildren;
+        },
+        async sort(data) {
+            const tree = this.$refs.tree;
+            const currentScrollPosition = tree.offsetParent.scrollTop;
+            this.processing = true;
+            if (data.oldParent !== data.newParent) {
+                await this.lockObject({ id: data.id, type: 'courseware-structural-elements' });
+                let element = this.structuralElementById({ id: data.id });
+                element.relationships.parent.data.id = data.newParent;
+                await this.updateStructuralElement({
+                    element: element,
+                    id: element.id,
+                });
+                await this.unlockObject({ id: data.id, type: 'courseware-structural-elements' });
+                await this.loadStructuralElement(data.id);
+            }
+            await this.loadStructuralElement(data.newParent);
+            const parent = this.structuralElementById({ id: data.newParent });
+            await this.sortChildrenInStructualElements({parent: parent, children: data.sortArray});
+            this.updateNestedChildren();
+            this.processing = false;
+            this.$nextTick(() => {
+                tree.offsetParent.scrollTop = currentScrollPosition;
+            });
+        },
+        moveItemUp(data) {
+            data.direction = 'up';
+            this.reorderNestedChildren(data);
+        },
+        moveItemDown(data) {
+            data.direction = 'down';
+            this.reorderNestedChildren(data);
+        },
+        moveItemPrevLevel(data) {
+            data.direction = 'prev';
+            this.reorderNestedChildren(data);
+        },
+        moveItemNextLevel(data) {
+            data.direction = 'next';
+            this.reorderNestedChildren(data);
+        },
+        reorderNestedChildren(data) {
+            this.rootElementWithNestedChildren = this.recursiveNestedChildrenUpdate(this.rootElementWithNestedChildren, data);
+        },
+        recursiveNestedChildrenUpdate(element, data) {
+            if (data.direction === 'prev' && data.parents[1] && element.id === data.parents[1]) {
+                //element is grandparent
+                let parentIndex = element.nestedChildren.findIndex((e) => e.id === data.parents[0]);
+                let movingElementIndex = element.nestedChildren[parentIndex].nestedChildren.findIndex((e) => e.id === data.element.id);
+                const newPos = parentIndex + 1;
+                element.nestedChildren.splice(newPos, 0, element.nestedChildren[parentIndex].nestedChildren[movingElementIndex]);
+                element.nestedChildren[parentIndex].nestedChildren.splice(movingElementIndex, 1);
+
+                element.nestedChildren[newPos].newPos = newPos;
+                element.nestedChildren[newPos].newParentId =  parseInt(element.id);
+                element.nestedChildren[newPos].sortArray = element.nestedChildren.map(c => {return {id: c.id, type: 'courseware-structural-elements'}});
+                element.nestedChildren[newPos].moveDirection = data.direction;
+
+                const assistiveLive = this.$gettextInterpolate(
+                    this.$gettext('%{elementTitle} eine Ebene nach oben bewegt. Übergeordnete Seite: %{parentTitle}. Aktuelle Position in der Liste: %{pos} von %{listLength}'), 
+                    { elementTitle: data.element.attributes.title, parentTitle: element.attributes.title, pos: newPos + 1, listLength: element.nestedChildren[newPos].sortArray.length }
+                );
+                this.setAssistiveLiveContents(assistiveLive);
+            }
+            if (element.id === data.parents[0]) {
+                if (data.direction === 'up' || data.direction === 'down') {
+                    const elementIndex = element.nestedChildren.findIndex((e) => e.id === data.element.id);
+                    let vertical = data.direction === 'up' ? -1 : data.direction === 'down' ? 1 : 0;
+                    const newPos = elementIndex + vertical;
+                    if (newPos >= 0 && newPos < element.nestedChildren.length) {
+                        element.nestedChildren.splice(newPos, 0, element.nestedChildren.splice(elementIndex, 1)[0]);
+                        element.nestedChildren[newPos].newPos = newPos;
+                        element.nestedChildren[newPos].newParentId = parseInt(element.id);
+                        element.nestedChildren[newPos].sortArray = element.nestedChildren.map(c => {return {id: c.id, type: 'courseware-structural-elements'}});
+                        element.nestedChildren[newPos].moveDirection = data.direction;
+
+                        const assistiveLive = this.$gettextInterpolate(
+                            this.$gettext('%{elementTitle} bewegt. Aktuelle Position in der Liste: %{pos} von %{listLength}'), 
+                            { elementTitle: data.element.attributes.title, pos: newPos + 1, listLength: element.nestedChildren[newPos].sortArray.length }
+                        );
+                        this.setAssistiveLiveContents(assistiveLive);
+                    }
+                }
+                if (data.direction === 'next') {
+                    const elementIndex = element.nestedChildren.findIndex((e) => e.id === data.element.id);
+                    if (elementIndex !== 0) {
+                        const newParentIndex = elementIndex - 1;
+                        element.nestedChildren[newParentIndex].nestedChildren.push(element.nestedChildren[elementIndex]);
+                        element.nestedChildren.splice(elementIndex, 1);
+                        const newPos = element.nestedChildren[newParentIndex].nestedChildren.length - 1;
+                        element.nestedChildren[newParentIndex].nestedChildren[newPos].newPos = newPos;
+                        const newParentId = element.nestedChildren[newParentIndex].id;
+                        element.nestedChildren[newParentIndex].nestedChildren[newPos].newParentId = parseInt(newParentId);
+                        element.nestedChildren[newParentIndex].nestedChildren[newPos].sortArray = element.nestedChildren[newParentIndex].nestedChildren.map(c => {return {id: c.id, type: 'courseware-structural-elements'}});
+
+                        const assistiveLive = this.$gettextInterpolate(
+                            this.$gettext('%{elementTitle} eine Ebene nach unten bewegt. Übergeordnete Seite: %{parentTitle}. Aktuelle Position in der Liste: %{pos} von %{listLength}'), 
+                            { elementTitle: data.element.attributes.title, parentTitle: element.nestedChildren[newParentIndex].attributes.title, pos: newPos + 1, listLength: element.nestedChildren[newParentIndex].nestedChildren[newPos].sortArray.length }
+                        );
+                        this.setAssistiveLiveContents(assistiveLive);
+                    }
+                }
+            } else {
+                element.nestedChildren.forEach((child,index) => {
+                    element.nestedChildren[index] = this.recursiveNestedChildrenUpdate(child, data);
+                });
+            }
+
+            return element;
+        },
     },
+    mounted() {
+        this.updateNestedChildren();
+    }
 };
 </script>
diff --git a/resources/vue/components/courseware/CoursewareTreeItem.vue b/resources/vue/components/courseware/CoursewareTreeItem.vue
index 4ccecf25b68..7181e31f69c 100644
--- a/resources/vue/components/courseware/CoursewareTreeItem.vue
+++ b/resources/vue/components/courseware/CoursewareTreeItem.vue
@@ -1,18 +1,31 @@
 <template>
-    <li v-if="showItem">
-        <div
-            :class="[
-                isRoot ? 'cw-tree-item-is-root' : '',
-                isFirstLevel ? 'cw-tree-item-first-level' : '',
+    <li v-if="showItem"
+        :draggable="editMode ? true : null"
+        :aria-selected="editMode ? keyboardSelected : null"
+        :class="[
                 hasPurposeClass ? 'cw-tree-item-' + purposeClass : '',
             ]"
-        >
+    >
+        <div class="cw-tree-item-wrapper">
+            <span
+                v-if="editMode && depth > 0"
+                class="cw-sortable-handle"
+                :tabindex="0"
+                aria-describedby="operation"
+                ref="sortableHandle"
+                @keydown="handleKeyEvent"
+            >
+            </span>
             <router-link
                 :to="'/structural_element/' + element.id"
                 class="cw-tree-item-link"
-                :class="{ 'cw-tree-item-link-current': isCurrent }"
+                :class="{
+                    'cw-tree-item-link-current': isCurrent,
+                    'cw-tree-item-link-edit': editMode,
+                    'cw-tree-item-link-selected': keyboardSelected
+                }"
             >
-                {{ element.attributes.title || "–" }}
+                {{ element.attributes?.title || "–" }}
                 <span v-if="task">| {{ solverName }}</span>
                 <span
                     v-if="hasReleaseOrWithdrawDate"
@@ -31,8 +44,8 @@
                 ></span>
             </router-link>
         </div>
-        <ul
-            v-if="hasChildren"
+        <ol
+            v-if="hasChildren && !editMode"
             :class="{
                 'cw-tree-chapter-list': isRoot,
                 'cw-tree-subchapter-list': isFirstLevel,
@@ -46,15 +59,51 @@
                 :depth="depth + 1"
                 class="cw-tree-item"
             />
-        </ul>
+        </ol>
+        <draggable
+            v-if="editMode"
+            :class="{ 'cw-tree-chapter-list-empty': nestedChildren.length === 0 }"
+            tag="ol"
+            :component-data="draggableData"
+            class="cw-tree-draggable-list"
+            handle=".cw-sortable-handle"
+            v-bind="dragOptions"
+            :elementId="element.id"
+            :list="nestedChildren" 
+            :group="{ name: 'g1' }"
+            @end="endDrag"
+        >
+            <courseware-tree-item 
+                v-for="el in nestedChildren"
+                :key="el.id"
+                :element="el"
+                :currentElement="currentElement"
+                :depth="depth + 1"
+                :newPos="el.newPos"
+                :newParentId="el.newParentId"
+                :siblingCount="nestedChildren.length"
+                class="cw-tree-item"
+                :elementid="el.id"
+                @sort="sort"
+                @moveItemUp="moveItemUp"
+                @moveItemDown="moveItemDown"
+                @moveItemPrevLevel="moveItemPrevLevel"
+                @moveItemNextLevel="moveItemNextLevel"
+                @childrenUpdated="$emit('childrenUpdated')"
+            />
+        </draggable>
     </li>
 </template>
 
 <script>
+import draggable from 'vuedraggable';
 import { mapGetters, mapActions } from 'vuex';
 
 export default {
     name: 'courseware-tree-item',
+    components: {
+        draggable
+    },
     props: {
         element: {
             type: Object,
@@ -67,6 +116,24 @@ export default {
             type: Number,
             default: 0,
         },
+        keyboardSelectedProp: {
+            type: Boolean,
+            default: false
+        },
+        newPos: {
+            type: Number
+        },
+        newParentId: {
+            type: Number
+        },
+        siblingCount: {
+            type: Number
+        }
+    },
+    data() {
+        return {
+            keyboardSelected: false
+        }
     },
     computed: {
         ...mapGetters({
@@ -76,7 +143,16 @@ export default {
             taskById: 'courseware-tasks/byId',
             userById: 'users/byId',
             groupById: 'status-groups/byId',
+            viewMode: 'viewMode',
         }),
+        draggableData() {
+            return {
+                attrs: {
+                    role: 'listbox',
+                    ['aria-label']: this.$gettextInterpolate(this.$gettext('Unterseiten von %{elementName}'),{ elementName: this.element.attributes?.title })
+                }
+            };
+        },
         children() {
             if (!this.element) {
                 return [];
@@ -84,7 +160,10 @@ export default {
 
             return this.childrenById(this.element.id)
                 .map((id) => this.structuralElementById({ id }))
-                .filter(Boolean);
+                .filter(Boolean).sort((a,b) => a.attributes.position - b.attributes.position);
+        },
+        nestedChildren() {
+            return this.element.nestedChildren ?? [];
         },
         hasChildren() {
             return this.childrenById(this.element.id).length;
@@ -100,24 +179,24 @@ export default {
         },
         hasReleaseOrWithdrawDate() {
             return (
-                this.element.attributes['release-date'] !== null || this.element.attributes['withdraw-date'] !== null
+                this.element.attributes?.['release-date'] !== null || this.element.attributes?.['withdraw-date'] !== null
             );
         },
         hasWriteApproval() {
-            const writeApproval = this.element.attributes['write-approval'];
+            const writeApproval = this.element.attributes?.['write-approval'];
 
-            if (Object.keys(writeApproval).length === 0) {
+            if (!writeApproval || Object.keys(writeApproval).length === 0) {
                 return false;
             }
-            return (writeApproval.all || writeApproval.groups.length > 0 || writeApproval.users.length > 0) && this.element.attributes['can-edit'];
+            return (writeApproval.all || writeApproval.groups.length > 0 || writeApproval.users.length > 0) && this.element.attributes?.['can-edit'];
         },
         hasNoReadApproval() {
             if (this.context.type === 'users') {
                 return false;
             }
-            const readApproval = this.element.attributes['read-approval'];
+            const readApproval = this.element.attributes?.['read-approval'];
 
-            if (Object.keys(readApproval).length === 0 || this.hasWriteApproval) {
+            if (!readApproval || Object.keys(readApproval).length === 0 || this.hasWriteApproval) {
                 return false;
             }
             return !readApproval.all && readApproval.groups.length === 0 && readApproval.users.length === 0;
@@ -128,16 +207,16 @@ export default {
         purposeClass() {
             if (
                 (this.isFirstLevel && this.context.type === 'users') ||
-                (this.context.type === 'courses' && this.element.attributes.purpose === 'task')
+                (this.context.type === 'courses' && this.element.attributes?.purpose === 'task')
             ) {
-                return this.element.attributes.purpose;
+                return this.element.attributes?.purpose;
             }
             return '';
         },
         task() {
-            if (this.element.relationships.task.data) {
+            if (this.element.relationships?.task?.data) {
                 return this.taskById({
-                    id: this.element.relationships.task.data.id,
+                    id: this.element.relationships?.task?.data?.id,
                 });
             }
 
@@ -172,7 +251,7 @@ export default {
             return '';
         },
         isTask() {
-            return this.element.attributes.purpose === 'task';
+            return this.element.attributes?.purpose === 'task';
         },
         showItem() {
             if (this.isTask) {
@@ -180,19 +259,181 @@ export default {
             }
 
             return true;
-        }
+        },
+        editMode() {
+            return this.viewMode === 'edit';
+        },
+        dragOptions() {
+            return {
+                animation: 0,
+                disabled: false,
+                ghostClass: "cw-tree-item-ghost"
+            };
+        },
     },
     methods: {
         ...mapActions({
             loadTask: 'loadTask',
+            setAssistiveLiveContents: 'setAssistiveLiveContents'
         }),
+        endDrag(e) {
+            let sortArray = [];
+            for (let child of e.to.childNodes) {
+                sortArray.push({id: child.attributes.elementid.nodeValue, type: 'courseware-structural-elements'});
+            }
+
+            let data = {
+                id: e.item._underlying_vm_.id,
+                newPos: e.newIndex,
+                oldPos: e.oldIndex,
+                oldParent: e.item._underlying_vm_.relationships.parent.data.id,
+                newParent: e.to.__vue__.$attrs.elementId,
+                sortArray: sortArray
+            };
+
+            if (data.oldParent === data.newParent && data.oldPos === data.newPos) {
+                return;
+            }
+            if (data.oldParent !== data.newParent) {
+                sortArray.splice(data.newPos, 0, {id: data.id, type: 'courseware-structural-elements'});
+            } 
+
+            data.sortArray = sortArray;
+            this.$emit('sort', data);
+        },
+        sort(data) {
+            this.$emit('sort', data);
+        },
+        handleKeyEvent(e) {
+            switch (e.keyCode) {
+                case 32: // space
+                    e.preventDefault();
+                    if (this.keyboardSelected) {
+                        this.storeKeyboardSorting();
+                    } else {
+                        this.keyboardSelected = true;
+                        const assistiveLive = 
+                            this.$gettextInterpolate(
+                                this.$gettext('%{elementTitle} ausgewählt. Aktuelle Position in der Liste: %{pos} von %{listLength}. Drücken Sie die Aufwärts- und Abwärtspfeiltasten, um die Position zu ändern, die Leertaste zum Ablegen, die Escape-Taste zum Abbrechen. Mit Pfeiltasten links und rechts kann die Position in der Hierarchie verändert werden.'),
+                                { elementTitle: this.element.attributes.title, pos: this.element.attributes.position + 1, listLength: this.siblingCount }
+                            );
+
+                            this.setAssistiveLiveContents(assistiveLive);
+                    }
+                    break;
+            }
+            if (this.keyboardSelected) {
+                const data = {
+                    element: this.element,
+                    parents: []
+                };
+                switch (e.keyCode) {
+                    case 27: // esc
+                    case 9: //tab
+                        this.abortKeyboardSorting();
+                        break;
+                    case 37: // left
+                        e.preventDefault();
+                        this.$emit('moveItemPrevLevel', data);
+                        break;
+                    case 38: // up
+                        e.preventDefault();    
+                        this.$emit('moveItemUp', data);
+                        break;
+                    case 39: // right
+                        e.preventDefault();
+                        this.$emit('moveItemNextLevel', data);
+                        break;
+                    case 40: // down
+                        e.preventDefault(); 
+                        this.$emit('moveItemDown', data);
+                        break;
+                }
+            }
+        },
+        moveItemPrevLevel(data) {
+            data.parents.push(this.element.id);
+            this.$emit('moveItemPrevLevel', data);
+        },
+        moveItemUp(data) {
+            data.parents.push(this.element.id);
+            this.$emit('moveItemUp', data);
+        },
+        moveItemNextLevel(data) {
+            data.parents.push(this.element.id);
+            this.$emit('moveItemNextLevel', data);
+        },
+        moveItemDown(data) {
+            data.parents.push(this.element.id);
+            this.$emit('moveItemDown', data);
+        },
+        abortKeyboardSorting() {
+            this.$emit('childrenUpdated');
+            const assistiveLive = this.$gettextInterpolate(
+                this.$gettext('%{elementTitle}. Neuordnung abgebrochen'),
+                { elementTitle: this.element.attributes.title }
+            );
+            this.setAssistiveLiveContents(assistiveLive);
+            this.$nextTick(() => {
+                this.keyboardSelected = false;
+            });
+        },
+        storeKeyboardSorting() {
+            const data = {
+                id: this.element.id,
+                newPos: this.element.newPos,
+                oldPos: this.element.attributes.position,
+                oldParent: this.element.relationships.parent.data.id,
+                newParent: this.element.newParentId,
+                sortArray: this.element.sortArray
+            };
+            this.keyboardSelected = false;
+
+            if (data.newParent === undefined || data.newPos === undefined) {
+                const assistiveLive = this.$gettextInterpolate(
+                    this.$gettext('%{elementTitle}. Neuordnung nicht möglich.'),
+                    {elementTitle: this.element.attributes.title}
+                );
+                this.setAssistiveLiveContents(assistiveLive);
+                return;
+            }
+
+            if (data.oldParent === data.newParent && data.oldPos === data.newPos) {
+                const assistiveLive = this.$gettextInterpolate(
+                    this.$gettext('%{elementTitle}. Neuordnung abgebrochen.'),
+                    {elementTitle: this.element.attributes.title}
+                );
+                this.setAssistiveLiveContents(assistiveLive);
+                return;
+            }
+            this.$emit('sort', data);
+            const assistiveLive = this.$gettextInterpolate(
+                this.$gettext('%{elementTitle}, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.'), 
+                {elementTitle: this.element.attributes.title, pos: data.newPos + 1, listLength: this.siblingCount }
+            );
+            this.setAssistiveLiveContents(assistiveLive);
+        }
     },
     mounted() {
-        if (this.element.relationships.task.data) {
+        if (this.element.relationships?.task?.data) {
             this.loadTask({
                 taskId: this.element.relationships.task.data.id,
             });
         }
+        if (this.newPos || this.newParentId) {
+            this.keyboardSelected = true;
+            this.$refs.sortableHandle.focus();
+        }
     },
+    watch: {
+        newPos() {
+            this.keyboardSelected = true;
+            this.$refs.sortableHandle.focus();
+        },
+        newParentId() {
+            this.keyboardSelected = true;
+            this.$refs.sortableHandle.focus();
+        },
+    }
 };
-</script>
+</script>
\ No newline at end of file
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index e6e14acea08..ae786bfd520 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -60,6 +60,8 @@ const getDefaultState = () => {
 
         showSearchResults: false,
         searchResults: [],
+
+        assistiveLiveContents: ''
     };
 };
 
@@ -241,6 +243,9 @@ const getters = {
     searchResults(state) {
         return state.searchResults;
     },
+    assistiveLiveContents(state) {
+        return state.assistiveLiveContents;
+    }
 };
 
 export const state = { ...initialState };
@@ -906,6 +911,9 @@ export const actions = {
     setSearchResults({ commit }, state) {
         commit('setSearchResults', state);
     },
+    setAssistiveLiveContents({ commit }, state) {
+        commit('setAssistiveLiveContents', state);
+    },
 
     addBookmark({ dispatch, rootGetters }, structuralElement) {
         const cw = rootGetters['courseware'];
@@ -1477,6 +1485,9 @@ export const mutations = {
     setSearchResults(state, results) {
         state.searchResults = results;
     },
+    setAssistiveLiveContents(state, text) {
+        state.assistiveLiveContents = text;
+    }
 };
 
 export default {
-- 
GitLab