Банк данных ПРОДУКЦИЯ РОССИИ

Всплывающее меню на веб-странице

Озниев Н.К. ктн

Дата публикации: 08.01.2018 г.

Введение

Нет особой нужды обосновывать практическую необходимость иметь всплывающее меня на веб-странице. Это очень удобный инструмент для решения самых разнообразных задач. Разумеется, на веб-портале Банк данных ПРОДУКЦИЯ РОССИИ (БДПР) такой инструмент появился. Другое дело – он появился не сразу, а спустя довольно много времени. Скорее, причина заключалась в том, что у автора в этой области было очень мало опыта. Но не только в этом дело. Еще суть заключается в том, что работа через всплывающее меню все-таки требует определенного окружения, другими, словами, в веб-приложении должна быть своего рода инфраструктура для работы через всплывающее меню. Недостаточно просто создать меню, нужно еще слегка перестроить логику работы.

Альтернативой всплывающему меню является и система кнопок, и обычное, раскрывающееся, в том числе многоуровневое меню. Но всплывающее меню имеет одну очень привлекательную черту – оно появляется там, где находится объект приложения центра внимания. И второе его качество – оно на интерфейсе не занимает так много места, как набор кнопок. Ну, и есть еще одно качество, которое во всплывающем меню имеет преимущество в виду простоты реализации – оно легко подстраивается под нужды, т.е. в составе всплывающего меню легко можно делать изменения, т.к. самую сложную работу по созданию этого меню приходится делать один раз для любого его состава.

После этих душещипательных вступительных фраз перейдем к делу.

В БДПР всплывающее меню пока реализовано на двух веб-страницах, очень схожих по содержанию: для веб-страницы ввода нового КЛП и для веб-страницы редактирования КЛП. Есть еще одна веб-страница, где пока только запланировано его применение – это веб-страница выпуска указателя Технических условий.

Есть и нерешенные проблемы, связанные с всплывающим меню. Оно полностью подавляет системное меню, а не встраивается в него, как это было бы более логично. Для веб-браузера, у которого системное меню – очень мощный инструмент, такая ущербность всплывающего меню – приличный недостаток. Но автор надеется, что если он приналяжет, то справится с этой задачей с помощью Интернета. Пока эта задача с близкого расстояния не рассматривалась – целина короче.

Всплывающее меню в БДПР имеет решение чисто на JavaScript, если не считать, что вызов пунктов меню решает много разных задач, включая и переходы между веб-страницами, и вызовы по технологии AJAX, и даже отправка формы на сервер. Но сама реализация – на JavaScript. И основной ее код, реализующий идею меню, заимствован из того же Интернет.

Итак, к сути.

Сначала общая часть.

Очевидно, вызов меню начинается с обработчика onContextMenu на целевом объекте веб-странице, где в данный момент находится курсор мыши при нажатии правой кнопки мыши. В этом обработчике стандартно решаются две задачи: инициализируется список пунктов меню и осуществляется старт самого всплывающего меню с предварительной передачей в него параметров целевого объекта. Иногда (но не всегда) перед этим приходится решать какие-нибудь вспомогательные задачи. Они хотя и вспомогательные, но очень важные. В частности, если пункт меню предназначен для переходов между веб-страницами, покидая данную страницу, а затем возвращаясь в нее при завершении работы – приходится решать очень непростую, как оказалось, задачу сохранения исходного вида веб-страницы. Автор консультировался по этому вопросу с опытными программистами, и ему при этом внушали, что в таких случаях вообще лучше не применять варианты перескоком между формами, а решать задачу путем перехода к другому слою, который всплывает, оставляя все объекты внимания при работе всплывающего меню на одной и той же веб-странице. Только слой в данном случае не совсем то, что обычно имеют ввиду под этим термином, а просто область веб-страницы, скрытая до того момента, пока не понадобилось его открыть.

Работа onContextMenu

Типичный вид обработчика имеет последовательно вызываемые функции:

initMenuList(this.className, this.getAttribute('name'), <Список меню>);

StartContextMemu(this.className, this.getAttribute('name'), this);

Первый аргумент – класс целевого объекта в его верстке, второй аргумент – имя целевого объекта. Очевидно this – ссылка на сам объект.

Список меню – самый интересный параметр. Он содержит идентификаторы тех меню, которые попадут в список, который увидит пользователь при вызове меню.

Дальнейшее объяснение всплывающего меню лучше всего делать с использованием его рабочего кода.

Функция initMenuList

// Инициализация списка пунктов меню дла заданной строки их имен

function initMenuList(c, n, mlist) {

  // Вытаскиваем список пунктов меню, что задан в шаблоне (спиcок li)

  var menuItems = document.querySelectorAll(".context-menu__item");

 

  // Сначала прячем все пункты меню

  for (var i = 0, len = menuItems.length; i < len; i++)

     menuItems[i].style.display = 'none';

 

  // Список имен меню, заданных для объекта инициализации,

// превращаем в массив mlists

  var mlists = mlist.split(",");

    

  // Пробегаем по списку меню еще раз, чтобы показать те, что заданы в списке mlist

  for (var i = 0, len = menuItems.length; i < len; i++) {

     var menuItem = menuItems[i];

     // Внутри ищем первый тег <i> (он должен быть единственным)

     var iTag = menuItem.getElementsByTagName("i")[0];

     // Если имя класса тега <i> совпало с каким-либо из списка mlists,

     // то показываем пункт меню с этим именем класса из списка menuItem

     if (iTag) {

       for (var j in mlists)

          if (iTag.className == mlists[j])

            menuItem.style.display = 'block';

     }

  }

}

Данный код особых пояснений не требует, т.к. все описано в комментариях. В результате его работы на пользовательском интерфейсе появляется список заданных пунктов меню, которые расположены так, как это задано в строке списка пунктов меню. Вариант того, что увидит пользователь портала БДПР, нажавший правую кнопку мыши над окном почтового индекса, приведен ниже:

В этом примере функция initMenuList вызвана с двумя пунктами меню в параметре mlist.

В ней упомянут шаблон меню, который реализован списком тегов <li> HTML. Он по сути представляет собой хранилище всех пунктов меню. Мы не будем приводить весь шаблон портала БДПР, а только часть кода, который пояснит структуру меню:

<nav id="context-menu" class="context-menu">

  <ul class="context-menu__items">

    <li class="context-menu__item" style="display: none;">

      <a href="#" class="context-menu__link" data-action="find_by_okpo" data-address="<?=$this->address?>" >

        <i class="fafa-find_by_okpo"></i>

        <span class="menu-text">Найти по ОКПО</span>

      </a>

    </li>.

...

    <li class="context-menu__item" style="display: none;">

      <a href="#" class="context-menu__link" data-action="find_by_posindex" data-address="<?=$this->address?>" >

        <i class="fafa-find_by_posindex"></i>

        <span class="menu-text">Найти по почт. индексу</span>

      </a>

    </li>

...

    <li class="context-menu__item" style="display: none;">

      <a href="#" class="context-menu__link"

        data-action="newholder"

        data-address="<?=$this->address?>"

        data-return_address="<?=$this->return_address?>">

        <i class="fafa-newholder"></i>

        <span class="menu-text">Новый держатель</span>

      </a>

    </li>

...

  </ul>

</nav>

Ближайшее рассмотрение кода показывает, что пункт меню – это содержимое тега <li>, в котором указано, что:

  1. по умолчанию пункт меню скрыт style="display: none;",
  2. в нем содержится ссылка с классом context-menu__link, у которой есть атрибуты data-action, data-address и, возможно другие, которые состоят из двух частей 1. data – префикс, показывающий, что через тире будет идти сам параметр, 2. параметр, который после знака тире. Затем идет тег <i> (см. функцию initMenuList), имя класса которого как-раз показывает, какое задание должен выполнять данный пункт меню.
  3. тег <span>, в котором содержится показываемое пользователю название пункта меню.

Параметры с префиксом data позволяют передать в меню различные данные, которые нужны для реализации заданной функции. Например, в пункте меню Новый держатель их три: наименование выполняемой функции data-action = newholder, адрес веб-сервера и адрес веб-страницы, куда нужно вернуться после выполнения задания. Причем два последних параметра передаются из контроллера веб-страницы, откуда идет управление веб-страницей ввода КЛП:

$this->address,

$this->return_address.

Здесь $this – ссылка на контроллер веб-страницы.

Как видим, нфраструктура поддержки всплывающего меню, о которой говорилось во введении, начинает проявляться, и она совсем не простая. Но зато она реализуется один раз. Как видим, ничего не стоит добавить новый пункт меню, для чего достаточно дописать еще один элемент, заключив его в тег <li>. Нужно только быть внимательным, чтобы не напутать.

Функция StartContextMemu

Данная функция запускает конфигуратор меню, в котором решается целый набор задач. Код данной функции, так же, как и все элементы контекстного меню, автор построил, руководствуясь материалами https://habrahabr.ru/post/258167/, http://dml.compkaluga.ru/forum/index.php?showtopic=103207, в том числе кодом https://github.com/callmenick/Custom-Context-Menu/blob/master/main.js, и желающие могут оттуда почерпнуть описание самих идей, использованных здесь. Есть, однако, принципиальное отличие от материалов по приведенным ссылкам. Там приведена самовыполняющаяся функция JavaScript, в которую обернуты внутренности функции StartContextMemu. Автор же использует явную инициализацию путем вызова StartContextMemu из обработчика onContextMenu. Поэтому полной идентичности поведения ожидать не следует.

Возможно, при доработке статьи автор добавит более подробные сведения о контекстном меню портала БДПР. Укажем лишь, что код этот обкатан и работает безотказно в браузерах Chrome, IE 10,11, Edge, Firefox, Opera, Safari текущих версий. Разве что при его вынесении в статью могли быть какие-нибудь случайные ошибки.

Но, как уже было выше сказано, предстоит еще обойтись корректно с проблемой интеграции с системным контекстным меню браузера, поэтому задача применения контекстного меню в БДПР нельзя считать окончательно решенной.

// Запуск конфигуратора меню

// c - имя класса элемента, вызвавшего событие contextmenu

// n - атрибут "name" того же элемента

// el- сам элемент, для которого запускается список меню

function StartContextMemu(c, n, el) { // ******************************************************************

  ///////////////////////////////////////

  // H E L P E R    F U N C T I O N S

  // Вспомогательные функции

  ///////////////////////////////////////

 

  // Function to check if we clicked inside an element with a particular class name.

  // @param {Object} e The event

  // @param {String} className The class name to check against

  // @return {Boolean}

  function clickInsideElement(e, className) {

     var el = e.srcElement || e.target;

     if (el.classList.contains(className)) {

       return el;

     } else {

       while (el = el.parentNode) {

          if (el.classList && el.classList.contains(className)) {

            return el;

          }

       }

     }

     return false;

  }

 

  // Get's exact position of event.

  // @param {Object} e The event passed in

  // @return {Object} Returns the x and y position

  function getPosition(e) {

     var posx = 0;

     var posy = 0;

 

     if (!e)

       var e = window.event;

     if (e.pageX || e.pageY) {

       //console.log('Вариант pageX, pageY');

      

       posx = e.pageX - 220; // Но это не элегантно!!!

       posy = e.pageY - 200;

     } else if (e.clientX || e.clientY) {

       posx = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;

       posy = e.clientY + document.body.scrollTop + document.documentElement.scrollTop;

     }

     var pos = {x:posx, y:posy};

     return pos;

  }

 

  //////////////////////////////////////////////////////////////////////////////

  // C O R E    F U N C T I O N S

  // Функции ядра

  //////////////////////////////////////////////////////////////////////////////

 

  // Переменные

  var contextMenuClassName = "context-menu";

  var contextMenuItemClassName = "context-menu__item";

  var contextMenuLinkClassName = "context-menu__link";

  var contextMenuActive = "context-menu--active";

 

  var taskItemClassName = "task";

  var taskItemClassName = c;

  var taskItemInContext;

 

  var clickCoords;

  var clickCoordsX;

  var clickCoordsY;

 

  var menu = document.querySelector("#context-menu");

 

  var menuItems = menu.querySelectorAll(".context-menu__item");

  var menuState = 0;

  var menuWidth;

  var menuHeight;

  var menuPosition;

  var menuPositionX;

  var menuPositionY;

 

  var windowWidth;

  var windowHeight;

 

  // Initialise our application's code

  function init() {

     contextListener();

     clickListener();

     keyupListener();

     resizeListener();

  }

 

  // Listens for contextmenu events

  function contMenuL(e) {

     // Производит щелчок внутри элемента e

     taskItemInContext = clickInsideElement(e, taskItemClassName);

 

     if (taskItemInContext) {

       e.preventDefault();

       toggleMenuOn();

       positionMenu(e);

     } else {

       taskItemInContext = null;

       toggleMenuOff();

     }

  }

 

  function contextListener() {

     // Начинаем с того, что удаляем предыдущий обработчик

     document.removeEventListener("contextmenu", contMenuL, false);

     // Добавляем теперь новый

     document.addEventListener("contextmenu", contMenuL, false);

  }

 

  function clickListener() {

     // Обработчик вынесен, чтобы его потом можно было удалить

     document.addEventListener("click", function(e) {

       var clickeElIsLink = clickInsideElement(e, contextMenuLinkClassName);

       //console.log('Нашли обработчик элемента меню?: ', e);

    

       if (clickeElIsLink) {

          e.preventDefault();

          menuItemListener(clickeElIsLink, e);

       } else {

          var button = e.which || e.button;

          if (button === 1) {

            toggleMenuOff();

          }

       }

     });

  }

 

  // Listens for keyup events

  function keyupListener() {

     window.onkeyup = function(e) {

       if (e.keyCode === 27) {

          toggleMenuOff();

       }

     }

  }

 

  // Window resize event listener

  function resizeListener() {

     window.onresize = function(e) {

       toggleMenuOff();

     };

  }

 

  // Turns the custom context menu on

  function toggleMenuOn() {

     if (menuState !== 1) {

       menuState = 1;

       menu.classList.add(contextMenuActive);

     }

  }

 

  // Turns the custom context menu off

  function toggleMenuOff() {

     if (menuState !== 0) {

       menuState = 0;

       menu.classList.remove(contextMenuActive);

     }

  }

 

  // Раскрыть часть меню с символами

  function openSymbols() {

     var mItem, iTag;

     for (var i = 0; i < menuItems.length; i++) {

       mItem = menuItems[i];

       iTag = mItem.getElementsByTagName('i')[0];

       if (iTag.className == 'fafa-put_symbol') {

          mItem.style.display = 'block';

       } else

          mItem.style.display = 'none';

     }

  }

 

  // Получить ссылку на вывод выбранного индекса

  function getDigitLink() {

     var mItem, iTag;

     for (var i = 0; i < menuItems.length; i++) {

       mItem = menuItems[i];

       iTag = mItem.getElementsByTagName('select')[0];

       if (iTag) {

          console.log('FireEvent', iTag);

          if (iTag.id == 'select_symbols_digits_down') {

            //console.log('Раскрываем список нижних индексов');

            showDropdown(iTag);

          }

       }

     }

  }

 

  // Координаты элемента на странице, http://javascript.ru/ui/offset

  function getOffset(elem) {

     if (elem.getBoundingClientRect) {

       return getOffsetRect(elem)

     } else {

       return getOffsetSum(elem)

     }

  }

  function getOffsetSum(elem) {

     var top=0, left=0;

     while(elem) {

       top = top + parseInt(elem.offsetTop)

       left = left + parseInt(elem.offsetLeft)

       elem = elem.offsetParent;

     }

     return {top: Math.round(top), left: Math.round(left)};

  }

  function getOffsetRect(elem) {

     var box = elem.getBoundingClientRect();

     var body = document.body;

     var docElem = document.documentElement;

     var scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop;

     var scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft;

     var clientTop = docElem.clientTop || body.clientTop || 0;

     var clientLeft = docElem.clientLeft || body.clientLeft || 0;

     var top  = box.top +  scrollTop - clientTop;

     var left = box.left + scrollLeft - clientLeft;

     return {top: Math.round(top), left: Math.round(left)};

  }

  function getOffset(elem) {

     if (elem.getBoundingClientRect) {

       return getOffsetRect(elem)

     } else {

       return getOffsetSum(elem)

     }

  }

 

  function ElemCoords(obj) {

     var curleft = 0;

     var curtop = 0;

     if (obj.offsetParent) {

       while (1) {

          curleft += obj.offsetLeft;

          curtop += obj.offsetTop;

          if (!obj.offsetParent)

            break;

          obj=obj.offsetParent;

       }

     } else if (obj.x || obj.y) {

       curleft += obj.x;

       curtop += obj.y;

     }

     return {"x":curleft, "y":curtop};

  }

  // координаты объекта

  function getAbsolutePosition(el) {

     var r = { x: el.offsetLeft, y: el.offsetTop };

     if (el.offsetParent) {

       var tmp = getAbsolutePosition(el.offsetParent);

       r.x += tmp.x;

       r.y += tmp.y;

     }

     return r;

  }

  function left (elem) {

     var left=0;

     alert(elem.offsetLeft);

     while(elem) {

       left += parseFloat(elem.offsetLeft);

       elem = elem.offsetParent;

     }

     return Math.round(left);

  }

 

  // Positions the menu properly

  // @param {Object} e The event

  // Данная функция конкретно заточена под веб-страницу с формой КЛП,

  // и при другой структуре страницы должна быть адаптирована

  function positionMenu(e) {

     var e_target = e.target;

     var e_target_name = e_target.name;

     var pos = getAbsolutePosition(e_target);

     menu.style.left = pos.x - e_target.offsetLeft - 215 + "px";

     menu.style.top = pos.y - e_target.offsetTop - e_target.offsetParent.offsetTop + "px";

    

     // Организуем пробежку по path события до элемента body документа

     var el, el_klp;

     var path = [];

     var Lefts = [];

     var node = e.target;

     while(node != null) {

       Lefts.push(node.offsetLeft);

       path.push(node);

       node = node.parentNode;

     }

 

     var klp = document.getElementById('klp');

     clickCoords = getPosition(e);

     clickCoordsX = clickCoords.x;

     clickCoordsY = clickCoords.y;

 

     menuWidth = menu.offsetWidth + 4;

     menuHeight = menu.offsetHeight + 4;

 

     windowWidth = window.innerWidth;

     windowHeight = window.innerHeight;

     var windowLeft = window.screenX;

    

     // Настройка по вертикали

     if ((windowHeight - clickCoordsY) < menuHeight) {

       menu.style.top = windowHeight - menuHeight + 15 + "px";

     } else {

       menu.style.top = clickCoordsY + 15 + "px";

     }

    

     // Начальное положение меню по оси X

     menu.style.left = clickCoordsX + "px";

    

     // Поправка для полей F01, F02

     if ((e_target_name == 'F01') || (e_target_name == 'F02')) {

       el = document.getElementById('container');

       if (el) {

          var addL = el.offsetLeft;

          menu.style.left = clickCoordsX - addL + "px";

       }

     }

    

     // Поправка для поля F10 и похожих

//(вытаскиваем окно меню из-под правого края окна браузера)

     if ((e_target_name == 'F10') || (e_target_name == 'F11') || (e_target_name == 'F12') ||

     (e_target_name == 'F13') ||  (e_target_name == 'F14') || (e_target_name == 'F15') ||

     (e_target_name == 'F16') || (e_target_name == 'F17') || (e_target_name == 'F18') ||

     (e_target_name == 'F18_1') || (e_target_name == 'F23') || (e_target_name == 'F24') ||

     (e_target_name == 'F24_1') || (e_target_name == 'F30_1') || (e_target_name.substring(0,1) == 'C')) {

       el = document.getElementById('container');

       el_klp = document.getElementById('klp');

 

       if (el) {

          var menuLeft = clickCoordsX - el.offsetLeft;

          menu.style.left = menuLeft + "px";

       }

      

       var delta = menuLeft + menuWidth - el_klp.offsetWidth;

       if (delta > 0) {

          menu.style.left = menuLeft - delta + "px";

       }

     }

  }

 

  function menuItemListener(link, mEvent) {

     if (Counter == 1)

       return;

    

     // Подготовка данных для вызова меню

     var ss = '&nbsp;';

     var taskName = link.getAttribute("data-action");

     var address = link.getAttribute("data-address");

     var return_address = link.getAttribute("data-return_address");

     var symbol = link.getAttribute("data-symbol");

     var clp_id = link.getAttribute("data-clp_id");

    

     // Элемент HTML, который инициировал меню

     var targetEl = taskItemInContext;

     // Переход к символам - пользователь выбрал пункт меню СИМВОЛЫ

     if (taskName == 'open_symbols') {

       //console.log('Кликнули пункт меню, link, mEvent: ', link, mEvent);

       // Раскрыть часть меню с символами

       openSymbols();

       return;

     }

    

 

     if (taskName == 'open_symbols_digits') {

       link = getDigitLink();

       taskName = 'open_symbols_value';

       return;

     }

    

     // Спрятать меню

     toggleMenuOff();

     // Вызов меню

     executeMenuItemTask(mEvent, taskName, address, return_address, symbol, targetEl, clp_id);

    

     // Счетчик-костыль

     Counter++;

  }

 

  init();

  return menu;

};

Вызов пункта всплывающего меню

Когда пользователь кликает на выбранный пункт меню, срабатывает функция executeMenuItemTask.

Приведем его полный код для тех пунктов меню, которые выше были показаны в фрагментах шаблона меню. Это полностью замкнет описание всплывающего меню.

// Функция, вызываемая при клике по пункту меню

function executeMenuItemTask(ev, taskName, address, return_address, symbol, targetEl, clp_id) {

  switch (taskName) {

     // Найти по почт. индексу (см. context_menu.tpl)

     case "find_by_posindex" : {

       getOrgByPostIndex(address, targetEl);

       break;

     }

    

....

     // Найти по ОКПО (см. context_menu.tpl)

     case "find_by_okpo" : {

       var requeststring = address +

          'functions.php?func=getPRODUCERbyOKPO&F16=' + document.getElementsByName('F16')[0].value;

       // ajax.js

       getPRODUCER(requeststring);

       break;

     }

....

     // Новый держатель (см. context_menu.tpl)

     case "newholder" : {

       if (Counter == 1)

          break;

       // Для боевого портала функция в разработке

       var url = address + 'newholder?dlg_return_link=' + address + return_address;

       if (address == '://prodrf.gostinfo.ru/') {

          alertify.alert('Функция в разработке!');

          break;

          //alertify.alert('Адрес перехода: <br />' + url + '<br />Функция newholder еще не реализована!');

       } else

          window.location = url;

       break;

     }

....

     default : {

     }

  }

  ev.stopImmediatePropagation();

}