diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e65d516 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# https://help.github.com/articles/dealing-with-line-endings/ +* text eol=lf diff --git a/README.md b/README.md index d986b9f..c23afe2 100644 --- a/README.md +++ b/README.md @@ -4,61 +4,94 @@ ## Controls - 1. Go to [https://axln.github.io/radial-menu-js/index.html](https://axln.github.io/radial-menu-js/index.html). - + 1. Go to [radial-menu-js/index.html](https://axln.github.io/radial-menu-js/index.html). 2. Click Open Menu button. - 3. You can use mouse, mouse wheel and keyboard for navigation: * Arrow keys and mouse wheel to select menu item. * Enter to choose the selected menu item. * Esc/Backspace to return to parent menu and close menu. - ## Usage Example +## Usage Example ```javascript -var svgMenu = new RadialMenu({ - parent : document.body, - size : 400, - closeOnClick: true, - menuItems : [ - { - id: 'item1', - title: 'Item 1' - }, - { - id: 'item2', - title: 'Item 2' - }, - { - id: 'more', - title: 'More...', - items: [ - { - id: 'subitem1', - title: 'Subitem 1' - }, - { - id: 'item2', - title: 'Subitem 2' - } - ] - } - ], - onClick: function (item) { - console.log('You have clicked:', item); - } +var svgMenu = new RadialMenu([ // menuItems + {id: 'item1', title: 'Item 1'}, + {id: 'item2', title: 'Item 2'}, + {id: 'more', title: 'More...', items: [ + {id: 'subitem1', title: 'Subitem 1'}, + {id: 'item2', title: 'Subitem 2'} + ]}], + 400, // size + { // params + onClick: function (item) { + console.log('You have clicked:', item); + } }); - - var openMenu = document.getElementById('menu'); - openMenu.onclick = function () { +var openMenu = document.getElementById('menu'); +openMenu.onclick = function () { svgMenu.open(); - }; +}; - var closeMenu = document.getElementById('close'); - closeMenu.onclick = function () { +var closeMenu = document.getElementById('close'); +closeMenu.onclick = function () { svgMenu.close(); - }; +}; +``` + +## Configuration + +```json5 +{ + closeOnClick: true, // boolean; will menu.close(), after item is selected. [default: true && menu.onClickFallback();] + closeOnClickOutside: true, // true or function(); it will menu.close(), when item is not selected and click is outside of menu. [default: true] + + ui: { // ui customization + fontSize: "38%", // text font-size of elements inside {menuContainer}, eg: text in {itemSector} [38%] + classes: { + menuContainer: "menuHolder", // whole radial-menu container, created dynamically! see: {params.parent} + menuCreate: "menu", + menuCreateParent: "inner", // main menu [{menuCreate} inner] + menuCreateNested: "outer", // nested menu [{menuCreate} outer] + menuOpen: "open", // menu is visible [open] + menuClose: "close", // menu is not-visible [close] + itemSectorActive: "sector", // item, which is active and can be selected [sector] + itemSectorNested: "more", // item, which has nested items... [more] + itemSectorDisabled: "dummy", // item, which is not-active/disabled [dummy] + itemSelected: "selected", // item, which is selected [selected] + closeBackButton: "center", // centered {close} or {back} button [centered] + iconsContainer: "icons", // item's icon container [icons] + }, + item: { // pre-defined items: {close} and {back} in similar way like: {menuItems} + close: {title: "Close", icon: "#close"}, + back: {title: "Back", icon: "#return"}, + // FYI: + // 1) if u want to change, eg: 'close' icon, just use item.close.icon = '#myIconId' + // 2) if u want to override default 'icon' generation, see: RadialMenu.defaultValues.ui.item.{close, back}.symbol + // 3) to change item's colors, etc use: CSS: + // svg.{menuCreate} > g.{itemSectorActive} > text, + // svg.{menuCreate} > g.{itemSectorActive} > use {...} + }, + nested: { // nested ~ inner-menu behavior + icon: "#return", // string(iconId:'#return') or true(for parentItem.icon) + title: true // show nested title? + }, + moveByWheel: true, // navigation by mouse-wheel. [default: true] + moveByKeys: { // navigation by keys. [default: true] + enabled: true, + back: ["escape", "backspace"], + select: ["enter"], + forward: ["arrowRight", "arrowUp"], + backward: ["arrowLeft", "arrowDown"] + } + } +} ``` ## License -MIT + +[MIT](LICENSE) + +## Contributors + +* [Alexey Nesterenko](https://github.com/axln) +* [Jan Smid](https://github.com/j3nda) diff --git a/css/RadialMenu.css b/css/RadialMenu.css index abd727e..26bfd98 100644 --- a/css/RadialMenu.css +++ b/css/RadialMenu.css @@ -4,7 +4,6 @@ div.menuHolder { position: relative; margin: 10px; } - svg.icons { display: none; } @@ -47,7 +46,6 @@ svg.menu > g.sector > use { svg.menu > g.sector:hover > path { fill: #F9A602D0; - } svg.menu > g.sector.selected > path { diff --git a/css/RadialMenuCustom.css b/css/RadialMenuCustom.css new file mode 100644 index 0000000..9aad1e5 --- /dev/null +++ b/css/RadialMenuCustom.css @@ -0,0 +1,115 @@ +@import url("https://fonts.googleapis.com/css?family=Bitter&subset=latin"); +div.menuHolder2 +{ /* menu-container */ + user-select: none; + -moz-user-select: none; + margin: 10px; + + position: fixed; + top: 0px; + left: 0px; + z-index: -1; + + font-family: Bitter; +} + +div.menuHolder2.open2 +{ /* menu-container:open => its visible */ + z-index: 11; +} + +div.menuHolder2.close2 +{ /* menu-container:close => its not visible */ + z-index: -1; + visibility: hidden; +} + +svg.icons +{ + display: none; +} + +svg.menu2 +{ + position: absolute; + overflow: visible; + transition: 0.2s; + transition-timing-function: ease-out; +} + +svg.menu2.open2 +{ /* menu-items:open => background, transition, gfx... */ + /* -- https://www.svgbackgrounds.com/*/ + background-color: #ffaa00; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25' viewBox='0 0 1600 800'%3E%3Cg %3E%3Cpath fill='%23ffb100' d='M486 705.8c-109.3-21.8-223.4-32.2-335.3-19.4C99.5 692.1 49 703 0 719.8V800h843.8c-115.9-33.2-230.8-68.1-347.6-92.2C492.8 707.1 489.4 706.5 486 705.8z'/%3E%3Cpath fill='%23ffb800' d='M1600 0H0v719.8c49-16.8 99.5-27.8 150.7-33.5c111.9-12.7 226-2.4 335.3 19.4c3.4 0.7 6.8 1.4 10.2 2c116.8 24 231.7 59 347.6 92.2H1600V0z'/%3E%3Cpath fill='%23ffbe00' d='M478.4 581c3.2 0.8 6.4 1.7 9.5 2.5c196.2 52.5 388.7 133.5 593.5 176.6c174.2 36.6 349.5 29.2 518.6-10.2V0H0v574.9c52.3-17.6 106.5-27.7 161.1-30.9C268.4 537.4 375.7 554.2 478.4 581z'/%3E%3Cpath fill='%23ffc500' d='M0 0v429.4c55.6-18.4 113.5-27.3 171.4-27.7c102.8-0.8 203.2 22.7 299.3 54.5c3 1 5.9 2 8.9 3c183.6 62 365.7 146.1 562.4 192.1c186.7 43.7 376.3 34.4 557.9-12.6V0H0z'/%3E%3Cpath fill='%23ffcc00' d='M181.8 259.4c98.2 6 191.9 35.2 281.3 72.1c2.8 1.1 5.5 2.3 8.3 3.4c171 71.6 342.7 158.5 531.3 207.7c198.8 51.8 403.4 40.8 597.3-14.8V0H0v283.2C59 263.6 120.6 255.7 181.8 259.4z'/%3E%3Cpath fill='%23ffd914' d='M1600 0H0v136.3c62.3-20.9 127.7-27.5 192.2-19.2c93.6 12.1 180.5 47.7 263.3 89.6c2.6 1.3 5.1 2.6 7.7 3.9c158.4 81.1 319.7 170.9 500.3 223.2c210.5 61 430.8 49 636.6-16.6V0z'/%3E%3Cpath fill='%23ffe529' d='M454.9 86.3C600.7 177 751.6 269.3 924.1 325c208.6 67.4 431.3 60.8 637.9-5.3c12.8-4.1 25.4-8.4 38.1-12.9V0H288.1c56 21.3 108.7 50.6 159.7 82C450.2 83.4 452.5 84.9 454.9 86.3z'/%3E%3Cpath fill='%23ffef3d' d='M1600 0H498c118.1 85.8 243.5 164.5 386.8 216.2c191.8 69.2 400 74.7 595 21.1c40.8-11.2 81.1-25.2 120.3-41.7V0z'/%3E%3Cpath fill='%23fff852' d='M1397.5 154.8c47.2-10.6 93.6-25.3 138.6-43.8c21.7-8.9 43-18.8 63.9-29.5V0H643.4c62.9 41.7 129.7 78.2 202.1 107.4C1020.4 178.1 1214.2 196.1 1397.5 154.8z'/%3E%3Cpath fill='%23ffff66' d='M1315.3 72.4c75.3-12.6 148.9-37.1 216.8-72.4h-723C966.8 71 1144.7 101 1315.3 72.4z'/%3E%3C/g%3E%3C/svg%3E"); + background-attachment: fixed; + background-size: cover; + + box-shadow: 10px 10px 5px 0px rgba(0, 0, 0, 0.75); + -webkit-box-shadow: 10px 10px 5px 0px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 10px 10px 5px 0px rgba(0, 0, 0, 0.75); + border-radius: 50%; +} + +svg.menu2.inner2 +{ + transform: scale(0.66) rotate(-10deg); + opacity: 0; + visibility: hidden; +} + +svg.menu2.outer2 +{ + transform: scale(1.5) rotate(10deg); + opacity: 0; + visibility: hidden; +} + +svg.menu2 > g > path +{ + fill: #00000080; +} + +svg.menu2 > g.sector > path +{ + cursor: pointer; + /*transition: 0.1s;*/ +} + +svg.menu2 > g.sector > text, +svg.menu2 > g.sector > use +{ + cursor: pointer; + fill: white; + text-shadow: 1px 1px 0 #000000A0; +} + +svg.menu2 > g.sector:hover > path +{ + fill: #9B13CDD0; +} + +svg.menu2 > g.sector.selected > path +{ + /*fill: #F9A602D0;*/ + /*fill: #009900D0 !important;*/ + fill: #32CD32D0 !important; +} + +svg.menu2 > g.center:hover > circle +{ + fill: #E70777D0; +} + +svg.menu2 > g.center > circle +{ + cursor: pointer; + fill: #00000080; +} + +svg.menu2 > g.center > text, +svg.menu2 > g.center > use +{ + cursor: pointer; + fill: white; +} diff --git a/css/main.css b/css/main.css index 1fe91cd..f545dc0 100644 --- a/css/main.css +++ b/css/main.css @@ -1,7 +1,11 @@ body { font-family: Arial, sans-serif; font-size: 13px; - background: url("../img/background.jpg") no-repeat; + background: url("../img/background.jpg") no-repeat center center fixed; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; } h1 { diff --git a/index.html b/index.html index d40c029..8d16dd2 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ Radial Menu Test + @@ -28,22 +29,17 @@ - + - + - + @@ -53,17 +49,8 @@ - - + + @@ -72,37 +59,18 @@ - + - + - + @@ -126,22 +94,17 @@ - + - + - + @@ -150,22 +113,12 @@ - + - + @@ -173,59 +126,37 @@ - - - - - + + + + + - - - + -

Radial Menu Test

+ +

Radial Menu Test

https://github.com/axln/radial-menu-js
+

+ + You can also try + "Context Menu" + → right-mouse-click(open) + → left-mouse-click(select or close). + +

- \ No newline at end of file + diff --git a/js/RadialMenu.js b/js/RadialMenu.js index a4ed8a7..0cd7352 100644 --- a/js/RadialMenu.js +++ b/js/RadialMenu.js @@ -1,577 +1,998 @@ 'use strict'; -var DEFAULT_SIZE = 100; -var MIN_SECTORS = 6; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -function RadialMenu(params) { - var self = this; - - self.parent = params.parent || []; - - self.size = params.size || DEFAULT_SIZE; - self.onClick = params.onClick || null; - self.menuItems = params.menuItems ? params.menuItems : [{id: 'one', title: 'One'}, {id: 'two', title: 'Two'}]; - - self.radius = 50; - self.innerRadius = self.radius * 0.4; - self.sectorSpace = self.radius * 0.06; - self.sectorCount = Math.max(self.menuItems.length, MIN_SECTORS); - self.closeOnClick = params.closeOnClick !== undefined ? !!params.closeOnClick : false; - - self.scale = 1; - self.holder = null; - self.parentMenu = []; - self.parentItems = []; - self.levelItems = null; - - self.createHolder(); - self.addIconSymbols(); - - self.currentMenu = null; - document.addEventListener('wheel', self.onMouseWheel.bind(self)); - document.addEventListener('keydown', self.onKeyDown.bind(self)); +/** + * Radial menu in pure JavaScript, HTML and SVG. + * License: MIT + * Copyright (c) 2019 Alexey Nesterenko + * -- https://github.com/axln/radial-menu-js + * -- https://github.com/j3nda/radial-menu-js + */ +class RadialMenu +{ + static _defaultValues = { + minSectors: 6, + radius: { + value: 50, + multiInnerRadius: 0.4, // multiplication for default.radius.value [or params.radius] + multiSectorSpace: 0.06 // multiplication for default.radius.value [or params.radius] + }, + radiusInner: 0.4, + radiusSectorSpace: 0.06, + closeOnClick: true, // true or function(); will close(); after item is selected. [default: onClickFallback()] + closeOnClickOutside: true, // true or function(); it will close(); when item is not selected and click is outside of menu. [default: true] + ui: { + fontSize: "38%", // text font-size of elements inside {menuContainer}, eg: text in {itemSector} [38%] + classes: { + menuContainer: "menuHolder", // whole radial-menu container, created dynamically! see: {params.parent} + menuCreate: "menu", + menuCreateParent: "inner", // main menu [{menuCreate} inner] + menuCreateNested: "outer", // nested menu [{menuCreate} outer] + menuOpen: "open", // menu is visible [open] + menuClose: "close", // menu is not-visible [close] + itemSectorActive: "sector", // item, which is active and can be selected + itemSectorNested: "more", // item, which has nested items... [more] + itemSectorDisabled: "dummy", // item, which is not-active/disabled [dummy] + itemSelected: "selected", // item, which is selected [selected] + closeBackButton: "center", // centered {close} or {back} button [centered] + iconsContainer: "icons", // item's icon container [icons] + }, + item: { // pre-defined items: {close} and {back} in similar way like: {menuItems} + close: { + title: "Close", + icon: "#close", + symbol: { // default icon fallback... + id: "close", + viewBox: "0 0 41.756 41.756", + paths: [ + "M27.948,20.878L40.291,8.536c1.953-1.953,1.953-5.119,0-7.071c-1.951-1.952-5.119-1.952-7.07,0L20.878,13.809L8.535,1.465c-1.951-1.952-5.119-1.952-7.07,0c-1.953,1.953-1.953,5.119,0,7.071l12.342,12.342L1.465,33.22c-1.953,1.953-1.953,5.119,0,7.071C2.44,41.268,3.721,41.755,5,41.755c1.278,0,2.56-0.487,3.535-1.464l12.343-12.342l12.343,12.343c0.976,0.977,2.256,1.464,3.535,1.464s2.56-0.487,3.535-1.464c1.953-1.953,1.953-5.119,0-7.071L27.948,20.878z" + ] + } + }, + back: { + title: "Back", + icon: "#return", + symbol: { // default icon fallback... + id: "return", + viewBox: "0 0 489.394 489.394", + paths: [ + "M375.789,92.867H166.864l17.507-42.795c3.724-9.132,1-19.574-6.691-25.744c-7.701-6.166-18.538-6.508-26.639-0.879L9.574,121.71c-6.197,4.304-9.795,11.457-9.563,18.995c0.231,7.533,4.261,14.446,10.71,18.359l147.925,89.823c8.417,5.108,19.18,4.093,26.481-2.499c7.312-6.591,9.427-17.312,5.219-26.202l-19.443-41.132h204.886c15.119,0,27.418,12.536,27.418,27.654v149.852c0,15.118-12.299,27.19-27.418,27.19h-226.74c-20.226,0-36.623,16.396-36.623,36.622v12.942c0,20.228,16.397,36.624,36.623,36.624h226.74c62.642,0,113.604-50.732,113.604-113.379V206.709C489.395,144.062,438.431,92.867,375.789,92.867z" + ] + } + }, + // TODO: [ui/item] fontColor, textColor, ?position? + // to change item's colors, etc use: CSS: + // svg.{menuCreate} > g.{itemSectorActive} > text, + // svg.{menuCreate} > g.{itemSectorActive} > use {...} + }, + nested: { + icon: "#return", // string(iconId:'#return') or true(for parentItem.icon) + title: true, // show nested title? + // TODO: [ui] ?it can show (number of nested menu)? + // TODO: [ui] ?it can combine 'nested.icon' with '#return' icon ~ bestFitForSizes? + }, + moveByWheel: true, // navigation by mouse-wheel. [default: true] + moveByKeys: { // navigation by keys. [default: true] + enabled: true, + back: ["escape", "backspace"], + select: ["enter"], + forward: ["arrowRight", "arrowUp"], + backward: ["arrowLeft", "arrowDown"] + } + } + } + + /** + * create RadialMenu + * @param menuItems array of items, eg: [{id: "one", icon: "One"}, {id: "two", title: "two"}, {id: "more", icon: "more", title: "More...", items: [...]}] + * @param sizeInPixels + * @param params custom parameters to override {...} + */ + constructor(menuItems, sizeInPixels, params) + { + const defaultValues = this.merge({}, RadialMenu._defaultValues); + + this.defaultValues = defaultValues; + this.uuid = this.generateUUID(); + this.parent = params.parent || document.body; + this.size = sizeInPixels; + this.menuItems = menuItems; + this.radius = params.radius ? params.radius : defaultValues.radius.value; + this.innerRadius = params.innerRadius + ? params.innerRadius + : this.radius * (params.multiInnerRadius ? params.multiInnerRadius : defaultValues.radius.multiInnerRadius) + ; + this.sectorSpace = params.sectorSpace + ? params.sectorSpace + : this.radius * (params.multiSectorSpace ? params.multiSectorSpace : defaultValues.radius.multiSectorSpace) + ; + this.sectorCount = Math.max(this.menuItems.length, defaultValues.minSectors); + this.closeOnClick = params.closeOnClick !== undefined ? !!params.closeOnClick : defaultValues.closeOnClick; + this.closeOnClickOutside = (params.closeOnClickOutside !== undefined + ? (params.closeOnClickOutside instanceof Function ? params.closeOnClickOutside : !!params.closeOnClickOutside) + : defaultValues.closeOnClickOutside + ); + this.onClick = params.onClick || this.onClickFallback; + this.ui = this.merge( + defaultValues.ui, + params.ui || {} + ); + this.scale = 1; + + // menu container ~ this.holder + this.parent.appendChild( + this.holder = this.createMenuContainer( + this.uuid, + this.size, + [this.ui.classes.menuContainer, this.ui.classes.menuClose].join(' ') + ) + ); + + // default icons(close, back) + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', this.ui.classes.iconsContainer); + svg.appendChild(this.createSvgSymbol(this.ui.item.close.symbol)); + svg.appendChild(this.createSvgSymbol(this.ui.item.back.symbol)); + this.holder.appendChild(svg); + + if (this.ui.moveByWheel) + { + document.addEventListener('wheel', this.onMouseWheel.bind(this)); + } + if (this.ui.moveByKeys) + { + document.addEventListener('keydown', this.onKeyDown.bind(this)); + } + + this.initialize(); + } + + /** + * generate UUID + * -- https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid + * -- https://stackoverflow.com/a/8809472/6130410 + * @returns {string} + */ + generateUUID() + { + let d1 = new Date().getTime(); // timestamp + let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0; // time in microseconds since page-load or 0 if unsupported + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + .replace(/[xy]/g, function(c) + { + let r = Math.random() * 16; // random number between 0 and 16 + if (d1 > 0) + { + // use timestamp until depleted + r = (d1 + r) % 16 | 0; + d1 = Math.floor(d1 / 16); + } + else + { + // use microseconds since page-load if supported + r = (d2 + r) % 16 | 0; + d2 = Math.floor(d2 / 16); + } + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }) + ; + } + + onClickFallback(item) + { + console.info(this.constructor.name + ".onClickFallback(item):"); + console.info(item); + console.error(this.constructor.name + "onClick: function(item) {...}; // must be defined by params or default!"); + } + + /** return true if menu is visible, otherwise returns false. */ + isOpen() + { + return (this.currentMenu !== null); + } + + /** + * handle mouse-click or tap, when its outside of menu. + * -- https://www.w3docs.com/snippets/javascript/how-to-detect-a-click-outside-an-element.html + */ + handleClickOutside(event, THIS) + { + const menu = document.getElementById(THIS.uuid); + if (!menu || THIS.uuid !== menu.id || !THIS.isOpen()) + { + return; + } + let target = event.target; + do + { + if (target === menu) + { + // click inside! do nothing... + return; + } + // go up thru DOM + target = target.parentNode; + } + while (target); + + // click outside! + if (THIS.closeOnClickOutside && THIS.closeOnClickOutside instanceof Function) + { + THIS.closeOnClickOutside(THIS); + } + THIS.close(true); + } + + open(x = undefined, y = undefined) + { + if (this.isOpen()) + { + return; + } + this.initialize(); + this.currentMenu = this.createMenu( + [this.ui.classes.menuCreate, this.ui.classes.menuCreateParent].join(' '), + this.menuItems + ); + + const alreadyOpened = this.holder.getElementsByClassName(this.ui.classes.menuCreate); + if (alreadyOpened) + { + // alreadyOpened? remove all... to start from scratch! + while(alreadyOpened.length > 0) + { + alreadyOpened[0].parentNode.removeChild(alreadyOpened[0]); + } + } + this.holder.appendChild(this.currentMenu); + + // wait DOM commands to apply and then set class to allow transition to take effect + const THIS = this; + this.postRunnable(function() + { + THIS.currentMenu.setAttribute( + 'class', + [THIS.ui.classes.menuCreate, THIS.ui.classes.menuOpen].join(' ') + ); + if (THIS.closeOnClickOutside) + { + document.addEventListener('click', THIS.closeOnClickOutsideListener = function(event) + { + THIS.handleClickOutside(event, THIS); + }); + } + }); + const menuContainer = document.getElementById(this.uuid); + menuContainer.classList.remove(this.ui.classes.menuClose); + menuContainer.classList.add(this.ui.classes.menuOpen); + if (x !== undefined) + { + menuContainer.style.left = (x - this.size / 2) + "px"; + } + if (y !== undefined) + { + menuContainer.style.top = (y - this.size / 2) + "px"; + } + } + + close(force = false) + { + if (!force && !this.isOpen()) + { + return; + } + const THIS = this; + if (this.currentMenu !== null) + { + this.setClassAndWaitForTransition( + this.currentMenu, + [this.ui.classes.menuCreate, this.ui.classes.menuCreateParent].join(' ') + ) + .then(function () + { + THIS.initialize(); + }); + } + if (this.closeOnClickOutside) + { + document.removeEventListener('click', this.closeOnClickOutsideListener); + } + this.postRunnable(function() + { + THIS.initialize(); + }, 250); + } + + initialize() + { + this.level = 0; + this.currentMenu = null; + this.levelItems = null; + this.parentMenu = []; + this.parentItems = []; + const alreadyOpened = this.holder.getElementsByClassName(this.ui.classes.menuCreate); + if (alreadyOpened) + { + // alreadyOpened? remove all... to start from scratch! + while(alreadyOpened.length > 0) + { + alreadyOpened[0].parentNode.removeChild(alreadyOpened[0]); + } + } + const menuContainer = document.getElementById(this.uuid); + if (menuContainer) + { + menuContainer.classList.remove(this.ui.classes.menuOpen); + menuContainer.classList.add(this.ui.classes.menuClose); + } + } + + /** default functionality as onClick(): function(), which MUST be overridden through params! */ + onClick(item) + { + return item; + } + + getParentMenu() + { + if (this.parentMenu.length > 0) + { + return this.parentMenu[this.parentMenu.length - 1]; + } + return null; + } + + createMenuContainer(uuid, size, classValue) + { + const container = document.createElement('div'); + + container.id = uuid; + container.className = classValue; + container.style.width = size + 'px'; + container.style.height = size + 'px'; + + return container; + } + + showNestedMenu(item) + { + if (!this.isOpen()) + { + return; + } + this.level++; + this.parentMenu.push(this.currentMenu); + this.parentItems.push(this.levelItems); + this.currentMenu = this.createMenu( + [this.ui.classes.menuCreate, this.ui.classes.menuCreateParent].join(' '), + item.items, + item + ); + this.holder.appendChild(this.currentMenu); + + // wait DOM commands to apply and then set class to allow transition to take effect + const THIS = this; + this.postRunnable(function() + { + if (!THIS.isOpen()) + { + return; + } + THIS.getParentMenu().setAttribute( + 'class', + [THIS.ui.classes.menuCreate, THIS.ui.classes.menuCreateNested, THIS.ui.classes.menuClose].join(' ') + ); + THIS.currentMenu.setAttribute( + 'class', + [THIS.ui.classes.menuCreate, THIS.ui.classes.menuOpen].join(' ') + ); + }); + } + + returnToParentMenu() + { + this.getParentMenu().setAttribute( + 'class', + [this.ui.classes.menuCreate, this.ui.classes.menuOpen].join(' ') + ); + const THIS = this; + this.setClassAndWaitForTransition( + this.currentMenu, + [this.ui.classes.menuCreate, this.ui.classes.menuCreateParent].join(' ') + ) + .then(function(){ + THIS.currentMenu.remove(); + THIS.currentMenu = THIS.parentMenu.pop(); + THIS.levelItems = THIS.parentItems.pop(); + THIS.currentMenu.setAttribute( + 'class', + [THIS.ui.classes.menuCreate, THIS.ui.classes.menuOpen].join(' ') + ); + }) + ; + } + + /** handle click inside menu, eg: choosing item. */ + handleClick() + { + if (!this.isOpen()) + { + return; + } + const selectedIndex = this.getSelectedIndex(); + if (selectedIndex >= 0) + { + const item = this.levelItems[selectedIndex]; + if (item.items) + { + this.showNestedMenu(item); + return; + } + let selectedItem = Object.assign({}, item); + if (this.closeOnClick) + { + this.close(true); + } + if (this.onClick && this.onClick instanceof Function) + { + this.onClick(selectedItem); + return; + } + this.onClickFallback(selectedItem); + } + } + + /** handle click in the center, eg: close or back-button */ + handleClickCloseOrBack() + { + if (!this.isOpen()) + { + return; + } + if (this.parentItems.length > 0) + { + this.returnToParentMenu(); + return; + } + this.close(true); + } + + /** + * create center button, eg: close or back-button + * @param item as {title: watawaka, icon: matafaka, etc...} + * @param size + * @param nested am i nested? yes, here is my parentItem + */ + createCloseBackButton(item, size, nested = undefined) + { + size = size || 8;//TODO:?magicNumber?default value?8? + + const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + group.setAttribute('class', this.ui.classes.closeBackButton); + + const centerCircle = this.createSvgCircle(0, 0, this.innerRadius - this.sectorSpace / 3); + group.appendChild(centerCircle); + + if (nested && this.ui.nested.title) + { + const text = this.createSvgText(0, +size, nested); + group.appendChild(text); + } + + if (item.icon) + { + let icon = item.icon; + if (nested && this.ui.nested.icon) + { + icon = (this.ui.nested.icon === true ? nested.icon : this.ui.nested.icon); + } + const use = this.createSvgUse(0, 0, icon); + use.setAttribute('width', size); + use.setAttribute('height', size); + use.setAttribute( + 'transform', + 'translate(-' + this.numberToString(size / 2) + ',-' + this.numberToString(size / 2) + ')' + ); + group.appendChild(use); + } + return group; + } + + getIndexOffset() + { + if (this.levelItems.length < this.sectorCount) + { + switch (this.levelItems.length) + { + case 1: + case 2: + case 3: + return -2; + + default: + return -1; + } + } + return -1; + } + + /** create all items for currently visible menu, eg: main menu or nested one */ + createMenu(classValue, levelItems, nested) + { + this.levelItems = levelItems; + this.sectorCount = Math.max(this.levelItems.length, this.defaultValues.minSectors); + this.scale = this.calculateScale(); + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', classValue); + svg.setAttribute('viewBox', '-50 -50 100 100'); + svg.setAttribute('width', this.size); + svg.setAttribute('height', this.size); + + const angleStep = 360 / this.sectorCount; + const angleShift = angleStep / 2 + 270; + const indexOffset = this.getIndexOffset(); + + for (let i = 0; i < this.sectorCount; ++i) + { + const startAngle = angleShift + angleStep * i; + const endAngle = angleShift + angleStep * (i + 1); + const itemIndex = this.resolveLoopIndex(this.sectorCount - i + indexOffset, this.sectorCount); + let item = null; + if (itemIndex >= 0 && itemIndex < this.levelItems.length) + { + item = this.levelItems[itemIndex]; + } + svg.appendChild( + this.createItemSector(startAngle, endAngle, item, itemIndex) + ); + } + + if (nested) + { + svg.appendChild( + this.createCloseBackButton(this.ui.item.back, 8, nested)//TODO:??magicNumber?8? + ); + } + else + { + svg.appendChild( + this.createCloseBackButton(this.ui.item.close, 7)//TODO:??magicNumber?7? + ); + } + + const THIS = this; + svg.addEventListener('mousedown', function(event) + { + const classNames = event.target.parentNode.getAttribute('class').split(' '); + for (let i = 0; i < classNames.length; i++) + { + if (classNames[i] === THIS.ui.classes.itemSectorActive) + { + const index = parseInt(event.target.parentNode.getAttribute('data-index')); + if (!isNaN(index)) + { + THIS.setSelectedIndex(index); + } + break; + } + } + }); + svg.addEventListener('click', function(event) + { + const classNames = event.target.parentNode.getAttribute('class').split(' '); + for (let i = 0; i < classNames.length; i++) + { + if (classNames[i] === THIS.ui.classes.itemSectorActive) + { + THIS.handleClick(); + break; + } + if (classNames[i] === THIS.ui.classes.closeBackButton) + { + THIS.handleClickCloseOrBack(); + break; + } + } + }); + return svg; + } + + /** setSelectedIndex() based on +/- indexDelta, eg: onMouseWheel */ + selectDelta(indexDelta) + { + let selectedIndex = this.getSelectedIndex(); + if (selectedIndex < 0) + { + selectedIndex = 0; + } + selectedIndex += indexDelta; + if (selectedIndex < 0) + { + selectedIndex = this.levelItems.length + selectedIndex; + } + else + if (selectedIndex >= this.levelItems.length) + { + selectedIndex -= this.levelItems.length; + } + this.setSelectedIndex(selectedIndex); + }; + + onKeyDown(event) + { + if (!this.isOpen()) + { + return; + } + if (this.isKeyDown(event, this.ui.moveByKeys.back)) + { + this.handleClickCloseOrBack(); + event.preventDefault(); + return; + } + if (this.isKeyDown(event, this.ui.moveByKeys.select)) + { + this.handleClick(); + event.preventDefault(); + return; + } + if (this.isKeyDown(event, this.ui.moveByKeys.forward)) + { + this.selectDelta(+1); + event.preventDefault(); + return; + } + if (this.isKeyDown(event, this.ui.moveByKeys.backward)) + { + this.selectDelta(-1); + event.preventDefault(); + return; + } + } + + isKeyDown(event, keySet) + { + const keyId = event.key.toLowerCase(); + for(let i = 0; i < keySet.length; i++) + { + if (keyId === keySet[i].toLowerCase()) + { + return true; + } + } + return false; + } + + onMouseWheel(event) + { + if (!this.isOpen()) + { + return; + } + const delta = -event.deltaY; + this.selectDelta(delta > 0 ? +1 : -1); + } + + getSelectedNode() + { + const items = this.currentMenu.getElementsByClassName(this.ui.classes.itemSelected); + if (items.length > 0) + { + return items[0]; + } + return null; + } + + getSelectedIndex() + { + const selectedNode = this.getSelectedNode(); + if (selectedNode) + { + return parseInt(selectedNode.getAttribute('data-index')); + } + return -1; + } + + setSelectedIndex(index) + { + if (index >= 0 && index < this.levelItems.length) + { + const items = this.currentMenu.querySelectorAll('g[data-index="' + index + '"]'); + if (items.length > 0) + { + const itemToSelect = items[0]; + const selectedNode = this.getSelectedNode(); + let itemClasses = [this.ui.classes.itemSectorActive, this.ui.classes.itemSelected]; + if (selectedNode) + { + selectedNode.setAttribute('class', this.ui.classes.itemSectorActive); + } + if (itemToSelect.items && itemToSelect.items.length > 0) + { + itemClasses.push(this.ui.classes.itemSectorNested); + } + itemToSelect.setAttribute('class', itemClasses.join(' ')); + } + } + } + + createSvgUse(x, y, link) + { + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + + use.setAttribute('x', this.numberToString(x)); + use.setAttribute('y', this.numberToString(y)); + use.setAttribute('width', '10');//TODO:??magicNumber?10? + use.setAttribute('height', '10');//TODO:??magicNumber?10? + use.setAttribute('fill', 'white');//TODO:??magicNumber?color? + use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', link); + + return use; + } + + createItemSector(startAngleDeg, endAngleDeg, item, index) + { + const centerPoint = this.getSectorPosition(startAngleDeg, endAngleDeg); + const translate = { + x: this.numberToString((1 - this.scale) * centerPoint.x), + y: this.numberToString((1 - this.scale) * centerPoint.y), + }; + + const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + group.setAttribute('transform', 'translate(' + translate.x + ' ,' + translate.y + ') scale(' + this.scale + ')'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', this.createItemSectorPath(startAngleDeg, endAngleDeg)); + group.appendChild(path); + + if (item) + { + let itemClasses = [this.ui.classes.itemSectorActive]; + if (item.selected && item.selected === true) + { + itemClasses.push(this.ui.classes.itemSelected); + } + if (item.items && item.items.length > 0) + { + itemClasses.push(this.ui.classes.itemSectorNested); + } + group.setAttribute('class', itemClasses.join(' ')); + group.setAttribute('data-id', item.id); + group.setAttribute('data-index', index); + + if (item.title) + { + const text = this.createSvgText(centerPoint.x, centerPoint.y, item); + if (item.icon) + { + text.setAttribute('transform', 'translate(0,8)'); + } + else + { + text.setAttribute('transform', 'translate(0,2)'); + } + group.appendChild(text); + } + + if (item.icon) + { + const use = this.createSvgUse(centerPoint.x, centerPoint.y, item.icon); + if (item.title) + { + use.setAttribute('transform', 'translate(-5,-8)'); + } + else + { + use.setAttribute('transform', 'translate(-5,-5)'); + } + group.appendChild(use); + } + } + else + { + group.setAttribute('class', this.ui.classes.itemSectorDisabled); + } + return group; + }; + + createItemSectorPath(startAngleDeg, endAngleDeg) + { + // FIXME: if (this.minSectors < 4) it looks weird! itemSectorPath is somehow deformed! + // TODO: createItemSectorPath(more params, regards to created item, eg: {start, end}Angle...); + const initPoint = this.getDegreePosition(startAngleDeg, this.radius); + let path = 'M' + this.pointToString(initPoint); + const radiusAfterScale = this.radius * (1 / this.scale); + + path += 'A' + radiusAfterScale + ' ' + radiusAfterScale + ' 0 0 0' + this.pointToString(this.getDegreePosition(endAngleDeg, this.radius)); + path += 'L' + this.pointToString(this.getDegreePosition(endAngleDeg, this.innerRadius)); + + const radiusDiff = this.radius - this.innerRadius; + const radiusDelta = (radiusDiff - (radiusDiff * this.scale)) / 2; + const innerRadius = (this.innerRadius + radiusDelta) * (1 / this.scale); + + path += 'A' + innerRadius + ' ' + innerRadius + ' 0 0 1 ' + this.pointToString(this.getDegreePosition(startAngleDeg, this.innerRadius)); + path += 'Z'; + + return path; + } + + createSvgText(x, y, item) + { + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('x', this.numberToString(x)); + text.setAttribute('y', this.numberToString(y)); + text.setAttribute('font-size', item.fontSize ?? this.ui.fontSize); + text.innerHTML = item.title; + + return text; + } + + createSvgCircle(x, y, r) + { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + + circle.setAttribute('cx', this.numberToString(x)); + circle.setAttribute('cy', this.numberToString(y)); + circle.setAttribute('r', r); + + return circle; + }; + + calculateScale() + { + const totalSpace = this.sectorSpace * this.sectorCount; + const circleLength = Math.PI * 2 * this.radius; + const radiusDelta = this.radius - (circleLength - totalSpace) / (Math.PI * 2); + + return (this.radius - radiusDelta) / this.radius; + } + + getSectorPosition(startAngleDeg, endAngleDeg) + { + return this.getDegreePosition( + (startAngleDeg + endAngleDeg) / 2, + this.innerRadius + (this.radius - this.innerRadius) / 2 + ); + } + + createSvgSymbol(item) + { + const symbol = document.createElementNS('http://www.w3.org/2000/svg', 'symbol'); + symbol.setAttribute('id', item.id); + symbol.setAttribute('viewBox', item.viewBox); + + for(let i = 0; i < item.paths.length; i++) + { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', item.paths[i]); + symbol.appendChild(path); + } + return symbol; + } + + getDegreePosition(angleDeg, length) + { + return { + x: Math.sin(this.degToRad(angleDeg)) * length, + y: Math.cos(this.degToRad(angleDeg)) * length + }; + } + + pointToString(point) + { + return this.numberToString(point.x) + ' ' + this.numberToString(point.y); + } + + numberToString(n) + { + if (Number.isInteger(n)) + { + return n.toString(); + } + else + if (n) + { + let r = (+n).toFixed(5); + if (r.match(/\./)) + { + r = r.replace(/\.?0+$/, ''); + } + return r; + } + return ""; + } + + resolveLoopIndex(index, length) + { + if (index < 0) + { + return length + index; + } + if (index >= length) + { + return index - length; + } + if (index < length) + { + return index; + } + return null; + } + + degToRad(deg) + { + return deg * (Math.PI / 180); + } + + setClassAndWaitForTransition(node, newClass) + { + return new Promise(function(resolve) + { + function handler(event) + { + if (event.target === node && event.propertyName === 'visibility') + { + node.removeEventListener('transitionend', handler); + resolve(); + } + } + node.addEventListener('transitionend', handler); + node.setAttribute('class', newClass); + }); + } + + /** + * well, well, well naaacelniku :P + * this will process function(); its very useful to be sure that data will be processed exactly same! + * (basically u want to encapsulate data-manipulation due transitions-shlitz, eg: setClassAndWaitForTransition()) + */ + postRunnable(fn, timeoutMs = 10) + { + setTimeout(fn, timeoutMs); + } + + /** + * return true if its object, otherwise returns false. + * (part of merge() functionality) + */ + isObject(item) + { + return (item && typeof item === 'object' && !Array.isArray(item)); + } + + /** + * Deep merge two objects. + * -- https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge?page=1&tab=scoredesc#tab-top + * @param target + * @param sources + */ + merge(target, ...sources) + { + if (!sources.length) + { + return target; + } + const source = sources.shift(); + if (this.isObject(target) && this.isObject(source)) + { + for (const key in source) + { + if (this.isObject(source[key])) + { + if (!target[key]) + { + Object.assign(target, {[key]: {}}); + } + this.merge(target[key], source[key]); + } + else + { + Object.assign(target, { [key]: source[key] }); + } + } + } + return this.merge(target, ...sources); + } } - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.open = function () { - var self = this; - if (!self.currentMenu) { - self.currentMenu = self.createMenu('menu inner', self.menuItems); - self.holder.appendChild(self.currentMenu); - - // wait DOM commands to apply and then set class to allow transition to take effect - RadialMenu.nextTick(function () { - self.currentMenu.setAttribute('class', 'menu'); - }); - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.close = function () { - var self = this; - - if (self.currentMenu) { - var parentMenu; - while (parentMenu = self.parentMenu.pop()) { - parentMenu.remove(); - } - self.parentItems = []; - - RadialMenu.setClassAndWaitForTransition(self.currentMenu, 'menu inner').then(function () { - self.currentMenu.remove(); - self.currentMenu = null; - }); - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.getParentMenu = function () { - var self = this; - if (self.parentMenu.length > 0) { - return self.parentMenu[self.parentMenu.length - 1]; - } else { - return null; - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createHolder = function () { - var self = this; - - self.holder = document.createElement('div'); - self.holder.className = 'menuHolder'; - self.holder.style.width = self.size + 'px'; - self.holder.style.height = self.size + 'px'; - - self.parent.appendChild(self.holder); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.showNestedMenu = function (item) { - var self = this; - self.parentMenu.push(self.currentMenu); - self.parentItems.push(self.levelItems); - self.currentMenu = self.createMenu('menu inner', item.items, true); - self.holder.appendChild(self.currentMenu); - - // wait DOM commands to apply and then set class to allow transition to take effect - RadialMenu.nextTick(function () { - self.getParentMenu().setAttribute('class', 'menu outer'); - self.currentMenu.setAttribute('class', 'menu'); - }); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.returnToParentMenu = function () { - var self = this; - self.getParentMenu().setAttribute('class', 'menu'); - RadialMenu.setClassAndWaitForTransition(self.currentMenu, 'menu inner').then(function () { - self.currentMenu.remove(); - self.currentMenu = self.parentMenu.pop(); - self.levelItems = self.parentItems.pop(); - }); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.handleClick = function () { - var self = this; - - var selectedIndex = self.getSelectedIndex(); - if (selectedIndex >= 0) { - var item = self.levelItems[selectedIndex]; - if (item.items) { - self.showNestedMenu(item); - } else { - if (self.onClick) { - self.onClick(item); - if (self.closeOnClick) { - self.close(); - } - } - } - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.handleCenterClick = function () { - var self = this; - if (self.parentItems.length > 0) { - self.returnToParentMenu(); - } else { - self.close(); - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createCenter = function (svg, title, icon, size) { - var self = this; - size = size || 8; - var g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - g.setAttribute('class', 'center'); - - var centerCircle = self.createCircle(0, 0, self.innerRadius - self.sectorSpace / 3); - g.appendChild(centerCircle); - if (text) { - var text = self.createText(0,0, title); - g.appendChild(text); - } - - if (icon) { - var use = self.createUseTag(0,0, icon); - use.setAttribute('width', size); - use.setAttribute('height', size); - use.setAttribute('transform', 'translate(-' + RadialMenu.numberToString(size / 2) + ',-' + RadialMenu.numberToString(size / 2) + ')'); - g.appendChild(use); - } - - svg.appendChild(g); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.getIndexOffset = function () { - var self = this; - if (self.levelItems.length < self.sectorCount) { - switch (self.levelItems.length) { - case 1: - return -2; - case 2: - return -2; - case 3: - return -2; - default: - return -1; - } - } else { - return -1; - } - -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createMenu = function (classValue, levelItems, nested) { - var self = this; - - self.levelItems = levelItems; - - self.sectorCount = Math.max(self.levelItems.length, MIN_SECTORS); - self.scale = self.calcScale(); - - var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('class', classValue); - svg.setAttribute('viewBox', '-50 -50 100 100'); - svg.setAttribute('width', self.size); - svg.setAttribute('height', self.size); - - var angleStep = 360 / self.sectorCount; - var angleShift = angleStep / 2 + 270; - - var indexOffset = self.getIndexOffset(); - - for (var i = 0; i < self.sectorCount; ++i) { - var startAngle = angleShift + angleStep * i; - var endAngle = angleShift + angleStep * (i + 1); - - var itemIndex = RadialMenu.resolveLoopIndex(self.sectorCount - i + indexOffset, self.sectorCount); - var item; - if (itemIndex >= 0 && itemIndex < self.levelItems.length) { - item = self.levelItems[itemIndex]; - } else { - item = null; - } - - self.appendSectorPath(startAngle, endAngle, svg, item, itemIndex); - } - - if (nested) { - self.createCenter(svg, 'Close', '#return', 8); - } else { - self.createCenter(svg, 'Close', '#close', 7); - } - - svg.addEventListener('mousedown', function (event) { - var className = event.target.parentNode.getAttribute('class').split(' ')[0]; - switch (className) { - case 'sector': - var index = parseInt(event.target.parentNode.getAttribute('data-index')); - if (!isNaN(index)) { - self.setSelectedIndex(index); - } - break; - default: - } - }); - - svg.addEventListener('click', function (event) { - var className = event.target.parentNode.getAttribute('class').split(' ')[0]; - switch (className) { - case 'sector': - self.handleClick(); - break; - case 'center': - self.handleCenterClick(); - break; - default: - } - }); - return svg; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.selectDelta = function (indexDelta) { - var self = this; - var selectedIndex = self.getSelectedIndex(); - if (selectedIndex < 0) { - selectedIndex = 0; - } - selectedIndex += indexDelta; - - if (selectedIndex < 0) { - selectedIndex = self.levelItems.length + selectedIndex; - } else if (selectedIndex >= self.levelItems.length) { - selectedIndex -= self.levelItems.length; - } - self.setSelectedIndex(selectedIndex); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.onKeyDown = function (event) { - var self = this; - if (self.currentMenu) { - switch (event.key) { - case 'Escape': - case 'Backspace': - self.handleCenterClick(); - event.preventDefault(); - break; - case 'Enter': - self.handleClick(); - event.preventDefault(); - break; - case 'ArrowRight': - case 'ArrowUp': - self.selectDelta(1); - event.preventDefault(); - break; - case 'ArrowLeft': - case 'ArrowDown': - self.selectDelta(-1); - event.preventDefault(); - break; - } - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.onMouseWheel = function (event) { - var self = this; - if (self.currentMenu) { - var delta = -event.deltaY; - - if (delta > 0) { - self.selectDelta(1) - } else { - self.selectDelta(-1) - } - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.getSelectedNode = function () { - var self = this; - var items = self.currentMenu.getElementsByClassName('selected'); - if (items.length > 0) { - return items[0]; - } else { - return null; - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.getSelectedIndex = function () { - var self = this; - var selectedNode = self.getSelectedNode(); - if (selectedNode) { - return parseInt(selectedNode.getAttribute('data-index')); - } else { - return -1; - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.setSelectedIndex = function (index) { - var self = this; - if (index >=0 && index < self.levelItems.length) { - var items = self.currentMenu.querySelectorAll('g[data-index="' + index + '"]'); - if (items.length > 0) { - var itemToSelect = items[0]; - var selectedNode = self.getSelectedNode(); - if (selectedNode) { - selectedNode.setAttribute('class', 'sector'); - } - itemToSelect.setAttribute('class', 'sector selected'); - } - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createUseTag = function (x, y, link) { - var use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); - use.setAttribute('x', RadialMenu.numberToString(x)); - use.setAttribute('y', RadialMenu.numberToString(y)); - use.setAttribute('width', '10'); - use.setAttribute('height', '10'); - use.setAttribute('fill', 'white'); - use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', link); - return use; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.appendSectorPath = function (startAngleDeg, endAngleDeg, svg, item, index) { - var self = this; - - var centerPoint = self.getSectorCenter(startAngleDeg, endAngleDeg); - var translate = { - x: RadialMenu.numberToString((1 - self.scale) * centerPoint.x), - y: RadialMenu.numberToString((1 - self.scale) * centerPoint.y) - }; - - var g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - g.setAttribute('transform','translate(' +translate.x + ' ,' + translate.y + ') scale(' + self.scale + ')'); - - var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', self.createSectorCmds(startAngleDeg, endAngleDeg)); - g.appendChild(path); - - if (item) { - g.setAttribute('class', 'sector'); - if (index == 0) { - g.setAttribute('class', 'sector selected'); - } - g.setAttribute('data-id', item.id); - g.setAttribute('data-index', index); - - if (item.title) { - var text = self.createText(centerPoint.x, centerPoint.y, item.title); - if (item.icon) { - text.setAttribute('transform', 'translate(0,8)'); - } else { - text.setAttribute('transform', 'translate(0,2)'); - } - - g.appendChild(text); - } - - if (item.icon) { - var use = self.createUseTag(centerPoint.x, centerPoint.y, item.icon); - if (item.title) { - use.setAttribute('transform', 'translate(-5,-8)'); - } else { - use.setAttribute('transform', 'translate(-5,-5)'); - } - - g.appendChild(use); - } - - } else { - g.setAttribute('class', 'dummy'); - } - - svg.appendChild(g); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createSectorCmds = function (startAngleDeg, endAngleDeg) { - var self = this; - - var initPoint = RadialMenu.getDegreePos(startAngleDeg, self.radius); - var path = 'M' + RadialMenu.pointToString(initPoint); - - var radiusAfterScale = self.radius * (1 / self.scale); - path += 'A' + radiusAfterScale + ' ' + radiusAfterScale + ' 0 0 0' + RadialMenu.pointToString(RadialMenu.getDegreePos(endAngleDeg, self.radius)); - path += 'L' + RadialMenu.pointToString(RadialMenu.getDegreePos(endAngleDeg, self.innerRadius)); - - var radiusDiff = self.radius - self.innerRadius; - var radiusDelta = (radiusDiff - (radiusDiff * self.scale)) / 2; - var innerRadius = (self.innerRadius + radiusDelta) * (1 / self.scale); - path += 'A' + innerRadius + ' ' + innerRadius + ' 0 0 1 ' + RadialMenu.pointToString(RadialMenu.getDegreePos(startAngleDeg, self.innerRadius)); - path += 'Z'; - - return path; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createText = function (x, y, title) { - var self = this; - var text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - text.setAttribute('text-anchor', 'middle'); - text.setAttribute('x', RadialMenu.numberToString(x)); - text.setAttribute('y', RadialMenu.numberToString(y)); - text.setAttribute('font-size', '38%'); - text.innerHTML = title; - return text; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.createCircle = function (x, y, r) { - var circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx',RadialMenu.numberToString(x)); - circle.setAttribute('cy',RadialMenu.numberToString(y)); - circle.setAttribute('r',r); - return circle; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.calcScale = function () { - var self = this; - var totalSpace = self.sectorSpace * self.sectorCount; - var circleLength = Math.PI * 2 * self.radius; - var radiusDelta = self.radius - (circleLength - totalSpace) / (Math.PI * 2); - return (self.radius - radiusDelta) / self.radius; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.getSectorCenter = function (startAngleDeg, endAngleDeg) { - var self = this; - return RadialMenu.getDegreePos((startAngleDeg + endAngleDeg) / 2, self.innerRadius + (self.radius - self.innerRadius) / 2); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.prototype.addIconSymbols = function () { - var self = this; - var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('class', 'icons'); - - // return - var returnSymbol = document.createElementNS('http://www.w3.org/2000/svg', 'symbol'); - returnSymbol.setAttribute('id', 'return'); - returnSymbol.setAttribute('viewBox', '0 0 489.394 489.394'); - var returnPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - returnPath.setAttribute('d', "M375.789,92.867H166.864l17.507-42.795c3.724-9.132,1-19.574-6.691-25.744c-7.701-6.166-18.538-6.508-26.639-0.879" + - "L9.574,121.71c-6.197,4.304-9.795,11.457-9.563,18.995c0.231,7.533,4.261,14.446,10.71,18.359l147.925,89.823" + - "c8.417,5.108,19.18,4.093,26.481-2.499c7.312-6.591,9.427-17.312,5.219-26.202l-19.443-41.132h204.886" + - "c15.119,0,27.418,12.536,27.418,27.654v149.852c0,15.118-12.299,27.19-27.418,27.19h-226.74c-20.226,0-36.623,16.396-36.623,36.622" + - "v12.942c0,20.228,16.397,36.624,36.623,36.624h226.74c62.642,0,113.604-50.732,113.604-113.379V206.709" + - "C489.395,144.062,438.431,92.867,375.789,92.867z"); - returnSymbol.appendChild(returnPath); - svg.appendChild(returnSymbol); - - var closeSymbol = document.createElementNS('http://www.w3.org/2000/svg', 'symbol'); - closeSymbol.setAttribute('id', 'close'); - closeSymbol.setAttribute('viewBox', '0 0 41.756 41.756'); - - var closePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - closePath.setAttribute('d', "M27.948,20.878L40.291,8.536c1.953-1.953,1.953-5.119,0-7.071c-1.951-1.952-5.119-1.952-7.07,0L20.878,13.809L8.535,1.465" + - "c-1.951-1.952-5.119-1.952-7.07,0c-1.953,1.953-1.953,5.119,0,7.071l12.342,12.342L1.465,33.22c-1.953,1.953-1.953,5.119,0,7.071" + - "C2.44,41.268,3.721,41.755,5,41.755c1.278,0,2.56-0.487,3.535-1.464l12.343-12.342l12.343,12.343" + - "c0.976,0.977,2.256,1.464,3.535,1.464s2.56-0.487,3.535-1.464c1.953-1.953,1.953-5.119,0-7.071L27.948,20.878z"); - closeSymbol.appendChild(closePath); - svg.appendChild(closeSymbol); - - self.holder.appendChild(svg); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.getDegreePos = function (angleDeg, length) { - return { - x: Math.sin(RadialMenu.degToRad(angleDeg)) * length, - y: Math.cos(RadialMenu.degToRad(angleDeg)) * length - }; -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.pointToString = function (point) { - return RadialMenu.numberToString(point.x) + ' ' + RadialMenu.numberToString(point.y); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.numberToString = function (n) { - if (Number.isInteger(n)) { - return n.toString(); - } else if (n) { - var r = (+n).toFixed(5); - if (r.match(/\./)) { - r = r.replace(/\.?0+$/, ''); - } - return r; - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.resolveLoopIndex = function (index, length) { - if (index < 0) { - index = length + index; - } - if (index >= length) { - index = index - length; - } - if (index < length) { - return index; - } else { - return null; - } -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.degToRad = function (deg) { - return deg * (Math.PI / 180); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.setClassAndWaitForTransition = function (node, newClass) { - return new Promise(function (resolve) { - function handler(event) { - if (event.target == node && event.propertyName == 'visibility') { - node.removeEventListener('transitionend', handler); - resolve(); - } - } - node.addEventListener('transitionend', handler); - node.setAttribute('class', newClass); - }); -}; - -//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -RadialMenu.nextTick = function (fn) { - setTimeout(fn, 10); -}; \ No newline at end of file diff --git a/js/main.js b/js/main.js index ab685bd..d4c06bc 100644 --- a/js/main.js +++ b/js/main.js @@ -1,122 +1,87 @@ 'use strict'; -var menuItems = [ - { - id : 'walk', - title: 'Walk', - icon: '#walk' - }, - { - id : 'run', - title: 'Run', - icon: '#run' - }, - { - id : 'drive', - title: 'Drive', - icon: '#drive' - }, - { - id : 'figth', - title: 'Fight', - icon: '#fight' - }, - { - id : 'more', - title: 'More...', - icon: '#more', - items: [ - { - id : 'eat', - title: 'Eat', - icon: '#eat' - }, - { - id : 'sleep', - title: 'Sleep', - icon: '#sleep' - }, - { - id : 'shower', - title: 'Take Shower', - icon: '#shower' - }, - { - id : 'workout', - icon : '#workout', - title: 'Work Out' - } - ] - }, - { - id: 'weapon', - title: 'Weapon...', - icon: '#weapon', - items: [ - { - id: 'firearm', - icon: '#firearm', - title: 'Firearm...', - items: [ - { - id: 'glock', - title: 'Glock 22' - }, - { - id: 'beretta', - title: 'Beretta M9' - }, - { - id: 'tt', - title: 'TT' - }, - { - id: 'm16', - title: 'M16 A2' - }, - { - id: 'ak47', - title: 'AK 47' - } - ] - }, - { - id: 'knife', - icon: '#knife', - title: 'Knife' - }, - { - id: 'machete', - icon: '#machete', - title: 'Machete' - }, { - id: 'grenade', - icon: '#grenade', - title: 'Grenade' - } - ] - } +const menuItems = [ + {id: 'walk', title: 'Walk', icon: '#walk'}, + {id: 'run', title: 'Run', icon: '#run'}, + {id: 'drive', title: 'Drive', icon: '#drive', selected: true}, // FYI: pre-selected on 1st show! + {id: 'fight', title: 'Fight', icon: '#fight'}, + {id: 'more', title: 'More...', icon: '#more', items: [ + {id: 'eat', title: 'Eat', icon: '#eat'}, + {id: 'sleep', title: 'Sleep', icon: '#sleep', selected: true}, // FYI: pre-selected on 1st show! + {id: 'shower', title: 'Take Shower', icon: '#shower'}, + {id: 'workout', title: 'Work Out', icon: '#workout'}, + ]}, + {id: 'weapon',title: 'Weapon...',icon: '#weapon',items: [ + {id: 'firearm', icon: '#firearm', title: 'Firearm...', items: [ // FYI: all items without icon! only text! + {id: 'glock', title: 'Glock 22'}, + {id: 'beretta', title: 'Beretta M9'}, + {id: 'tt', title: 'TT'}, + {id: 'm16', title: 'M16 A2'}, + {id: 'ak47', title: 'AK 47'} + ]}, + {id: 'knife', icon: '#knife', title: 'Knife'}, + {id: 'machete', icon: '#machete', title: 'Machete'}, + {id: 'grenade', icon: '#grenade', title: 'Grenade'} + ]} ]; //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -window.onload = function () { - var svgMenu = new RadialMenu({ - parent : document.body, - size : 400, - closeOnClick: true, - menuItems : menuItems, - onClick : function (item) { - console.log('You have clicked:', item.id, item.title); - } - }); - - var openMenu = document.getElementById('openMenu'); - openMenu.onclick = function () { - svgMenu.open(); - }; - - var closeMenu = document.getElementById('closeMenu'); - closeMenu.onclick = function () { - svgMenu.close(); - }; -}; \ No newline at end of file +window.onload = function () +{ + const radialMenu = new RadialMenu(menuItems, 400, { + parent: document.body, + closeOnClick: false, + closeOnClickOutside: false, + onClick: function(item) + { + console.log('You have clicked:', item.id, item.title); + console.log(item); + } + }); + document.getElementById('openMenu').addEventListener('click', function(event) + { + radialMenu.open(); + }); + document.getElementById('closeMenu').addEventListener('click', function(event) + { + radialMenu.close(); + }); + const radialContextMenu = new RadialMenu(// 2nd RadialMenu with different {menuItems} + [ + {id: 'walk2', title: 'Walk', icon: '#walk'}, + {id: 'more2', title: 'More...', fontSize: "60%", items: [ // FYI: {more} without icon! only text! fontSize: 60% + {id: 'eat2', title: 'Eat', icon: '#eat'}, + {id: 'sleep2', title: 'Sleep', icon: '#sleep', selected: true}, + {id: 'more3', title: 'More >>', fontSize: "60%", items: [ // FYI: {more} without icon! only text! fontSize: 60% + {id: 'shower2', title: 'Take Shower', icon: '#shower'}, + {id: 'workout2', icon: '#workout', title: 'Work Out'}, + ]} + ]} + ], + 432, + { + multiInnerRadius: 0.2, + ui: { + classes: { + menuContainer: "menuHolder2", + menuCreate: "menu2", + menuCreateParent: "inner2", + menuCreateNested: "outer2", + menuOpen: "open2", + menuClose: "close2" + }, + nested: { + title: false + } + } + }); + document.addEventListener('contextmenu', function(event) + { // right-mouse(as context-menu) opened at position[x,y] of mouse-click + event.preventDefault(); + if (radialContextMenu.isOpen()) + { + return; + } + radialContextMenu.open(event.x, event.y); + }); +};