mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-10-06 03:46:39 +08:00
Fix results sticky toolbar [SCI-9344]
This commit is contained in:
parent
3a22d7715d
commit
1373994559
5 changed files with 195 additions and 77 deletions
43
app/javascript/vue/mixins/moduleNameObserver.js
Normal file
43
app/javascript/vue/mixins/moduleNameObserver.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Mixin to observe and reflect DOM changes of a module's name in '.my_module-name .view-mode'.
|
||||
* - Initializes a MutationObserver to watch for modifications.
|
||||
* - Updates the `moduleName` data property on detected changes.
|
||||
*/
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
observer: null,
|
||||
moduleName: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.observeChanges();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
observeChanges() {
|
||||
const targetNode = document.querySelector('.my_module-name .view-mode');
|
||||
if (!targetNode) return;
|
||||
|
||||
this.moduleName = targetNode.textContent;
|
||||
|
||||
const config = { characterData: true, childList: true, subtree: true };
|
||||
|
||||
const callback = (mutationsList) => {
|
||||
mutationsList.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' || mutation.type === 'characterData') {
|
||||
this.moduleName = targetNode.textContent;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.observer = new MutationObserver(callback);
|
||||
this.observer.observe(targetNode, config);
|
||||
},
|
||||
},
|
||||
};
|
103
app/javascript/vue/mixins/stackableHeadersMixin.js
Normal file
103
app/javascript/vue/mixins/stackableHeadersMixin.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Mixin for adjusting stackable headers on scroll.
|
||||
* - Tracks scroll position to modify headers' styles & positions.
|
||||
* - Observes changes in the secondary navigation's height.
|
||||
* - Adjusts TinyMCE editor header offset if present.
|
||||
*/
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
lastScrollTop: 0,
|
||||
headerSticked: false,
|
||||
secondaryNavigation: null,
|
||||
taskSecondaryMenuHeight: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
headerRef() {
|
||||
return this.getHeader();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.secondaryNavigation = document.querySelector('#taskSecondaryMenu');
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
this.taskSecondaryMenuHeight = entry.target.offsetHeight;
|
||||
});
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.secondaryNavigation);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initStackableHeaders() {
|
||||
const header = this.headerRef;
|
||||
const headerHeight = header.offsetHeight;
|
||||
const headerTop = header.getBoundingClientRect().top;
|
||||
const secondaryNavigationTop = this.secondaryNavigation.getBoundingClientRect().top;
|
||||
|
||||
// TinyMCE offset calculation
|
||||
let stickyNavigationHeight = this.taskSecondaryMenuHeight;
|
||||
if ($('.tox-editor-header').length > 0 && $('.tox-editor-header')[0].getBoundingClientRect().top > headerTop) {
|
||||
stickyNavigationHeight += headerHeight;
|
||||
}
|
||||
|
||||
// Add shadow to secondary navigation when it starts fly
|
||||
if (this.secondaryNavigation.getBoundingClientRect().top === 0 && !this.headerSticked) {
|
||||
this.secondaryNavigation.style.boxShadow = '0 9px 8px -2px rgba(0, 0, 0, 0.1)';
|
||||
this.secondaryNavigation.style.zIndex = 252;
|
||||
} else {
|
||||
this.secondaryNavigation.style.boxShadow = 'none';
|
||||
if (secondaryNavigationTop > 10) this.secondaryNavigation.style.zIndex = 11;
|
||||
}
|
||||
|
||||
if (headerTop - 5 < this.taskSecondaryMenuHeight) { // When secondary navigation touch header
|
||||
this.secondaryNavigation.style.top = `${headerTop - headerHeight}px`; // Secondary navigation starts slowly disappear
|
||||
header.style.boxShadow = '0 9px 8px -2px rgba(0, 0, 0, 0.1)'; // Flying shadow
|
||||
header.style.zIndex = 250;
|
||||
|
||||
this.headerSticked = true;
|
||||
|
||||
|
||||
if (this.lastScrollTop > window.scrollY) { // When user scroll up
|
||||
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
|
||||
if (newSecondaryTop > 0) newSecondaryTop = 0;
|
||||
|
||||
this.secondaryNavigation.style.top = `${newSecondaryTop}px`; // Secondary navigation starts slowly appear
|
||||
this.secondaryNavigation.style.zIndex = 252;
|
||||
header.style.top = `${this.taskSecondaryMenuHeight + newSecondaryTop - 1}px`; // Header starts getting offset to compensate secondary navigation position
|
||||
// -1 to compensate small gap between header and secondary navigation
|
||||
} else { // When user scroll down
|
||||
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
|
||||
if (newSecondaryTop * -1 > this.taskSecondaryMenuHeight) newSecondaryTop = this.taskSecondaryMenuHeight * -1;
|
||||
|
||||
this.secondaryNavigation.style.top = `${newSecondaryTop}px`; // Secondary navigation starts slowly disappear
|
||||
header.style.top = `${newSecondaryTop + this.taskSecondaryMenuHeight - 1}px`; // Header starts getting offset to compensate secondary navigation position
|
||||
// -1 to compensate small gap between header and secondary navigation
|
||||
if (newSecondaryTop * -1 >= this.taskSecondaryMenuHeight) this.secondaryNavigation.style.zIndex = 11;
|
||||
}
|
||||
} else {
|
||||
// Just reset secondary navigation and header styles to initial state
|
||||
this.secondaryNavigation.style.top = '0px';
|
||||
header.style.top = '0px';
|
||||
header.style.boxShadow = 'none';
|
||||
header.style.zIndex = 10;
|
||||
this.headerSticked = false;
|
||||
}
|
||||
|
||||
// Apply TinyMCE offset
|
||||
$('.tox-editor-header').css(
|
||||
'top',
|
||||
stickyNavigationHeight + parseInt($(this.secondaryNavigation).css('top'), 10)
|
||||
);
|
||||
|
||||
this.lastScrollTop = window.scrollY; // Save last scroll position to when user scroll up/down
|
||||
},
|
||||
},
|
||||
};
|
|
@ -2,10 +2,10 @@
|
|||
<div v-if="protocol.id" class="task-protocol">
|
||||
<div ref="header" class="task-section-header ml-[-1rem] w-[calc(100%_+_2rem)] px-4 bg-sn-white sticky top-0 transition" v-if="!inRepository">
|
||||
<div class="portocol-header-left-part truncate grow">
|
||||
<template v-if="headerSticked && protocol.attributes.assignable_my_module_name">
|
||||
<template v-if="headerSticked && moduleName">
|
||||
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
|
||||
<div @click="scrollTop" class="task-section-title w-[calc(100%_-_4rem)] cursor-pointer">
|
||||
<h2 class="truncate">{{ protocol.attributes.assignable_my_module_name }}</h2>
|
||||
<h2 class="truncate leading-6">{{ moduleName }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else >
|
||||
|
@ -204,6 +204,8 @@
|
|||
import PublishProtocol from './modals/publish_protocol.vue'
|
||||
|
||||
import UtilsMixin from '../mixins/utils.js'
|
||||
import stackableHeadersMixin from '../mixins/stackableHeadersMixin';
|
||||
import moduleNameObserver from '../mixins/moduleNameObserver';
|
||||
|
||||
export default {
|
||||
name: 'ProtocolContainer',
|
||||
|
@ -214,7 +216,7 @@
|
|||
}
|
||||
},
|
||||
components: { Step, InlineEdit, ProtocolOptions, Tinymce, ReorderableItemsModal, ProtocolMetadata, PublishProtocol},
|
||||
mixins: [UtilsMixin],
|
||||
mixins: [UtilsMixin, stackableHeadersMixin, moduleNameObserver],
|
||||
computed: {
|
||||
inRepository() {
|
||||
return this.protocol.attributes.in_repository
|
||||
|
@ -235,17 +237,16 @@
|
|||
reordering: false,
|
||||
publishing: false,
|
||||
stepToReload: null,
|
||||
headerSticked: false,
|
||||
lastScrollTop: 0,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
mounted() {
|
||||
$.get(this.protocolUrl, (result) => {
|
||||
this.protocol = result.data;
|
||||
this.$nextTick(() => {
|
||||
this.refreshProtocolStatus();
|
||||
if (!this.inRepository) {
|
||||
window.addEventListener('scroll', this.initStackableHeaders, false);
|
||||
this.initStackableHeaders();
|
||||
}
|
||||
});
|
||||
$.get(this.urls.steps_url, (result) => {
|
||||
|
@ -259,6 +260,9 @@
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
getHeader() {
|
||||
return this.$refs.header;
|
||||
},
|
||||
reloadStep(step) {
|
||||
this.stepToReload = step;
|
||||
},
|
||||
|
@ -405,74 +409,12 @@
|
|||
$.post(this.urls.publish_url, {version_comment: comment, view: 'show'})
|
||||
},
|
||||
scrollTop() {
|
||||
console.log("clicekd")
|
||||
window.scrollTo(0, 0);
|
||||
setTimeout(() => {
|
||||
$('.my_module-name .view-mode').trigger('click');
|
||||
$('.my_module-name .input-field').focus();
|
||||
}, 300)
|
||||
},
|
||||
initStackableHeaders() {
|
||||
let protocolHeader = this.$refs.header;
|
||||
let secondaryNavigation = document.querySelector('#taskSecondaryMenu');
|
||||
let protocolHeaderHeight = protocolHeader.offsetHeight;
|
||||
let protocolHeaderTop = protocolHeader.getBoundingClientRect().top;
|
||||
let secondaryNavigationHeight = secondaryNavigation.offsetHeight;
|
||||
let secondaryNavigationTop = secondaryNavigation.getBoundingClientRect().top;
|
||||
|
||||
// TinyMCE offset calculation
|
||||
let stickyNavigationHeight = secondaryNavigationHeight;
|
||||
if ($('.tox-editor-header').length > 0 && $('.tox-editor-header')[0].getBoundingClientRect().top > protocolHeaderTop) {
|
||||
stickyNavigationHeight += protocolHeaderHeight;
|
||||
}
|
||||
|
||||
// Add shadow to secondary navigation when it starts fly
|
||||
if (secondaryNavigation.getBoundingClientRect().top == 0 && !this.headerSticked) {
|
||||
secondaryNavigation.style.boxShadow = '0px 5px 8px 0px rgba(0, 0, 0, 0.10)';
|
||||
secondaryNavigation.style.zIndex= 251;
|
||||
} else {
|
||||
secondaryNavigation.style.boxShadow = 'none';
|
||||
if (secondaryNavigationTop > 10) secondaryNavigation.style.zIndex= 0;
|
||||
}
|
||||
|
||||
if (protocolHeaderTop - 5 < protocolHeaderHeight) { // When secondary navigation touch protocol header
|
||||
secondaryNavigation.style.top = protocolHeaderTop - protocolHeaderHeight + 'px'; // Secondary navigation starts slowly disappear
|
||||
protocolHeader.style.boxShadow = '0px 5px 8px 0px rgba(0, 0, 0, 0.10)'; // Flying shadow
|
||||
protocolHeader.style.zIndex= 250;
|
||||
|
||||
this.headerSticked = true;
|
||||
|
||||
|
||||
if (this.lastScrollTop > window.scrollY) { // When user scroll up
|
||||
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
|
||||
if (newSecondaryTop > 0) newSecondaryTop = 0;
|
||||
|
||||
secondaryNavigation.style.top = newSecondaryTop + 'px'; // Secondary navigation starts slowly appear
|
||||
secondaryNavigation.style.zIndex= 251;
|
||||
protocolHeader.style.top = secondaryNavigationHeight + newSecondaryTop - 1 + 'px'; // Protocol header starts getting offset to compensate secondary navigation position
|
||||
// -1 to compensate small gap between protocol header and secondary navigation
|
||||
} else { // When user scroll down
|
||||
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
|
||||
if (newSecondaryTop * -1 > secondaryNavigationHeight ) newSecondaryTop = secondaryNavigationHeight * -1;
|
||||
|
||||
secondaryNavigation.style.top = newSecondaryTop + 'px'; // Secondary navigation starts slowly disappear
|
||||
protocolHeader.style.top = newSecondaryTop + secondaryNavigationHeight - 1 + 'px'; // Protocol header starts getting offset to compensate secondary navigation position
|
||||
// -1 to compensate small gap between protocol header and secondary navigation
|
||||
if (newSecondaryTop * -1 >= secondaryNavigationHeight) secondaryNavigation.style.zIndex= 0;
|
||||
}
|
||||
} else {
|
||||
// Just reset secondary navigation and protocol header styles to initial state
|
||||
secondaryNavigation.style.top = '0px';
|
||||
protocolHeader.style.boxShadow = 'none';
|
||||
protocolHeader.style.zIndex= 0;
|
||||
this.headerSticked = false;
|
||||
}
|
||||
|
||||
// Apply TinyMCE offset
|
||||
$('.tox-editor-header').css('top',
|
||||
stickyNavigationHeight + parseInt($(secondaryNavigation).css('top'), 10)
|
||||
);
|
||||
|
||||
this.lastScrollTop = window.scrollY; // Save last scroll position to when user scroll up/down
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
<template>
|
||||
<div class="results-wrapper">
|
||||
<ResultsToolbar :sort="sort"
|
||||
<ResultsToolbar
|
||||
ref="resultsToolbar"
|
||||
:sort="sort"
|
||||
:canCreate="canCreate == 'true'"
|
||||
:archived="archived == 'true'"
|
||||
:active_url="active_url"
|
||||
:archived_url="archived_url"
|
||||
:headerSticked="headerSticked"
|
||||
:moduleName="moduleName"
|
||||
@setSort="setSort"
|
||||
@setFilters="setFilters"
|
||||
@newResult="createResult"
|
||||
|
@ -34,9 +38,13 @@
|
|||
import ResultsToolbar from './results_toolbar.vue';
|
||||
import Result from './result.vue';
|
||||
|
||||
import stackableHeadersMixin from '../mixins/stackableHeadersMixin';
|
||||
import moduleNameObserver from '../mixins/moduleNameObserver';
|
||||
|
||||
export default {
|
||||
name: 'Results',
|
||||
components: { ResultsToolbar, Result },
|
||||
mixins: [stackableHeadersMixin, moduleNameObserver],
|
||||
props: {
|
||||
url: { type: String, required: true },
|
||||
canCreate: { type: String, required: true },
|
||||
|
@ -51,18 +59,24 @@
|
|||
filters: {},
|
||||
resultToReload: null,
|
||||
nextPageUrl: null,
|
||||
loadingPage: false
|
||||
loadingPage: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('scroll', this.loadResults, false);
|
||||
window.addEventListener('scroll', this.initStackableHeaders, false);
|
||||
this.nextPageUrl = this.url;
|
||||
this.loadResults();
|
||||
this.initStackableHeaders();
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('scroll', this.loadResults, false);
|
||||
window.removeEventListener('scroll', this.initStackableHeaders, false);
|
||||
},
|
||||
methods: {
|
||||
getHeader() {
|
||||
return this.$refs.resultsToolbar.$refs.resultsHeaderToolbar;
|
||||
},
|
||||
reloadResult(result) {
|
||||
this.resultToReload = result;
|
||||
},
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
<template>
|
||||
<div class="result-toolbar p-3 flex justify-between rounded-md bg-sn-white">
|
||||
<div class="result-toolbar__left">
|
||||
<button v-if="canCreate" :title="i18n.t('my_modules.results.add_title')" class="btn btn-secondary" @click="$emit('newResult')">
|
||||
<div ref="resultsHeaderToolbar" class="result-toolbar sticky top-0 transition p-3 flex justify-between bg-sn-white">
|
||||
<div v-if="headerSticked" class="flex items-center truncate grow">
|
||||
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
|
||||
<div @click="scrollTop" class="w-[calc(100%_-_4rem)] cursor-pointer">
|
||||
<h2 class="truncate leading-6 mt-2.5 mb-2.5">{{ moduleName }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-toolbar__left flex items-center">
|
||||
<button v-if="canCreate" :title="i18n.t('my_modules.results.add_title')" class="btn btn-secondary" :class="{'mr-3': headerSticked}" @click="$emit('newResult')">
|
||||
<i class="sn-icon sn-icon-new-task"></i>
|
||||
{{ i18n.t('my_modules.results.add_label') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="dropdown view-switch" >
|
||||
<div class="btn btn-secondary view-switch-button prevent-shrink" id="viewSwitchButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
<div class="dropdown view-switch flex items-center">
|
||||
<div class="btn btn-secondary view-switch-button prevent-shrink" :class="{'mr-3': headerSticked}" id="viewSwitchButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
<span v-if="archived" class="state-view-switch-btn-name">{{ i18n.t('my_modules.results.archived_results') }}</span>
|
||||
<span v-else class="state-view-switch-btn-name">{{ i18n.t('my_modules.results.active_results') }}</span>
|
||||
<span class="sn-icon sn-icon-down"></span>
|
||||
|
@ -66,8 +73,10 @@
|
|||
sort: { type: String, required: true },
|
||||
canCreate: { type: Boolean, required: true },
|
||||
archived: { type: Boolean, required: true },
|
||||
headerSticked: { type: Boolean, required: true },
|
||||
active_url: { type: String, required: true },
|
||||
archived_url: { type: String, required: true }
|
||||
archived_url: { type: String, required: true },
|
||||
moduleName: { type: String, required: true }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -120,6 +129,13 @@
|
|||
},
|
||||
expandResults() {
|
||||
$('.result-wrapper .collapse').collapse('show')
|
||||
},
|
||||
scrollTop() {
|
||||
window.scrollTo(0, 0);
|
||||
setTimeout(() => {
|
||||
$('.my_module-name .view-mode').trigger('click');
|
||||
$('.my_module-name .input-field').focus();
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue