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