diff --git a/app/controllers/course/wiki.php b/app/controllers/course/wiki.php index 8e72f8a7d96b7e508689772cdddbf2e95f445b99..926f641fe4fbd61fe13bfc6d470daa71f3d5e6f6 100644 --- a/app/controllers/course/wiki.php +++ b/app/controllers/course/wiki.php @@ -30,7 +30,7 @@ class Course_WikiController extends AuthenticatedController } Navigation::activateItem('/course/wiki/start'); - $this->page = new WikiPage($page_id); + $this->page = WikiPage::find($page_id) ?: new WikiPage(); $this->validateWikiPage($this->page, $this->range); $sidebar = Sidebar::Get(); @@ -97,6 +97,13 @@ class Course_WikiController extends AuthenticatedController $sidebar->addWidget($actions); } + $contentbarProps = [ + 'icon' => 'wiki', + 'isContentBar' => true + ]; + + $toc = CoreWiki::getTOC($this->page); + if (!$this->page->isNew()) { //then the wiki is not empty $search = new SearchWidget($this->searchURL()); @@ -116,12 +123,18 @@ class Course_WikiController extends AuthenticatedController Icon::create('file-pdf') ); $sidebar->addWidget($exports); + + $contentbarProps['toc'] = $toc; } - $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID); - $this->contentbar = ContentBar::get() - ->setTOC(CoreWiki::getTOC($this->page)) - ->setIcon(Icon::create('wiki')); + // Content bar + $this->contentBarVueApp = \Studip\VueApp::create('ContentBar') + ->withProps($contentbarProps) + ->withComponent('ContentBarBreadcrumbs') + ->withSlot('breadcrumb-list', + sprintf("<content-bar-breadcrumbs :toc='%s'/>", json_encode($toc)) + ); + if (!$this->page->isNew()) { $author = _('unbekannt'); if ($this->page->user) { @@ -132,8 +145,8 @@ class Course_WikiController extends AuthenticatedController ); } - $this->contentbar->setInfoHTML(sprintf( - _('Version %1$s, geändert von %2$s <br> am %3$s'), + $this->contentBarVueApp = $this->contentBarVueApp->withSlot('info-text', sprintf( + _('Version %1$s, geändert von %2$s am %3$s'), $this->page->versionnumber, $author, date('d.m.Y H:i:s', $this->page['chdate']) @@ -167,13 +180,7 @@ class Course_WikiController extends AuthenticatedController ); } } - $action_menu->addLink( - '#', - _('Als Vollbild anzeigen'), - Icon::create('screen-full'), - ['class' => 'fullscreen-trigger hidden-medium-down'] - ); - $this->contentbar->setActionMenu($action_menu); + $this->contentBarVueApp = $this->contentBarVueApp->withSlot('menu', $action_menu->render()); } } @@ -478,10 +485,6 @@ class Course_WikiController extends AuthenticatedController ORDER BY Nachname, Vorname", [$page->id] ); - $this->contentbar = ContentBar::get() - ->setTOC(CoreWiki::getTOC($page)) - ->setIcon(Icon::create('wiki')) - ->setInfoHTML(_('Zuletzt gespeichert') .': '. '<span class="wiki-last-edited-' . $this->page->id . '"></span>'); } public function apply_editing_action(WikiPage $page) @@ -715,15 +718,24 @@ class Course_WikiController extends AuthenticatedController Navigation::activateItem('/course/wiki/start'); Sidebar::Get()->addWidget($this->getViewsWidget($version->page, 'history')); - $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID); - $this->contentbar = ContentBar::get() - ->setTOC(CoreWiki::getTOC($version->page)) - ->setIcon(Icon::create('wiki')) - ->setInfoHTML(sprintf( + + $toc = CoreWiki::getTOC($version->page); + $this->contentBarVueApp = \Studip\VueApp::create('ContentBar') + ->withProps([ + 'icon' => 'wiki', + 'isContentBar' => true, + 'toc' => $toc, + ]) + ->withSlot('info-text', sprintf( _('Version %1$s vom %2$s'), $version->versionnumber, date('d.m.Y H:i:s', $version['mkdate']) - )); + )) + ->withComponent('ContentBarBreadcrumbs') + ->withSlot('breadcrumb-list', sprintf( + "<content-bar-breadcrumbs :toc='%s'/>", + json_encode($toc)) + ); } public function blame_action(WikiPage $page) diff --git a/app/controllers/oer/market.php b/app/controllers/oer/market.php index 72133d7e60777d2f1e7c3a5d6487a035f2145a10..69030c2e93ffbad6ab31132f8d0ee2490a5aa5df 100644 --- a/app/controllers/oer/market.php +++ b/app/controllers/oer/market.php @@ -328,10 +328,11 @@ class Oer_MarketController extends StudipController } } - $this->contentbar = ContentBar::get() - ->setTOC(new TOCItem($this->material['name'])) - ->setInfoHTML(htmlReady($infotext)) - ->setIcon(Icon::create('oer-campus')); + $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')->withProps([ + 'title' => $this->material['name'], + 'icon' => 'oer-campus', + 'isContentBar' => true, + ])->withSlot('info-text', htmlReady($infotext)); } public function embed_action($material_id) diff --git a/app/views/course/wiki/edit.php b/app/views/course/wiki/edit.php index 983a02cce132b237feeb757c5568e360502a3d27..40e01114776b0d33d6e0fbdc46ad6802b736d180 100644 --- a/app/views/course/wiki/edit.php +++ b/app/views/course/wiki/edit.php @@ -3,12 +3,9 @@ * @var WikiPage $page * @var Course_WikiController $controller * @var WikiOnlineEditingUser $me_online - * @var ContentBar $contentbar */ ?> -<?= $contentbar ?> - <?= Studip\VueApp::create('WikiEditor') ->withProps([ 'cancel-url' => $controller->leave_editingURL($page), @@ -18,5 +15,6 @@ 'page-id' => (int) $page->id, 'save-url' => $controller->saveURL($page), 'users' => $page->getOnlineUsers(), + 'toc' => CoreWiki::getTOC($page), ]) ?> diff --git a/app/views/course/wiki/page.php b/app/views/course/wiki/page.php index e3c67cfbf3474e6d08f6cbc5e10ef66f7a1c36cd..21ae0317562cb832cd6ded58da4b85f3e58f4126 100644 --- a/app/views/course/wiki/page.php +++ b/app/views/course/wiki/page.php @@ -4,10 +4,11 @@ * @var string $edit_perms * @var Context $range * @var Course_WikiController $controller + * @var \Studip\VueApp $contentBarVueApp */ -echo $contentbar; ?> +<?= $contentBarVueApp->render() ?> <? if ($page->isEditable()) : ?> <form action="<?= $controller->delete($page->id) ?>" method="post" id="delete_page"> diff --git a/app/views/course/wiki/version.php b/app/views/course/wiki/version.php index 26094ae6161e598e08bf6b8adbf3db70d1500778..177d7d0e0d1e7b9636444efdcb5b75eafa9f3b33 100644 --- a/app/views/course/wiki/version.php +++ b/app/views/course/wiki/version.php @@ -1,4 +1,10 @@ -<?= $contentbar ?> +<?php +/** + * @var \Studip\VueApp $contentBarVueApp + */ +?> + +<?= $contentBarVueApp->render() ?> <div class="wiki_page_content"> <?= wikiReady($version['content']) ?> diff --git a/app/views/oer/market/details.php b/app/views/oer/market/details.php index 9e1c60ba368e95dcd137a681140106fd8158aa22..c05bbdfceb2b1c322d7795e8b04251821cd9465c 100644 --- a/app/views/oer/market/details.php +++ b/app/views/oer/market/details.php @@ -1,4 +1,9 @@ -<?= $contentbar ?> +<?php +/** + * @var Studip\VueApp $contentBarVueApp + */ +?> +<?= $contentBarVueApp->render() ?> <? $url = $material->getDownloadUrl() ?> diff --git a/lib/classes/Debug/VueCollector.php b/lib/classes/Debug/VueCollector.php index a2d90f3ab540ca5de88dc5e1938ca7fccc15b310..0dd479b447146313be192ee11cfc0d5a1c5ea73a 100644 --- a/lib/classes/Debug/VueCollector.php +++ b/lib/classes/Debug/VueCollector.php @@ -45,6 +45,23 @@ final class VueCollector extends DataCollector implements Renderable } } + $slots = $this->app->getSlots(); + if (count($slots) > 0) { + ksort($slots); + + $data['== SLOTS =='] = count($slots) . ' items'; + foreach ($slots as $key => $value) { + $data[$key] = $this->dumpVar($value); + } + } + + $components = $this->app->getComponents(); + ksort($components); + $data['== COMPONENTS =='] = count($components) . ' items'; + foreach ($components as $value) { + $data[$value] = ''; + } + return $data; } diff --git a/lib/classes/Icon.php b/lib/classes/Icon.php index 6c586a03d29d8a9e96574c2ec9961f561d14d4cb..fd2a25b5e9aedb217c0de2ac703e873422a91115 100644 --- a/lib/classes/Icon.php +++ b/lib/classes/Icon.php @@ -9,7 +9,7 @@ * @license GPL2 or any later version * @since 3.2 */ -class Icon +class Icon implements JsonSerializable { const SVG = 1; const CSS_BACKGROUND = 4; @@ -196,6 +196,11 @@ class Icon return $this->asImg(); } + public function jsonSerialize(): mixed + { + return get_object_vars($this); + } + /** * Renders the icon inside an img html tag. * diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php index 76c9a52ae3d171199db2679bd3c820d469fa65ca..c26b0111163d3bde4d019ad6460714e6aa5843b4 100644 --- a/lib/classes/VueApp.php +++ b/lib/classes/VueApp.php @@ -43,6 +43,7 @@ final class VueApp implements Stringable private array $slots = []; private array $stores = []; private array $storeData = []; + private array $components = []; /** * Private constructor since we want to enforce the use of VueApp::create(). @@ -50,6 +51,7 @@ final class VueApp implements Stringable private function __construct( private readonly string $base_component ) { + $this->components[] = $base_component; } /** @@ -152,13 +154,31 @@ final class VueApp implements Stringable return $this->plugins; } + /** + * Registers a component for use e.g. in slots. + */ + public function withComponent(string $component): VueApp + { + $clone = clone $this; + $clone->components[] = $component; + return $clone; + } + + /** + * Returns all components + */ + public function getComponents(): array + { + return $this->components; + } + /** * Returns the template to render the vue app */ public function getTemplate(): Template { $data = [ - 'components' => [$this->base_component], + 'components' => $this->components, ]; if (count($this->stores) > 0) { @@ -174,6 +194,7 @@ final class VueApp implements Stringable $template->attributes = ['data-vue-app' => json_encode($data)]; $template->props = $this->getPreparedProps(); $template->storeData = $this->storeData; + $template->slots = $this->getSlots(); return $template; } diff --git a/lib/models/WikiPage.php b/lib/models/WikiPage.php index 817dfaec0b2bd1330ffb17889f3c2930bab9a104..bb41d1b19e7830fb43a4a01cfa46ecfa2c5c6531 100644 --- a/lib/models/WikiPage.php +++ b/lib/models/WikiPage.php @@ -201,32 +201,6 @@ class WikiPage extends SimpleORMap implements PrivacyObject } - /** - * Returns the start page of a wiki for a given course. The start page has - * the keyword 'WikiWikiWeb'. - * - * @param string $range_id Course id - * @return WikiPage - */ - public static function getStartPage($range_id): WikiPage - { - $page_id = CourseConfig::get($range_id)->WIKI_STARTPAGE_ID; - - if ($page_id) { - return self::find($page_id); - } - - $page = new WikiPage(); - $page->setValue('content', _('Dieses Wiki ist noch leer.')); - if ($page->isEditable()) { - $page->setValue( - 'content', - $page->getValue('content') . ' ' . _("Bearbeiten Sie es!\nNeue Seiten oder Links werden einfach durch Eingeben von [nop][[Wikinamen]][/nop] in doppelten eckigen Klammern angelegt.") - ); - } - return $page; - } - /** * Export available data of a given user into a storage object * (an instance of the StoredUserData class) for that user. diff --git a/lib/modules/CoreWiki.php b/lib/modules/CoreWiki.php index a447451b8e34a9104e0e79e99ed3191718b204a0..b544af3f54fa9c16ed9ba86965a51562fd3e76cc 100644 --- a/lib/modules/CoreWiki.php +++ b/lib/modules/CoreWiki.php @@ -178,28 +178,45 @@ class CoreWiki extends CorePlugin implements StudipModule /** - * Generates a page hierarchy for table of contents/breadcrumbs. - * @return TOCItem + * Generates a TOCItem tree containing all pages in the currently opened wiki + * for use in table of contents/breadcrumbs. + * To prevent cyclic data references, the TOCItems in the tree do not contain + * references to their parent pages. + * This allows the resultant TOCItem to be serialized via json_decode for + * use in Vue. + * + * @param $activePage WikiPage The page that the user has currently navigated to. + * @return TOCItem A TOCItem for the root of the wiki and all of its descendants. */ - public static function getTOC($page, $first = true): TOCItem + public static function getTOC(WikiPage $activePage): TOCItem { - $root = new TOCItem( - ($page && ($page->isNew() || $page->name === 'WikiWikiWeb')) - ? _('Wiki-Startseite') - : $page->name - ); - $root->setURL(URLHelper::getURL('dispatch.php/course/wiki/page/'.$page->id)); - if ($page->name == 'WikiWikiWeb' || $page->id == CourseConfig::get($page->range_id)->WIKI_STARTPAGE_ID) { - $root->setIcon(Icon::create('wiki')); - } - $root->setActive($first); + $rootId = CourseConfig::get(Context::getId())->WIKI_STARTPAGE_ID; + $rootPage = WikiPage::find($rootId) ?: $activePage; - if ($page->parent) { - $parent = self::getTOC($page->parent, false); - $root->setParent($parent); - } + $rootToc = self::getTOCRecursive($rootPage, $activePage->page_id); + $rootToc->setTitle(_('Wiki-Startseite')); + $rootToc->setIcon(Icon::create('wiki')); + return $rootToc; + } - return $root; + /** + * Using a recursive depth-first traversal of the wiki page hierarchy, + * create a TOCItem tree starting at the given $page. + * + * @param WikiPage $page The currently visited page in the traversal. + * @param int|null $active_page_id The id of the page that the user has navigated to. + * @return TOCItem A TOCItem for the given $page and all of its descendants + */ + private static function getTOCRecursive(WikiPage $page, int|null $active_page_id): TOCItem + { + $toc = new TOCItem($page->isNew() ? _('Wiki-Startseite') : $page->name); + $toc->setURL($page->isNew() ? URLHelper::getURL('dispatch.php/course/wiki/page') : URLHelper::getURL('dispatch.php/course/wiki/page/' . $page->id)); + $toc->setActive($page->page_id === $active_page_id); + foreach ($page->children as $child) { + $childToc = self::getTOCRecursive($child, $active_page_id); + $toc->children[] = $childToc; + } + return $toc; } } diff --git a/resources/assets/javascripts/lib/actionmenu.js b/resources/assets/javascripts/lib/actionmenu.js index a81f78abddbdf7cc4b9de13183221a12a6bd6ce4..da85e332b8e40e4f3919d088484c681401b07d53 100644 --- a/resources/assets/javascripts/lib/actionmenu.js +++ b/resources/assets/javascripts/lib/actionmenu.js @@ -103,7 +103,11 @@ class ActionMenu // Reposition the menu? if (position) { - let parents = getScrollableParents(this.element, menu_width, menu_height); + let parents = getScrollableParents(this.element, menu_width, menu_height) + // Prevent us from appending the actionmenu outside of the <body>. + // (If it's appended outside of <body>, some CSS rules will not + // be applied, and the Z-ordering will be incorrect.) + .filter(parent => parent !== document.documentElement); if (parents.length > 0) { const form = this.element.closest('form'); if (form) { diff --git a/resources/assets/stylesheets/scss/contentbar.scss b/resources/assets/stylesheets/scss/contentbar.scss index 3b0aff353705eb811e52c17015132f9436c69ad2..338737bac03b05c0c41c1236057b48aa40505100 100644 --- a/resources/assets/stylesheets/scss/contentbar.scss +++ b/resources/assets/stylesheets/scss/contentbar.scss @@ -2,7 +2,6 @@ background-color: var(--dark-gray-color-10); display: flex; flex-wrap: nowrap; - height: auto; align-items: center; justify-content: flex-start; margin-bottom: 15px; @@ -85,6 +84,9 @@ margin: 0 7px; @-moz-document url-prefix() { + &.contentbar-toc-wrapper { + margin-top: -4px; + } &.contentbar-action-menu-wrapper { margin-top: 2px; } diff --git a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss index 457cd4decd772324de985deb74e0b9ab9be79a27..28ecfbe2c716d4d53911c9f65d5aea62c87310c0 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss @@ -14,17 +14,13 @@ $consum_ribbon_width: calc(100% - 58px); } .cw-ribbon-wrapper-consume { - position: fixed; - padding-top: 15px; + padding-top: 7px; padding-bottom: 15px; background-color: var(--white); - width: $consum_ribbon_width; - height: 46px; z-index: 42; } .cw-ribbon-consume-bottom { - position: fixed; top: 75px; height: 15px; left: 0; @@ -56,38 +52,46 @@ $consum_ribbon_width: calc(100% - 58px); } .cw-ribbon { + position: relative; display: flex; flex-wrap: wrap; - height: auto; - min-height: 30px; + align-items: center; + height: 60px; + min-height: 60px; margin-bottom: 15px; - padding: 1em; + padding: 0 2em; justify-content: space-between; background-color: var(--dark-gray-color-10); &.cw-ribbon-sticky { position: fixed; top: 50px; - width: calc(100% - 346px); + width: calc(100% - 375px); z-index: 40; } &.cw-ribbon-consume { width: $consum_ribbon_width; - position: fixed; margin-bottom: 0; } .cw-ribbon-wrapper-left { display: flex; - max-width: calc(100% - 106px); + gap: 1em; + min-width: 0; + width: 100%; + flex: 1; .cw-ribbon-nav { display: flex; - min-width: 75px; + align-items: center; + + .contentbar-icon { + margin: 0 15px 0 10px; - &.single-icon { - min-width: 45px; + img { + vertical-align: middle; + } } } @@ -138,11 +142,18 @@ $consum_ribbon_width: calc(100% - 58px); } } } + + .cw-ribbon-info-text { + font-size: small; + line-height: 1em; + } } } .cw-ribbon-wrapper-right { display: flex; + gap: 0.5em; + align-items: center; button { border: none; @@ -174,7 +185,7 @@ $consum_ribbon_width: calc(100% - 58px); &.cw-ribbon-button-next { @include background-icon(arr_1right, clickable, 24); - margin: 0 1em 0 0; + margin: 0 0.5em 0 0; } &.cw-ribbon-button-prev-disabled { @@ -185,15 +196,13 @@ $consum_ribbon_width: calc(100% - 58px); &.cw-ribbon-button-next-disabled { @include background-icon(arr_1right, inactive, 24); - margin: 0 1em 0 0; + margin: 0 0.5em 0 0; cursor: default; } } } .cw-ribbon-action-menu { - vertical-align: text-top; - margin: 2px 0 0 2px; &.is-open { z-index: 32; } @@ -204,26 +213,25 @@ $consum_ribbon_width: calc(100% - 58px); border: solid thin var(--content-color-40); box-shadow: 2px 2px var(--dark-gray-color-30); position: absolute; - right: -570px; - top: 15px; + right: 0; + top: 0; height: 100%; max-width: calc(100% - 28px); display: flex; flex-flow: row; - transition: right 0.8s; z-index: 42; - &.unfold { - right: 0px; - margin-right: 15px; - } - &.cw-ribbon-tools-consume { - position: fixed; + right: 15px; + } - &.unfold { - right: 15px; - } + &.cw-ribbon-slide-enter-active, + &.cw-ribbon-slide-leave-active { + transition: transform var(--transition-duration-superslow); + } + &.cw-ribbon-slide-enter, + &.cw-ribbon-slide-leave-to { + transform: translateX(calc(100% + 30px)); } &.cw-ribbon-tools-sticky { @@ -274,6 +282,7 @@ $consum_ribbon_width: calc(100% - 58px); > .cw-tabs-nav { border: none; + height: 57px; width: calc(100% - 48px); > button { @@ -281,7 +290,7 @@ $consum_ribbon_width: calc(100% - 58px); max-width: unset; flex-grow: 0.5; &::after { - margin-top: 16px; + margin-top: 20px; } } } @@ -348,7 +357,7 @@ $consum_ribbon_width: calc(100% - 58px); .cw-structural-element-consumemode { .cw-ribbon-tools { - top: 25px; + top: 18px; } } @@ -370,3 +379,48 @@ $consum_ribbon_width: calc(100% - 58px); height: 75px; } } + +// Rules extracted from PHP contentbar for use in ContentBar.vue +.contentbar-button-wrapper { + height: 24px; + margin: 0 7px; + + @-moz-document url-prefix() { + &.contentbar-toc-wrapper { + margin-top: -4px; + } + &.contentbar-action-menu-wrapper { + margin-top: 2px; + } + } + + .contentbar-button, + .cw-ribbon-button { + background-color: transparent; + background-position: center; + background-repeat: no-repeat; + background-size: 24px; + border: none; + cursor: pointer; + display: inline-block; + height: 24px; + width: 24px; + + &.contentbar-button-menu, + &.cw-ribbon-button-menu { + @include background-icon(table-of-contents, clickable, 24); + } + + &.contentbar-button-zoom::before { + left: -5px; + position: relative; + top: -2px; + } + + @-moz-document url-prefix() { + &.contentbar-button-zoom::before { + top: -3px; + } + } + } +} diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss index cf28364678c8806436cc29d6ad111a2f803c1368..023b31d162b2e3a435f06c7f81d1a5c05997c82e 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss @@ -24,7 +24,7 @@ &::after { display: block; margin-top: 4px; - margin-bottom: -5px; + margin-bottom: -12px; margin-left: -14px; width: calc(100% + 28px); content: ''; diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss index c2ca5c367fd34555d9aa8be3946e4e88041feca5..0a84d3ea95e36ea4ee3000e14842daf919c7086b 100644 --- a/resources/assets/stylesheets/scss/responsive.scss +++ b/resources/assets/stylesheets/scss/responsive.scss @@ -329,7 +329,7 @@ $sidebarOut: -330px; &.responsive-show { animation: slide-in var(--transition-duration) forwards; position: sticky; - top: 100px; + top: 110px; visibility: visible; } @@ -383,10 +383,6 @@ $sidebarOut: -330px; } #responsive-contentbar { - justify-content: stretch; - margin-bottom: 15px; - padding-bottom: 0.5em; - .contentbar-nav, .cw-ribbon-nav { .contentbar-button { @@ -407,71 +403,20 @@ $sidebarOut: -330px; } } - } - - .contentbar-wrapper-left { - flex: 1; - max-width: calc(100% - 70px); - min-width: 0; - width:100%; - - & > .contentbar-icon { - margin-right: 15px; - } - - .contentbar-breadcrumb { - font-size: $font-size-large; - - > img { - margin-left: 15px; - width: 24px; - } - - > span { - display: inline; - flex-shrink: 10000; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - } - - > .contentbar-wrapper-right { - flex: 0; - left: 5px; - position: relative; - - .contentbar-button, - nav { - position: relative; + img { + vertical-align: middle; } } - &.cw-ribbon { - .cw-ribbon-tools { - max-width: calc(100% - 2px); - top: 0; - margin-right: 0; - } + .cw-ribbon-tools { + right: 16px; } - &.cw-ribbon-sticky { - position: unset; - width: calc(100vw - 30px); - } } #toc { - max-width: 100vw; - position: absolute; - right: -8px; - top: -21px; - } - - #toc_header { - height: 47px; + margin-right: -2px; + margin-top: -7px; } #main-footer { @@ -509,7 +454,30 @@ $sidebarOut: -330px; #responsive-contentbar { &.cw-ribbon-sticky { position: unset; - width: calc(100vw - 30px); + width: unset; + } + + .cw-ribbon-breadcrumb { + max-width: 100%; + min-width: unset; + } + + .cw-ribbon-info-text, + .contentbar-icon { + display: none; + } + + &:not(.cw-ribbon) { + .contentbar-wrapper-left { + flex: 1; + max-width: 100%; + text-overflow: ellipsis; + } + } + + .cw-ribbon-tools { + max-width: 100vw; + right: 0; } } @@ -521,10 +489,9 @@ $sidebarOut: -330px; height: calc(100% - 100px); overflow-y: auto; position: fixed; - top: 75px; transform: translateX($sidebarOut); -webkit-transform: translateX($sidebarOut); - top: 80px; + top: 110px; z-index: 100; &.responsive-show { @@ -536,6 +503,12 @@ $sidebarOut: -330px; } } + #toc { + margin-right: -16px; + max-width: 100vw; + width: 100vw; + } + #system-notifications { top: 0; position: fixed; @@ -619,13 +592,13 @@ $sidebarOut: -330px; .contentbar-nav, .cw-ribbon-nav { - margin-left: -8px; + margin-left: -6px; } } #content-wrapper { flex: 1; - margin-top: 75px; + margin-top: 55px; min-height: calc(100vh - 150px); } } @@ -686,11 +659,8 @@ $sidebarOut: -330px; } #toc { - position: absolute; - right: -29px; - top: -25px; + margin-right: 12px; } - } html:not(.responsive-display):not(.fullscreen-mode) { diff --git a/resources/assets/stylesheets/scss/table_of_contents.scss b/resources/assets/stylesheets/scss/table_of_contents.scss index b361a9033fd96a23d06c5dcd95dba1d6720ecbd8..e323c794c612f8ca8d934181e697549cf4b9276a 100644 --- a/resources/assets/stylesheets/scss/table_of_contents.scss +++ b/resources/assets/stylesheets/scss/table_of_contents.scss @@ -15,45 +15,57 @@ ul.numberedchapters { display: none; } -#cb-toc:checked + .check-box + #cb-toc-close + article.toc_overview, button#toc-button:hover article.toc_overview { - visibility: visible; - width: 540px; - overflow: hidden; -} - -#cb-toc-close:checked article.toc_overview { - visibility: hidden; - width: 0; -} - -.toc_overview { - visibility: hidden; - width: 0%; +#toc { + margin: 11px; + text-align: left; z-index: 100; position: absolute; - right: -22px; - top: -25px; + right: -10px; + top: -11px; background-color: var(--white); border: 1px solid var(--content-color-40); margin-bottom: 10px; box-shadow: 2px 2px var(--dark-gray-color-30); - + width: min(100%, 540px); > section { max-width: 100%; overflow-y: scroll; height: 580px; margin-top: 7px; + padding: 5px 15px; } -} -#toc { - margin: 10px; - text-align: left; + .toc-hide-button { + position: absolute; + border: none; + height: 36px; + width: 24px; + min-width: 24px; + margin-right: 1em; + padding: 0 4px; + right: 0; + top: 12px; + cursor: pointer; + @include background-icon(decline, clickable, 24); + background-repeat: no-repeat; + background-size: 24px; + background-position: center right; + background-color: var(--white); + } + + &.cw-ribbon-slide-enter-active, + &.cw-ribbon-slide-leave-active { + transition: transform var(--transition-duration-superslow); + } + &.cw-ribbon-slide-enter, + &.cw-ribbon-slide-leave-to { + transform: translateX(calc(100% + 30px)); + } } #toc_header { - height: 58px; + height: 57px; overflow: hidden; background-color: var(--white); color: var(--black); @@ -73,14 +85,10 @@ ul.numberedchapters { #toc_h1 { color: var(--black); font-weight: 500; - margin-left: 10px; + margin-left: 25px; margin-bottom: unset; } -.toc_transform { - transition: all var(--transition-duration) ease; -} - #main_content { opacity: 1; @@ -121,6 +129,13 @@ section > .toc { img, svg { vertical-align: bottom; } + + a, + span { + img { + margin-right: 10px; + } + } } li#chap1 { @@ -196,10 +211,6 @@ section > .toc { width:375px; } - #toc { - max-width: 94%; - } - ul.breadcrumb { list-style: none; font-size: 18px; diff --git a/resources/studip.d.ts b/resources/studip.d.ts index 5ec8fef976240e59684d18ee3de1eb3cf657cc4d..ec5c065698cb6b778a29c76eafd97d82524f3fa3 100644 --- a/resources/studip.d.ts +++ b/resources/studip.d.ts @@ -22,3 +22,24 @@ export interface InstalledLanguage { picture: string; selected: boolean; } + +enum iconTypes { + 'accept', + 'attention', + 'clickable', + 'inactive', + 'info', + 'info_alt', + 'navigation', + 'new', + 'sort', + 'status-green', + 'status-red', + 'status-yellow' +} + +export interface Icon { + role: iconTypes; + shape: string; + attributes: Record<string, unknown>; +} diff --git a/resources/vue/components/ContentBar.vue b/resources/vue/components/ContentBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..539c5825f8f8af9eaddcbd75d04b48e3ce1aacb8 --- /dev/null +++ b/resources/vue/components/ContentBar.vue @@ -0,0 +1,163 @@ +<template> + <div :class="{ 'cw-ribbon-wrapper-consume': consumeMode }" + :id="isContentBar ? 'contentbar' : undefined"> + <div v-show="stickyRibbon" + class="cw-ribbon-sticky-top"></div> + <div class="cw-ribbon-header-container" + ref="headerContainer"> + <header + ref="header" + :id="isContentBar ? 'cw-ribbon' : undefined" + class="cw-ribbon" + :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }" + > + <div class="cw-ribbon-wrapper-left"> + <nav class="cw-ribbon-nav contentbar-nav" + :class="buttonsClass"> + <div class="contentbar-icon" + v-if="icon"> + <a href=""> + <studip-icon :shape="icon" + role="navigation" + :size="32" /> + </a> + </div> + <slot name="buttons-left" /> + </nav> + <nav class="cw-ribbon-breadcrumb"> + <span v-if="title"> + <a href=""> + {{ title ?? $gettext('(Kein Titel)') }} + </a> + </span> + <slot v-if="breadcrumbFallback && $slots['breadcrumb-fallback']" name="breadcrumb-fallback" /> + <slot v-else name="breadcrumb-list" /> + <div class="cw-ribbon-info-text"> + <slot name="info-text" /> + </div> + </nav> + </div> + <div class="cw-ribbon-wrapper-right"> + <slot name="buttons-right" /> + <ContentBarTableOfContents v-if="toc" :toc="toc" /> + <slot name="menu" /> + </div> + <slot name="other" /> + </header> + </div> + <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div> + <div v-if="stickyRibbon" class="cw-ribbon-sticky-spacer"></div> + </div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; + +import '../../assets/stylesheets/scss/courseware/layouts/ribbon.scss'; +import StudipIcon from './StudipIcon.vue'; +import { store } from '../../assets/javascripts/chunks/vue'; +import { TOCItem, traverse } from './table-of-contents'; +import ContentBarTableOfContents from './ContentBarTableOfContents.vue'; + +export default defineComponent({ + name: 'ContentBar', + components: { ContentBarTableOfContents, StudipIcon }, + data() { + return { + stickyRibbon: false, + // This flag lets us run a hook the first time that this component's + // dom is updated by vue. See updated() hook. + hasPerformedFirstUpdate: false, + // This intersection observer allows us to respond to changes in + // the contentbar's visibility so that it is always displayed + // at the correct height on the page after being hidden or shown for + // any reason (e.g. when Courseware search results are opened/closed). + observer: undefined as IntersectionObserver | undefined, + }; + }, + props: { + // (Optional) A class that is applied to the <nav> element where the + // 'icon', if any (see 'icon' prop), and the buttons-left slot are displayed. + buttonsClass: String, + // (Optional) If provided, displayed in the same place as the breadcrumb-list + // and breadcrumb-fallback slots. + title: String, + // (Optional) If provided, displays the given icon before the 'icons-left' slot. + icon: String, + // If true, this element serves as the global contentbar. + // It will stick to the top of the screen when the page is scrolled down, + // and it will be pinned to the top of the screen in compact mode. + // If false, this element will not be used as the global contentbar. + // It will just be a normal element on the page that looks the same as the + // global contentbar, but does not have any special sticky behavior. + isContentBar: { + type: Boolean, + required: false, + }, + // (Optional) If provided, a 'table of contents' icon will be shown on + // the right side of the ContentBar. When clicked, it will open/close a + // panel with the table of contents inside. + toc: Object as PropType<TOCItem>, + }, + mounted() { + window.addEventListener('scroll', this.handleScroll); + this.observer = new IntersectionObserver(this.intersectionCallback); + this.observer.observe(this.$el); + this.$forceUpdate(); + }, + updated() { + // The "Responsive Toolbar" works by reaching inside the DOM template of + // this component and grabbing some elements from it to stick them + // in the ResponsiveToolbar, jquery-style. + // That only works if the elements are actually present in the DOM + // when the courseware-contentbar-mounted event is fired. + // To ensure that that is the case, we defer emitting that event until + // this component's dom has been fully rendered for the first time. + // This trick is brought to you by the Vue 2 docs: + // https://v2.vuejs.org/v2/api/#updated + if (this.hasPerformedFirstUpdate) { + return; + } + this.hasPerformedFirstUpdate = true; + this.$nextTick(() => { + if (this.isContentBar) { + // TODO rename this event. + window.STUDIP.eventBus.emit('courseware-contentbar-mounted', this); + } + }); + }, + beforeDestroy() { + if (this.isContentBar) { + window.STUDIP.eventBus.emit('courseware-contentbar-before-destroy', this); + } + window.removeEventListener('scroll', this.handleScroll); + this.observer!.disconnect(); + }, + watch: { + stickyRibbon(value) { + this.$emit('stickyRibbonChange', value); + }, + }, + computed: { + consumeMode(): boolean { + // We have to access the global studipStore over an import rather than + // using $store/mapState/mapGetters/etc., because this component is + // compatible with Courseware, and in the various Courseware apps, + // $store does not include the global StudipStore. + return store.state.studip.consumeMode; + }, + breadcrumbFallback(): boolean { + return window.outerWidth < 1200; + }, + }, + methods: { + intersectionCallback(entries: IntersectionObserverEntry[]) { + this.handleScroll(); + }, + handleScroll() { + const top = this.$el.getBoundingClientRect().top; + this.stickyRibbon = this.isContentBar && top <= 50 && !this.consumeMode; + }, + }, +}); +</script> diff --git a/resources/vue/components/ContentBarBreadcrumbs.vue b/resources/vue/components/ContentBarBreadcrumbs.vue new file mode 100644 index 0000000000000000000000000000000000000000..35606b46178940d9d9e5f4e8adc4e59ae16debd5 --- /dev/null +++ b/resources/vue/components/ContentBarBreadcrumbs.vue @@ -0,0 +1,78 @@ +<template> + <ul> + <li v-for="(breadcrumb, index) in breadcrumbs" class="cw-ribbon-breadcrumb-item" :key="index"> + <span v-if="breadcrumb.active">{{ breadcrumb.title }}</span> + <a v-else :href="breadcrumb.url">{{ breadcrumb.title }}</a> + </li> + </ul> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import { TOCItem, traverse } from './table-of-contents'; + +interface Breadcrumb { + title: string; + url: string; + active: boolean; +} + +export default defineComponent({ + name: 'ContentBarBreadcrumbs', + props: { + // The table of contents tree for the page that is currently open. + toc: { + type: Object as PropType<TOCItem>, + required: true, + }, + }, + computed: { + // Convert the nested TOCItem into a list of breadcrumbs we can iterate through + // in the template. + breadcrumbs(): Breadcrumb[] { + // First, clone the toc and add parent references to it. + // (The parent references are lost in serialization from PHP to JS.) + const tocClone = JSON.parse(JSON.stringify(this.toc)); + this.addParentReferences(tocClone); + + // Then, find the TOCItem corresponding to the page that is currently open. + const activeTocItem = this.findActiveTocItem(tocClone); + if (!activeTocItem) { + console.error('No TOCItem is marked as active. No breadcrumbs will be rendered.'); + return []; + } + + // Finally, iterate upwards from the active TOC Item, through its parent, grandparent, ... + // up to the root, generating a breadcrumb at each step of the way. + const breadcrumbs = [{ title: activeTocItem.title, url: activeTocItem.url, active: true }]; + let current = activeTocItem; + while (current.parent) { + current = current.parent; + breadcrumbs.push({ title: current.title, url: current.url, active: false }); + } + return breadcrumbs.reverse(); + }, + }, + methods: { + // Find the TOCItem, if any, that is marked as active in the given toc tree. + findActiveTocItem(toc: TOCItem): TOCItem | undefined { + let activeItem: TOCItem | undefined; + traverse(toc, (item) => { + if (item.active) { + activeItem = item; + } + }); + return activeItem; + }, + // Augment each node in the given toc tree with a reference to its parent. + addParentReferences(tocItem: TOCItem, parent?: TOCItem): void { + if (parent) { + tocItem.parent = parent; + } + for (let child of tocItem.children) { + this.addParentReferences(child, tocItem); + } + }, + }, +}); +</script> diff --git a/resources/vue/components/ContentBarTableOfContents.vue b/resources/vue/components/ContentBarTableOfContents.vue new file mode 100644 index 0000000000000000000000000000000000000000..525c4d69872b298a5791dc3f3d3980efd7da75c9 --- /dev/null +++ b/resources/vue/components/ContentBarTableOfContents.vue @@ -0,0 +1,63 @@ +<template> + <div class="contentbar-button-wrapper contentbar-toc-wrapper"> + <button v-if="!tocOpen" + class="cw-ribbon-button cw-ribbon-button-menu" + :title="$gettext('Inhaltsverzeichnis öffnen')" + @click.prevent="showTOC(true)"></button> + <transition name="cw-ribbon-slide" appear> + <article v-if="tocOpen" id="toc"> + <header id="toc_header"> + <h1 id="toc_h1"> + {{ $gettextInterpolate('Inhalt (%{count} Elemente)', { count: tocItemsCount }) }} + </h1> + <button class="toc-hide-button" + :title="$gettext('Inhaltsverzeichnis schließen')" + @click.prevent="showTOC(false)"></button> + </header> + <section> + <ul class="toc"> + <ContentBarTocItemList :toc="toc" /> + </ul> + </section> + </article> + </transition> + </div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import { TOCItem, traverse } from './table-of-contents'; +import ContentBarTocItemList from './ContentBarTocItemList.vue'; + +export default defineComponent({ + name: 'ContentBarTableOfContents', + components: { ContentBarTocItemList }, + props: { + toc: { + required: true, + type: Object as PropType<TOCItem>, + }, + }, + data() { + return { + tocOpen: false + } + }, + computed: { + tocItemsCount(): number { + if (!this.toc) { + return 0; + } + // Count how many items are in the TOC tree. + let count = 0; + traverse(this.toc, (item) => count++); + return count; + }, + }, + methods: { + showTOC(state = true) { + this.tocOpen = state; + } + } +}); +</script> diff --git a/resources/vue/components/ContentBarTocItemList.vue b/resources/vue/components/ContentBarTocItemList.vue new file mode 100644 index 0000000000000000000000000000000000000000..15101c480ca224c98de0443498514ebb0dd42d45 --- /dev/null +++ b/resources/vue/components/ContentBarTocItemList.vue @@ -0,0 +1,33 @@ +<template> + <li class="chapter" :class="{ active: toc.active }"> + <span v-if="toc.active"> + <StudipIcon v-if="toc.icon" :shape="toc.icon.shape" role="info" :size="24" /> + {{ toc.title }} + </span> + <a v-else class="navigate" :href="toc.url"> + <StudipIcon v-if="toc.icon" :shape="toc.icon.shape" role="info" :size="24" /> + {{ toc.title }} + </a> + <ul v-if="toc.children.length > 0"> + <ContentBarTocItemList v-for="child in toc.children" :key="child.url" :toc="child" /> + </ul> + </li> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; + +import { TOCItem } from './table-of-contents'; +import StudipIcon from './StudipIcon.vue'; + +export default defineComponent({ + name: 'ContentBarTocItemList', + components: { StudipIcon }, + props: { + toc: { + required: true, + type: Object as PropType<TOCItem>, + }, + }, +}); +</script> diff --git a/resources/vue/components/WikiEditor.vue b/resources/vue/components/WikiEditor.vue index 069b2ff750d1213547a660bd3f63faeda172c7ee..3b15bb03a7a925f19825eca135c2346bc3254339 100644 --- a/resources/vue/components/WikiEditor.vue +++ b/resources/vue/components/WikiEditor.vue @@ -1,5 +1,14 @@ <template> <div> + <ContentBar isContentBar icon="wiki" :toc="toc"> + <template #info-text> + {{ $gettext('Zuletzt gespeichert') }}: + <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)" + :relative="true" + /> + </template> + <template #breadcrumb-list><content-bar-breadcrumbs :toc="toc"/></template> + </ContentBar> <form :action="saveUrl" method="post" class="default" v-show="isEditing"> <input type="hidden" :name="csrf.name" :value="csrf.value"> @@ -67,21 +76,18 @@ <wiki-editor-online-users :users="onlineUsers"></wiki-editor-online-users> - <mounting-portal :mount-to="`.wiki-last-edited-${pageId}`"> - <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)" - :relative="true" - ></studip-date-time> - </mounting-portal> </div> </template> <script> import WikiEditorOnlineUsers from "./WikiEditorOnlineUsers.vue"; import StudipDateTime from "./StudipDateTime.vue"; import JSUpdater from "@/assets/javascripts/lib/jsupdater"; +import ContentBar from "./ContentBar.vue"; +import ContentBarBreadcrumbs from "./ContentBarBreadcrumbs.vue"; export default { name: 'wiki-editor', - components: {StudipDateTime, WikiEditorOnlineUsers }, + components: { ContentBarBreadcrumbs, ContentBar, StudipDateTime, WikiEditorOnlineUsers }, props: { cancelUrl: { type: String, @@ -114,6 +120,10 @@ export default { users: { type: Array, default: () => [] + }, + toc: { + type: Object, + required: true } }, data() { diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue index f7cfff04c1066d8b4ae077227289fc10b921efa3..3f6eaba4489d9f5736bd6ecea2136f980230e4b0 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue @@ -1,168 +1,99 @@ <template> - <div :class="{ 'cw-ribbon-wrapper-consume': consumeMode }" :id="isContentBar ? 'contentbar' : null" > - <div v-show="stickyRibbon" class="cw-ribbon-sticky-top"></div> - <header :id="isContentBar ? 'cw-ribbon' : null" class="cw-ribbon" :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }"> - <div class="cw-ribbon-wrapper-left"> - <nav class="cw-ribbon-nav" :class="buttonsClass"> - <slot name="buttons" /> - </nav> - <nav class="cw-ribbon-breadcrumb"> - <ul> - <slot v-if="breadcrumbFallback" name="breadcrumbFallback" /> - <slot v-else name="breadcrumbList" /> - </ul> - </nav> - </div> - <div class="cw-ribbon-wrapper-right"> - <button - v-if="showToolbarButton" - class="cw-ribbon-button cw-ribbon-button-menu" - :title="textRibbon.toolbar" - @click.prevent="activeToolbar" - > - </button> - <slot name="menu" /> - </div> - <div v-if="consumeMode" class="cw-ribbon-consume-bottom"></div> - <courseware-ribbon-toolbar - v-if="showTools" - :toolsActive="unfold" - :stickyRibbon="stickyRibbon" - :class="{ 'cw-ribbon-tools-sticky': stickyRibbon }" - :style="{ height: toolbarHeight + 'px' }" - :canEdit="canEdit" - @deactivate="deactivateToolbar" - @blockAdded="$emit('blockAdded')" - /> - </header> - <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div> - <div v-if="stickyRibbon" class="cw-ribbon-sticky-spacer"></div> - </div> + <content-bar is-content-bar @stickyRibbonChange="onStickyRibbonChange"> + <template #buttons-right> + <button + class="cw-ribbon-button cw-ribbon-button-menu" + :title="strings.toolbar" + @click.prevent="activateToolbar" + ></button> + </template> + <template #other> + <transition name="cw-ribbon-slide"> + <courseware-ribbon-toolbar + ref="toolbar" + v-show="showToolbar" + :stickyRibbon="stickyRibbon" + :class="{ 'cw-ribbon-tools-sticky': stickyRibbon }" + :style="{ height: toolbarHeight + 'px' }" + @deactivate="deactivateToolbar" + @blockAdded="$emit('blockAdded')" + /> + </transition> + </template> + <!-- Pass these slots through to the ContentBar. --> + <template #menu><slot name="menu" /></template> + <template #buttons-left><slot name="buttons-left" /></template> + <template #breadcrumb-list><slot name="breadcrumb-list" /></template> + <template #breadcrumb-fallback><slot name="breadcrumb-fallback" /></template> + <template #info-text><slot name="info-text"/></template> + </content-bar> </template> -<script> +<script lang="ts"> +import ContentBar from '../../ContentBar.vue'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import Vue from 'vue'; import CoursewareRibbonToolbar from './CoursewareRibbonToolbar.vue'; -import { mapActions, mapGetters } from 'vuex'; +import { store } from '../../../../assets/javascripts/chunks/vue'; -export default { - name: 'courseware-ribbon', - components: { - CoursewareRibbonToolbar, - }, - props: { - canEdit: Boolean, - showToolbarButton: { - default: true, - type: Boolean - }, - showModeSwitchButton: { - default: true, - type: Boolean - }, - buttonsClass: String, - isContentBar: { - type: Boolean, - default: false - } - }, +export default Vue.extend({ + name: 'CoursewareRibbon', + components: { CoursewareRibbonToolbar, ContentBar }, data() { return { - readModeActive: false, + // This value is derived from stickyRibbonChange events emitted by + // the ContentBar component (see template). stickyRibbon: false, - textRibbon: { - toolbar: this.$gettext('Inhaltsverzeichnis'), - fullscreen_on: this.$gettext('Fokusmodus einschalten'), - fullscreen_off: this.$gettext('Fokusmodus ausschalten'), - }, - unfold: false, - showTools: false, }; }, computed: { ...mapGetters({ - consumeMode: 'consumeMode', - toolsActive: 'showToolbar' + showToolbar: 'showToolbar', }), - breadcrumbFallback() { - return window.outerWidth < 1200; + consumeMode(): boolean { + // TODO ensure that there is only one global StudipStore / 'studip' store module + // across Courseware and chunks/vue.js. + // Currently, the 'studip' module of the courseware store is deceivingly named. + // It is a completely different store than the one in chunks/vue.js. + // It just happens to have a module with the same name, 'studip'. + // So, to access the global studipStore, we have to import it and access it like this. + return store.state.studip.consumeMode; + }, + strings() { + return { + toolbar: this.$gettext('Inhaltsverzeichnis'), + }; }, toolbarHeight() { if (this.stickyRibbon) { - return parseInt(window.innerHeight * 0.75); + return window.innerHeight * 0.75; } else { - return parseInt(Math.min(window.innerHeight * 0.75, window.innerHeight - 197)); + return Math.min(window.innerHeight * 0.75, window.innerHeight - 197); } - } + }, + }, + watch: { + consumeMode(newState: boolean) { + if (newState) { + console.log('consumeMode watcher ', newState, 'setting coursewareViewMode "read"'); + this.coursewareViewMode('read'); + } + }, }, methods: { + onStickyRibbonChange(value: boolean) { + this.stickyRibbon = value; + }, ...mapActions({ - coursewareConsumeMode: 'coursewareConsumeMode', coursewareViewMode: 'coursewareViewMode', - coursewareShowToolbar: 'coursewareShowToolbar' - + coursewareShowToolbar: 'coursewareShowToolbar', }), - toggleConsumeMode() { - STUDIP.eventBus.emit('toggle-focus-mode', !this.consumeMode); - if (!this.consumeMode) { - document.body.classList.add('consuming_mode'); - this.coursewareConsumeMode(true); - this.coursewareViewMode('read'); - } else { - this.coursewareConsumeMode(false); - document.body.classList.remove('consuming_mode'); - } - }, - activeToolbar() { + activateToolbar() { this.coursewareShowToolbar(true); }, deactivateToolbar() { this.coursewareShowToolbar(false); }, - handleScroll() { - if (window.outerWidth > 767) { - this.stickyRibbon = window.scrollY > 128 && !this.consumeMode; - } else { - this.stickyRibbon = window.scrollY > 75 && !this.consumeMode; - } - }, - }, - mounted() { - window.addEventListener('scroll', this.handleScroll); - if (this.isContentBar) { - STUDIP.eventBus.emit('courseware-contentbar-mounted', this); - } - - this.globalOn('switch-focus-mode', (state) => { - if (state !== this.consumeMode) { - this.toggleConsumeMode(); - } - }); }, - beforeDestroy() { - STUDIP.eventBus.off('switch-focus-mode'); - }, - watch: { - toolsActive(newState, oldState) { - let view = this; - if(newState) { - this.showTools = true; - setTimeout(() => {view.unfold = true}, 10); - } else { - this.unfold = false; - setTimeout(() => { - if(!view.toolsActive) { - view.showTools = false; - } - }, 800); - } - }, - consumeMode(newState) { - if (newState) { - document.body.classList.add('consuming_mode'); - } else { - document.body.classList.remove('consuming_mode'); - } - } - } -}; +}); </script> diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue index 2a92579fdc0f749e01acfac679e296069cce98a8..fe99808e6406006045751341a6e18f678c246626 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue @@ -2,7 +2,7 @@ <focus-trap v-model="trap" :initial-focus="() => initialFocusElement" :clickOutsideDeactivates="true" :fallbackFocus ="() => fallbackFocusElement"> <div class="cw-ribbon-tools" - :class="{ unfold: toolsActive, 'cw-ribbon-tools-consume': consumeMode }" + :class="{ 'cw-ribbon-tools-consume': consumeMode }" > <div class="cw-ribbon-tool-content"> <div class="cw-ribbon-tool-content-nav"> @@ -49,6 +49,7 @@ import CoursewareToolsContents from './CoursewareToolsContents.vue'; import CoursewareToolsUnits from './CoursewareToolsUnits.vue'; import { FocusTrap } from 'focus-trap-vue'; import { mapActions, mapGetters } from 'vuex'; +import { store } from "../../../../assets/javascripts/chunks/vue"; export default { name: 'courseware-ribbon-toolbar', @@ -60,16 +61,6 @@ export default { FocusTrap, }, props: { - toolsActive: Boolean, - canEdit: Boolean, - disableSettings: { - type: Boolean, - default: false, - }, - disableAdder: { - type: Boolean, - default: false, - }, stickyRibbon: { type: Boolean, default: false, @@ -84,9 +75,11 @@ export default { }; }, computed: { + consumeMode() { + return store.state.studip.consumeMode; + }, ...mapGetters({ userIsTeacher: 'userIsTeacher', - consumeMode: 'consumeMode', containerAdder: 'containerAdder', adderStorage: 'blockAdder', viewMode: 'viewMode', @@ -108,28 +101,25 @@ export default { coursewareContainerAdder: 'coursewareContainerAdder', }), scrollToCurrent() { - setTimeout(() => { - let contents = this.$refs.contents.$el; - let current = contents.querySelector('.cw-tree-item-link-current'); - if (current) { - contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' }); - } - }, 360); + let contents = this.$refs.contents.$el; + let current = contents.querySelector('.cw-tree-item-link-current'); + if (current) { + contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' }); + } }, - }, - mounted () { - this.scrollToCurrent(); - }, - watch: { - toolsActive(newValue) { + activate() { const focusElement = this.$refs.tabs.getTabButtonByAlias(this.selectedToolbarItem); - if (newValue && focusElement) { - setTimeout(() => { - this.initialFocusElement = focusElement; - this.trap = true; - }, 300); + if (focusElement) { + this.initialFocusElement = focusElement; + this.trap = true; } }, }, + mounted() { + this.$nextTick(() => { + this.activate(); + this.$nextTick(() => this.scrollToCurrent()); + }); + }, }; </script> diff --git a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue index 6efbca260e2e82b396bdfe6ee6e1281da37431e0..5d7547d4d9e42bc0ae2532b4b01fefb199aa624e 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue @@ -1,14 +1,10 @@ <template> <div role="region" id="search" aria-live="polite"> - <courseware-ribbon - :showToolbarButton="false" - :showModeSwitchButton="false" - buttonsClass="single-icon" - > - <template #buttons> + <ContentBar> + <template #buttons-left> <studip-icon shape="search" :size="24" /> </template> - <template #breadcrumbList> + <template #breadcrumb-list> <translate>Suchergebnisse</translate> </template> <template #menu> @@ -16,7 +12,7 @@ <studip-icon shape="decline" :size="24"/> </button> </template> - </courseware-ribbon> + </ContentBar> <div id="search-results"> <article v-if="searchResults.length > 0"> <section v-for="result in searchResults" :key="result['structural-element-id']"> @@ -49,15 +45,15 @@ </template> <script> -import CoursewareRibbon from './CoursewareRibbon.vue'; import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; import StudipIcon from '../../StudipIcon.vue'; import { mapActions, mapGetters } from 'vuex'; +import ContentBar from "../../ContentBar.vue"; export default { name: 'courseware-search-results', components: { - CoursewareRibbon, + ContentBar, CoursewareCompanionBox, StudipIcon }, diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue index 2b7cdbc610b571c5b25442f6d6d550c5ce0e0841..271af3ec19a6275397bedbd2fe673a1158869d03 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue @@ -8,11 +8,8 @@ > <div v-if="structuralElement" class="cw-structural-element-content"> <courseware-ribbon - :canEdit="canEdit && canAddElements" - :isContentBar="true" - @blockAdded="updateContainerList" - > - <template #buttons> + @blockAdded="updateContainerList"> + <template #buttons-left> <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id"> <div class="cw-ribbon-button cw-ribbon-button-prev" :title="$gettext('zurück')" /> </router-link> @@ -30,66 +27,68 @@ :title="$gettext('Keine nächste Seite')" /> </template> - <template #breadcrumbList> - <li - v-for="ancestor in ancestors" - :key="ancestor.id" - :title="ancestor.attributes.title" - class="cw-ribbon-breadcrumb-item" - > - <span> - <router-link :to="'/structural_element/' + ancestor.id">{{ - ancestor.attributes.title || '–' - }}</router-link> - </span> - </li> - <li - class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" - :title="structuralElement.attributes.title" - > - <span>{{ structuralElement.attributes.title || '–' }}</span> - <span v-if="isTask">[ {{ solverName }} ]</span> - <template v-if="!userIsTeacher && inCourse"> - <studip-icon - v-if="complete" - shape="accept" - role="info" - :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')" - /> - <span - v-else + <template #breadcrumb-list> + <ul> + <li + v-for="ancestor in ancestors" + :key="ancestor.id" + :title="ancestor.attributes.title" + class="cw-ribbon-breadcrumb-item" + > + <span> + <router-link :to="'/structural_element/' + ancestor.id">{{ ancestor.attributes.title || '–' }}</router-link> + </span> + </li> + <li + class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" + :title="structuralElement.attributes.title" + > + <span>{{ structuralElement.attributes.title || '–' }}</span> + <span v-if="isTask">[ {{ solverName }} ]</span> + <template v-if="!userIsTeacher && inCourse"> + <studip-icon + v-if="complete" + shape="accept" + role="info" + :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')" + /> + <span + v-else + :title="$gettextInterpolate( + $gettext('Fortschritt: %{progress} %'),{ + progress: elementProgress, + }) + " + > + ({{ elementProgress }} %) + </span> + </template> + <studip-five-stars + v-if="showFeedbackInContentbar && hasFeedbackElement" + :amount="hasFeedbackAverage ? feedbackAverage : 5" + :size="16" + :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'" :title=" - $gettextInterpolate($gettext('Fortschritt: %{progress} %'), { - progress: elementProgress, + hasFeedbackAverage + ?$gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), { + avg: feedbackAverage, }) + :$gettext('Seite wurde noch nicht bewertet') " - > - ({{ elementProgress }} %) - </span> - </template> - <studip-five-stars - v-if="showFeedbackInContentbar && hasFeedbackElement" - :amount="hasFeedbackAverage ? feedbackAverage : 5" - :size="16" - :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'" - :title=" - hasFeedbackAverage - ? $gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), { - avg: feedbackAverage, - }) - : $gettext('Seite wurde noch nicht bewertet') - " - @click="menuAction('showFeedback')" - /> - </li> + @click="menuAction('showFeedback')" + /> + </li> + </ul> </template> - <template #breadcrumbFallback> - <li - class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" - :title="structuralElement.attributes.title" - > - <span>{{ structuralElement.attributes.title }}</span> - </li> + <template #breadcrumb-fallback> + <ul> + <li + class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" + :title="structuralElement.attributes.title" + > + <span>{{ structuralElement.attributes.title }}</span> + </li> + </ul> </template> <template #menu> <studip-action-menu @@ -440,6 +439,7 @@ import CoursewareStructuralElementDialogPublicLink from './CoursewareStructuralE import CoursewareStructuralElementDiscussion from './CoursewareStructuralElementDiscussion.vue'; import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue'; +import CoursewareRibbon from "./CoursewareRibbon.vue"; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import colorMixin from '@/vue/mixins/courseware/colors.js'; @@ -455,6 +455,7 @@ import StudipProgressIndicator from '../../StudipProgressIndicator.vue'; import draggable from 'vuedraggable'; import containerMixin from '@/vue/mixins/courseware/container.js'; import { mapActions, mapGetters } from 'vuex'; +import { store } from "../../../../assets/javascripts/chunks/vue"; export default { name: 'courseware-structural-element', @@ -488,6 +489,7 @@ export default { StudipDialog, StudipProgressIndicator, draggable, + CoursewareRibbon, }), props: ['canVisit', 'orderedStructuralElements', 'structuralElement'], @@ -526,12 +528,14 @@ export default { }, computed: { + consumeMode() { + return store.state.studip.consumeMode; + }, ...mapGetters({ courseware: 'courseware', rootId: 'rootId', currentUnit: 'currentUnit', context: 'context', - consumeMode: 'consumeMode', containerById: 'courseware-containers/byId', relatedContainers: 'courseware-containers/related', relatedStructuralElements: 'courseware-structural-elements/related', diff --git a/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue b/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue index e968f7c4f5363fe3dbb0b364b3a563dc59d61719..8f0ec3bc966f1b9e51fed94f8a3acddf8a712482 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue @@ -25,7 +25,6 @@ export default { name: 'courseware-welcome-screen', computed: { ...mapGetters({ - consumeMode: 'consumeMode', lastCreatedBlocks: 'courseware-blocks/lastCreated', lastCreatedContainers: 'courseware-containers/lastCreated' }), @@ -40,13 +39,11 @@ export default { lockObject: 'lockObject', unlockObject: 'unlockObject', - coursewareConsumeMode: 'coursewareConsumeMode', coursewareContainerAdder: 'coursewareContainerAdder', coursewareShowToolbar: 'coursewareShowToolbar' }), addContainer() { - this.coursewareConsumeMode(false); this.coursewareShowToolbar(true); this.$nextTick(() => { this.coursewareContainerAdder(true); @@ -67,7 +64,6 @@ export default { section: 0, blockType: 'text', }); - this.coursewareConsumeMode(false); this.companionSuccess({ info: this.$gettext('Das Elemente für Ihren ersten Inhalt wurde angelegt.'), }); @@ -76,7 +72,7 @@ export default { const structuralElementId = this.$route.params.id await this.updateContainer({ container: newContainer, structuralElementId: structuralElementId }); await this.unlockObject({ id: newContainer.id, type: 'courseware-containers' }); - + } } diff --git a/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue index 565117198b9357b71945ef8f3f0e4db40a2b583c..a63d1f82d9f37ddb7eefec1fc3e13bc024594352 100644 --- a/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue @@ -5,8 +5,8 @@ class="cw-structural-element" > <div v-if="structuralElement" class="cw-structural-element-content"> - <courseware-ribbon :canEdit="false" :disableSettings="true" :disableAdder="true"> - <template #buttons> + <ContentBar isContentBar> + <template #buttons-left> <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id"> <div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" /> </router-link> @@ -16,7 +16,7 @@ </router-link> <div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('keine nächste Seite')"/> </template> - <template #breadcrumbList> + <template #breadcrumb-list> <li v-for="ancestor in ancestors" :key="ancestor.id" @@ -34,7 +34,7 @@ <span>{{ structuralElement.attributes.title || "–" }}</span> </li> </template> - <template #breadcrumbFallback> + <template #breadcrumb-fallback> <li class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" :title="structuralElement.attributes.title" @@ -42,7 +42,7 @@ <span>{{ structuralElement.attributes.title }}</span> </li> </template> - </courseware-ribbon> + </ContentBar> <div class="cw-container-wrapper" @@ -79,12 +79,15 @@ import CoursewarePluginComponents from '../plugin-components.js'; import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue'; import { mapActions, mapGetters } from 'vuex'; +import ContentBar from "../../ContentBar.vue"; +import { store } from "../../../../assets/javascripts/chunks/vue"; export default { name: 'public-courseware-structural-element', components: Object.assign(StructuralElementComponents, { CoursewareCompanionOverlay, + ContentBar, }), props: ['orderedStructuralElements', 'structuralElement'], @@ -100,10 +103,12 @@ export default { }, computed: { + consumeMode() { + return store.state.studip.consumeMode; + }, ...mapGetters({ courseware: 'courseware', context: 'context', - consumeMode: 'consumeMode', containerById: 'courseware-containers/byId', pluginManager: 'pluginManager', relatedContainers: 'courseware-containers/related', diff --git a/resources/vue/components/courseware/structural-element/structural-element-components.js b/resources/vue/components/courseware/structural-element/structural-element-components.js index 40586c2e683737871649aeb14f48aa81afb94d88..0627eb9a52776e062e863de86c7e281d1275492f 100644 --- a/resources/vue/components/courseware/structural-element/structural-element-components.js +++ b/resources/vue/components/courseware/structural-element/structural-element-components.js @@ -1,6 +1,6 @@ import CoursewareToolbar from './../toolbar/CoursewareToolbar.vue'; // contentbar -import CoursewareRibbon from './CoursewareRibbon.vue'; +import ContentBar from "../../ContentBar.vue"; import CoursewareTabs from '../layouts/CoursewareTabs.vue'; import CoursewareTab from '../layouts/CoursewareTab.vue'; import { FocusTrap } from 'focus-trap-vue'; @@ -16,7 +16,7 @@ import CoursewareTabsContainer from '../containers/CoursewareTabsContainer.vue'; const StructuralElementComponents = { CoursewareToolbar, //contentbar - CoursewareRibbon, + ContentBar, CoursewareTabs, CoursewareTab, FocusTrap, @@ -30,4 +30,4 @@ const StructuralElementComponents = { CoursewareTabsContainer } -export default StructuralElementComponents; \ No newline at end of file +export default StructuralElementComponents; diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue index 426b0cbff4b36668e11434772c41d1fb5fb4bcd6..2b86c713769c9b1c084626080bd7e03fd66da085 100644 --- a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue @@ -1,17 +1,17 @@ <template> <div class="cw-dashboard-students-wrapper"> - <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> - <template #buttons> + <ContentBar isContentBar> + <template #buttons-left> <router-link :to="{ name: 'task-groups-index' }"> <StudipIcon shape="category-task" :size="24" /> </router-link> </template> - <template #breadcrumbList> + <template #breadcrumb-list> <li> {{ $gettext('Aufgaben') }} </li> </template> - </CoursewareRibbon> + </ContentBar> <table class="default" v-if="taskGroups.length"> <thead> <tr class="sortable"> @@ -79,7 +79,6 @@ import _ from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; -import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; import StudipActionMenu from '../../StudipActionMenu.vue'; import StudipDate from '../../StudipDate.vue'; @@ -88,12 +87,13 @@ import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; import { getStatus } from './task-groups-helper.js'; +import ContentBar from "../../ContentBar.vue"; export default { name: 'courseware-dashboard-students', components: { + ContentBar, CompanionBox, - CoursewareRibbon, CoursewareTasksDialogDistribute, StudipActionMenu, StudipDate, @@ -207,12 +207,6 @@ export default { </script> <style scoped> -.cw-dashboard-students-wrapper >>> .cw-ribbon-nav { - min-width: 24px; - padding: 0 1em; - height: 24px; - margin-top: 2px; -} th { cursor: pointer; } diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue index e17d18e60a4701d77f26ac69c0072fb5ee7e40e3..b0530c45da39361c713f037d02683644f09dc61f 100644 --- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue @@ -5,13 +5,13 @@ </MountingPortal> <div v-if="taskGroup" class="cw-tasks-list"> - <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> - <template #buttons> + <ContentBar isContentBar> + <template #buttons-left> <router-link :to="{ name: 'task-groups-index' }"> <StudipIcon shape="category-task" :size="24" /> </router-link> </template> - <template #breadcrumbList> + <template #breadcrumb-list> <li> <router-link :to="{ name: 'task-groups-index' }"> {{ $gettext('Aufgaben') }} @@ -19,7 +19,7 @@ </li> <li>{{ taskGroup.attributes['title'] }}</li> </template> - </CoursewareRibbon> + </ContentBar> <TaskGroup :taskGroup="taskGroup" @@ -67,7 +67,6 @@ import { mapActions, mapGetters } from 'vuex'; import AddFeedbackDialog from './AddFeedbackDialog.vue'; import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; -import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; import EditFeedbackDialog from './EditFeedbackDialog.vue'; @@ -76,12 +75,13 @@ import TaskGroup from './TaskGroup.vue'; import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; +import ContentBar from "../../ContentBar.vue"; export default { components: { + ContentBar, AddFeedbackDialog, CompanionBox, - CoursewareRibbon, CoursewareTasksActionWidget, CoursewareTasksDialogDistribute, EditFeedbackDialog, @@ -215,10 +215,4 @@ export default { </script> <style scoped> -.cw-tasks-wrapper >>> .cw-ribbon-nav { - min-width: 24px; - padding: 0 1em; - height: 24px; - margin-top: 2px; -} </style> diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue index 8988a2251ce41e427a2ea2a0bad15a96cc98a359..bbf322c5991ef48f699626b8aec53543749c9a96 100644 --- a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue +++ b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue @@ -172,7 +172,7 @@ export default { return; } - const ribbon = document.getElementById('cw-ribbon') ?? document.getElementById('contentbar'); + const ribbon = document.getElementById('cw-ribbon'); if (ribbon) { const contentbarRect = ribbon.getBoundingClientRect(); if (ribbon.classList.contains('cw-ribbon-sticky')) { diff --git a/resources/vue/components/responsive/ResponsiveContentBar.vue b/resources/vue/components/responsive/ResponsiveContentBar.vue index eb6dd9636b962fd63422987d01750aa2c3c486dc..d21ae21028c78afa6903d7b9be4ef50cca6f2333 100644 --- a/resources/vue/components/responsive/ResponsiveContentBar.vue +++ b/resources/vue/components/responsive/ResponsiveContentBar.vue @@ -63,6 +63,22 @@ export default { } }, methods: { + onCoursewareContentbarMounted(vueInstance) { + STUDIP.eventBus.emit('has-contentbar', true); + + this.realContentbar = vueInstance.$refs.header; + this.realContentbarSource = vueInstance.$refs.headerContainer; + this.realContentbarIconContainer = '.cw-ribbon-nav'; + this.realContentbarType = 'courseware'; + this.adjustExistingContentbar(true); + + document.querySelectorAll('.sidebar-widget button span').forEach(item => { + item.addEventListener('click', () => this.toggleSidebar()); + }); + }, + onCoursewareContentbarBeforeDestroy(vueInstance) { + this.adjustExistingContentbar(false); + }, toggleSidebar() { const sidebar = document.getElementById('sidebar'); @@ -75,8 +91,8 @@ export default { if (document.documentElement.classList.contains('responsive-display') && !document.documentElement.classList.contains('fullscreen-mode')) { - content.style.display = ''; - pageTitle.style.display = ''; + content.style.visibility = ''; + pageTitle.style.visibility = ''; } if (!document.documentElement.classList.contains('responsive-display')) { @@ -95,8 +111,8 @@ export default { && !document.documentElement.classList.contains('fullscreen-mode')) { // Set a timeout here so that the content "disappears" after slide-in aninmation is finished. setTimeout(() => { - content.style.display = 'none'; - pageTitle.style.display = 'none'; + content.style.visibility = 'hidden'; + pageTitle.style.visibility = 'hidden'; }, 300); } @@ -134,11 +150,9 @@ export default { const contentbarContainer = document.getElementById('responsive-contentbar-container'); contentbarContainer.prepend(this.realContentbar); - - document.getElementById('content-wrapper').style.marginTop = `${contentbarContainer.clientHeight}px`; } else { - this.realContentbar.id = 'contentbar'; - document.getElementById('toggle-sidebar').remove(); + this.realContentbar.id = 'cw-ribbon'; + document.getElementById('toggle-sidebar')?.remove(); if (this.realContentbarType === 'courseware') { this.realContentbar.classList.remove('contentbar'); @@ -148,9 +162,7 @@ export default { .classList.remove('contentbar-wrapper-right'); } - document.querySelector(this.realContentbarSource).prepend(this.realContentbar); - - document.getElementById('content-wrapper').style.marginTop = 'initial'; + this.realContentbarSource.append(this.realContentbar); } } }, @@ -196,7 +208,7 @@ export default { STUDIP.eventBus.emit('has-contentbar', true); this.realContentbar = cwContentbar; - this.realContentbarSource = '.cw-structural-element-content > div'; + this.realContentbarSource = cwContentbar.parentElement; this.realContentbarIconContainer = '.cw-ribbon-nav'; this.realContentbarType = 'courseware'; this.adjustExistingContentbar(true); @@ -210,35 +222,16 @@ export default { }) // Use courseware contentbar instead of this Vue component. - this.globalOn('courseware-contentbar-mounted', element => { - STUDIP.eventBus.emit('has-contentbar', true); - - this.realContentbar = element.$el.querySelector('header'); - this.realContentbarSource = '.cw-structural-element-content > div'; - this.realContentbarIconContainer = '.cw-ribbon-nav'; - this.realContentbarType = 'courseware'; - this.adjustExistingContentbar(true); - - document.querySelectorAll('.sidebar-widget button span').forEach(item => { - item.addEventListener('click', () => this.toggleSidebar()); - }); - }) - - this.globalOn('toggle-focus-mode', (state) => { - const html = document.querySelector('html'); - if (html.classList.contains('responsive-display') || html.classList.contains('fullscreen-mode')) { - this.adjustExistingContentbar(!state); - } - }); - + this.globalOn('courseware-contentbar-mounted', this.onCoursewareContentbarMounted) + this.globalOn('courseware-contentbar-before-destroy', this.onCoursewareContentbarBeforeDestroy); }, beforeDestroy() { + this.globalOff('courseware-contentbar-mounted', this.onCoursewareContentbarMounted); + this.globalOff('courseware-contentbar-before-destroy', this.onCoursewareContentbarBeforeDestroy); if (this.realContentbar) { this.adjustExistingContentbar(false); } - STUDIP.eventBus.off('toggle-focus-mode'); - STUDIP.eventBus.off('courseware-contentbar-mounted'); } } </script> diff --git a/resources/vue/components/table-of-contents.ts b/resources/vue/components/table-of-contents.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dd74dd0cdfe66e49a2551ec400f3b61ae2f4672 --- /dev/null +++ b/resources/vue/components/table-of-contents.ts @@ -0,0 +1,19 @@ +import { Icon } from '../../studip'; + +// Corresponds to the PHP class TOCItem +export interface TOCItem { + title: string; + url: string; + parent?: TOCItem; + children: TOCItem[]; + active: boolean; + icon?: Icon; +} + +// Depth-first traversal of a TOCItem hierachy starting at its root. +export function traverse(tocRoot: TOCItem, callback: (item: TOCItem) => void) { + callback(tocRoot); + for (let tocItem of tocRoot.children) { + traverse(tocItem, callback); + } +} diff --git a/resources/vue/mixins/courseware/export.js b/resources/vue/mixins/courseware/export.js index 3d9e33ab4e8848e90429463bffa66a39403e12d8..f3002c3bc287d28c2abee0229f3d891e8ffb593d 100644 --- a/resources/vue/mixins/courseware/export.js +++ b/resources/vue/mixins/courseware/export.js @@ -196,6 +196,7 @@ export default { formData.append("data[difficulty_start]", difficulty_start); formData.append("data[difficulty_end]", difficulty_end); formData.append("data[category]", 'elearning'); + formData.append(STUDIP.CSRF_TOKEN.name, STUDIP.CSRF_TOKEN.value); axios({ method: 'post', @@ -256,13 +257,13 @@ export default { if (fileType === 'file-refs') { await this.loadFileRefsById({id: fileId}); let fileRef = this.fileRefsById({id: fileId}); - + let fileRefData = {}; fileRefData.id = fileRef.id; fileRefData.attributes = fileRef.attributes; fileRefData.related_element_id = element.id; fileRefData.folder = null; - + this.exportFiles.json.push(fileRefData); this.exportFiles.download[fileRef.id] = { folder: null, diff --git a/resources/vue/store/StudipStore.js b/resources/vue/store/StudipStore.js index 7a733c08d40dacc97b8d29fa13fbfe513a5637fe..7c404a4f18b9037f094b2f2d1b24c9698fccc4cb 100644 --- a/resources/vue/store/StudipStore.js +++ b/resources/vue/store/StudipStore.js @@ -1,8 +1,10 @@ -export default { +import { eventBus, store } from '../../assets/javascripts/chunks/vue'; + +const studipStore = { namespaced: true, - state () { - return {...STUDIP.config}; + state() { + return { ...STUDIP.config, consumeMode: false }; }, getters: { getConfig: (state) => (key) => { @@ -10,6 +12,13 @@ export default { throw new Error(`Invalid access to unknown configuration item "${key}"`); } return state[key]; - } - } -} + }, + }, +}; + +// Make the current state of "focus mode" (fullscreen) available to Vue components. +eventBus.on('switch-focus-mode', (mode) => { + store.state.studip.consumeMode = mode; +}); + +export default studipStore; diff --git a/resources/vue/store/courseware/courseware-public.module.js b/resources/vue/store/courseware/courseware-public.module.js index 1982fe28fcf9e6ce7318145344e87f8941663da7..403cfe451fe4b6fc91963ba5bba97165cd0fad12 100644 --- a/resources/vue/store/courseware/courseware-public.module.js +++ b/resources/vue/store/courseware/courseware-public.module.js @@ -1,7 +1,6 @@ const getDefaultState = () => { return { blockAdder: {}, - consumeMode: true, containerAdder: false, context: null, courseware: {}, @@ -22,10 +21,6 @@ const getters = { return state.blockAdder; }, - consumeMode(state) { - return state.consumeMode; - }, - containerAdder(state) { return state.containerAdder; }, @@ -71,10 +66,6 @@ export const state = { ...initialState }; export const actions = { // setters - coursewareConsumeMode({ commit }, consumeMode) { - commit('setConsumeMode', consumeMode); - }, - coursewareContainerAdder(context, adder) { context.commit('setContainerAdder', adder); }, @@ -134,10 +125,6 @@ export const mutations = { state.courseware = data; }, - setConsumeMode(state, consumeMode) { - state.consumeMode = consumeMode; - }, - setContainerAdder(state, containerAdder) { state.containerAdder = containerAdder; }, diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 53ef6aa396de1f19c953746a8dc6c33513be1ee1..cb844e15abca66548fecc29ae5c69662a7ddfd17 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -989,10 +989,6 @@ export const actions = { context.commit('coursewareStyleCompanionOverlaySet', companion_overlay_style); }, - coursewareConsumeMode(context, mode) { - context.commit('coursewareConsumeModeSet', mode); - }, - setHttpClient({ commit }, httpClient) { commit('setHttpClient', httpClient); }, @@ -1581,10 +1577,6 @@ export const mutations = { state.styleCompanionOverlay = data; }, - coursewareConsumeModeSet(state, data) { - state.consumeMode = data; - }, - setHttpClient(state, httpClient) { state.httpClient = httpClient; }, diff --git a/templates/vue-app.php b/templates/vue-app.php index 2c34929f9bdb6098241c0171309a4463efa586c0..7f0841ba093a6ffaffc7fe6f227f41456ada70a4 100644 --- a/templates/vue-app.php +++ b/templates/vue-app.php @@ -4,11 +4,16 @@ * @var string $baseComponent * @var array $props * @var array $storeData + * @var array $slots */ ?> <? foreach ($storeData as $store => $data): ?> <script type="application/json" id="vue-store-data-<?= htmlReady($store) ?>"><?= json_encode($data) ?></script> <? endforeach; ?> <div <?= arrayToHtmlAttributes($attributes) ?>> - <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>/> + <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>> + <? foreach ($slots as $slotname => $slot): ?> + <template #<?= htmlReady($slotname) ?>><?= $slot ?></template> + <? endforeach; ?> + </<?= strtokebabcase($baseComponent) ?>> </div>