popup.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. window.Popup = new (class {
  2. constructor() {
  3. // The template we use to render popups
  4. this.template = Template.popup;
  5. // We only want to display one popup at a time and we keep the view object
  6. // in this `Popup.current` variable. If there is no popup currently opened
  7. // the value is `null`.
  8. this.current = null;
  9. // It's possible to open a sub-popup B from a popup A. In that case we keep
  10. // the data of popup A so we can return back to it. Every time we open a new
  11. // popup the stack grows, every time we go back the stack decrease, and if
  12. // we close the popup the stack is reseted to the empty stack [].
  13. this._stack = [];
  14. // We invalidate this internal dependency every time the top of the stack
  15. // has changed and we want to re-render a popup with the new top-stack data.
  16. this._dep = new Tracker.Dependency();
  17. }
  18. /// This function returns a callback that can be used in an event map:
  19. /// Template.tplName.events({
  20. /// 'click .elementClass': Popup.open("popupName"),
  21. /// });
  22. /// The popup inherit the data context of its parent.
  23. open(name) {
  24. const self = this;
  25. const popupName = `${name}Popup`;
  26. function clickFromPopup(evt) {
  27. return $(evt.target).closest('.js-pop-over').length !== 0;
  28. }
  29. return function(evt) {
  30. // If a popup is already opened, clicking again on the opener element
  31. // should close it -- and interrupt the current `open` function.
  32. if (self.isOpen()) {
  33. const previousOpenerElement = self._getTopStack().openerElement;
  34. if (previousOpenerElement === evt.currentTarget) {
  35. self.close();
  36. return;
  37. } else {
  38. $(previousOpenerElement).removeClass('is-active');
  39. }
  40. }
  41. // We determine the `openerElement` (the DOM element that is being clicked
  42. // and the one we take in reference to position the popup) from the event
  43. // if the popup has no parent, or from the parent `openerElement` if it
  44. // has one. This allows us to position a sub-popup exactly at the same
  45. // position than its parent.
  46. let openerElement;
  47. if (clickFromPopup(evt)) {
  48. openerElement = self._getTopStack().openerElement;
  49. } else {
  50. self._stack = [];
  51. openerElement = evt.currentTarget;
  52. }
  53. $(openerElement).addClass('is-active');
  54. evt.preventDefault();
  55. // We push our popup data to the stack. The top of the stack is always
  56. // used as the data source for our current popup.
  57. self._stack.push({
  58. popupName,
  59. openerElement,
  60. hasPopupParent: clickFromPopup(evt),
  61. title: self._getTitle(popupName),
  62. depth: self._stack.length,
  63. offset: self._getOffset(openerElement),
  64. dataContext: (this && this.currentData && this.currentData()) || this,
  65. });
  66. // If there are no popup currently opened we use the Blaze API to render
  67. // one into the DOM. We use a reactive function as the data parameter that
  68. // return the complete along with its top element and depends on our
  69. // internal dependency that is being invalidated every time the top
  70. // element of the stack has changed and we want to update the popup.
  71. //
  72. // Otherwise if there is already a popup open we just need to invalidate
  73. // our internal dependency, and since we just changed the top element of
  74. // our internal stack, the popup will be updated with the new data.
  75. if (!self.isOpen()) {
  76. self.current = Blaze.renderWithData(
  77. self.template,
  78. () => {
  79. self._dep.depend();
  80. return { ...self._getTopStack(), stack: self._stack };
  81. },
  82. document.body,
  83. );
  84. } else {
  85. self._dep.changed();
  86. }
  87. };
  88. }
  89. /// This function returns a callback that can be used in an event map:
  90. /// Template.tplName.events({
  91. /// 'click .elementClass': Popup.afterConfirm("popupName", function() {
  92. /// // What to do after the user has confirmed the action
  93. /// }),
  94. /// });
  95. afterConfirm(name, action) {
  96. const self = this;
  97. return function(evt, tpl) {
  98. const context = (this.currentData && this.currentData()) || this;
  99. context.__afterConfirmAction = action;
  100. self.open(name).call(context, evt, tpl);
  101. };
  102. }
  103. /// The public reactive state of the popup.
  104. isOpen() {
  105. this._dep.changed();
  106. return Boolean(this.current);
  107. }
  108. /// In case the popup was opened from a parent popup we can get back to it
  109. /// with this `Popup.back()` function. You can go back several steps at once
  110. /// by providing a number to this function, e.g. `Popup.back(2)`. In this case
  111. /// intermediate popup won't even be rendered on the DOM. If the number of
  112. /// steps back is greater than the popup stack size, the popup will be closed.
  113. back(n = 1) {
  114. if (this._stack.length > n) {
  115. _.times(n, () => this._stack.pop());
  116. this._dep.changed();
  117. } else {
  118. this.close();
  119. }
  120. }
  121. /// Close the current opened popup.
  122. close() {
  123. if (this.isOpen()) {
  124. Blaze.remove(this.current);
  125. this.current = null;
  126. const openerElement = this._getTopStack().openerElement;
  127. $(openerElement).removeClass('is-active');
  128. this._stack = [];
  129. }
  130. }
  131. getOpenerComponent() {
  132. const { openerElement } = Template.parentData(4);
  133. return BlazeComponent.getComponentForElement(openerElement);
  134. }
  135. // An utility fonction that returns the top element of the internal stack
  136. _getTopStack() {
  137. return this._stack[this._stack.length - 1];
  138. }
  139. // We automatically calculate the popup offset from the reference element
  140. // position and dimensions. We also reactively use the window dimensions to
  141. // ensure that the popup is always visible on the screen.
  142. _getOffset(element) {
  143. const $element = $(element);
  144. return () => {
  145. Utils.windowResizeDep.depend();
  146. if (Utils.isMiniScreen()) return { left: 0, top: 0 };
  147. const offset = $element.offset();
  148. const popupWidth = 300 + 15;
  149. return {
  150. left: Math.min(offset.left, $(window).width() - popupWidth),
  151. top: offset.top + $element.outerHeight(),
  152. };
  153. };
  154. }
  155. // We get the title from the translation files. Instead of returning the
  156. // result, we return a function that compute the result and since `TAPi18n.__`
  157. // is a reactive data source, the title will be changed reactively.
  158. _getTitle(popupName) {
  159. return () => {
  160. const translationKey = `${popupName}-title`;
  161. // XXX There is no public API to check if there is an available
  162. // translation for a given key. So we try to translate the key and if the
  163. // translation output equals the key input we deduce that no translation
  164. // was available and returns `false`. There is a (small) risk a false
  165. // positives.
  166. const title = TAPi18n.__(translationKey);
  167. // when popup showed as full of small screen, we need a default header to clearly see [X] button
  168. const defaultTitle = Utils.isMiniScreen() ? '' : false;
  169. return title !== translationKey ? title : defaultTitle;
  170. };
  171. }
  172. })();
  173. // We close a potential opened popup on any left click on the document, or go
  174. // one step back by pressing escape.
  175. const escapeActions = ['back', 'close'];
  176. escapeActions.forEach(actionName => {
  177. EscapeActions.register(
  178. `popup-${actionName}`,
  179. () => Popup[actionName](),
  180. () => Popup.isOpen(),
  181. {
  182. noClickEscapeOn: '.js-pop-over,.js-open-card-title-popup',
  183. enabledOnClick: actionName === 'close',
  184. },
  185. );
  186. });
  187. // Prevent @member mentions on Add Comment input field
  188. // from closing card, part 5.
  189. // This duplicate below of above popup function is needed, because at
  190. // wekan/components/main/editor.js at bottom is popping up visible
  191. // @member mention, and it seems to trigger closing also card popup,
  192. // so in below closing popup is disabled.
  193. window.PopupNoClose = new (class {
  194. constructor() {
  195. // The template we use to render popups
  196. this.template = Template.popup;
  197. // We only want to display one popup at a time and we keep the view object
  198. // in this `Popup.current` variable. If there is no popup currently opened
  199. // the value is `null`.
  200. this.current = null;
  201. // It's possible to open a sub-popup B from a popup A. In that case we keep
  202. // the data of popup A so we can return back to it. Every time we open a new
  203. // popup the stack grows, every time we go back the stack decrease, and if
  204. // we close the popup the stack is reseted to the empty stack [].
  205. this._stack = [];
  206. // We invalidate this internal dependency every time the top of the stack
  207. // has changed and we want to re-render a popup with the new top-stack data.
  208. this._dep = new Tracker.Dependency();
  209. }
  210. /// This function returns a callback that can be used in an event map:
  211. /// Template.tplName.events({
  212. /// 'click .elementClass': Popup.open("popupName"),
  213. /// });
  214. /// The popup inherit the data context of its parent.
  215. open(name) {
  216. const self = this;
  217. const popupName = `${name}Popup`;
  218. function clickFromPopup(evt) {
  219. return $(evt.target).closest('.js-pop-over').length !== 0;
  220. }
  221. return function(evt) {
  222. // If a popup is already opened, clicking again on the opener element
  223. // should close it -- and interrupt the current `open` function.
  224. /*
  225. if (self.isOpen()) {
  226. const previousOpenerElement = self._getTopStack().openerElement;
  227. if (previousOpenerElement === evt.currentTarget) {
  228. self.close();
  229. return;
  230. } else {
  231. $(previousOpenerElement).removeClass('is-active');
  232. }
  233. }
  234. */
  235. // We determine the `openerElement` (the DOM element that is being clicked
  236. // and the one we take in reference to position the popup) from the event
  237. // if the popup has no parent, or from the parent `openerElement` if it
  238. // has one. This allows us to position a sub-popup exactly at the same
  239. // position than its parent.
  240. let openerElement;
  241. if (clickFromPopup(evt)) {
  242. openerElement = self._getTopStack().openerElement;
  243. } else {
  244. self._stack = [];
  245. openerElement = evt.currentTarget;
  246. }
  247. $(openerElement).addClass('is-active');
  248. evt.preventDefault();
  249. // We push our popup data to the stack. The top of the stack is always
  250. // used as the data source for our current popup.
  251. self._stack.push({
  252. popupName,
  253. openerElement,
  254. hasPopupParent: clickFromPopup(evt),
  255. title: self._getTitle(popupName),
  256. depth: self._stack.length,
  257. offset: self._getOffset(openerElement),
  258. dataContext: (this && this.currentData && this.currentData()) || this,
  259. });
  260. // If there are no popup currently opened we use the Blaze API to render
  261. // one into the DOM. We use a reactive function as the data parameter that
  262. // return the complete along with its top element and depends on our
  263. // internal dependency that is being invalidated every time the top
  264. // element of the stack has changed and we want to update the popup.
  265. //
  266. // Otherwise if there is already a popup open we just need to invalidate
  267. // our internal dependency, and since we just changed the top element of
  268. // our internal stack, the popup will be updated with the new data.
  269. if (!self.isOpen()) {
  270. self.current = Blaze.renderWithData(
  271. self.template,
  272. () => {
  273. self._dep.depend();
  274. return { ...self._getTopStack(), stack: self._stack };
  275. },
  276. document.body,
  277. );
  278. } else {
  279. self._dep.changed();
  280. }
  281. };
  282. }
  283. /// This function returns a callback that can be used in an event map:
  284. /// Template.tplName.events({
  285. /// 'click .elementClass': Popup.afterConfirm("popupName", function() {
  286. /// // What to do after the user has confirmed the action
  287. /// }),
  288. /// });
  289. afterConfirm(name, action) {
  290. const self = this;
  291. return function(evt, tpl) {
  292. const context = (this.currentData && this.currentData()) || this;
  293. context.__afterConfirmAction = action;
  294. self.open(name).call(context, evt, tpl);
  295. };
  296. }
  297. /// The public reactive state of the popup.
  298. isOpen() {
  299. this._dep.changed();
  300. return Boolean(this.current);
  301. }
  302. /// In case the popup was opened from a parent popup we can get back to it
  303. /// with this `Popup.back()` function. You can go back several steps at once
  304. /// by providing a number to this function, e.g. `Popup.back(2)`. In this case
  305. /// intermediate popup won't even be rendered on the DOM. If the number of
  306. /// steps back is greater than the popup stack size, the popup will be closed.
  307. back(n = 1) {
  308. if (this._stack.length > n) {
  309. _.times(n, () => this._stack.pop());
  310. this._dep.changed();
  311. }
  312. // else {
  313. // this.close();
  314. //}
  315. }
  316. /// Close the current opened popup.
  317. /*
  318. close() {
  319. if (this.isOpen()) {
  320. Blaze.remove(this.current);
  321. this.current = null;
  322. const openerElement = this._getTopStack().openerElement;
  323. $(openerElement).removeClass('is-active');
  324. this._stack = [];
  325. }
  326. }
  327. */
  328. getOpenerComponent() {
  329. const { openerElement } = Template.parentData(4);
  330. return BlazeComponent.getComponentForElement(openerElement);
  331. }
  332. // An utility fonction that returns the top element of the internal stack
  333. _getTopStack() {
  334. return this._stack[this._stack.length - 1];
  335. }
  336. // We automatically calculate the popup offset from the reference element
  337. // position and dimensions. We also reactively use the window dimensions to
  338. // ensure that the popup is always visible on the screen.
  339. _getOffset(element) {
  340. const $element = $(element);
  341. return () => {
  342. Utils.windowResizeDep.depend();
  343. if (Utils.isMiniScreen()) return { left: 0, top: 0 };
  344. const offset = $element.offset();
  345. const popupWidth = 300 + 15;
  346. return {
  347. left: Math.min(offset.left, $(window).width() - popupWidth),
  348. top: offset.top + $element.outerHeight(),
  349. };
  350. };
  351. }
  352. // We get the title from the translation files. Instead of returning the
  353. // result, we return a function that compute the result and since `TAPi18n.__`
  354. // is a reactive data source, the title will be changed reactively.
  355. _getTitle(popupName) {
  356. return () => {
  357. const translationKey = `${popupName}-title`;
  358. // XXX There is no public API to check if there is an available
  359. // translation for a given key. So we try to translate the key and if the
  360. // translation output equals the key input we deduce that no translation
  361. // was available and returns `false`. There is a (small) risk a false
  362. // positives.
  363. const title = TAPi18n.__(translationKey);
  364. // when popup showed as full of small screen, we need a default header to clearly see [X] button
  365. const defaultTitle = Utils.isMiniScreen() ? '' : false;
  366. return title !== translationKey ? title : defaultTitle;
  367. };
  368. }
  369. })();