diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 11816754397d5e04185fff149ad8f4855721a462..3640c9ab2ccee02d007a881021b5046e1f1a1718 100644 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -828,7 +828,12 @@ ribbon end .cw-container-header { background-color: $content-color-20; max-height: 30px; - padding: 4px 10px; + padding: 4px 10px 4px 22px; + + .cw-container-header-toggle { + display: inline-block; + width: calc(100% - 40px); + } span { color: $base-color; @@ -935,7 +940,6 @@ form.cw-container-dialog-edit-form { /* * * block * * */ - .cw-default-block { display: flex; flex-flow: row; @@ -977,7 +981,12 @@ form.cw-container-dialog-edit-form { .cw-block-header { background-color: $content-color-20; max-height: 30px; - padding: 4px 10px; + padding: 4px 10px 4px 22px; + + .cw-block-header-toggle { + display: inline-block; + width: calc(100% - 50px); + } span { color: $base-color; @@ -1241,70 +1250,72 @@ label[for="cw-keypoint-color"] { /* * * * * * * * sortable handle * * * * * * * */ -.cw-container-list-sort-mode { - .block-ghost { - opacity: 0.6; - } - &.cw-container-list-sort-mode-empty { - min-height: 4em; - border: dashed thin $content-color-40; + +.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; + &.cw-sortable-handle-dragging { + cursor: grabbing; } } -.cw-structural-element-list-sort-mode { - list-style: none; - padding-left: 0; - .cw-container-item-sortable { - border: solid thin $content-color-40; - background-color: $content-color-20; - color: $base-color; - font-weight: 700; - margin-bottom: 0.5em; - padding: 0.5em; +.cw-block-item-sortable { + .cw-sortable-handle { + position: relative; + left: 12px; } - .container-ghost { - opacity: 0.6; + .cw-block { + margin-top: -32px; } } -.cw-structural-element-list-sort-mode, -.cw-container-list-sort-mode { + +.cw-container-item-sortable { .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; + position: relative; + left: 12px; } - .cw-content-wrapper-active:hover { - border: solid thin $base-color; + .cw-container { + margin-top: -32px; } } -.cw-container-item-sortable.sortable-chosen { - .cw-sortable-handle { - cursor: grabbing; - } +.cw-collapsible-open { + .cw-container-list-sort-mode { + margin-top: 8px; + } } -.cw-container-sort-buttons { - display: block; +.container-ghost, +.block-ghost { + opacity: 0.6; } -.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; + +.cw-container-wrapper-edit { + .cw-structural-element-list { + width: 100%; + padding: 0; + list-style: none; } } + +.cw-block-item-selected { + .cw-block-header { + font-style: italic; + } +} +.cw-container-item-selected { + .cw-container-header { + font-style: italic; + } +} + /* * * * * * * * * * * sortable handle end * * * * * * * * * * */ diff --git a/resources/vue/components/courseware/CoursewareAccordionContainer.vue b/resources/vue/components/courseware/CoursewareAccordionContainer.vue index 8e4d0984d1aab3ccb4ff8ec19a30fd83c6bc7b90..02b789131e1285cf97aa1c7d2233dcf37f2ab3ee 100644 --- a/resources/vue/components/courseware/CoursewareAccordionContainer.vue +++ b/resources/vue/components/courseware/CoursewareAccordionContainer.vue @@ -7,17 +7,22 @@ @showEdit="setShowEdit" @storeContainer="storeContainer" @closeEdit="initCurrentData" - @sortBlocks="enableSort" > <template v-slot:containerContent> + <template v-if="showEditMode && canEdit"> + <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> <courseware-collapsible-box v-for="(section, index) in currentSections" :key="index" :title="section.name" :icon="section.icon" - :open="index === 0" + :open="index === 0 || sortInSlots.includes(index)" > - <ul v-if="!sortMode" class="cw-container-accordion-block-list"> + <ul v-if="!showEditMode" class="cw-container-accordion-block-list"> <li v-for="block in section.blocks" :key="block.id" class="cw-block-item"> <component :is="component(block)" @@ -26,33 +31,49 @@ :isTeacher="isTeacher" /> </li> - <li v-if="showEditMode && canAddElements"> - <courseware-block-adder-area :container="container" :section="index" @updateContainerContent="updateContent"/> - </li> </ul> <draggable - v-if="sortMode && canEdit" + v-if="showEditMode && canEdit && !processing" class="cw-container-list-block-list cw-container-list-sort-mode" :class="[section.blocks.length === 0 ? 'cw-container-list-sort-mode-empty' : '']" - tag="ul" + tag="ol" + role="listbox" v-model="section.blocks" v-bind="dragOptions" handle=".cw-sortable-handle" + group="blocks" @start="isDragging = true" - @end="isDragging = false" + @end="dropBlock" + :containerId="container.id" + :sectionId="index" > - <transition-group type="transition" name="flip-blocks" tag="div"> - <li v-for="block in section.blocks" :key="block.id" class="cw-block-item cw-block-item-sortable"> - <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> - </li> - </transition-group> - + <li v-for="block in section.blocks" :key="block.id" class="cw-block-item cw-block-item-sortable"> + <span + :class="{ 'cw-sortable-handle-dragging': isDragging }" + class="cw-sortable-handle" + tabindex="0" + role="option" + aria-describedby="operation" + :ref="'sortableHandle' + block.id" + @keydown="keyHandler($event, block.id, index)" + ></span> + <component + :is="component(block)" + :block="block" + :canEdit="canEdit" + :isTeacher="isTeacher" + :class="{ 'cw-block-item-selected': keyboardSelected === block.id}" + :blockId="block.id" + /> + </li> </draggable> + <template v-if="showEditMode && canAddElements && !processing"> + <courseware-block-adder-area :container="container" :section="index" @updateContainerContent="updateContent"/> + </template> + <div v-if="showEditMode && processing" class="progress-wrapper"> + <studip-progress-indicator :description="$gettext('Vorgang wird bearbeitet...')" /> + </div> </courseware-collapsible-box> - <div v-if="sortMode && canEdit"> - <button class="button accept" @click="storeSort"><translate>Sortierung speichern</translate></button> - <button class="button cancel" @click="resetSort"><translate>Sortieren abbrechen</translate></button> - </div> </template> <template v-slot:containerEditDialog> <form class="default cw-container-dialog-edit-form" @submit.prevent=""> @@ -127,12 +148,20 @@ export default { disabled: false, ghostClass: "block-ghost" }, + processing: false, + keyboardSelected: null, + sortInSlots: [], + assistiveLive: '' }; }, computed: { ...mapGetters({ blockById: 'courseware-blocks/byId', + viewMode: 'viewMode' }), + showEditMode() { + return this.viewMode === 'edit'; + }, blocks() { if (!this.container) { return []; @@ -140,9 +169,6 @@ export default { return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })).filter((a) => a); }, - showEditMode() { - return this.$store.getters.viewMode === 'edit'; - }, icons() { return contentIcons; }, @@ -153,6 +179,7 @@ export default { methods: { ...mapActions({ updateContainer: 'updateContainer', + loadContainer: 'courseware-containers/loadById', lockObject: 'lockObject', unlockObject: 'unlockObject', }), @@ -181,6 +208,7 @@ export default { } this.currentSections = sections; + this.sortInSlots = []; }, setShowEdit(state) { this.showEdit = state; @@ -210,6 +238,7 @@ export default { this.currentContainer.attributes.payload.sections.splice(index, 1); }, async storeContainer() { + const timeout = setTimeout(() => this.processing = true, 800); this.currentContainer.attributes.payload.sections = this.currentContainer.attributes.payload.sections.filter(section => !section.locked); this.currentContainer.attributes.payload.sections.forEach(section => { section.blocks = section.blocks.map((block) => {return block.id;}); @@ -220,20 +249,22 @@ export default { structuralElementId: this.currentContainer.relationships['structural-element'].data.id, }); await this.unlockObject({ id: this.currentContainer.id, type: 'courseware-containers' }); + await this.loadContainer({id : this.container.id }); this.initCurrentData(); - }, - enableSort() { - this.sortMode = true; + clearTimeout(timeout); + this.processing = false; }, async storeSort() { - this.sortMode = false; - this.storeContainer(); - }, - async resetSort() { - await this.unlockObject({ id: this.currentContainer.id, type: 'courseware-containers' }); - this.sortMode = false; - this.initCurrentData(); + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + this.loadContainer({id : this.container.id }); + return false; + } + await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); + await this.storeContainer(); + this.assistiveLive = ''; }, + component(block) { if (block.attributes) { return 'courseware-' + block.attributes["block-type"] + '-block'; @@ -244,6 +275,102 @@ export default { if (blockAdder.container !== undefined && blockAdder.container.id === this.container.id) { this.initCurrentData(); } + }, + keyHandler(e, blockId, sectionIndex) { + switch (e.keyCode) { + case 27: // esc + this.abortKeyboardSorting(blockId, sectionIndex); + break; + case 32: // space + e.preventDefault(); + if (this.keyboardSelected) { + this.storeKeyboardSorting(blockId, sectionIndex); + } else { + this.keyboardSelected = blockId; + const block = this.blockById({id: blockId}); + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block 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.') + , {blockTitle: block.attributes.title, pos: currentIndex + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + } + break; + } + if (this.keyboardSelected) { + switch (e.keyCode) { + case 9: //tab + this.abortKeyboardSorting(blockId, sectionIndex); + break; + case 38: // up + e.preventDefault(); + this.moveItemUp(blockId, sectionIndex); + break; + case 40: // down + e.preventDefault(); + this.moveItemDown(blockId, sectionIndex); + break; + } + } + }, + moveItemUp(blockId, sectionIndex) { + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + const block = this.blockById({id: blockId}); + if (currentIndex !== 0) { + const newPos = currentIndex - 1; + this.currentSections[sectionIndex].blocks.splice(newPos, 0, this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: newPos + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + } else if (sectionIndex !== 0) { + const newSectionIndex = sectionIndex - 1; + if (!this.sortInSlots.includes(newSectionIndex)) { + this.sortInSlots.push(newSectionIndex); + } + this.currentSections[newSectionIndex].blocks.push(this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + } + }, + moveItemDown(blockId, sectionIndex) { + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + const block = this.blockById({id: blockId}); + if (this.currentSections[sectionIndex].blocks.length - 1 > currentIndex) { + const newPos = currentIndex + 1; + this.currentSections[sectionIndex].blocks.splice(newPos, 0, this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: newPos + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + } else if (this.currentSections.length - 1 > sectionIndex) { + const newSectionIndex = sectionIndex + 1; + if (!this.sortInSlots.includes(newSectionIndex)) { + this.sortInSlots.push(newSectionIndex); + } + this.currentSections[newSectionIndex].blocks.splice(0, 0, this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + } + }, + abortKeyboardSorting(blockId, sectionIndex) { + const block = this.blockById({id: blockId}); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block, Neuordnung abgebrochen') + , {blockTitle: block.attributes.title} + ); + this.initCurrentData(); + }, + storeKeyboardSorting(blockId, sectionIndex) { + const block = this.blockById({id: blockId}); + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: currentIndex + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + this.storeSort(); } }, watch: { @@ -251,6 +378,16 @@ export default { if (!this.showEdit) { this.initCurrentData(); } + }, + currentSections: { + handler() { + if (this.keyboardSelected) { + this.$nextTick(() => { + this.$refs['sortableHandle' + this.keyboardSelected][0].focus(); + }); + } + }, + deep: true } } }; diff --git a/resources/vue/components/courseware/CoursewareActionWidget.vue b/resources/vue/components/courseware/CoursewareActionWidget.vue index f4d690683aa4b0c1ea723a4af2dbc9f20a250306..c6be07da16c307704f87a68f65c9e57b960e4679 100644 --- a/resources/vue/components/courseware/CoursewareActionWidget.vue +++ b/resources/vue/components/courseware/CoursewareActionWidget.vue @@ -22,11 +22,6 @@ {{ $gettext('Sperre aufheben') }} </button> </li> - <li v-if="canEdit && !blockedByAnotherUser" class="cw-action-widget-sort"> - <button @click="sortContainers"> - {{ $gettext('Abschnitte sortieren') }} - </button> - </li> <li v-if="canEdit" class="cw-action-widget-add"> <button @click="addElement"> {{ $gettext('Seite hinzufügen') }} @@ -122,7 +117,6 @@ export default { showElementLinkDialog: 'showElementLinkDialog', showElementRemoveLockDialog: 'showElementRemoveLockDialog', updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', - setStructuralElementSortMode: 'setStructuralElementSortMode', companionInfo: 'companionInfo', addBookmark: 'addBookmark', lockObject: 'lockObject', @@ -155,26 +149,6 @@ export default { async removeElementLock() { this.showElementRemoveLockDialog(true); }, - async sortContainers() { - await this.loadStructuralElement(this.currentId); - if (this.blockedByAnotherUser) { - this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); - - return false; - } - try { - await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); - } catch (error) { - if (error.status === 409) { - this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); - } else { - console.log(error); - } - - return false; - } - this.setStructuralElementSortMode(true); - }, async deleteElement() { await this.loadStructuralElement(this.currentId); if (this.blockedByAnotherUser) { diff --git a/resources/vue/components/courseware/CoursewareBlockadderItem.vue b/resources/vue/components/courseware/CoursewareBlockadderItem.vue index 6339e0dbae76f18ad4a98a0a1da9d737bcd36b43..c882cd2569682c9e71c2234b5ba83794dbd348a1 100644 --- a/resources/vue/components/courseware/CoursewareBlockadderItem.vue +++ b/resources/vue/components/courseware/CoursewareBlockadderItem.vue @@ -30,6 +30,7 @@ export default { computed: { ...mapGetters({ blockAdder: 'blockAdder', + blockById: 'courseware-blocks/byId' }), }, methods: { @@ -41,6 +42,7 @@ export default { updateContainer: 'updateContainer', lockObject: 'lockObject', unlockObject: 'unlockObject', + loadBlock: 'courseware-blocks/loadById', }), async addBlock() { if (Object.keys(this.blockAdder).length !== 0) { diff --git a/resources/vue/components/courseware/CoursewareContainerActions.vue b/resources/vue/components/courseware/CoursewareContainerActions.vue index fdea003c0100850385b78c9883f0561bc05cdbb6..4795984797de6435cd486085d5a01cf586979381 100644 --- a/resources/vue/components/courseware/CoursewareContainerActions.vue +++ b/resources/vue/components/courseware/CoursewareContainerActions.vue @@ -5,7 +5,6 @@ :context="container.attributes.title" @editContainer="editContainer" @deleteContainer="deleteContainer" - @sortBlocks="sortBlocks" @removeLock="removeLock" /> </div> @@ -42,8 +41,7 @@ export default { if (this.container.attributes["container-type"] !== 'list') { menuItems.push({ id: 1, label: this.$gettext('Abschnitt bearbeiten'), icon: 'edit', emit: 'editContainer' }); } - menuItems.push({ id: 2, label: this.$gettext('Blöcke sortieren'), icon: 'arr_1sort', emit: 'sortBlocks' }); - menuItems.push({ id: 3, label: this.$gettext('Abschnitt löschen'), icon: 'trash', emit: 'deleteContainer' }); + menuItems.push({ id: 2, label: this.$gettext('Abschnitt löschen'), icon: 'trash', emit: 'deleteContainer' }); } if (this.blocked && this.blockedByAnotherUser && this.userIsTeacher) { @@ -72,9 +70,6 @@ export default { deleteContainer() { this.$emit('deleteContainer'); }, - sortBlocks() { - this.$emit('sortBlocks'); - }, removeLock() { this.$emit('removeLock'); } diff --git a/resources/vue/components/courseware/CoursewareDefaultBlock.vue b/resources/vue/components/courseware/CoursewareDefaultBlock.vue index 14ab304ec61394975a38038b5cceb7db59b517ba..6c85484c036685b693248595f56de7881de3515b 100644 --- a/resources/vue/components/courseware/CoursewareDefaultBlock.vue +++ b/resources/vue/components/courseware/CoursewareDefaultBlock.vue @@ -2,17 +2,18 @@ <div v-if="block.attributes.visible || canEdit" class="cw-default-block"> <div class="cw-content-wrapper" :class="[showEditMode ? 'cw-content-wrapper-active' : '']"> <header v-if="showEditMode" class="cw-block-header"> - <span class="cw-sortable-handle"></span> - <studip-icon v-if="!block.attributes.visible" shape="visibility-invisible" /> - <studip-icon v-if="blockedByAnotherUser" shape="lock-locked" /> - <span>{{ blockTitle }}</span> - <span v-if="blockedByAnotherUser" class="cw-default-block-blocker-warning"> - | {{ $gettextInterpolate($gettext('wird im Moment von %{ userName } bearbeitet'), { userName: this.blockingUserName }) }} - </span> - - <span v-if="!block.attributes.visible" class="cw-default-block-invisible-info"> - | {{ $gettext('unsichtbar für Nutzende ohne Schreibrecht') }} - </span> + <a href="#" class="cw-block-header-toggle" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen"> + <studip-icon :shape="isOpen ? 'arr_1down' : 'arr_1right'" /> + <span>{{ blockTitle }}</span> + <studip-icon v-if="blockedByAnotherUser" shape="lock-locked" /> + <span v-if="blockedByAnotherUser" class="cw-default-block-blocker-warning"> + {{ $gettextInterpolate($gettext('wird im Moment von %{ userName } bearbeitet'), { userName: this.blockingUserName }) }} + </span> + <studip-icon v-if="!block.attributes.visible" shape="visibility-invisible" /> + <span v-if="!block.attributes.visible" class="cw-default-block-invisible-info"> + {{ $gettext('unsichtbar für Nutzende ohne Schreibrecht') }} + </span> + </a> <courseware-block-actions :block="block" :canEdit="canEdit" @@ -24,30 +25,32 @@ @removeLock="displayRemoveLockDialog()" /> </header> - <div v-if="showContent" class="cw-block-content"> - <slot name="content" /> - </div> - <div v-if="showFeatures" class="cw-block-features cw-block-features-default"> - <courseware-block-export-options - v-if="canEdit && showExportOptions" - :block="block" - @close="displayFeature(false)" - /> - <courseware-block-edit - v-if="canEdit && showEdit" - :block="block" - @store="prepareStoreEdit" - @close="closeEdit" - > - <template #edit> - <slot name="edit" /> - </template> - </courseware-block-edit> - <courseware-block-info v-if="showInfo" :block="block" @close="displayFeature(false)"> - <template #info> - <slot name="info" /> - </template> - </courseware-block-info> + <div v-show="isOpen"> + <div v-if="showContent" class="cw-block-content"> + <slot name="content" /> + </div> + <div v-if="showFeatures" class="cw-block-features cw-block-features-default"> + <courseware-block-export-options + v-if="canEdit && showExportOptions" + :block="block" + @close="displayFeature(false)" + /> + <courseware-block-edit + v-if="canEdit && showEdit" + :block="block" + @store="prepareStoreEdit" + @close="closeEdit" + > + <template #edit> + <slot name="edit" /> + </template> + </courseware-block-edit> + <courseware-block-info v-if="showInfo" :block="block" @close="displayFeature(false)"> + <template #info> + <slot name="info" /> + </template> + </courseware-block-info> + </div> </div> </div> <div v-if="discussView" class="cw-discuss-wrapper"> @@ -130,6 +133,7 @@ export default { textDeleteAlert: this.$gettext('Möchten Sie diesen Block wirklich löschen?'), textRemoveLockTitle: this.$gettext('Sperre aufheben'), textRemoveLockAlert: this.$gettext('Möchten Sie die Sperre dieses Blocks wirklich aufheben?'), + isOpen: true, }; }, computed: { @@ -302,6 +306,9 @@ export default { this.showDeleteDialog = false; }, async executeDelete() { + this.showDeleteDialog = false; + this.displayFeature(false); + this.$emit('closeEdit'); await this.loadBlock({ id: this.block.id, options: { include: 'edit-blocker' } }); if (this.blockedByAnotherUser) { this.companionInfo({ diff --git a/resources/vue/components/courseware/CoursewareDefaultContainer.vue b/resources/vue/components/courseware/CoursewareDefaultContainer.vue index 70a6da708f0f66215174ea40408d847e2b221fe6..aa1923bfabc90a13c0991606232d92fd880bc5ff 100644 --- a/resources/vue/components/courseware/CoursewareDefaultContainer.vue +++ b/resources/vue/components/courseware/CoursewareDefaultContainer.vue @@ -4,22 +4,29 @@ :class="['cw-container-colspan-' + colSpan, showEditMode && canEdit ? 'cw-container-active' : '']" > <div class="cw-container-content"> - <header v-if="showEditMode && canEdit" class="cw-container-header"> - <studip-icon v-if="blockedByAnotherUser" shape="lock-locked" /> - <span>{{ container.attributes.title }} ({{container.attributes.width}})</span> - <span v-if="blockedByAnotherUser" class="cw-default-container-blocker-warning"> - | {{ $gettextInterpolate($gettext('wird im Moment von %{ userName } bearbeitet'), { userName: this.blockingUserName }) }} - </span> + <header v-if="showEditMode && canEdit" class="cw-container-header" :class="{ 'cw-container-header-open': isOpen }"> + <a href="#" class="cw-container-header-toggle" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen"> + <studip-icon :shape="isOpen ? 'arr_1down' : 'arr_1right'" /> + <span>{{ container.attributes.title }} ({{container.attributes.width}})</span> + <studip-icon v-if="blockedByAnotherUser" shape="lock-locked" /> + <span v-if="blockedByAnotherUser" class="cw-default-container-blocker-warning"> + {{ $gettextInterpolate($gettext('wird im Moment von %{ userName } bearbeitet'), { userName: this.blockingUserName }) }} + </span> + </a> <courseware-container-actions :canEdit="canEdit" :container="container" @editContainer="displayEditDialog" @deleteContainer="displayDeleteDialog" - @sortBlocks="sortBlocks" @removeLock="displayRemoveLockDialog" /> </header> - <div class="cw-block-wrapper" :class="{ 'cw-block-wrapper-active': showEditMode }"> + <div v-show="isOpen" + class="cw-block-wrapper" + :class="{ + 'cw-block-wrapper-active': showEditMode, + }" + > <slot name="containerContent"></slot> </div> @@ -93,15 +100,17 @@ export default { textDeleteAlert: this.$gettext('Möchten Sie diesen Abschnitt wirklich löschen?'), textRemoveLockTitle: this.$gettext('Sperre aufheben'), textRemoveLockAlert: this.$gettext('Möchten Sie die Sperre dieses Abschnitts wirklich aufheben?'), + isOpen: true, }; }, computed: { ...mapGetters({ userId: 'userId', userById: 'users/byId', + viewMode: 'viewMode' }), showEditMode() { - return this.$store.getters.viewMode === 'edit'; + return this.viewMode === 'edit'; }, colSpan() { return this.container.attributes.payload.colspan ? this.container.attributes.payload.colspan : 'full'; @@ -223,16 +232,6 @@ export default { } this.showDeleteDialog = false; }, - async sortBlocks() { - await this.loadContainer({ id: this.container.id, options: { include: 'edit-blocker' } }); - if (this.blockedByAnotherUser) { - this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); - - return false; - } - await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); - this.$emit('sortBlocks'); - }, displayRemoveLockDialog() { this.showRemoveLockDialog = true; }, diff --git a/resources/vue/components/courseware/CoursewareListContainer.vue b/resources/vue/components/courseware/CoursewareListContainer.vue index 8e889f86c8692712f21cc36d10049cc717517978..fea688c7b61f091c3e16d1189c4f7854b3b1182a 100644 --- a/resources/vue/components/courseware/CoursewareListContainer.vue +++ b/resources/vue/components/courseware/CoursewareListContainer.vue @@ -5,37 +5,62 @@ :canEdit="canEdit" :isTeacher="isTeacher" @storeContainer="storeContainer" - @sortBlocks="enableSort" > <template v-slot:containerContent> - <ul v-if="!sortMode" class="cw-container-list-block-list"> + <ul v-if="!showEditMode" class="cw-container-list-block-list"> <li v-for="block in blocks" :key="block.id" class="cw-block-item"> <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> </li> - <li v-if="showEditMode && canEdit && canAddElements"><courseware-block-adder-area :container="container" :section="0" /></li> </ul> - <draggable - v-if="sortMode && canEdit" - class="cw-container-list-block-list cw-container-list-sort-mode" - tag="ul" - v-model="blockList" - v-bind="dragOptions" - handle=".cw-sortable-handle" - @start="isDragging = true" - @end="isDragging = false" - > - <transition-group type="transition" name="flip-blocks"> - <li v-for="block in blockList" :key="block.id" class="cw-block-item cw-block-item-sortable"> - <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> + <template v-if="showEditMode && !processing"> + <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> + <draggable + v-if="showEditMode && canEdit" + class="cw-container-list-block-list cw-container-list-sort-mode" + tag="ol" + role="listbox" + v-model="blockList" + v-bind="dragOptions" + handle=".cw-sortable-handle" + group="blocks" + @start="isDragging = true" + @end="dropBlock" + ref="sortables" + :containerId="container.id" + sectionId="0" + > + <li + v-for="block in blockList" + :key="block.id" + class="cw-block-item cw-block-item-sortable" + > + <span + :class="{ 'cw-sortable-handle-dragging': isDragging }" + class="cw-sortable-handle" + tabindex="0" + role="option" + aria-describedby="operation" + :ref="'sortableHandle' + block.id" + @keydown="keyHandler($event, block.id)" + ></span> + <component + :is="component(block)" + :block="block" + :canEdit="canEdit" + :isTeacher="isTeacher" + :class="{ 'cw-block-item-selected': keyboardSelected === block.id}" + :blockId="block.id" + /> </li> - </transition-group> - - </draggable> - <div v-if="sortMode && canEdit"> - <button class="button accept" @click="storeSort"><translate>Sortierung speichern</translate></button> - <button class="button cancel" @click="resetSort"><translate>Sortieren abbrechen</translate></button> + </draggable> + <courseware-block-adder-area :container="container" :section="0" /> + </template> + <div v-if="showEditMode && processing" class="progress-wrapper" :style="{ height: contentHeight + 'px' }"> + <studip-progress-indicator :description="$gettext('Vorgang wird bearbeitet...')" /> </div> - </template> </courseware-default-container> </template> @@ -43,6 +68,7 @@ <script> import ContainerComponents from './container-components.js'; import containerMixin from '../../mixins/courseware/container.js'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; import draggable from 'vuedraggable'; import { mapActions, mapGetters } from 'vuex'; @@ -50,7 +76,8 @@ export default { name: 'courseware-list-container', mixins: [containerMixin], components: Object.assign(ContainerComponents, { - draggable + draggable, + StudipProgressIndicator }), props: { container: Object, @@ -60,7 +87,6 @@ export default { }, data() { return { - sortMode: false, isDragging: false, dragOptions: { animation: 0, @@ -69,12 +95,33 @@ export default { ghostClass: "block-ghost" }, blockList: [], + processing: false, + contentHeight: 0, + keyboardSelected: null, + assistiveLive: '' }; }, computed: { ...mapGetters({ blockById: 'courseware-blocks/byId', + containerById: 'courseware-containers/byId', + viewMode: 'viewMode' }), + blocked() { + return this.container?.relationships?.['edit-blocker']?.data !== null; + }, + blockerId() { + return this.blocked ? this.container?.relationships?.['edit-blocker']?.data?.id : null; + }, + blockedByThisUser() { + return this.blocked && this.userId === this.blockerId; + }, + blockedByAnotherUser() { + return this.blocked && this.userId !== this.blockerId; + }, + showEditMode() { + return this.viewMode === 'edit'; + }, blocks() { if (!this.container) { return []; @@ -87,28 +134,31 @@ export default { return sortedBlocks.concat(unallocatedBlocks); }, - showEditMode() { - return this.$store.getters.viewMode === 'edit'; - }, }, methods: { ...mapActions({ updateContainer: 'updateContainer', + loadContainer: 'courseware-containers/loadById', lockObject: 'lockObject', unlockObject: 'unlockObject', + companionInfo: 'companionInfo' }), storeContainer(data) { }, initCurrentData() { this.blockList = this.blocks; }, - enableSort() { - this.initCurrentData(); - this.sortMode = true; - }, async storeSort() { - this.sortMode = false; - + this.contentHeight = this.$refs.sortables.$el.offsetHeight; + const timeout = setTimeout(() => this.processing = true, 800); + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + clearTimeout(timeout); + this.processing = false; + this.loadContainer({id : this.container.id }); + return false; + } + await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); let currentContainer = this.container; currentContainer.attributes.payload.sections[0].blocks = this.blockList.map(block => {return block.id}); await this.updateContainer({ @@ -116,12 +166,11 @@ export default { structuralElementId: currentContainer.relationships['structural-element'].data.id, }); await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); + await this.loadContainer({id : this.container.id }); this.initCurrentData(); - }, - async resetSort() { - await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); - this.sortMode = false; - this.blockList = this.blocks; + clearTimeout(timeout); + this.processing = false; + this.assistiveLive = ''; }, component(block) { if (block.attributes["block-type"] !== undefined) { @@ -129,9 +178,107 @@ export default { } return null; }, + keyHandler(e, blockId) { + switch (e.keyCode) { + case 27: // esc + this.abortKeyboardSorting(blockId); + break; + case 32: // space + e.preventDefault(); + if (this.keyboardSelected) { + this.storeKeyboardSorting(blockId); + } else { + this.keyboardSelected = blockId; + const block = this.blockById({id: blockId}); + const index = this.blockList.findIndex(b => b.id === block.id); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block 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.') + , {blockTitle: block.attributes.title, pos: index + 1, listLength: this.blockList.length} + ); + } + break; + } + if (this.keyboardSelected) { + switch (e.keyCode) { + case 9: //tab + this.abortKeyboardSorting(blockId); + break; + case 38: // up + e.preventDefault(); + this.moveItemUp(blockId); + break; + case 40: // down + e.preventDefault(); + this.moveItemDown(blockId); + break; + } + } + }, + moveItemUp(blockId) { + const currentIndex = this.blockList.findIndex(block => block.id === blockId); + if (currentIndex !== 0) { + const block = this.blockById({id: blockId}); + const newPos = currentIndex - 1; + this.blockList.splice(newPos, 0, this.blockList.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: newPos + 1, listLength: this.blockList.length} + ); + } + }, + moveItemDown(blockId) { + const currentIndex = this.blockList.findIndex(block => block.id === blockId); + if (this.blockList.length - 1 > currentIndex) { + const block = this.blockById({id: blockId}); + const newPos = currentIndex + 1; + this.blockList.splice(newPos, 0, this.blockList.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: newPos + 1, listLength: this.blockList.length} + ); + } + }, + abortKeyboardSorting(blockId) { + const block = this.blockById({id: blockId}); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block, Neuordnung abgebrochen') + , {blockTitle: block.attributes.title} + ); + this.initCurrentData(); + }, + storeKeyboardSorting(blockId) { + const block = this.blockById({id: blockId}); + const currentIndex = this.blockList.findIndex(block => block.id === blockId); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: currentIndex + 1, listLength: this.blockList.length} + ); + this.storeSort(); + } }, mounted() { this.initCurrentData(); }, + watch: { + blocks() { + this.initCurrentData(); + }, + blockList() { + if (this.keyboardSelected) { + this.$nextTick(() => { + const selected = this.$refs['sortableHandle' + this.keyboardSelected][0]; + selected.focus(); + selected.scrollIntoView({behavior: "smooth", block: "center"}); + }); + } + } + } }; </script> diff --git a/resources/vue/components/courseware/CoursewareRibbon.vue b/resources/vue/components/courseware/CoursewareRibbon.vue index 119448c7f061fdba4392c6c7745425fdbe299c13..066a56fc69f2b2d0d3d91c47482132ed262251c5 100644 --- a/resources/vue/components/courseware/CoursewareRibbon.vue +++ b/resources/vue/components/courseware/CoursewareRibbon.vue @@ -41,6 +41,7 @@ :style="{ maxHeight: maxHeight + 'px' }" :canEdit="canEdit" @deactivate="deactivateToolbar" + @blockAdded="$emit('blockAdded')" /> </header> <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div> diff --git a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue index 7d6a779dadadafcb3246d6aa873f3a808dd86aec..d90616c78f1e2aa9ba298d31be8f88af05b06d9a 100644 --- a/resources/vue/components/courseware/CoursewareRibbonToolbar.vue +++ b/resources/vue/components/courseware/CoursewareRibbonToolbar.vue @@ -33,6 +33,7 @@ <courseware-tools-blockadder id="cw-ribbon-tool-blockadder" :stickyRibbon="stickyRibbon" + @blockAdded="$emit('blockAdded')" /> </courseware-tab> <courseware-tab diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 76a949d04ee9f904eb35aba4f4d528f24bb394ee..c45e8532b922a34da81c90f4e693347672792f13 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -7,7 +7,7 @@ v-if="validContext" > <div class="cw-structural-element-content" v-if="structuralElement"> - <courseware-ribbon :canEdit="canEdit && canAddElements" :isContentBar="true"> + <courseware-ribbon :canEdit="canEdit && canAddElements" :isContentBar="true" @blockAdded="updateContainerList"> <template #buttons> <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id"> <div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" /> @@ -68,7 +68,7 @@ </courseware-ribbon> <div - v-if="canVisit && !sortMode && !isLink" + v-if="canVisit && !editView && !isLink" class="cw-container-wrapper" :class="{ 'cw-container-wrapper-consume': consumeMode, @@ -139,35 +139,50 @@ class="cw-container-item" /> </div> - <div v-if="canVisit && canEdit && sortMode" class="cw-container-wrapper-sort-mode"> - <draggable - class="cw-structural-element-list-sort-mode" - tag="ul" - v-model="containerList" - v-bind="dragOptions" - handle=".cw-sortable-handle" - @start="isDragging = true" - @end="isDragging = false" - > - <transition-group type="transition" name="flip-containers"> + <div v-if="canVisit && canEdit && editView" class="cw-container-wrapper cw-container-wrapper-edit"> + <template v-if="!processing"> + <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> + <draggable + class="cw-structural-element-list" + tag="ol" + role="listbox" + v-model="containerList" + v-bind="dragOptions" + handle=".cw-sortable-handle" + @start="isDragging = true" + @end="dropContainer" + > <li v-for="container in containerList" :key="container.id" class="cw-container-item-sortable" > - <span class="cw-sortable-handle"></span> - <span>{{ container.attributes.title }} ({{ container.attributes.width }})</span> + <span + :class="{ 'cw-sortable-handle-dragging': isDragging }" + class="cw-sortable-handle" + tabindex="0" + role="option" + aria-describedby="operation" + :ref="'sortableHandle' + container.id" + @keydown="keyHandler($event, container.id)" + ></span> + <component + :is="containerComponent(container)" + :container="container" + :canEdit="canEdit" + :canAddElements="canAddElements" + :isTeacher="userIsTeacher" + class="cw-container-item" + ref="containers" + :class="{ 'cw-container-item-selected': keyboardSelected === container.id}" + /> </li> - </transition-group> - </draggable> - <div class="cw-container-sort-buttons"> - <button class="button accept" @click="storeSort"> - <translate>Sortierung speichern</translate> - </button> - <button class="button cancel" @click="resetSort"> - <translate>Sortieren abbrechen</translate> - </button> - </div> + </draggable> + </template> + <studip-progress-indicator v-if="processing" :description="$gettext('Vorgang wird bearbeitet...')" /> </div> <div v-if="!canVisit" @@ -732,6 +747,9 @@ export default { 'expire-date': '' }, deletingPreviewImage: false, + processing: false, + keyboardSelected: null, + assistiveLive: '' }; }, @@ -763,7 +781,6 @@ export default { exportState: 'exportState', exportProgress: 'exportProgress', userId: 'userId', - sortMode: 'structuralElementSortMode', viewMode: 'viewMode', taskById: 'courseware-tasks/byId', userById: 'users/byId', @@ -988,12 +1005,6 @@ export default { icon: 'edit', emit: 'editCurrentElement', }); - menu.push({ - id: 2, - label: this.$gettext('Abschnitte sortieren'), - icon: 'arr_1sort', - emit: 'sortContainers', - }); } if (this.blockedByAnotherUser && this.userIsTeacher) { menu.push({ @@ -1335,7 +1346,6 @@ export default { showElementRemoveLockDialog: 'showElementRemoveLockDialog', updateShowSuggestOerDialog: 'updateShowSuggestOerDialog', updateContainer: 'updateContainer', - setStructuralElementSortMode: 'setStructuralElementSortMode', sortContainersInStructualElements: 'sortContainersInStructualElements', loadTask: 'loadTask', loadStructuralElement: 'loadStructuralElement', @@ -1410,26 +1420,6 @@ export default { case 'setBookmark': this.setBookmark(); break; - case 'sortContainers': - await this.loadStructuralElement(this.currentId); - if (this.blockedByAnotherUser) { - this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); - - return false; - } - try { - await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); - } catch (error) { - if (error.status === 409) { - this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); - } else { - console.log(error); - } - - return false; - } - this.enableSortContainers(); - break; case 'linkElement': this.showElementLinkDialog(true); break; @@ -1516,23 +1506,41 @@ export default { this.initCurrent(); }, - enableSortContainers() { - this.setStructuralElementSortMode(true); + dropContainer() { + this.isDragging = false; + this.storeSort(); }, - storeSort() { - this.setStructuralElementSortMode(false); + async storeSort() { + const timeout = setTimeout(() => this.processing = true, 800); + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); + clearTimeout(timeout); + this.processing = false; + return false; + } + try { + await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' }); + } catch (error) { + if (error.status === 409) { + this.companionInfo({ info: this.$gettext('Diese Seite wird bereits bearbeitet.') }); + } else { + console.log(error); + } - this.sortContainersInStructualElements({ + clearTimeout(timeout); + this.processing = false; + return false; + } + + await this.sortContainersInStructualElements({ structuralElement: this.structuralElement, containers: this.containerList, }); this.$emit('select', this.currentId); - }, - resetSort() { - this.setStructuralElementSortMode(false); - this.containerList = this.containers; + clearTimeout(timeout); + this.processing = false; }, async exportCurrentElement(data) { @@ -1711,6 +1719,97 @@ export default { await this.unlockObject({ id: this.currentId, type: 'courseware-structural-elements' }); await this.loadStructuralElement(this.currentElement.id); this.showElementRemoveLockDialog(false); + }, + updateContainerList() { + this.containerList = this.containers; + const containerRefs = this.$refs.containers; + for (let ref of containerRefs) { + ref.initCurrentData(); + } + }, + keyHandler(e, containerId) { + switch (e.keyCode) { + case 27: // esc + this.abortKeyboardSorting(containerId); + break; + case 32: // space + e.preventDefault(); + if (this.keyboardSelected) { + this.storeKeyboardSorting(containerId); + } else { + this.keyboardSelected = containerId; + const container = this.containerById({id: containerId}); + const index = this.containerList.findIndex(c => c.id === container.id); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{containerTitle} Abschnitt 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.') + , {containerTitle: container.attributes.title, pos: index + 1, listLength: this.containerList.length} + ); + } + break; + } + if (this.keyboardSelected) { + switch (e.keyCode) { + case 9: //tab + this.abortKeyboardSorting(containerId); + break; + case 38: // up + e.preventDefault(); + this.moveItemUp(containerId); + break; + case 40: // down + e.preventDefault(); + this.moveItemDown(containerId); + break; + } + } + }, + moveItemUp(containerId) { + const currentIndex = this.containerList.findIndex(container => container.id === containerId); + if (currentIndex !== 0) { + const container = this.containerById({id: containerId}); + const newPos = currentIndex - 1; + this.containerList.splice(newPos, 0, this.containerList.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{containerTitle} Abschnitt. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {containerTitle: container.attributes.title, pos: newPos + 1, listLength: this.containerList.length} + ); + } + }, + moveItemDown(containerId) { + const currentIndex = this.containerList.findIndex(container => container.id === containerId); + if (this.containerList.length - 1 > currentIndex) { + const container = this.containerById({id: containerId}); + const newPos = currentIndex + 1; + this.containerList.splice(newPos, 0, this.containerList.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{containerTitle} Abschnitt. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {containerTitle: container.attributes.title, pos: newPos + 1, listLength: this.containerList.length} + ); + } + }, + abortKeyboardSorting(containerId) { + const container = this.containerById({id: containerId}); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{containerTitle} Abschnitt, Neuordnung abgebrochen') + , {containerTitle: container.attributes.title} + ); + this.$emit('select', this.currentId); + }, + storeKeyboardSorting(containerId) { + const container = this.containerById({id: containerId}); + const currentIndex = this.containerList.findIndex(container => container.id === containerId); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{containerTitle} Abschnitt, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.') + , {containerTitle: container.attributes.title, pos: currentIndex + 1, listLength: this.containerList.length} + ); + this.storeSort(); } }, created() { @@ -1734,6 +1833,15 @@ export default { containers() { this.containerList = this.containers; }, + containerList() { + if (this.keyboardSelected) { + this.$nextTick(() => { + const selected = this.$refs['sortableHandle' + this.keyboardSelected][0]; + selected.focus(); + selected.scrollIntoView({behavior: "smooth", block: "center"}); + }); + } + }, consumeMode(newState) { this.consumModeTrap = newState; }, diff --git a/resources/vue/components/courseware/CoursewareTabs.vue b/resources/vue/components/courseware/CoursewareTabs.vue index b5cc5a60f76a18a79f1c2aeb7fc38c90e05819a0..b6e2bc63b778c82708bdea8957109dd9b28d94b5 100644 --- a/resources/vue/components/courseware/CoursewareTabs.vue +++ b/resources/vue/components/courseware/CoursewareTabs.vue @@ -127,7 +127,7 @@ export default { return this.$refs[selectorId][0]; } return null; - } + }, }, watch: { setSelected(tab) { diff --git a/resources/vue/components/courseware/CoursewareTabsContainer.vue b/resources/vue/components/courseware/CoursewareTabsContainer.vue index b07d2a6b35b40bd8854820207def326c54916715..8de73a661650e57dd2faa37fadecafe6e4b9dac4 100644 --- a/resources/vue/components/courseware/CoursewareTabsContainer.vue +++ b/resources/vue/components/courseware/CoursewareTabsContainer.vue @@ -7,19 +7,24 @@ @showEdit="setShowEdit" @storeContainer="storeContainer" @closeEdit="initCurrentData" - @sortBlocks="enableSort" > <template v-slot:containerContent> - <courseware-tabs v-if="!sortMode"> + <template v-if="showEditMode && canEdit"> + <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> + <courseware-tabs> <courseware-tab v-for="(section, index) in currentSections" :key="index" :index="index" :name="section.name" :icon="section.icon" - :selected="index === 0" + :selected="sortInTab === index" > - <ul class="cw-container-tabs-block-list"> + <ul v-if="!showEditMode" class="cw-container-tabs-block-list"> <li v-for="block in section.blocks" :key="block.id" class="cw-block-item"> <component :is="component(block)" @@ -28,42 +33,48 @@ :isTeacher="isTeacher" /> </li> - <li v-if="showEditMode && canAddElements"> - <courseware-block-adder-area :container="container" :section="index" @updateContainerContent="updateContent"/> - </li> </ul> - </courseware-tab> - </courseware-tabs> - <div v-if="sortMode && canEdit" class="cw-container-tabs-sort"> - <courseware-collapsible-box - v-for="(section, index) in currentSections" - :key="index" - :title="section.name" - :icon="section.icon" - :open="index === 0" - > - <draggable - class="cw-container-list-block-list cw-container-list-sort-mode" - :class="[section.blocks.length === 0 ? 'cw-container-list-sort-mode-empty' : '']" - tag="ul" - v-model="section.blocks" - v-bind="dragOptions" - handle=".cw-sortable-handle" - @start="isDragging = true" - @end="isDragging = false" - > - <transition-group type="transition" name="flip-blocks" tag="div"> + <template v-if="showEditMode && canEdit"> + <draggable + class="cw-container-list-block-list cw-container-list-sort-mode" + :class="[section.blocks.length === 0 ? 'cw-container-list-sort-mode-empty' : '']" + tag="ol" + role="listbox" + v-model="section.blocks" + v-bind="dragOptions" + handle=".cw-sortable-handle" + group="blocks" + @start="isDragging = true" + @end="dropBlock" + :containerId="container.id" + :sectionId="index" + > <li v-for="block in section.blocks" :key="block.id" class="cw-block-item cw-block-item-sortable"> - <component :is="component(block)" :block="block" :canEdit="canEdit" :isTeacher="isTeacher" /> + <span + :class="{ 'cw-sortable-handle-dragging': isDragging }" + class="cw-sortable-handle" + tabindex="0" + role="option" + aria-describedby="operation" + :ref="'sortableHandle' + block.id" + @keydown="keyHandler($event, block.id, index)" + ></span> + <component + :is="component(block)" + :block="block" + :canEdit="canEdit" + :isTeacher="isTeacher" + :class="{ 'cw-block-item-selected': keyboardSelected === block.id}" + :blockId="block.id" + /> </li> - </transition-group> - </draggable> - </courseware-collapsible-box> - <div> - <button class="button accept" @click="storeSort"><translate>Sortierung speichern</translate></button> - <button class="button cancel" @click="resetSort"><translate>Sortieren abbrechen</translate></button> - </div> - </div> + </draggable> + <template v-if="canAddElements"> + <courseware-block-adder-area :container="container" :section="index" @updateContainerContent="updateContent"/> + </template> + </template> + </courseware-tab> + </courseware-tabs> </template> <template v-slot:containerEditDialog> <form class="default cw-container-dialog-edit-form" @submit.prevent=""> @@ -144,12 +155,20 @@ export default { disabled: false, ghostClass: "block-ghost" }, + processing: false, + keyboardSelected: null, + sortInTab: 0, + assistiveLive: '' }; }, computed: { ...mapGetters({ blockById: 'courseware-blocks/byId', + viewMode: 'viewMode' }), + showEditMode() { + return this.viewMode === 'edit'; + }, blocks() { if (!this.container) { return []; @@ -157,9 +176,6 @@ export default { return this.container.relationships.blocks.data.map(({ id }) => this.blockById({ id })).filter((a) => a); }, - showEditMode() { - return this.$store.getters.viewMode === 'edit'; - }, icons() { return contentIcons; }, @@ -170,6 +186,7 @@ export default { methods: { ...mapActions({ updateContainer: 'updateContainer', + loadContainer: 'courseware-containers/loadById', lockObject: 'lockObject', unlockObject: 'unlockObject', }), @@ -227,31 +244,31 @@ export default { this.currentContainer.attributes.payload.sections.splice(index, 1); }, async storeContainer() { + const timeout = setTimeout(() => this.processing = true, 800); this.currentContainer.attributes.payload.sections = this.currentContainer.attributes.payload.sections.filter(section => !section.locked); this.currentContainer.attributes.payload.sections.forEach(section => { section.blocks = section.blocks.map((block) => {return block.id;}); delete section.locked; }); - await this.updateContainer({ container: this.currentContainer, structuralElementId: this.currentContainer.relationships['structural-element'].data.id, }); await this.unlockObject({ id: this.container.id, type: 'courseware-containers' }); + await this.loadContainer({id : this.container.id }); this.initCurrentData(); - }, - enableSort() { - this.sortMode = true; + clearTimeout(timeout); + this.processing = false; }, async storeSort() { - this.sortMode = false; + if (this.blockedByAnotherUser) { + this.companionInfo({ info: this.$gettext('Dieser Abschnitt wird bereits bearbeitet.') }); + this.loadContainer({id : this.container.id }); + return false; + } + await this.lockObject({ id: this.container.id, type: 'courseware-containers' }); this.storeContainer(); }, - async resetSort() { - await this.unlockObject({ id: this.currentContainer.id, type: 'courseware-containers' }); - this.sortMode = false; - this.initCurrentData(); - }, component(block) { if (block.attributes) { return 'courseware-' + block.attributes["block-type"] + '-block'; @@ -262,6 +279,98 @@ export default { if(blockAdder.container !== undefined && blockAdder.container.id === this.container.id) { this.initCurrentData(); } + }, + keyHandler(e, blockId, sectionIndex) { + switch (e.keyCode) { + case 27: // esc + this.abortKeyboardSorting(blockId, sectionIndex); + break; + case 32: // space + e.preventDefault(); + if (this.keyboardSelected) { + this.storeKeyboardSorting(blockId, sectionIndex); + } else { + this.keyboardSelected = blockId; + const block = this.blockById({id: blockId}); + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block 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.') + , {blockTitle: block.attributes.title, pos: currentIndex + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + } + break; + } + if (this.keyboardSelected) { + switch (e.keyCode) { + case 9: //tab + this.abortKeyboardSorting(blockId, sectionIndex); + break; + case 38: // up + e.preventDefault(); + this.moveItemUp(blockId, sectionIndex); + break; + case 40: // down + e.preventDefault(); + this.moveItemDown(blockId, sectionIndex); + break; + } + } + }, + moveItemUp(blockId, sectionIndex) { + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + const block = this.blockById({id: blockId}); + if (currentIndex !== 0) { + const newPos = currentIndex - 1; + this.currentSections[sectionIndex].blocks.splice(newPos, 0, this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: newPos + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + } else if (sectionIndex !== 0) { + const newSectionIndex = sectionIndex - 1; + this.sortInTab = newSectionIndex; + this.currentSections[newSectionIndex].blocks.push(this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + } + }, + moveItemDown(blockId, sectionIndex) { + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + const block = this.blockById({id: blockId}); + if (this.currentSections[sectionIndex].blocks.length - 1 > currentIndex) { + const newPos = currentIndex + 1; + this.currentSections[sectionIndex].blocks.splice(newPos, 0, this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block. Aktuelle Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: newPos + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + } else if (this.currentSections.length - 1 > sectionIndex) { + const newSectionIndex = sectionIndex + 1; + this.sortInTab = newSectionIndex; + this.currentSections[newSectionIndex].blocks.splice(0, 0, this.currentSections[sectionIndex].blocks.splice(currentIndex, 1)[0]); + } + }, + abortKeyboardSorting(blockId, sectionIndex) { + const block = this.blockById({id: blockId}); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block, Neuordnung abgebrochen') + , {blockTitle: block.attributes.title} + ); + this.initCurrentData(); + }, + storeKeyboardSorting(blockId, sectionIndex) { + const block = this.blockById({id: blockId}); + const currentIndex = this.currentSections[sectionIndex].blocks.findIndex(block => block.id === blockId); + this.keyboardSelected = null; + this.assistiveLive = + this.$gettextInterpolate( + this.$gettext('%{blockTitle} Block, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.') + , {blockTitle: block.attributes.title, pos: currentIndex + 1, listLength: this.currentSections[sectionIndex].blocks.length} + ); + this.storeSort(); } }, watch: { @@ -269,6 +378,16 @@ export default { if (!this.showEdit) { this.initCurrentData(); } + }, + currentSections: { + handler() { + if (this.keyboardSelected) { + this.$nextTick(() => { + this.$refs['sortableHandle' + this.keyboardSelected][0].focus(); + }); + } + }, + deep: true } } }; diff --git a/resources/vue/components/courseware/CoursewareToolsBlockadder.vue b/resources/vue/components/courseware/CoursewareToolsBlockadder.vue index dde956fa20fdbe57c9e2bebd6f390b6f047b39b8..2485eb3ec8948062a21971c190680b0eecd7afe2 100644 --- a/resources/vue/components/courseware/CoursewareToolsBlockadder.vue +++ b/resources/vue/components/courseware/CoursewareToolsBlockadder.vue @@ -19,6 +19,7 @@ :icon="block.icon" :type="block.type" :description="block.description" + @blockAdded="$emit('blockAdded')" /> </div> <div class="cw-element-adder-favs-wrapper" v-if="showEditFavs"> @@ -29,6 +30,7 @@ :title="block.title" :type="block.type" :description="block.description" + @blockAdded="$emit('blockAdded')" /> </div> <div class="cw-element-adder-favs"> @@ -56,6 +58,7 @@ :title="block.title" :type="block.type" :description="block.description" + @blockAdded="$emit('blockAdded')" /> </div> </courseware-collapsible-box> @@ -72,6 +75,7 @@ :icon="block.icon" :type="block.type" :description="block.description" + @blockAdded="$emit('blockAdded')" /> </div> </courseware-collapsible-box> diff --git a/resources/vue/mixins/courseware/container.js b/resources/vue/mixins/courseware/container.js index fac1ea604ef1d16bf91a1ffc9d19f012c92c3ec8..99b30edfabe5d09cb5c425c23becef7a3fe27943 100644 --- a/resources/vue/mixins/courseware/container.js +++ b/resources/vue/mixins/courseware/container.js @@ -1,12 +1,79 @@ -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; const containerMixin = { computed: { - ...mapGetters(['pluginManager']), + ...mapGetters({ + blockById: 'courseware-blocks/byId', + containerById: 'courseware-containers/byId', + pluginManager: 'pluginManager', + }), }, created: function () { this.pluginManager.registerComponentsLocally(this); }, + methods: { + ...mapActions({ + updateBlock: 'updateBlock', + updateContainer: 'updateContainer', + loadContainer: 'courseware-containers/loadById', + lockObject: 'lockObject', + unlockObject: 'unlockObject', + }), + dropBlock(e) { + this.isDragging = false; // implemented bei echt container type + let data = {}; + data.originContainerId = e.from.__vue__.$attrs.containerId; + data.targetContainerId = e.to.__vue__.$attrs.containerId; + if (data.originContainerId === data.targetContainerId) { + this.storeSort(); // implemented bei echt container type + } else { + data.originSectionId = e.from.__vue__.$attrs.sectionId; + data.originSectionBlockList = e.from.__vue__.$children.map(b => { return b.$attrs.blockId; }); + data.targetSectionId = e.to.__vue__.$attrs.sectionId; + data.targetSectionBlockList = e.to.__vue__.$children.map(b => { return b.$attrs.blockId; }); + data.blockId = e.item._underlying_vm_.id; + data.newPos = e.newIndex; + const indexInBlockList = data.targetSectionBlockList.findIndex(b => b === data.blockId); + data.targetSectionBlockList.splice(data.newPos, 0, data.targetSectionBlockList.splice(indexInBlockList,1)); + this.storeInAnotherContainer(data); + } + }, + async storeInAnotherContainer(data) { + // update block container id + let block = this.blockById({id: data.blockId }); + block.relationships.container.data.id = data.targetContainerId; + block.attributes.position = data.newPos; + await this.lockObject({ id: data.blockId, type: 'courseware-blocks' }); + await this.updateBlock({ + block: block, + containerId: data.targetContainerId, + }); + await this.unlockObject({ id: data.blockId, type: 'courseware-blocks' }); + + // update origin container + let originContainer = this.containerById({ id: data.originContainerId}); + originContainer.attributes.payload.sections[data.originSectionId].blocks = data.originSectionBlockList; + await this.lockObject({ id: data.originContainerId, type: 'courseware-containers' }); + await this.updateContainer({ + container: originContainer, + structuralElementId: originContainer.relationships['structural-element'].data.id, + }); + await this.unlockObject({ id: data.originContainerId, type: 'courseware-containers' }); + + // update target container + let targetContainer = this.containerById({ id: data.targetContainerId}); + targetContainer.attributes.payload.sections[data.targetSectionId].blocks = data.targetSectionBlockList; + await this.lockObject({ id: data.targetContainerId, type: 'courseware-containers' }); + await this.updateContainer({ + container: targetContainer, + structuralElementId: targetContainer.relationships['structural-element'].data.id, + }); + await this.unlockObject({ id: data.targetContainerId, type: 'courseware-containers' }); + + this.loadContainer({id : data.originContainerId }); + this.loadContainer({id : data.targetContainerId }); + }, + } }; export default containerMixin; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index ae786bfd52034ad4df1bfb24d652bd5df4c3ffbf..b1f12f80914b4bc86aae794b8226ed0801667a58 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -40,8 +40,6 @@ const getDefaultState = () => { showSuggestOerDialog: false, - structuralElementSortMode: false, - importFilesState: '', importFilesProgress: 0, importStructuresState: '', @@ -201,9 +199,6 @@ const getters = { showSuggestOerDialog(state) { return state.showSuggestOerDialog; }, - structuralElementSortMode(state) { - return state.structuralElementSortMode; - }, importFilesState(state) { return state.importFilesState; }, @@ -877,10 +872,6 @@ export const actions = { context.commit('setShowOverviewElementAddDialog', bool); }, - setStructuralElementSortMode({ commit }, bool) { - commit('setStructuralElementSortMode', bool); - }, - setImportFilesState({ commit }, state) { commit('setImportFilesState', state); }, @@ -1438,10 +1429,6 @@ export const mutations = { state.showStructuralElementRemoveLockDialog = showRemoveLock; }, - setStructuralElementSortMode(state, mode) { - state.structuralElementSortMode = mode; - }, - setImportFilesState(state, importFilesState) { state.importFilesState = importFilesState; },