Просмотр исходного кода

Merge pull request #15248 from overleaf/mj-eslint-angular-components

[web] Add eslint rules for angularjs components

GitOrigin-RevId: 1343d584368faeb912f04c5879228bcbd07a042a
Mathias Jakobsen 3 лет назад
Родитель
Сommit
1a92e1b664
33 измененных файлов с 1799 добавлено и 1672 удалено
  1. 26 1
      services/web/.eslintrc
  2. 162 158
      services/web/frontend/js/directives/asyncForm.js
  3. 57 50
      services/web/frontend/js/directives/bookmarkableTabset.js
  4. 75 72
      services/web/frontend/js/directives/complexPassword.js
  5. 14 12
      services/web/frontend/js/directives/equals.js
  6. 62 58
      services/web/frontend/js/directives/eventTracking.js
  7. 20 18
      services/web/frontend/js/directives/expandableTextArea.js
  8. 73 59
      services/web/frontend/js/directives/focus.js
  9. 12 10
      services/web/frontend/js/directives/maxHeight.js
  10. 3 4
      services/web/frontend/js/directives/onEnter.js
  11. 12 10
      services/web/frontend/js/directives/rightClick.js
  12. 42 40
      services/web/frontend/js/directives/scroll.js
  13. 83 73
      services/web/frontend/js/directives/selectAll.js
  14. 16 12
      services/web/frontend/js/directives/stopPropagation.js
  15. 18 16
      services/web/frontend/js/directives/videoPlayState.js
  16. 247 244
      services/web/frontend/js/ide/directives/layout.js
  17. 9 7
      services/web/frontend/js/ide/directives/validFile.js
  18. 123 117
      services/web/frontend/js/ide/directives/verticalResizablePanes.js
  19. 15 13
      services/web/frontend/js/ide/editor/directives/formattingButtons.js
  20. 12 10
      services/web/frontend/js/ide/editor/directives/toggleSwitch.js
  21. 33 31
      services/web/frontend/js/ide/history/directives/historyDraggableBoundary.js
  22. 26 22
      services/web/frontend/js/ide/history/directives/historyDroppableArea.js
  23. 34 32
      services/web/frontend/js/ide/history/directives/infiniteScroll.js
  24. 49 47
      services/web/frontend/js/ide/review-panel/directives/addCommentEntry.js
  25. 53 51
      services/web/frontend/js/ide/review-panel/directives/aggregateChangeEntry.js
  26. 15 13
      services/web/frontend/js/ide/review-panel/directives/bulkActionsEntry.js
  27. 40 38
      services/web/frontend/js/ide/review-panel/directives/changeEntry.js
  28. 70 68
      services/web/frontend/js/ide/review-panel/directives/commentEntry.js
  29. 23 21
      services/web/frontend/js/ide/review-panel/directives/resolvedCommentEntry.js
  30. 82 80
      services/web/frontend/js/ide/review-panel/directives/resolvedCommentsDropdown.js
  31. 21 19
      services/web/frontend/js/ide/review-panel/directives/reviewPanelCollapseHeight.js
  32. 242 238
      services/web/frontend/js/ide/review-panel/directives/reviewPanelSorted.js
  33. 30 28
      services/web/frontend/js/ide/review-panel/directives/reviewPanelToggle.js

+ 26 - 1
services/web/.eslintrc

@@ -226,7 +226,32 @@
         // Require .jsx or .tsx file extension when using JSX
         "react/jsx-filename-extension": ["error", {
           "extensions": [".jsx", ".tsx"]
-        }]
+        }],
+        "no-restricted-syntax": [
+          "error",
+          // Begin: Make sure angular can withstand minification
+          {
+            "selector": "CallExpression[callee.object.name='App'][callee.property.name=/run|directive|config|controller/] > :function[params.length > 0]",
+            "message": "Wrap the function in an array with the parameter names, to withstand minifcation. E.g. App.controller('MyController', ['param1', function(param1) {}]"
+          },
+          {
+            "selector": "CallExpression[callee.object.name='App'][callee.property.name=/run|directive|config|controller/] > ArrayExpression > ArrowFunctionExpression",
+            "message": "Use standard function syntax instead of arrow function syntax in angular components. E.g. function(param1) {}"
+          },
+          {
+            "selector": "CallExpression[callee.object.name='App'][callee.property.name=/run|directive|config|controller/] > ArrowFunctionExpression",
+            "message": "Use standard function syntax instead of arrow function syntax in angular components. E.g. function(param1) {}"
+          },
+          {
+            "selector": "CallExpression[callee.object.name='App'][callee.property.name=/run|directive|config|controller/] > ArrayExpression > :not(:function, Identifier):last-child",
+            "message": "Last element of the array must be a function. E.g ['param1', function(param1) {}]"
+          },
+          {
+            "selector": "CallExpression[callee.object.name='App'][callee.property.name=/run|directive|config|controller/] > ArrayExpression[elements.length=0]",
+            "message": "Array must not be empty. Add parameters and a function. E.g ['param1', function(param1) {}]"
+          }
+          // End: Make sure angular can withstand minification
+        ]
       }
     },
     // React + TypeScript-specific rules

+ 162 - 158
services/web/frontend/js/directives/asyncForm.js

@@ -4,177 +4,180 @@ App.directive('asyncForm', [
   '$http',
   'validateCaptcha',
   'validateCaptchaV3',
-  ($http, validateCaptcha, validateCaptchaV3) => ({
-    controller: [
-      '$scope',
-      '$location',
-      function ($scope, $location) {
-        this.getEmail = () => $scope.email
-        this.getEmailFromQuery = () =>
-          $location.search().email || $location.search().new_email
-        return this
-      },
-    ],
-    link(scope, element, attrs, ctrl) {
-      let response
-      const formName = attrs.asyncForm
-
-      scope[attrs.name].response = response = {}
-      scope[attrs.name].inflight = false
-      scope.email =
-        scope.email ||
-        scope.usersEmail ||
-        ctrl.getEmailFromQuery() ||
-        attrs.newEmail
-
-      const validateCaptchaIfEnabled = function (callback) {
-        scope.$applyAsync(() => {
-          scope[attrs.name].inflight = true
-        })
+  function ($http, validateCaptcha, validateCaptchaV3) {
+    return {
+      controller: [
+        '$scope',
+        '$location',
+        function ($scope, $location) {
+          this.getEmail = () => $scope.email
+          this.getEmailFromQuery = () =>
+            $location.search().email || $location.search().new_email
+          return this
+        },
+      ],
+      link(scope, element, attrs, ctrl) {
+        let response
+        const formName = attrs.asyncForm
+
+        scope[attrs.name].response = response = {}
+        scope[attrs.name].inflight = false
+        scope.email =
+          scope.email ||
+          scope.usersEmail ||
+          ctrl.getEmailFromQuery() ||
+          attrs.newEmail
+
+        const validateCaptchaIfEnabled = function (callback) {
+          scope.$applyAsync(() => {
+            scope[attrs.name].inflight = true
+          })
 
-        if (attrs.captchaActionName) {
-          validateCaptchaV3(attrs.captchaActionName)
+          if (attrs.captchaActionName) {
+            validateCaptchaV3(attrs.captchaActionName)
+          }
+          if (attrs.captcha != null) {
+            validateCaptcha(callback)
+          } else {
+            callback()
+          }
         }
-        if (attrs.captcha != null) {
-          validateCaptcha(callback)
-        } else {
-          callback()
-        }
-      }
 
-      const _submitRequest = function (grecaptchaResponse) {
-        const formData = {}
-        for (const data of Array.from(element.serializeArray())) {
-          formData[data.name] = data.value
-        }
+        const _submitRequest = function (grecaptchaResponse) {
+          const formData = {}
+          for (const data of Array.from(element.serializeArray())) {
+            formData[data.name] = data.value
+          }
 
-        if (grecaptchaResponse) {
-          formData['g-recaptcha-response'] = grecaptchaResponse
-        }
+          if (grecaptchaResponse) {
+            formData['g-recaptcha-response'] = grecaptchaResponse
+          }
 
-        // clear the response object which may be referenced downstream
-        Object.keys(response).forEach(field => delete response[field])
+          // clear the response object which may be referenced downstream
+          Object.keys(response).forEach(field => delete response[field])
 
-        // for asyncForm prevent automatic redirect to /login if
-        // authentication fails, we will handle it ourselves
-        const httpRequestFn = _httpRequestFn(element.attr('method'))
-        return httpRequestFn(element.attr('action'), formData, {
-          disableAutoLoginRedirect: true,
-        })
-          .then(function (httpResponse) {
-            const { data, headers } = httpResponse
-            scope[attrs.name].inflight = false
-            response.success = true
-            response.error = false
-
-            const onSuccessHandler = scope[attrs.onSuccess]
-            if (onSuccessHandler) {
-              onSuccessHandler(httpResponse)
-              return
-            }
-
-            if (data.redir) {
-              ga('send', 'event', formName, 'success')
-              return (window.location = data.redir)
-            } else if (data.message) {
-              response.message = data.message
-
-              if (data.message.type === 'error') {
-                response.success = false
-                response.error = true
-                return ga('send', 'event', formName, 'failure', data.message)
-              } else {
-                return ga('send', 'event', formName, 'success')
-              }
-            } else if (scope.$eval(attrs.asyncFormDownloadResponse)) {
-              const blob = new Blob([data], {
-                type: headers('Content-Type'),
-              })
-              location.href = URL.createObjectURL(blob) // Trigger file save
-            }
+          // for asyncForm prevent automatic redirect to /login if
+          // authentication fails, we will handle it ourselves
+          const httpRequestFn = _httpRequestFn(element.attr('method'))
+          return httpRequestFn(element.attr('action'), formData, {
+            disableAutoLoginRedirect: true,
           })
-          .catch(function (httpResponse) {
-            const { data, status } = httpResponse
-            scope[attrs.name].inflight = false
-            response.success = false
-            response.error = true
-            response.status = status
-            response.data = data
-
-            const onErrorHandler = scope[attrs.onError]
-            if (onErrorHandler) {
-              onErrorHandler(httpResponse)
-              return
-            }
-
-            let responseMessage
-            if (data.message && data.message.text) {
-              responseMessage = data.message.text
-            } else {
-              responseMessage = data.message
-            }
-
-            if (status === 400) {
-              // Bad Request
-              response.message = {
-                text:
-                  responseMessage ||
-                  'Invalid Request. Please correct the data and try again.',
-                type: 'error',
+            .then(function (httpResponse) {
+              const { data, headers } = httpResponse
+              scope[attrs.name].inflight = false
+              response.success = true
+              response.error = false
+
+              const onSuccessHandler = scope[attrs.onSuccess]
+              if (onSuccessHandler) {
+                onSuccessHandler(httpResponse)
+                return
               }
-            } else if (status === 403) {
-              // Forbidden
-              response.message = {
-                text:
-                  responseMessage ||
-                  'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
-                type: 'error',
+
+              if (data.redir) {
+                ga('send', 'event', formName, 'success')
+                return (window.location = data.redir)
+              } else if (data.message) {
+                response.message = data.message
+
+                if (data.message.type === 'error') {
+                  response.success = false
+                  response.error = true
+                  return ga('send', 'event', formName, 'failure', data.message)
+                } else {
+                  return ga('send', 'event', formName, 'success')
+                }
+              } else if (scope.$eval(attrs.asyncFormDownloadResponse)) {
+                const blob = new Blob([data], {
+                  type: headers('Content-Type'),
+                })
+                location.href = URL.createObjectURL(blob) // Trigger file save
+              }
+            })
+            .catch(function (httpResponse) {
+              const { data, status } = httpResponse
+              scope[attrs.name].inflight = false
+              response.success = false
+              response.error = true
+              response.status = status
+              response.data = data
+
+              const onErrorHandler = scope[attrs.onError]
+              if (onErrorHandler) {
+                onErrorHandler(httpResponse)
+                return
               }
-            } else if (status === 429) {
-              response.message = {
-                text:
-                  responseMessage ||
-                  'Too many attempts. Please wait for a while and try again.',
-                type: 'error',
+
+              let responseMessage
+              if (data.message && data.message.text) {
+                responseMessage = data.message.text
+              } else {
+                responseMessage = data.message
               }
-            } else {
-              response.message = {
-                text:
-                  responseMessage ||
-                  'Something went wrong talking to the server :(. Please try again.',
-                type: 'error',
+
+              if (status === 400) {
+                // Bad Request
+                response.message = {
+                  text:
+                    responseMessage ||
+                    'Invalid Request. Please correct the data and try again.',
+                  type: 'error',
+                }
+              } else if (status === 403) {
+                // Forbidden
+                response.message = {
+                  text:
+                    responseMessage ||
+                    'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
+                  type: 'error',
+                }
+              } else if (status === 429) {
+                response.message = {
+                  text:
+                    responseMessage ||
+                    'Too many attempts. Please wait for a while and try again.',
+                  type: 'error',
+                }
+              } else {
+                response.message = {
+                  text:
+                    responseMessage ||
+                    'Something went wrong talking to the server :(. Please try again.',
+                  type: 'error',
+                }
               }
-            }
-            ga('send', 'event', formName, 'failure', data.message)
-          })
-      }
+              ga('send', 'event', formName, 'failure', data.message)
+            })
+        }
 
-      const submit = () =>
-        validateCaptchaIfEnabled(response => _submitRequest(response))
+        const submit = () =>
+          validateCaptchaIfEnabled(response => _submitRequest(response))
 
-      const _httpRequestFn = (method = 'post') => {
-        const $HTTP_FNS = {
-          post: $http.post,
-          get: $http.get,
+        const _httpRequestFn = (method = 'post') => {
+          const $HTTP_FNS = {
+            post: $http.post,
+            get: $http.get,
+          }
+          return $HTTP_FNS[method.toLowerCase()]
         }
-        return $HTTP_FNS[method.toLowerCase()]
-      }
 
-      element.on('submit', function (e) {
-        e.preventDefault()
-        submit()
-      })
+        element.on('submit', function (e) {
+          e.preventDefault()
+          submit()
+        })
 
-      if (attrs.autoSubmit) {
-        submit()
-      }
-    },
-  }),
+        if (attrs.autoSubmit) {
+          submit()
+        }
+      },
+    }
+  },
 ])
 
-App.directive('formMessages', () => ({
-  restrict: 'E',
-  template: `\
+App.directive('formMessages', function () {
+  return {
+    restrict: 'E',
+    template: `\
 <div class="alert" ng-class="{
 'alert-danger': form.response.message.type == 'error',
 'alert-success': form.response.message.type != 'error'
@@ -182,8 +185,9 @@ App.directive('formMessages', () => ({
 </div>
 <div ng-transclude></div>\
 `,
-  transclude: true,
-  scope: {
-    form: '=for',
-  },
-}))
+    transclude: true,
+    scope: {
+      form: '=for',
+    },
+  }
+})

+ 57 - 50
services/web/frontend/js/directives/bookmarkableTabset.js

@@ -2,63 +2,70 @@ import _ from 'lodash'
 import App from '../base'
 App.directive('bookmarkableTabset', [
   '$location',
-  $location => ({
-    restrict: 'A',
-    require: 'tabset',
-    link(scope, el, attrs, tabset) {
-      const _makeActive = function (hash) {
-        if (hash && hash !== '') {
-          const matchingTab = _.find(
-            tabset.tabs,
-            tab => tab.bookmarkableTabId === hash
-          )
-          if (matchingTab) {
-            matchingTab.select()
-            return el.children()[0].scrollIntoView({ behavior: 'smooth' })
+  function ($location) {
+    return {
+      restrict: 'A',
+      require: 'tabset',
+      link(scope, el, attrs, tabset) {
+        const _makeActive = function (hash) {
+          if (hash && hash !== '') {
+            const matchingTab = _.find(
+              tabset.tabs,
+              tab => tab.bookmarkableTabId === hash
+            )
+            if (matchingTab) {
+              matchingTab.select()
+              return el.children()[0].scrollIntoView({ behavior: 'smooth' })
+            }
           }
         }
-      }
 
-      scope.$applyAsync(function () {
-        // for page load
-        const hash = $location.hash()
-        _makeActive(hash)
-
-        // for links within page to a tab
-        // this needs to be within applyAsync because there could be a link
-        // within a tab to another tab
-        const linksToTabs = document.querySelectorAll('.link-to-tab')
-        const _clickLinkToTab = event => {
-          const hash = event.currentTarget.getAttribute('href').split('#').pop()
+        scope.$applyAsync(function () {
+          // for page load
+          const hash = $location.hash()
           _makeActive(hash)
-        }
 
-        if (linksToTabs) {
-          Array.from(linksToTabs).map(link =>
-            link.addEventListener('click', _clickLinkToTab)
-          )
-        }
-      })
-    },
-  }),
+          // for links within page to a tab
+          // this needs to be within applyAsync because there could be a link
+          // within a tab to another tab
+          const linksToTabs = document.querySelectorAll('.link-to-tab')
+          const _clickLinkToTab = event => {
+            const hash = event.currentTarget
+              .getAttribute('href')
+              .split('#')
+              .pop()
+            _makeActive(hash)
+          }
+
+          if (linksToTabs) {
+            Array.from(linksToTabs).map(link =>
+              link.addEventListener('click', _clickLinkToTab)
+            )
+          }
+        })
+      },
+    }
+  },
 ])
 
 App.directive('bookmarkableTab', [
   '$location',
-  $location => ({
-    restrict: 'A',
-    require: 'tab',
-    link(scope, el, attrs, tab) {
-      const tabScope = el.isolateScope()
-      const tabId = attrs.bookmarkableTab
-      if (tabScope && tabId && tabId !== '') {
-        tabScope.bookmarkableTabId = tabId
-        tabScope.$watch('active', function (isActive, wasActive) {
-          if (isActive && !wasActive && $location.hash() !== tabId) {
-            return $location.hash(tabId)
-          }
-        })
-      }
-    },
-  }),
+  function ($location) {
+    return {
+      restrict: 'A',
+      require: 'tab',
+      link(scope, el, attrs, tab) {
+        const tabScope = el.isolateScope()
+        const tabId = attrs.bookmarkableTab
+        if (tabScope && tabId && tabId !== '') {
+          tabScope.bookmarkableTabId = tabId
+          tabScope.$watch('active', function (isActive, wasActive) {
+            if (isActive && !wasActive && $location.hash() !== tabId) {
+              return $location.hash(tabId)
+            }
+          })
+        }
+      },
+    }
+  },
 ])

+ 75 - 72
services/web/frontend/js/directives/complexPassword.js

@@ -6,85 +6,88 @@ import _ from 'lodash'
 */
 import App from '../base'
 import '../vendor/libs/passfield'
-App.directive('complexPassword', () => ({
-  require: ['^asyncForm', 'ngModel'],
+App.directive('complexPassword', function () {
+  return {
+    require: ['^asyncForm', 'ngModel'],
 
-  link(scope, element, attrs, ctrl) {
-    PassField.Config.blackList = []
-    const defaultPasswordOpts = {
-      pattern: '',
-      length: {
-        min: 6,
-        max: 72,
-      },
-      allowEmpty: false,
-      allowAnyChars: false,
-      isMasked: true,
-      showToggle: false,
-      showGenerate: false,
-      showTip: false,
-      showWarn: false,
-      checkMode: PassField.CheckModes.STRICT,
-      chars: {
-        digits: '1234567890',
-        letters: 'abcdefghijklmnopqrstuvwxyz',
-        letters_up: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
-        symbols: '@#$%^&*()-_=+[]{};:<>/?!£€.,',
-      },
-    }
+    link(scope, element, attrs, ctrl) {
+      PassField.Config.blackList = []
+      const defaultPasswordOpts = {
+        pattern: '',
+        length: {
+          min: 6,
+          max: 72,
+        },
+        allowEmpty: false,
+        allowAnyChars: false,
+        isMasked: true,
+        showToggle: false,
+        showGenerate: false,
+        showTip: false,
+        showWarn: false,
+        checkMode: PassField.CheckModes.STRICT,
+        chars: {
+          digits: '1234567890',
+          letters: 'abcdefghijklmnopqrstuvwxyz',
+          letters_up: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+          symbols: '@#$%^&*()-_=+[]{};:<>/?!£€.,',
+        },
+      }
 
-    const opts = _.defaults(
-      window.passwordStrengthOptions || {},
-      defaultPasswordOpts
-    )
+      const opts = _.defaults(
+        window.passwordStrengthOptions || {},
+        defaultPasswordOpts
+      )
 
-    if (opts.length.min === 1) {
-      // this allows basically anything to be a valid password
-      opts.acceptRate = 0
-    }
+      if (opts.length.min === 1) {
+        // this allows basically anything to be a valid password
+        opts.acceptRate = 0
+      }
 
-    if (opts.length.max > 72) {
-      // there is a hard limit of 71 characters in the password at the backend
-      opts.length.max = 72
-    }
+      if (opts.length.max > 72) {
+        // there is a hard limit of 71 characters in the password at the backend
+        opts.length.max = 72
+      }
 
-    if (opts.length.max > 0) {
-      // PassField's notion of 'max' is non-inclusive
-      opts.length.max += 1
-    }
+      if (opts.length.max > 0) {
+        // PassField's notion of 'max' is non-inclusive
+        opts.length.max += 1
+      }
 
-    const passField = new PassField.Field('passwordField', opts)
-    const [asyncFormCtrl, ngModelCtrl] = Array.from(ctrl)
+      const passField = new PassField.Field('passwordField', opts)
+      const [asyncFormCtrl, ngModelCtrl] = Array.from(ctrl)
 
-    ngModelCtrl.$parsers.unshift(function (modelValue) {
-      let isValid = passField.validatePass()
-      const email = asyncFormCtrl.getEmail() || window.usersEmail
+      ngModelCtrl.$parsers.unshift(function (modelValue) {
+        let isValid = passField.validatePass()
+        const email = asyncFormCtrl.getEmail() || window.usersEmail
 
-      if (!isValid) {
-        scope.complexPasswordErrorMessage = passField.getPassValidationMessage()
-      } else if (typeof email === 'string' && email !== '') {
-        const startOfEmail = email.split('@')[0]
-        if (
-          modelValue.indexOf(email) !== -1 ||
-          modelValue.indexOf(startOfEmail) !== -1
-        ) {
-          isValid = false
+        if (!isValid) {
           scope.complexPasswordErrorMessage =
-            'Password can not contain email address'
+            passField.getPassValidationMessage()
+        } else if (typeof email === 'string' && email !== '') {
+          const startOfEmail = email.split('@')[0]
+          if (
+            modelValue.indexOf(email) !== -1 ||
+            modelValue.indexOf(startOfEmail) !== -1
+          ) {
+            isValid = false
+            scope.complexPasswordErrorMessage =
+              'Password can not contain email address'
+          }
         }
-      }
-      if (opts.length.max != null && modelValue.length >= opts.length.max) {
-        isValid = false
-        scope.complexPasswordErrorMessage = `Maximum password length ${
-          opts.length.max - 1
-        } exceeded`
-      }
-      if (opts.length.min != null && modelValue.length < opts.length.min) {
-        isValid = false
-        scope.complexPasswordErrorMessage = `Password too short, minimum ${opts.length.min}`
-      }
-      ngModelCtrl.$setValidity('complexPassword', isValid)
-      return modelValue
-    })
-  },
-}))
+        if (opts.length.max != null && modelValue.length >= opts.length.max) {
+          isValid = false
+          scope.complexPasswordErrorMessage = `Maximum password length ${
+            opts.length.max - 1
+          } exceeded`
+        }
+        if (opts.length.min != null && modelValue.length < opts.length.min) {
+          isValid = false
+          scope.complexPasswordErrorMessage = `Password too short, minimum ${opts.length.min}`
+        }
+        ngModelCtrl.$setValidity('complexPassword', isValid)
+        return modelValue
+      })
+    },
+  }
+})

+ 14 - 12
services/web/frontend/js/directives/equals.js

@@ -7,15 +7,17 @@
  */
 import App from '../base'
 
-export default App.directive('equals', () => ({
-  require: 'ngModel',
-  link(scope, elem, attrs, ctrl) {
-    const firstField = `#${attrs.equals}`
-    return elem.add(firstField).on('keyup', () =>
-      scope.$apply(function () {
-        const equal = elem.val() === $(firstField).val()
-        return ctrl.$setValidity('areEqual', equal)
-      })
-    )
-  },
-}))
+export default App.directive('equals', function () {
+  return {
+    require: 'ngModel',
+    link(scope, elem, attrs, ctrl) {
+      const firstField = `#${attrs.equals}`
+      return elem.add(firstField).on('keyup', () =>
+        scope.$apply(function () {
+          const equal = elem.val() === $(firstField).val()
+          return ctrl.$setValidity('areEqual', equal)
+        })
+      )
+    },
+  }
+})

+ 62 - 58
services/web/frontend/js/directives/eventTracking.js

@@ -63,68 +63,72 @@ const isInViewport = function (element) {
 
 export default App.directive('eventTracking', [
   'eventTracking',
-  eventTracking => ({
-    scope: {
-      eventTracking: '@',
-      eventSegmentation: '=?',
-    },
-    link(scope, element, attrs) {
-      const sendGA = attrs.eventTrackingGa || false
-      const sendMB = attrs.eventTrackingMb || false
-      const sendMBFunction = attrs.eventTrackingSendOnce
-        ? 'sendMBOnce'
-        : 'sendMB'
-      const sendGAFunction = attrs.eventTrackingSendOnce ? 'sendGAOnce' : 'send'
-      const segmentation = scope.eventSegmentation || {}
-      segmentation.page = window.location.pathname
+  function (eventTracking) {
+    return {
+      scope: {
+        eventTracking: '@',
+        eventSegmentation: '=?',
+      },
+      link(scope, element, attrs) {
+        const sendGA = attrs.eventTrackingGa || false
+        const sendMB = attrs.eventTrackingMb || false
+        const sendMBFunction = attrs.eventTrackingSendOnce
+          ? 'sendMBOnce'
+          : 'sendMB'
+        const sendGAFunction = attrs.eventTrackingSendOnce
+          ? 'sendGAOnce'
+          : 'send'
+        const segmentation = scope.eventSegmentation || {}
+        segmentation.page = window.location.pathname
 
-      const sendEvent = function (scrollEvent) {
-        /*
+        const sendEvent = function (scrollEvent) {
+          /*
                       @param {boolean}		scrollEvent		Use to unbind scroll event
                   */
-        if (sendMB) {
-          eventTracking[sendMBFunction](scope.eventTracking, segmentation)
+          if (sendMB) {
+            eventTracking[sendMBFunction](scope.eventTracking, segmentation)
+          }
+          if (sendGA) {
+            eventTracking[sendGAFunction](
+              attrs.eventTrackingGa,
+              attrs.eventTrackingAction || scope.eventTracking,
+              attrs.eventTrackingLabel || ''
+            )
+          }
+          if (scrollEvent) {
+            return $(window).unbind('resize scroll')
+          }
         }
-        if (sendGA) {
-          eventTracking[sendGAFunction](
-            attrs.eventTrackingGa,
-            attrs.eventTrackingAction || scope.eventTracking,
-            attrs.eventTrackingLabel || ''
-          )
-        }
-        if (scrollEvent) {
-          return $(window).unbind('resize scroll')
-        }
-      }
 
-      if (attrs.eventTrackingTrigger === 'load') {
-        return sendEvent()
-      } else if (attrs.eventTrackingTrigger === 'click') {
-        return element.on('click', e => sendEvent())
-      } else if (attrs.eventTrackingTrigger === 'hover') {
-        let timer = null
-        let timeoutAmt = 500
-        if (attrs.eventHoverAmt) {
-          timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
+        if (attrs.eventTrackingTrigger === 'load') {
+          return sendEvent()
+        } else if (attrs.eventTrackingTrigger === 'click') {
+          return element.on('click', e => sendEvent())
+        } else if (attrs.eventTrackingTrigger === 'hover') {
+          let timer = null
+          let timeoutAmt = 500
+          if (attrs.eventHoverAmt) {
+            timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
+          }
+          return element
+            .on('mouseenter', function () {
+              timer = setTimeout(() => sendEvent(), timeoutAmt)
+            })
+            .on('mouseleave', () => clearTimeout(timer))
+        } else if (
+          attrs.eventTrackingTrigger === 'scroll' &&
+          !eventTracking.eventInCache(scope.eventTracking)
+        ) {
+          $(window).on(
+            'resize scroll',
+            _.throttle(() => {
+              if (isInViewport(element)) {
+                sendEvent(true)
+              }
+            }, 500)
+          )
         }
-        return element
-          .on('mouseenter', function () {
-            timer = setTimeout(() => sendEvent(), timeoutAmt)
-          })
-          .on('mouseleave', () => clearTimeout(timer))
-      } else if (
-        attrs.eventTrackingTrigger === 'scroll' &&
-        !eventTracking.eventInCache(scope.eventTracking)
-      ) {
-        $(window).on(
-          'resize scroll',
-          _.throttle(() => {
-            if (isInViewport(element)) {
-              sendEvent(true)
-            }
-          }, 500)
-        )
-      }
-    },
-  }),
+      },
+    }
+  },
 ])

+ 20 - 18
services/web/frontend/js/directives/expandableTextArea.js

@@ -7,23 +7,25 @@
  */
 import App from '../base'
 
-export default App.directive('expandableTextArea', () => ({
-  restrict: 'A',
-  link(scope, el) {
-    const resetHeight = function () {
-      const curHeight = el.outerHeight()
-      const fitHeight = el.prop('scrollHeight')
-      // clear height if text area is empty
-      if (el.val() === '') {
-        el.css('height', 'unset')
+export default App.directive('expandableTextArea', function () {
+  return {
+    restrict: 'A',
+    link(scope, el) {
+      const resetHeight = function () {
+        const curHeight = el.outerHeight()
+        const fitHeight = el.prop('scrollHeight')
+        // clear height if text area is empty
+        if (el.val() === '') {
+          el.css('height', 'unset')
+        }
+        // otherwise expand to fit text
+        else if (fitHeight > curHeight) {
+          scope.$emit('expandable-text-area:resize')
+          el.css('height', fitHeight)
+        }
       }
-      // otherwise expand to fit text
-      else if (fitHeight > curHeight) {
-        scope.$emit('expandable-text-area:resize')
-        el.css('height', fitHeight)
-      }
-    }
 
-    return scope.$watch(() => el.val(), resetHeight)
-  },
-}))
+      return scope.$watch(() => el.val(), resetHeight)
+    },
+  }
+})

+ 73 - 59
services/web/frontend/js/directives/focus.js

@@ -12,82 +12,96 @@
 import App from '../base'
 App.directive('focusWhen', [
   '$timeout',
-  $timeout => ({
-    restrict: 'A',
-    link(scope, element, attr) {
-      return scope.$watch(attr.focusWhen, function (value) {
-        if (value) {
-          return $timeout(() => element.focus())
-        }
-      })
-    },
-  }),
+  function ($timeout) {
+    return {
+      restrict: 'A',
+      link(scope, element, attr) {
+        return scope.$watch(attr.focusWhen, function (value) {
+          if (value) {
+            return $timeout(() => element.focus())
+          }
+        })
+      },
+    }
+  },
 ])
 
-App.directive('focusOn', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return scope.$on(attrs.focusOn, () => element.focus())
-  },
-}))
+App.directive('focusOn', function () {
+  return {
+    restrict: 'A',
+    link(scope, element, attrs) {
+      return scope.$on(attrs.focusOn, () => element.focus())
+    },
+  }
+})
 
 App.directive('selectWhen', [
   '$timeout',
-  $timeout => ({
-    restrict: 'A',
-    link(scope, element, attr) {
-      return scope.$watch(attr.selectWhen, function (value) {
-        if (value) {
-          return $timeout(() => element.select())
-        }
-      })
-    },
-  }),
+  function ($timeout) {
+    return {
+      restrict: 'A',
+      link(scope, element, attr) {
+        return scope.$watch(attr.selectWhen, function (value) {
+          if (value) {
+            return $timeout(() => element.select())
+          }
+        })
+      },
+    }
+  },
 ])
 
-App.directive('selectOn', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return scope.$on(attrs.selectOn, () => element.select())
-  },
-}))
+App.directive('selectOn', function () {
+  return {
+    restrict: 'A',
+    link(scope, element, attrs) {
+      return scope.$on(attrs.selectOn, () => element.select())
+    },
+  }
+})
 
 App.directive('selectNameWhen', [
   '$timeout',
-  $timeout => ({
+  function ($timeout) {
+    return {
+      restrict: 'A',
+      link(scope, element, attrs) {
+        return scope.$watch(attrs.selectNameWhen, function (value) {
+          if (value) {
+            return $timeout(() => selectName(element))
+          }
+        })
+      },
+    }
+  },
+])
+
+App.directive('selectNameOn', function () {
+  return {
     restrict: 'A',
     link(scope, element, attrs) {
-      return scope.$watch(attrs.selectNameWhen, function (value) {
-        if (value) {
-          return $timeout(() => selectName(element))
-        }
-      })
+      return scope.$on(attrs.selectNameOn, () => selectName(element))
     },
-  }),
-])
-
-App.directive('selectNameOn', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return scope.$on(attrs.selectNameOn, () => selectName(element))
-  },
-}))
+  }
+})
 
 App.directive('focus', [
   '$timeout',
-  $timeout => ({
-    scope: {
-      trigger: '@focus',
-    },
+  function ($timeout) {
+    return {
+      scope: {
+        trigger: '@focus',
+      },
 
-    link(scope, element) {
-      return scope.$watch('trigger', function (value) {
-        if (value === 'true') {
-          return $timeout(() => element[0].focus())
-        }
-      })
-    },
-  }),
+      link(scope, element) {
+        return scope.$watch('trigger', function (value) {
+          if (value === 'true') {
+            return $timeout(() => element[0].focus())
+          }
+        })
+      },
+    }
+  },
 ])
 
 function selectName(element) {

+ 12 - 10
services/web/frontend/js/directives/maxHeight.js

@@ -8,13 +8,15 @@
  */
 import App from '../base'
 
-export default App.directive('maxHeight', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return scope.$watch(attrs.maxHeight, function (value) {
-      if (value != null) {
-        return element.css({ 'max-height': value })
-      }
-    })
-  },
-}))
+export default App.directive('maxHeight', function () {
+  return {
+    restrict: 'A',
+    link(scope, element, attrs) {
+      return scope.$watch(attrs.maxHeight, function (value) {
+        if (value != null) {
+          return element.css({ 'max-height': value })
+        }
+      })
+    },
+  }
+})

+ 3 - 4
services/web/frontend/js/directives/onEnter.js

@@ -7,13 +7,12 @@
  */
 import App from '../base'
 
-export default App.directive(
-  'onEnter',
-  () => (scope, element, attrs) =>
+export default App.directive('onEnter', function () {
+  return (scope, element, attrs) =>
     element.bind('keydown keypress', function (event) {
       if (event.which === 13) {
         scope.$apply(() => scope.$eval(attrs.onEnter, { event }))
         return event.preventDefault()
       }
     })
-)
+})

+ 12 - 10
services/web/frontend/js/directives/rightClick.js

@@ -7,13 +7,15 @@
  */
 import App from '../base'
 
-export default App.directive('rightClick', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return element.bind('contextmenu', function (e) {
-      e.preventDefault()
-      e.stopPropagation()
-      return scope.$eval(attrs.rightClick)
-    })
-  },
-}))
+export default App.directive('rightClick', function () {
+  return {
+    restrict: 'A',
+    link(scope, element, attrs) {
+      return element.bind('contextmenu', function (e) {
+        e.preventDefault()
+        e.stopPropagation()
+        return scope.$eval(attrs.rightClick)
+      })
+    },
+  }
+})

+ 42 - 40
services/web/frontend/js/directives/scroll.js

@@ -13,47 +13,49 @@ import App from '../base'
 
 export default App.directive('updateScrollBottomOn', [
   '$timeout',
-  $timeout => ({
-    restrict: 'A',
-    link(scope, element, attrs, ctrls) {
-      // We keep the offset from the bottom fixed whenever the event fires
-      //
-      // ^   | ^
-      // |   | | scrollTop
-      // |   | v
-      // |   |-----------
-      // |   | ^
-      // |   | |
-      // |   | | clientHeight (viewable area)
-      // |   | |
-      // |   | |
-      // |   | v
-      // |   |-----------
-      // |   | ^
-      // |   | | scrollBottom
-      // v   | v
-      //  \
-      //   scrollHeight
+  function ($timeout) {
+    return {
+      restrict: 'A',
+      link(scope, element, attrs, ctrls) {
+        // We keep the offset from the bottom fixed whenever the event fires
+        //
+        // ^   | ^
+        // |   | | scrollTop
+        // |   | v
+        // |   |-----------
+        // |   | ^
+        // |   | |
+        // |   | | clientHeight (viewable area)
+        // |   | |
+        // |   | |
+        // |   | v
+        // |   |-----------
+        // |   | ^
+        // |   | | scrollBottom
+        // v   | v
+        //  \
+        //   scrollHeight
 
-      let scrollBottom = 0
-      element.on(
-        'scroll',
-        e =>
-          (scrollBottom =
-            element[0].scrollHeight -
-            element[0].scrollTop -
-            element[0].clientHeight)
-      )
+        let scrollBottom = 0
+        element.on(
+          'scroll',
+          e =>
+            (scrollBottom =
+              element[0].scrollHeight -
+              element[0].scrollTop -
+              element[0].clientHeight)
+        )
 
-      return scope.$on(attrs.updateScrollBottomOn, () =>
-        $timeout(
-          () =>
-            element.scrollTop(
-              element[0].scrollHeight - element[0].clientHeight - scrollBottom
-            ),
-          0
+        return scope.$on(attrs.updateScrollBottomOn, () =>
+          $timeout(
+            () =>
+              element.scrollTop(
+                element[0].scrollHeight - element[0].clientHeight - scrollBottom
+              ),
+            0
+          )
         )
-      )
-    },
-  }),
+      },
+    }
+  },
 ])

+ 83 - 73
services/web/frontend/js/directives/selectAll.js

@@ -11,88 +11,98 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 import App from '../base'
-App.directive('selectAllList', () => ({
-  controller: [
-    '$scope',
-    function ($scope) {
-      // Selecting or deselecting all should apply to all projects
-      this.selectAll = () => $scope.$broadcast('select-all:select')
+App.directive('selectAllList', function () {
+  return {
+    controller: [
+      '$scope',
+      function ($scope) {
+        // Selecting or deselecting all should apply to all projects
+        this.selectAll = () => $scope.$broadcast('select-all:select')
 
-      this.deselectAll = () => $scope.$broadcast('select-all:deselect')
+        this.deselectAll = () => $scope.$broadcast('select-all:deselect')
 
-      this.clearSelectAllState = () => $scope.$broadcast('select-all:clear')
-    },
-  ],
-  link(scope, element, attrs) {},
-}))
+        this.clearSelectAllState = () => $scope.$broadcast('select-all:clear')
+      },
+    ],
+    link(scope, element, attrs) {},
+  }
+})
 
-App.directive('selectAll', () => ({
-  require: '^selectAllList',
-  link(scope, element, attrs, selectAllListController) {
-    scope.$on('select-all:clear', () => element.prop('checked', false))
+App.directive('selectAll', function () {
+  return {
+    require: '^selectAllList',
+    link(scope, element, attrs, selectAllListController) {
+      scope.$on('select-all:clear', () => element.prop('checked', false))
 
-    return element.change(function () {
-      if (element.is(':checked')) {
-        selectAllListController.selectAll()
-      } else {
-        selectAllListController.deselectAll()
-      }
-      return true
-    })
-  },
-}))
+      return element.change(function () {
+        if (element.is(':checked')) {
+          selectAllListController.selectAll()
+        } else {
+          selectAllListController.deselectAll()
+        }
+        return true
+      })
+    },
+  }
+})
 
-App.directive('selectIndividual', () => ({
-  require: '^selectAllList',
-  scope: {
-    ngModel: '=',
-  },
-  link(scope, element, attrs, selectAllListController) {
-    let ignoreChanges = false
+App.directive('selectIndividual', function () {
+  return {
+    require: '^selectAllList',
+    scope: {
+      ngModel: '=',
+    },
+    link(scope, element, attrs, selectAllListController) {
+      let ignoreChanges = false
 
-    scope.$watch('ngModel', function (value) {
-      if (value != null && !ignoreChanges) {
-        return selectAllListController.clearSelectAllState()
-      }
-    })
+      scope.$watch('ngModel', function (value) {
+        if (value != null && !ignoreChanges) {
+          return selectAllListController.clearSelectAllState()
+        }
+      })
 
-    scope.$on('select-all:select', function () {
-      if (element.prop('disabled')) {
-        return
-      }
-      ignoreChanges = true
-      scope.$apply(() => (scope.ngModel = true))
-      return (ignoreChanges = false)
-    })
+      scope.$on('select-all:select', function () {
+        if (element.prop('disabled')) {
+          return
+        }
+        ignoreChanges = true
+        scope.$apply(() => (scope.ngModel = true))
+        return (ignoreChanges = false)
+      })
 
-    scope.$on('select-all:deselect', function () {
-      if (element.prop('disabled')) {
-        return
-      }
-      ignoreChanges = true
-      scope.$apply(() => (scope.ngModel = false))
-      return (ignoreChanges = false)
-    })
+      scope.$on('select-all:deselect', function () {
+        if (element.prop('disabled')) {
+          return
+        }
+        ignoreChanges = true
+        scope.$apply(() => (scope.ngModel = false))
+        return (ignoreChanges = false)
+      })
 
-    return scope.$on('select-all:row-clicked', function () {
-      if (element.prop('disabled')) {
-        return
-      }
-      ignoreChanges = true
-      scope.$apply(function () {
-        scope.ngModel = !scope.ngModel
-        if (!scope.ngModel) {
-          return selectAllListController.clearSelectAllState()
+      return scope.$on('select-all:row-clicked', function () {
+        if (element.prop('disabled')) {
+          return
         }
+        ignoreChanges = true
+        scope.$apply(function () {
+          scope.ngModel = !scope.ngModel
+          if (!scope.ngModel) {
+            return selectAllListController.clearSelectAllState()
+          }
+        })
+        return (ignoreChanges = false)
       })
-      return (ignoreChanges = false)
-    })
-  },
-}))
+    },
+  }
+})
 
-export default App.directive('selectRow', () => ({
-  scope: true,
-  link(scope, element, attrs) {
-    return element.on('click', e => scope.$broadcast('select-all:row-clicked'))
-  },
-}))
+export default App.directive('selectRow', function () {
+  return {
+    scope: true,
+    link(scope, element, attrs) {
+      return element.on('click', e =>
+        scope.$broadcast('select-all:row-clicked')
+      )
+    },
+  }
+})

+ 16 - 12
services/web/frontend/js/directives/stopPropagation.js

@@ -9,16 +9,20 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 import App from '../base'
-App.directive('stopPropagation', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return element.bind(attrs.stopPropagation, e => e.stopPropagation())
-  },
-}))
+App.directive('stopPropagation', function () {
+  return {
+    restrict: 'A',
+    link(scope, element, attrs) {
+      return element.bind(attrs.stopPropagation, e => e.stopPropagation())
+    },
+  }
+})
 
-export default App.directive('preventDefault', () => ({
-  restrict: 'A',
-  link(scope, element, attrs) {
-    return element.bind(attrs.preventDefault, e => e.preventDefault())
-  },
-}))
+export default App.directive('preventDefault', function () {
+  return {
+    restrict: 'A',
+    link(scope, element, attrs) {
+      return element.bind(attrs.preventDefault, e => e.preventDefault())
+    },
+  }
+})

+ 18 - 16
services/web/frontend/js/directives/videoPlayState.js

@@ -12,21 +12,23 @@ import App from '../base'
 
 export default App.directive('videoPlayState', [
   '$parse',
-  $parse => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      const videoDOMEl = element[0]
-      return scope.$watch(
-        () => $parse(attrs.videoPlayState)(scope),
-        function (shouldPlay) {
-          if (shouldPlay) {
-            videoDOMEl.currentTime = 0
-            return videoDOMEl.play()
-          } else {
-            return videoDOMEl.pause()
+  function ($parse) {
+    return {
+      restrict: 'A',
+      link(scope, element, attrs) {
+        const videoDOMEl = element[0]
+        return scope.$watch(
+          () => $parse(attrs.videoPlayState)(scope),
+          function (shouldPlay) {
+            if (shouldPlay) {
+              videoDOMEl.currentTime = 0
+              return videoDOMEl.play()
+            } else {
+              return videoDOMEl.pause()
+            }
           }
-        }
-      )
-    },
-  }),
+        )
+      },
+    }
+  },
 ])

+ 247 - 244
services/web/frontend/js/ide/directives/layout.js

@@ -21,197 +21,199 @@ export default App.directive('layout', [
   '$parse',
   '$compile',
   'ide',
-  ($parse, $compile, ide) => ({
-    compile() {
-      return {
-        pre(scope, element, attrs) {
-          let customTogglerEl, spacingClosed, spacingOpen, state
-          const name = attrs.layout
+  function ($parse, $compile, ide) {
+    return {
+      compile() {
+        return {
+          pre(scope, element, attrs) {
+            let customTogglerEl, spacingClosed, spacingOpen, state
+            const name = attrs.layout
 
-          const { customTogglerPane } = attrs
-          const { customTogglerMsgWhenOpen } = attrs
-          const { customTogglerMsgWhenClosed } = attrs
-          const hasCustomToggler =
-            customTogglerPane != null &&
-            customTogglerMsgWhenOpen != null &&
-            customTogglerMsgWhenClosed != null
+            const { customTogglerPane } = attrs
+            const { customTogglerMsgWhenOpen } = attrs
+            const { customTogglerMsgWhenClosed } = attrs
+            const hasCustomToggler =
+              customTogglerPane != null &&
+              customTogglerMsgWhenOpen != null &&
+              customTogglerMsgWhenClosed != null
 
-          if (attrs.spacingOpen != null) {
-            spacingOpen = parseInt(attrs.spacingOpen, 10)
-          } else {
-            spacingOpen = 7
-          }
+            if (attrs.spacingOpen != null) {
+              spacingOpen = parseInt(attrs.spacingOpen, 10)
+            } else {
+              spacingOpen = 7
+            }
 
-          if (attrs.spacingClosed != null) {
-            spacingClosed = parseInt(attrs.spacingClosed, 10)
-          } else {
-            spacingClosed = 7
-          }
+            if (attrs.spacingClosed != null) {
+              spacingClosed = parseInt(attrs.spacingClosed, 10)
+            } else {
+              spacingClosed = 7
+            }
 
-          const options = {
-            spacing_open: spacingOpen,
-            spacing_closed: spacingClosed,
-            slidable: false,
-            enableCursorHotkey: false,
-            onopen: pane => {
-              return onPaneOpen(pane)
-            },
-            onclose: pane => {
-              return onPaneClose(pane)
-            },
-            onresize: () => {
-              return onInternalResize()
-            },
-            maskIframesOnResize: scope.$eval(
-              attrs.maskIframesOnResize || 'false'
-            ),
-            east: {
-              size: scope.$eval(attrs.initialSizeEast),
-              initClosed: scope.$eval(attrs.initClosedEast),
-            },
-            west: {
-              size: scope.$eval(attrs.initialSizeWest),
-              initClosed: scope.$eval(attrs.initClosedWest),
-            },
-          }
+            const options = {
+              spacing_open: spacingOpen,
+              spacing_closed: spacingClosed,
+              slidable: false,
+              enableCursorHotkey: false,
+              onopen: pane => {
+                return onPaneOpen(pane)
+              },
+              onclose: pane => {
+                return onPaneClose(pane)
+              },
+              onresize: () => {
+                return onInternalResize()
+              },
+              maskIframesOnResize: scope.$eval(
+                attrs.maskIframesOnResize || 'false'
+              ),
+              east: {
+                size: scope.$eval(attrs.initialSizeEast),
+                initClosed: scope.$eval(attrs.initClosedEast),
+              },
+              west: {
+                size: scope.$eval(attrs.initialSizeWest),
+                initClosed: scope.$eval(attrs.initClosedWest),
+              },
+            }
 
-          // Restore previously recorded state
-          if ((state = ide.localStorage(`layout.${name}`)) != null) {
-            if (state.east != null) {
-              if (
-                attrs.minimumRestoreSizeEast == null ||
-                (state.east.size >= attrs.minimumRestoreSizeEast &&
-                  !state.east.initClosed)
-              ) {
-                options.east = state.east
+            // Restore previously recorded state
+            if ((state = ide.localStorage(`layout.${name}`)) != null) {
+              if (state.east != null) {
+                if (
+                  attrs.minimumRestoreSizeEast == null ||
+                  (state.east.size >= attrs.minimumRestoreSizeEast &&
+                    !state.east.initClosed)
+                ) {
+                  options.east = state.east
+                }
+                options.east.initClosed = state.east.initClosed
               }
-              options.east.initClosed = state.east.initClosed
-            }
-            if (state.west != null) {
-              if (
-                attrs.minimumRestoreSizeWest == null ||
-                (state.west.size >= attrs.minimumRestoreSizeWest &&
-                  !state.west.initClosed)
-              ) {
-                options.west = state.west
+              if (state.west != null) {
+                if (
+                  attrs.minimumRestoreSizeWest == null ||
+                  (state.west.size >= attrs.minimumRestoreSizeWest &&
+                    !state.west.initClosed)
+                ) {
+                  options.west = state.west
+                }
+                // NOTE: disabled so that the file tree re-opens on page load
+                // options.west.initClosed = state.west.initClosed
               }
-              // NOTE: disabled so that the file tree re-opens on page load
-              // options.west.initClosed = state.west.initClosed
             }
-          }
 
-          options.east.resizerCursor = 'ew-resize'
-          options.west.resizerCursor = 'ew-resize'
+            options.east.resizerCursor = 'ew-resize'
+            options.west.resizerCursor = 'ew-resize'
 
-          function repositionControls() {
-            state = layout.readState()
-            if (state.east != null) {
-              const controls = element.find('> .ui-layout-resizer-controls')
-              if (state.east.initClosed) {
-                return controls.hide()
-              } else {
-                controls.show()
-                return controls.css({
-                  right: state.east.size,
-                })
+            function repositionControls() {
+              state = layout.readState()
+              if (state.east != null) {
+                const controls = element.find('> .ui-layout-resizer-controls')
+                if (state.east.initClosed) {
+                  return controls.hide()
+                } else {
+                  controls.show()
+                  return controls.css({
+                    right: state.east.size,
+                  })
+                }
               }
             }
-          }
 
-          function repositionCustomToggler() {
-            if (customTogglerEl == null) {
-              return
-            }
-            state = layout.readState()
-            const positionAnchor =
-              customTogglerPane === 'east' ? 'right' : 'left'
-            const paneState = state[customTogglerPane]
-            if (paneState != null) {
-              return customTogglerEl.css(
-                positionAnchor,
-                paneState.initClosed ? 0 : paneState.size
-              )
+            function repositionCustomToggler() {
+              if (customTogglerEl == null) {
+                return
+              }
+              state = layout.readState()
+              const positionAnchor =
+                customTogglerPane === 'east' ? 'right' : 'left'
+              const paneState = state[customTogglerPane]
+              if (paneState != null) {
+                return customTogglerEl.css(
+                  positionAnchor,
+                  paneState.initClosed ? 0 : paneState.size
+                )
+              }
             }
-          }
 
-          function resetOpenStates() {
-            state = layout.readState()
-            if (attrs.openEast != null && state.east != null) {
-              const openEast = $parse(attrs.openEast)
-              return openEast.assign(scope, !state.east.initClosed)
+            function resetOpenStates() {
+              state = layout.readState()
+              if (attrs.openEast != null && state.east != null) {
+                const openEast = $parse(attrs.openEast)
+                return openEast.assign(scope, !state.east.initClosed)
+              }
             }
-          }
 
-          // Someone moved the resizer
-          function onInternalResize() {
-            state = layout.readState()
-            scope.$broadcast(`layout:${name}:resize`, state)
-            repositionControls()
-            if (hasCustomToggler) {
-              repositionCustomToggler()
+            // Someone moved the resizer
+            function onInternalResize() {
+              state = layout.readState()
+              scope.$broadcast(`layout:${name}:resize`, state)
+              repositionControls()
+              if (hasCustomToggler) {
+                repositionCustomToggler()
+              }
+              return resetOpenStates()
             }
-            return resetOpenStates()
-          }
 
-          let oldWidth = element.width()
-          // Something resized our parent element
-          const onExternalResize = function () {
-            if (
-              attrs.resizeProportionally != null &&
-              scope.$eval(attrs.resizeProportionally)
-            ) {
-              const eastState = layout.readState().east
-              if (eastState != null) {
-                const currentWidth = element.width()
-                if (currentWidth > 0) {
-                  const newInternalWidth =
-                    (eastState.size / oldWidth) * currentWidth
-                  oldWidth = currentWidth
-                  layout.sizePane('east', newInternalWidth)
+            let oldWidth = element.width()
+            // Something resized our parent element
+            const onExternalResize = function () {
+              if (
+                attrs.resizeProportionally != null &&
+                scope.$eval(attrs.resizeProportionally)
+              ) {
+                const eastState = layout.readState().east
+                if (eastState != null) {
+                  const currentWidth = element.width()
+                  if (currentWidth > 0) {
+                    const newInternalWidth =
+                      (eastState.size / oldWidth) * currentWidth
+                    oldWidth = currentWidth
+                    layout.sizePane('east', newInternalWidth)
+                  }
+                  return
                 }
-                return
               }
-            }
 
-            ide.$timeout(() => {
-              layout.resizeAll()
-            })
-          }
+              ide.$timeout(() => {
+                layout.resizeAll()
+              })
+            }
 
-          const layout = element.layout(options)
-          layout.resizeAll()
+            const layout = element.layout(options)
+            layout.resizeAll()
 
-          if (attrs.resizeOn != null) {
-            for (const event of Array.from(attrs.resizeOn.split(','))) {
-              scope.$on(event, () => onExternalResize())
+            if (attrs.resizeOn != null) {
+              for (const event of Array.from(attrs.resizeOn.split(','))) {
+                scope.$on(event, () => onExternalResize())
+              }
             }
-          }
 
-          if (hasCustomToggler) {
-            state = layout.readState()
-            const customTogglerScope = scope.$new()
+            if (hasCustomToggler) {
+              state = layout.readState()
+              const customTogglerScope = scope.$new()
 
-            customTogglerScope.isOpen = true
-            customTogglerScope.isVisible = true
+              customTogglerScope.isOpen = true
+              customTogglerScope.isVisible = true
 
-            if (
-              (state[customTogglerPane] != null
-                ? state[customTogglerPane].initClosed
-                : undefined) === true
-            ) {
-              customTogglerScope.isOpen = false
-            }
+              if (
+                (state[customTogglerPane] != null
+                  ? state[customTogglerPane].initClosed
+                  : undefined) === true
+              ) {
+                customTogglerScope.isOpen = false
+              }
 
-            customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
-            customTogglerScope.tooltipMsgWhenClosed = customTogglerMsgWhenClosed
+              customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
+              customTogglerScope.tooltipMsgWhenClosed =
+                customTogglerMsgWhenClosed
 
-            customTogglerScope.tooltipPlacement =
-              customTogglerPane === 'east' ? 'left' : 'right'
-            customTogglerScope.handleClick = function () {
-              layout.toggle(customTogglerPane)
-              return repositionCustomToggler()
-            }
-            customTogglerEl = $compile(`\
+              customTogglerScope.tooltipPlacement =
+                customTogglerPane === 'east' ? 'left' : 'right'
+              customTogglerScope.handleClick = function () {
+                layout.toggle(customTogglerPane)
+                return repositionCustomToggler()
+              }
+              customTogglerEl = $compile(`\
 <a href \
 ng-show=\"isVisible\" \
 class=\"custom-toggler ${`custom-toggler-${customTogglerPane}`}\" \
@@ -221,100 +223,101 @@ tooltip-placement=\"{{ tooltipPlacement }}\" \
 ng-click=\"handleClick()\" \
 aria-label=\"{{ isOpen ? tooltipMsgWhenOpen : tooltipMsgWhenClosed }}\">\
 `)(customTogglerScope)
-            element.append(customTogglerEl)
-          }
+              element.append(customTogglerEl)
+            }
 
-          function onPaneOpen(pane) {
-            if (!hasCustomToggler || pane !== customTogglerPane) {
-              return
+            function onPaneOpen(pane) {
+              if (!hasCustomToggler || pane !== customTogglerPane) {
+                return
+              }
+              return customTogglerEl
+                .scope()
+                .$applyAsync(() => (customTogglerEl.scope().isOpen = true))
             }
-            return customTogglerEl
-              .scope()
-              .$applyAsync(() => (customTogglerEl.scope().isOpen = true))
-          }
 
-          function onPaneClose(pane) {
-            if (!hasCustomToggler || pane !== customTogglerPane) {
-              return
+            function onPaneClose(pane) {
+              if (!hasCustomToggler || pane !== customTogglerPane) {
+                return
+              }
+              return customTogglerEl
+                .scope()
+                .$applyAsync(() => (customTogglerEl.scope().isOpen = false))
+            }
+
+            // Ensure editor resizes after loading. This is to handle the case where
+            // the window has been resized while the editor is loading
+            scope.$on('editor:loaded', () => {
+              ide.$timeout(() => layout.resizeAll())
+            })
+
+            // Save state when exiting
+            $(window).unload(() => {
+              // Save only the state properties for the current layout, ignoring sublayouts inside it.
+              // If we save sublayouts state (`children`), the layout library will use it when
+              // initializing. This raises errors when the sublayout elements aren't available (due to
+              // being loaded at init or just not existing for the current project/user).
+              const stateToSave = _.mapValues(layout.readState(), pane =>
+                _.omit(pane, 'children')
+              )
+              ide.localStorage(`layout.${name}`, stateToSave)
+            })
+
+            if (attrs.openEast != null) {
+              scope.$watch(attrs.openEast, function (value, oldValue) {
+                if (value != null && value !== oldValue) {
+                  if (value) {
+                    layout.open('east')
+                  } else {
+                    layout.close('east')
+                  }
+                  if (hasCustomToggler && customTogglerPane === 'east') {
+                    repositionCustomToggler()
+                    customTogglerEl.scope().$applyAsync(function () {
+                      customTogglerEl.scope().isOpen = value
+                    })
+                  }
+                }
+                return setTimeout(() => scope.$digest(), 0)
+              })
             }
-            return customTogglerEl
-              .scope()
-              .$applyAsync(() => (customTogglerEl.scope().isOpen = false))
-          }
 
-          // Ensure editor resizes after loading. This is to handle the case where
-          // the window has been resized while the editor is loading
-          scope.$on('editor:loaded', () => {
-            ide.$timeout(() => layout.resizeAll())
-          })
+            if (attrs.allowOverflowOn != null) {
+              const overflowPane = scope.$eval(attrs.allowOverflowOn)
+              const overflowPaneEl = layout.panes[overflowPane]
+              // Set the panel as overflowing (gives it higher z-index and sets overflow rules)
+              layout.allowOverflow(overflowPane)
+              // Read the given z-index value and increment it, so that it's higher than synctex controls.
+              const overflowPaneZVal = overflowPaneEl.zIndex()
+              overflowPaneEl.css('z-index', overflowPaneZVal + 1)
+            }
 
-          // Save state when exiting
-          $(window).unload(() => {
-            // Save only the state properties for the current layout, ignoring sublayouts inside it.
-            // If we save sublayouts state (`children`), the layout library will use it when
-            // initializing. This raises errors when the sublayout elements aren't available (due to
-            // being loaded at init or just not existing for the current project/user).
-            const stateToSave = _.mapValues(layout.readState(), pane =>
-              _.omit(pane, 'children')
-            )
-            ide.localStorage(`layout.${name}`, stateToSave)
-          })
+            resetOpenStates()
+            onInternalResize()
 
-          if (attrs.openEast != null) {
-            scope.$watch(attrs.openEast, function (value, oldValue) {
-              if (value != null && value !== oldValue) {
+            if (attrs.layoutDisabled != null) {
+              return scope.$watch(attrs.layoutDisabled, function (value) {
                 if (value) {
-                  layout.open('east')
+                  layout.hide('east')
                 } else {
-                  layout.close('east')
+                  layout.show('east')
                 }
-                if (hasCustomToggler && customTogglerPane === 'east') {
-                  repositionCustomToggler()
-                  customTogglerEl.scope().$applyAsync(function () {
-                    customTogglerEl.scope().isOpen = value
+                if (hasCustomToggler) {
+                  return customTogglerEl.scope().$applyAsync(function () {
+                    customTogglerEl.scope().isOpen = !value
+                    return (customTogglerEl.scope().isVisible = !value)
                   })
                 }
-              }
-              return setTimeout(() => scope.$digest(), 0)
-            })
-          }
-
-          if (attrs.allowOverflowOn != null) {
-            const overflowPane = scope.$eval(attrs.allowOverflowOn)
-            const overflowPaneEl = layout.panes[overflowPane]
-            // Set the panel as overflowing (gives it higher z-index and sets overflow rules)
-            layout.allowOverflow(overflowPane)
-            // Read the given z-index value and increment it, so that it's higher than synctex controls.
-            const overflowPaneZVal = overflowPaneEl.zIndex()
-            overflowPaneEl.css('z-index', overflowPaneZVal + 1)
-          }
-
-          resetOpenStates()
-          onInternalResize()
-
-          if (attrs.layoutDisabled != null) {
-            return scope.$watch(attrs.layoutDisabled, function (value) {
-              if (value) {
-                layout.hide('east')
-              } else {
-                layout.show('east')
-              }
-              if (hasCustomToggler) {
-                return customTogglerEl.scope().$applyAsync(function () {
-                  customTogglerEl.scope().isOpen = !value
-                  return (customTogglerEl.scope().isVisible = !value)
-                })
-              }
-            })
-          }
-        },
+              })
+            }
+          },
 
-        post(scope, element, attrs) {
-          const name = attrs.layout
-          const state = element.layout().readState()
-          return scope.$broadcast(`layout:${name}:linked`, state)
-        },
-      }
-    },
-  }),
+          post(scope, element, attrs) {
+            const name = attrs.layout
+            const state = element.layout().readState()
+            return scope.$broadcast(`layout:${name}:linked`, state)
+          },
+        }
+      },
+    }
+  },
 ])

+ 9 - 7
services/web/frontend/js/ide/directives/validFile.js

@@ -12,10 +12,12 @@
 import App from '../../base'
 import SafePath from './SafePath'
 
-export default App.directive('validFile', () => ({
-  require: 'ngModel',
-  link(scope, element, attrs, ngModelCtrl) {
-    return (ngModelCtrl.$validators.validFile = filename =>
-      SafePath.isCleanFilename(filename))
-  },
-}))
+export default App.directive('validFile', function () {
+  return {
+    require: 'ngModel',
+    link(scope, element, attrs, ngModelCtrl) {
+      return (ngModelCtrl.$validators.validFile = filename =>
+        SafePath.isCleanFilename(filename))
+    },
+  }
+})

+ 123 - 117
services/web/frontend/js/ide/directives/verticalResizablePanes.js

@@ -3,131 +3,137 @@ import App from '../../base'
 export default App.directive('verticalResizablePanes', [
   'localStorage',
   'ide',
-  (localStorage, ide) => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      const name = attrs.verticalResizablePanes
-      const minSize = scope.$eval(attrs.verticalResizablePanesMinSize)
-      const maxSize = scope.$eval(attrs.verticalResizablePanesMaxSize)
-      const defaultSize = scope.$eval(attrs.verticalResizablePanesDefaultSize)
-      let storedSize = null
-      let manualResizeIncoming = false
-
-      if (name) {
-        const storageKey = `vertical-resizable:${name}:south-size`
-        storedSize = localStorage(storageKey)
-        $(window).unload(() => {
-          if (storedSize) {
-            localStorage(storageKey, storedSize)
-          }
-        })
-      }
-
-      const layoutOptions = {
-        center: {
-          paneSelector: '[vertical-resizable-top]',
-          paneClass: 'vertical-resizable-top',
-          size: 'auto',
-        },
-        south: {
-          paneSelector: '[vertical-resizable-bottom]',
-          paneClass: 'vertical-resizable-bottom',
-          resizerClass: 'vertical-resizable-resizer',
-          resizerCursor: 'ns-resize',
-          size: 'auto',
-          resizable: true,
-          closable: false,
-          slidable: false,
-          spacing_open: 6,
-          spacing_closed: 6,
-          maxSize: '75%',
-        },
-      }
-
-      const toggledExternally = attrs.verticalResizablePanesToggledExternallyOn
-      const hiddenExternally = attrs.verticalResizablePanesHiddenExternallyOn
-      const hiddenInitially = attrs.verticalResizablePanesHiddenInitially
-      const resizeOn = attrs.verticalResizablePanesResizeOn
-      const resizerDisabledClass = `${layoutOptions.south.resizerClass}-disabled`
-
-      function enableResizer() {
-        if (layoutHandle.resizers && layoutHandle.resizers.south) {
-          layoutHandle.resizers.south.removeClass(resizerDisabledClass)
+  function (localStorage, ide) {
+    return {
+      restrict: 'A',
+      link(scope, element, attrs) {
+        const name = attrs.verticalResizablePanes
+        const minSize = scope.$eval(attrs.verticalResizablePanesMinSize)
+        const maxSize = scope.$eval(attrs.verticalResizablePanesMaxSize)
+        const defaultSize = scope.$eval(attrs.verticalResizablePanesDefaultSize)
+        let storedSize = null
+        let manualResizeIncoming = false
+
+        if (name) {
+          const storageKey = `vertical-resizable:${name}:south-size`
+          storedSize = localStorage(storageKey)
+          $(window).unload(() => {
+            if (storedSize) {
+              localStorage(storageKey, storedSize)
+            }
+          })
         }
-      }
 
-      function disableResizer() {
-        if (layoutHandle.resizers && layoutHandle.resizers.south) {
-          layoutHandle.resizers.south.addClass(resizerDisabledClass)
+        const layoutOptions = {
+          center: {
+            paneSelector: '[vertical-resizable-top]',
+            paneClass: 'vertical-resizable-top',
+            size: 'auto',
+          },
+          south: {
+            paneSelector: '[vertical-resizable-bottom]',
+            paneClass: 'vertical-resizable-bottom',
+            resizerClass: 'vertical-resizable-resizer',
+            resizerCursor: 'ns-resize',
+            size: 'auto',
+            resizable: true,
+            closable: false,
+            slidable: false,
+            spacing_open: 6,
+            spacing_closed: 6,
+            maxSize: '75%',
+          },
         }
-      }
 
-      function handleDragEnd() {
-        manualResizeIncoming = true
-      }
+        const toggledExternally =
+          attrs.verticalResizablePanesToggledExternallyOn
+        const hiddenExternally = attrs.verticalResizablePanesHiddenExternallyOn
+        const hiddenInitially = attrs.verticalResizablePanesHiddenInitially
+        const resizeOn = attrs.verticalResizablePanesResizeOn
+        const resizerDisabledClass = `${layoutOptions.south.resizerClass}-disabled`
 
-      function handleResize(paneName, paneEl, paneState) {
-        if (manualResizeIncoming) {
-          storedSize = paneState.size
+        function enableResizer() {
+          if (layoutHandle.resizers && layoutHandle.resizers.south) {
+            layoutHandle.resizers.south.removeClass(resizerDisabledClass)
+          }
         }
-        manualResizeIncoming = false
-      }
-
-      if (toggledExternally) {
-        scope.$on(toggledExternally, (e, open) => {
-          if (open) {
-            enableResizer()
-            layoutHandle.sizePane('south', storedSize ?? defaultSize ?? 'auto')
-          } else {
-            disableResizer()
-            layoutHandle.sizePane('south', 'auto')
+
+        function disableResizer() {
+          if (layoutHandle.resizers && layoutHandle.resizers.south) {
+            layoutHandle.resizers.south.addClass(resizerDisabledClass)
           }
-        })
-      }
-
-      if (hiddenExternally) {
-        ide.$scope.$on(hiddenExternally, (e, open) => {
-          if (open) {
-            layoutHandle.show('south')
-          } else {
-            layoutHandle.hide('south')
+        }
+
+        function handleDragEnd() {
+          manualResizeIncoming = true
+        }
+
+        function handleResize(paneName, paneEl, paneState) {
+          if (manualResizeIncoming) {
+            storedSize = paneState.size
           }
-        })
-      }
+          manualResizeIncoming = false
+        }
+
+        if (toggledExternally) {
+          scope.$on(toggledExternally, (e, open) => {
+            if (open) {
+              enableResizer()
+              layoutHandle.sizePane(
+                'south',
+                storedSize ?? defaultSize ?? 'auto'
+              )
+            } else {
+              disableResizer()
+              layoutHandle.sizePane('south', 'auto')
+            }
+          })
+        }
 
-      if (resizeOn) {
-        ide.$scope.$on(resizeOn, () => {
-          ide.$timeout(() => {
-            layoutHandle.resizeAll()
+        if (hiddenExternally) {
+          ide.$scope.$on(hiddenExternally, (e, open) => {
+            if (open) {
+              layoutHandle.show('south')
+            } else {
+              layoutHandle.hide('south')
+            }
           })
-        })
-      }
-
-      if (maxSize) {
-        layoutOptions.south.maxSize = maxSize
-      }
-
-      if (minSize) {
-        layoutOptions.south.minSize = minSize
-      }
-
-      if (defaultSize) {
-        layoutOptions.south.size = defaultSize
-      }
-
-      // The `drag` event fires only when the user manually resizes the panes; the `resize` event fires even when
-      // the layout library internally resizes itself. In order to get explicit user-initiated resizes, we need to
-      // listen to `drag` events. However, when the `drag` event fires, the panes aren't yet finished sizing so we
-      // get the pane size *before* the resize happens. We do get the correct size in the next `resize` event.
-      // The solution to work around this is to set up a flag in `drag` events which tells the next `resize` event
-      // that it was user-initiated (therefore, storing the value).
-      layoutOptions.south.ondrag_end = handleDragEnd
-      layoutOptions.south.onresize = handleResize
-
-      const layoutHandle = element.layout(layoutOptions)
-      if (hiddenInitially === 'true') {
-        layoutHandle.hide('south')
-      }
-    },
-  }),
+        }
+
+        if (resizeOn) {
+          ide.$scope.$on(resizeOn, () => {
+            ide.$timeout(() => {
+              layoutHandle.resizeAll()
+            })
+          })
+        }
+
+        if (maxSize) {
+          layoutOptions.south.maxSize = maxSize
+        }
+
+        if (minSize) {
+          layoutOptions.south.minSize = minSize
+        }
+
+        if (defaultSize) {
+          layoutOptions.south.size = defaultSize
+        }
+
+        // The `drag` event fires only when the user manually resizes the panes; the `resize` event fires even when
+        // the layout library internally resizes itself. In order to get explicit user-initiated resizes, we need to
+        // listen to `drag` events. However, when the `drag` event fires, the panes aren't yet finished sizing so we
+        // get the pane size *before* the resize happens. We do get the correct size in the next `resize` event.
+        // The solution to work around this is to set up a flag in `drag` events which tells the next `resize` event
+        // that it was user-initiated (therefore, storing the value).
+        layoutOptions.south.ondrag_end = handleDragEnd
+        layoutOptions.south.onresize = handleResize
+
+        const layoutHandle = element.layout(layoutOptions)
+        if (hiddenInitially === 'true') {
+          layoutHandle.hide('south')
+        }
+      },
+    }
+  },
 ])

+ 15 - 13
services/web/frontend/js/ide/editor/directives/formattingButtons.js

@@ -1,17 +1,19 @@
 import App from '../../../base'
 
-export default App.directive('formattingButtons', () => ({
-  scope: {
-    buttons: '=',
-    opening: '=',
-    isFullscreenEditor: '=',
-  },
+export default App.directive('formattingButtons', function () {
+  return {
+    scope: {
+      buttons: '=',
+      opening: '=',
+      isFullscreenEditor: '=',
+    },
 
-  link(scope, element, attrs) {
-    scope.showMore = false
-    scope.shownButtons = scope.buttons
-    scope.overflowedButtons = []
-  },
+    link(scope, element, attrs) {
+      scope.showMore = false
+      scope.shownButtons = scope.buttons
+      scope.overflowedButtons = []
+    },
 
-  templateUrl: 'formattingButtonsTpl',
-}))
+    templateUrl: 'formattingButtonsTpl',
+  }
+})

+ 12 - 10
services/web/frontend/js/ide/editor/directives/toggleSwitch.js

@@ -1,14 +1,15 @@
 import App from '../../../base'
 
-export default App.directive('toggleSwitch', () => ({
-  restrict: 'E',
-  scope: {
-    description: '@',
-    labelFalse: '@',
-    labelTrue: '@',
-    ngModel: '=',
-  },
-  template: `\
+export default App.directive('toggleSwitch', function () {
+  return {
+    restrict: 'E',
+    scope: {
+      description: '@',
+      labelFalse: '@',
+      labelTrue: '@',
+      ngModel: '=',
+    },
+    template: `\
 <fieldset class="toggle-switch">
   <legend class="sr-only">{{description}}</legend>
 
@@ -33,4 +34,5 @@ export default App.directive('toggleSwitch', () => ({
   <label for="toggle-switch-true-{{$id}}" class="toggle-switch-label"><span>{{labelTrue}}</span></label>
 </fieldset>\
 `,
-}))
+  }
+})

+ 33 - 31
services/web/frontend/js/ide/history/directives/historyDraggableBoundary.js

@@ -1,33 +1,35 @@
 import App from '../../../base'
 
-export default App.directive('historyDraggableBoundary', () => ({
-  scope: {
-    historyDraggableBoundary: '@',
-    historyDraggableBoundaryOnDragStart: '&',
-    historyDraggableBoundaryOnDragStop: '&',
-  },
-  restrict: 'A',
-  link(scope, element, attrs) {
-    element.data('selectionBoundary', {
-      boundary: scope.historyDraggableBoundary,
-    })
-    element.draggable({
-      axis: 'y',
-      opacity: false,
-      helper: 'clone',
-      revert: true,
-      scroll: true,
-      cursor: 'row-resize',
-      start(e, ui) {
-        ui.helper.data('wasProperlyDropped', false)
-        scope.historyDraggableBoundaryOnDragStart()
-      },
-      stop(e, ui) {
-        scope.historyDraggableBoundaryOnDragStop({
-          isValidDrop: ui.helper.data('wasProperlyDropped'),
-          boundary: scope.historyDraggableBoundary,
-        })
-      },
-    })
-  },
-}))
+export default App.directive('historyDraggableBoundary', function () {
+  return {
+    scope: {
+      historyDraggableBoundary: '@',
+      historyDraggableBoundaryOnDragStart: '&',
+      historyDraggableBoundaryOnDragStop: '&',
+    },
+    restrict: 'A',
+    link(scope, element, attrs) {
+      element.data('selectionBoundary', {
+        boundary: scope.historyDraggableBoundary,
+      })
+      element.draggable({
+        axis: 'y',
+        opacity: false,
+        helper: 'clone',
+        revert: true,
+        scroll: true,
+        cursor: 'row-resize',
+        start(e, ui) {
+          ui.helper.data('wasProperlyDropped', false)
+          scope.historyDraggableBoundaryOnDragStart()
+        },
+        stop(e, ui) {
+          scope.historyDraggableBoundaryOnDragStop({
+            isValidDrop: ui.helper.data('wasProperlyDropped'),
+            boundary: scope.historyDraggableBoundary,
+          })
+        },
+      })
+    },
+  }
+})

+ 26 - 22
services/web/frontend/js/ide/history/directives/historyDroppableArea.js

@@ -1,24 +1,28 @@
 import App from '../../../base'
 
-export default App.directive('historyDroppableArea', () => ({
-  scope: {
-    historyDroppableAreaOnDrop: '&',
-    historyDroppableAreaOnOver: '&',
-    historyDroppableAreaOnOut: '&',
-  },
-  restrict: 'A',
-  link(scope, element, attrs) {
-    element.droppable({
-      accept: e => '.history-entry-toV-handle, .history-entry-fromV-handle',
-      drop: (e, ui) => {
-        const draggedBoundary = ui.draggable.data('selectionBoundary').boundary
-        ui.helper.data('wasProperlyDropped', true)
-        scope.historyDroppableAreaOnDrop({ boundary: draggedBoundary })
-      },
-      over: (e, ui) => {
-        const draggedBoundary = ui.draggable.data('selectionBoundary').boundary
-        scope.historyDroppableAreaOnOver({ boundary: draggedBoundary })
-      },
-    })
-  },
-}))
+export default App.directive('historyDroppableArea', function () {
+  return {
+    scope: {
+      historyDroppableAreaOnDrop: '&',
+      historyDroppableAreaOnOver: '&',
+      historyDroppableAreaOnOut: '&',
+    },
+    restrict: 'A',
+    link(scope, element, attrs) {
+      element.droppable({
+        accept: e => '.history-entry-toV-handle, .history-entry-fromV-handle',
+        drop: (e, ui) => {
+          const draggedBoundary =
+            ui.draggable.data('selectionBoundary').boundary
+          ui.helper.data('wasProperlyDropped', true)
+          scope.historyDroppableAreaOnDrop({ boundary: draggedBoundary })
+        },
+        over: (e, ui) => {
+          const draggedBoundary =
+            ui.draggable.data('selectionBoundary').boundary
+          scope.historyDroppableAreaOnOver({ boundary: draggedBoundary })
+        },
+      })
+    },
+  }
+})

+ 34 - 32
services/web/frontend/js/ide/history/directives/infiniteScroll.js

@@ -11,43 +11,45 @@
  */
 import App from '../../../base'
 
-export default App.directive('infiniteScroll', () => ({
-  link(scope, element, attrs, ctrl) {
-    const innerElement = element.find('.infinite-scroll-inner')
-    element.css({ 'overflow-y': 'auto' })
-
-    const atEndOfListView = function () {
-      if (attrs.infiniteScrollUpwards != null) {
-        return atTopOfListView()
-      } else {
-        return atBottomOfListView()
+export default App.directive('infiniteScroll', function () {
+  return {
+    link(scope, element, attrs, ctrl) {
+      const innerElement = element.find('.infinite-scroll-inner')
+      element.css({ 'overflow-y': 'auto' })
+
+      const atEndOfListView = function () {
+        if (attrs.infiniteScrollUpwards != null) {
+          return atTopOfListView()
+        } else {
+          return atBottomOfListView()
+        }
       }
-    }
 
-    const atTopOfListView = () => element.scrollTop() < 30
+      const atTopOfListView = () => element.scrollTop() < 30
 
-    const atBottomOfListView = () =>
-      element.scrollTop() + element.height() >= innerElement.height() - 30
+      const atBottomOfListView = () =>
+        element.scrollTop() + element.height() >= innerElement.height() - 30
 
-    const listShorterThanContainer = () =>
-      element.height() > innerElement.height()
+      const listShorterThanContainer = () =>
+        element.height() > innerElement.height()
 
-    function loadUntilFull() {
-      if (
-        (listShorterThanContainer() || atEndOfListView()) &&
-        !scope.$eval(attrs.infiniteScrollDisabled)
-      ) {
-        const promise = scope.$eval(attrs.infiniteScroll)
-        return promise.then(() => setTimeout(() => loadUntilFull(), 0))
+      function loadUntilFull() {
+        if (
+          (listShorterThanContainer() || atEndOfListView()) &&
+          !scope.$eval(attrs.infiniteScrollDisabled)
+        ) {
+          const promise = scope.$eval(attrs.infiniteScroll)
+          return promise.then(() => setTimeout(() => loadUntilFull(), 0))
+        }
       }
-    }
 
-    element.on('scroll', event => loadUntilFull())
+      element.on('scroll', event => loadUntilFull())
 
-    return scope.$watch(attrs.infiniteScrollInitialize, function (value) {
-      if (value) {
-        return loadUntilFull()
-      }
-    })
-  },
-}))
+      return scope.$watch(attrs.infiniteScrollInitialize, function (value) {
+        if (value) {
+          return loadUntilFull()
+        }
+      })
+    },
+  }
+})

+ 49 - 47
services/web/frontend/js/ide/review-panel/directives/addCommentEntry.js

@@ -1,60 +1,62 @@
 import App from '../../../base'
 let content = ''
-App.directive('addCommentEntry', () => ({
-  restrict: 'E',
-  templateUrl: 'addCommentEntryTemplate',
-  scope: {
-    onStartNew: '&',
-    onSubmit: '&',
-    onCancel: '&',
-  },
-  link(scope, element, attrs) {
-    scope.state = {
-      isAdding: false,
-      content,
-    }
+App.directive('addCommentEntry', function () {
+  return {
+    restrict: 'E',
+    templateUrl: 'addCommentEntryTemplate',
+    scope: {
+      onStartNew: '&',
+      onSubmit: '&',
+      onCancel: '&',
+    },
+    link(scope, element, attrs) {
+      scope.state = {
+        isAdding: false,
+        content,
+      }
 
-    scope.$on('comment:start_adding', () => scope.startNewComment())
-    scope.$on('$destroy', function () {
-      content = scope.state.content
-    })
+      scope.$on('comment:start_adding', () => scope.startNewComment())
+      scope.$on('$destroy', function () {
+        content = scope.state.content
+      })
 
-    scope.startNewComment = function () {
-      scope.state.isAdding = true
-      scope.onStartNew()
-      setTimeout(() => scope.$broadcast('comment:new:open'))
-    }
+      scope.startNewComment = function () {
+        scope.state.isAdding = true
+        scope.onStartNew()
+        setTimeout(() => scope.$broadcast('comment:new:open'))
+      }
 
-    scope.cancelNewComment = function () {
-      scope.state.isAdding = false
-      scope.state.content = ''
-      scope.onCancel()
-    }
+      scope.cancelNewComment = function () {
+        scope.state.isAdding = false
+        scope.state.content = ''
+        scope.onCancel()
+      }
 
-    const ignoreKeysInTextAreas = ['PageDown', 'PageUp']
+      const ignoreKeysInTextAreas = ['PageDown', 'PageUp']
 
-    scope.handleCommentKeyDown = function (ev) {
-      if (ignoreKeysInTextAreas.includes(ev.key)) {
-        if (ev.target.closest('textarea')) {
-          ev.preventDefault()
+      scope.handleCommentKeyDown = function (ev) {
+        if (ignoreKeysInTextAreas.includes(ev.key)) {
+          if (ev.target.closest('textarea')) {
+            ev.preventDefault()
+          }
         }
       }
-    }
 
-    scope.handleCommentKeyPress = function (ev) {
-      if (ev.keyCode === 13 && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
-        ev.preventDefault()
-        if (scope.state.content.length > 0) {
-          scope.submitNewComment()
+      scope.handleCommentKeyPress = function (ev) {
+        if (ev.keyCode === 13 && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
+          ev.preventDefault()
+          if (scope.state.content.length > 0) {
+            scope.submitNewComment()
+          }
         }
       }
-    }
 
-    scope.submitNewComment = function (event) {
-      scope.onSubmit({ content: scope.state.content })
-      content = scope.state.content
-      scope.state.isAdding = false
-      scope.state.content = ''
-    }
-  },
-}))
+      scope.submitNewComment = function (event) {
+        scope.onSubmit({ content: scope.state.content })
+        content = scope.state.content
+        scope.state.isAdding = false
+        scope.state.content = ''
+      }
+    },
+  }
+})

+ 53 - 51
services/web/frontend/js/ide/review-panel/directives/aggregateChangeEntry.js

@@ -13,60 +13,62 @@ import App from '../../../base'
 
 export default App.directive('aggregateChangeEntry', [
   '$timeout',
-  $timeout => ({
-    restrict: 'E',
-    templateUrl: 'aggregateChangeEntryTemplate',
-    scope: {
-      entry: '=',
-      user: '=',
-      permissions: '=',
-      onAccept: '&',
-      onReject: '&',
-      onIndicatorClick: '&',
-      onMouseEnter: '&',
-      onMouseLeave: '&',
-      onBodyClick: '&',
-    },
-    link(scope, element, attrs) {
-      scope.contentLimit = 17
-      scope.isDeletionCollapsed = true
-      scope.isInsertionCollapsed = true
-      scope.deletionNeedsCollapsing = false
-      scope.insertionNeedsCollapsing = false
+  function ($timeout) {
+    return {
+      restrict: 'E',
+      templateUrl: 'aggregateChangeEntryTemplate',
+      scope: {
+        entry: '=',
+        user: '=',
+        permissions: '=',
+        onAccept: '&',
+        onReject: '&',
+        onIndicatorClick: '&',
+        onMouseEnter: '&',
+        onMouseLeave: '&',
+        onBodyClick: '&',
+      },
+      link(scope, element, attrs) {
+        scope.contentLimit = 17
+        scope.isDeletionCollapsed = true
+        scope.isInsertionCollapsed = true
+        scope.deletionNeedsCollapsing = false
+        scope.insertionNeedsCollapsing = false
 
-      element.on('click', function (e) {
-        if (
-          $(e.target).is(
-            '.rp-entry, .rp-entry-description, .rp-entry-body, .rp-entry-action-icon i'
-          )
-        ) {
-          return scope.onBodyClick()
-        }
-      })
+        element.on('click', function (e) {
+          if (
+            $(e.target).is(
+              '.rp-entry, .rp-entry-description, .rp-entry-body, .rp-entry-action-icon i'
+            )
+          ) {
+            return scope.onBodyClick()
+          }
+        })
 
-      scope.toggleDeletionCollapse = function () {
-        scope.isDeletionCollapsed = !scope.isDeletionCollapsed
-        return $timeout(() => scope.$emit('review-panel:layout'))
-      }
+        scope.toggleDeletionCollapse = function () {
+          scope.isDeletionCollapsed = !scope.isDeletionCollapsed
+          return $timeout(() => scope.$emit('review-panel:layout'))
+        }
 
-      scope.toggleInsertionCollapse = function () {
-        scope.isInsertionCollapsed = !scope.isInsertionCollapsed
-        return $timeout(() => scope.$emit('review-panel:layout'))
-      }
+        scope.toggleInsertionCollapse = function () {
+          scope.isInsertionCollapsed = !scope.isInsertionCollapsed
+          return $timeout(() => scope.$emit('review-panel:layout'))
+        }
 
-      scope.$watch(
-        'entry.metadata.replaced_content.length',
-        deletionContentLength =>
-          (scope.deletionNeedsCollapsing =
-            deletionContentLength > scope.contentLimit)
-      )
+        scope.$watch(
+          'entry.metadata.replaced_content.length',
+          deletionContentLength =>
+            (scope.deletionNeedsCollapsing =
+              deletionContentLength > scope.contentLimit)
+        )
 
-      return scope.$watch(
-        'entry.content.length',
-        insertionContentLength =>
-          (scope.insertionNeedsCollapsing =
-            insertionContentLength > scope.contentLimit)
-      )
-    },
-  }),
+        return scope.$watch(
+          'entry.content.length',
+          insertionContentLength =>
+            (scope.insertionNeedsCollapsing =
+              insertionContentLength > scope.contentLimit)
+        )
+      },
+    }
+  },
 ])

+ 15 - 13
services/web/frontend/js/ide/review-panel/directives/bulkActionsEntry.js

@@ -10,16 +10,18 @@
  */
 import App from '../../../base'
 
-export default App.directive('bulkActionsEntry', () => ({
-  restrict: 'E',
-  templateUrl: 'bulkActionsEntryTemplate',
-  scope: {
-    onBulkAccept: '&',
-    onBulkReject: '&',
-    nEntries: '=',
-  },
-  link(scope, element, attrs) {
-    scope.bulkAccept = () => scope.onBulkAccept()
-    return (scope.bulkReject = () => scope.onBulkReject())
-  },
-}))
+export default App.directive('bulkActionsEntry', function () {
+  return {
+    restrict: 'E',
+    templateUrl: 'bulkActionsEntryTemplate',
+    scope: {
+      onBulkAccept: '&',
+      onBulkReject: '&',
+      nEntries: '=',
+    },
+    link(scope, element, attrs) {
+      scope.bulkAccept = () => scope.onBulkAccept()
+      return (scope.bulkReject = () => scope.onBulkReject())
+    },
+  }
+})

+ 40 - 38
services/web/frontend/js/ide/review-panel/directives/changeEntry.js

@@ -13,45 +13,47 @@ import App from '../../../base'
 
 export default App.directive('changeEntry', [
   '$timeout',
-  $timeout => ({
-    restrict: 'E',
-    templateUrl: 'changeEntryTemplate',
-    scope: {
-      entry: '=',
-      user: '=',
-      permissions: '=',
-      onAccept: '&',
-      onReject: '&',
-      onIndicatorClick: '&',
-      onMouseEnter: '&',
-      onMouseLeave: '&',
-      onBodyClick: '&',
-    },
-    link(scope, element, attrs) {
-      scope.contentLimit = 40
-      scope.isCollapsed = true
-      scope.needsCollapsing = false
+  function ($timeout) {
+    return {
+      restrict: 'E',
+      templateUrl: 'changeEntryTemplate',
+      scope: {
+        entry: '=',
+        user: '=',
+        permissions: '=',
+        onAccept: '&',
+        onReject: '&',
+        onIndicatorClick: '&',
+        onMouseEnter: '&',
+        onMouseLeave: '&',
+        onBodyClick: '&',
+      },
+      link(scope, element, attrs) {
+        scope.contentLimit = 40
+        scope.isCollapsed = true
+        scope.needsCollapsing = false
 
-      element.on('click', function (e) {
-        if (
-          $(e.target).is(
-            '.rp-entry, .rp-entry-description, .rp-entry-body, .rp-entry-action-icon i'
-          )
-        ) {
-          return scope.onBodyClick()
-        }
-      })
+        element.on('click', function (e) {
+          if (
+            $(e.target).is(
+              '.rp-entry, .rp-entry-description, .rp-entry-body, .rp-entry-action-icon i'
+            )
+          ) {
+            return scope.onBodyClick()
+          }
+        })
 
-      scope.toggleCollapse = function () {
-        scope.isCollapsed = !scope.isCollapsed
-        return $timeout(() => scope.$emit('review-panel:layout'))
-      }
+        scope.toggleCollapse = function () {
+          scope.isCollapsed = !scope.isCollapsed
+          return $timeout(() => scope.$emit('review-panel:layout'))
+        }
 
-      return scope.$watch(
-        'entry.content.length',
-        contentLength =>
-          (scope.needsCollapsing = contentLength > scope.contentLimit)
-      )
-    },
-  }),
+        return scope.$watch(
+          'entry.content.length',
+          contentLength =>
+            (scope.needsCollapsing = contentLength > scope.contentLimit)
+        )
+      },
+    }
+  },
 ])

+ 70 - 68
services/web/frontend/js/ide/review-panel/directives/commentEntry.js

@@ -13,83 +13,85 @@ import App from '../../../base'
 
 export default App.directive('commentEntry', [
   '$timeout',
-  $timeout => ({
-    restrict: 'E',
-    templateUrl: 'commentEntryTemplate',
-    scope: {
-      entry: '=',
-      threads: '=',
-      permissions: '=',
-      onResolve: '&',
-      onReply: '&',
-      onIndicatorClick: '&',
-      onMouseEnter: '&',
-      onMouseLeave: '&',
-      onSaveEdit: '&',
-      onDelete: '&',
-      onBodyClick: '&',
-    },
-    link(scope, element, attrs) {
-      scope.state = { animating: false }
+  function ($timeout) {
+    return {
+      restrict: 'E',
+      templateUrl: 'commentEntryTemplate',
+      scope: {
+        entry: '=',
+        threads: '=',
+        permissions: '=',
+        onResolve: '&',
+        onReply: '&',
+        onIndicatorClick: '&',
+        onMouseEnter: '&',
+        onMouseLeave: '&',
+        onSaveEdit: '&',
+        onDelete: '&',
+        onBodyClick: '&',
+      },
+      link(scope, element, attrs) {
+        scope.state = { animating: false }
 
-      element.on('click', function (e) {
-        if (
-          $(e.target).is(
-            '.rp-entry, .rp-comment-loaded, .rp-comment-content, .rp-comment-reply, .rp-entry-metadata'
-          )
-        ) {
-          return scope.onBodyClick()
-        }
-      })
+        element.on('click', function (e) {
+          if (
+            $(e.target).is(
+              '.rp-entry, .rp-comment-loaded, .rp-comment-content, .rp-comment-reply, .rp-entry-metadata'
+            )
+          ) {
+            return scope.onBodyClick()
+          }
+        })
 
-      scope.handleCommentReplyKeyPress = function (ev) {
-        if (ev.keyCode === 13 && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
-          ev.preventDefault()
-          if (scope.entry.replyContent.length > 0) {
-            ev.target.blur()
-            return scope.onReply()
+        scope.handleCommentReplyKeyPress = function (ev) {
+          if (ev.keyCode === 13 && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
+            ev.preventDefault()
+            if (scope.entry.replyContent.length > 0) {
+              ev.target.blur()
+              return scope.onReply()
+            }
           }
         }
-      }
-
-      scope.animateAndCallOnResolve = function () {
-        scope.state.animating = true
-        element.find('.rp-entry').css('top', 0)
-        $timeout(() => scope.onResolve(), 350)
-        return true
-      }
 
-      scope.startEditing = function (comment) {
-        comment.editing = true
-        return setTimeout(() => scope.$emit('review-panel:layout'))
-      }
+        scope.animateAndCallOnResolve = function () {
+          scope.state.animating = true
+          element.find('.rp-entry').css('top', 0)
+          $timeout(() => scope.onResolve(), 350)
+          return true
+        }
 
-      scope.saveEdit = function (comment) {
-        comment.editing = false
-        return scope.onSaveEdit({ comment })
-      }
+        scope.startEditing = function (comment) {
+          comment.editing = true
+          return setTimeout(() => scope.$emit('review-panel:layout'))
+        }
 
-      scope.confirmDelete = function (comment) {
-        comment.deleting = true
-        return setTimeout(() => scope.$emit('review-panel:layout'))
-      }
+        scope.saveEdit = function (comment) {
+          comment.editing = false
+          return scope.onSaveEdit({ comment })
+        }
 
-      scope.cancelDelete = function (comment) {
-        comment.deleting = false
-        return setTimeout(() => scope.$emit('review-panel:layout'))
-      }
+        scope.confirmDelete = function (comment) {
+          comment.deleting = true
+          return setTimeout(() => scope.$emit('review-panel:layout'))
+        }
 
-      scope.doDelete = function (comment) {
-        comment.deleting = false
-        return scope.onDelete({ comment })
-      }
+        scope.cancelDelete = function (comment) {
+          comment.deleting = false
+          return setTimeout(() => scope.$emit('review-panel:layout'))
+        }
 
-      return (scope.saveEditOnEnter = function (ev, comment) {
-        if (ev.keyCode === 13 && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
-          ev.preventDefault()
-          return scope.saveEdit(comment)
+        scope.doDelete = function (comment) {
+          comment.deleting = false
+          return scope.onDelete({ comment })
         }
-      })
-    },
-  }),
+
+        return (scope.saveEditOnEnter = function (ev, comment) {
+          if (ev.keyCode === 13 && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
+            ev.preventDefault()
+            return scope.saveEdit(comment)
+          }
+        })
+      },
+    }
+  },
 ])

+ 23 - 21
services/web/frontend/js/ide/review-panel/directives/resolvedCommentEntry.js

@@ -11,26 +11,28 @@
  */
 import App from '../../../base'
 
-export default App.directive('resolvedCommentEntry', () => ({
-  restrict: 'E',
-  templateUrl: 'resolvedCommentEntryTemplate',
-  scope: {
-    thread: '=',
-    permissions: '=',
-    onUnresolve: '&',
-    onDelete: '&',
-  },
-  link(scope, element, attrs) {
-    scope.contentLimit = 40
-    scope.needsCollapsing = false
-    scope.isCollapsed = true
+export default App.directive('resolvedCommentEntry', function () {
+  return {
+    restrict: 'E',
+    templateUrl: 'resolvedCommentEntryTemplate',
+    scope: {
+      thread: '=',
+      permissions: '=',
+      onUnresolve: '&',
+      onDelete: '&',
+    },
+    link(scope, element, attrs) {
+      scope.contentLimit = 40
+      scope.needsCollapsing = false
+      scope.isCollapsed = true
 
-    scope.toggleCollapse = () => (scope.isCollapsed = !scope.isCollapsed)
+      scope.toggleCollapse = () => (scope.isCollapsed = !scope.isCollapsed)
 
-    return scope.$watch(
-      'thread.content.length',
-      contentLength =>
-        (scope.needsCollapsing = contentLength > scope.contentLimit)
-    )
-  },
-}))
+      return scope.$watch(
+        'thread.content.length',
+        contentLength =>
+          (scope.needsCollapsing = contentLength > scope.contentLimit)
+      )
+    },
+  }
+})

+ 82 - 80
services/web/frontend/js/ide/review-panel/directives/resolvedCommentsDropdown.js

@@ -14,96 +14,98 @@ import _ from 'lodash'
  */
 import App from '../../../base'
 
-export default App.directive('resolvedCommentsDropdown', () => ({
-  restrict: 'E',
-  templateUrl: 'resolvedCommentsDropdownTemplate',
-  scope: {
-    entries: '=',
-    threads: '=',
-    resolvedIds: '=',
-    docs: '=',
-    permissions: '=',
-    onOpen: '&',
-    onUnresolve: '&',
-    onDelete: '&',
-    isLoading: '=',
-  },
+export default App.directive('resolvedCommentsDropdown', function () {
+  return {
+    restrict: 'E',
+    templateUrl: 'resolvedCommentsDropdownTemplate',
+    scope: {
+      entries: '=',
+      threads: '=',
+      resolvedIds: '=',
+      docs: '=',
+      permissions: '=',
+      onOpen: '&',
+      onUnresolve: '&',
+      onDelete: '&',
+      isLoading: '=',
+    },
 
-  link(scope, element, attrs) {
-    let filterResolvedComments
-    scope.state = { isOpen: false }
+    link(scope, element, attrs) {
+      let filterResolvedComments
+      scope.state = { isOpen: false }
 
-    scope.toggleOpenState = function () {
-      scope.state.isOpen = !scope.state.isOpen
-      if (scope.state.isOpen) {
-        return scope.onOpen().then(() => filterResolvedComments())
+      scope.toggleOpenState = function () {
+        scope.state.isOpen = !scope.state.isOpen
+        if (scope.state.isOpen) {
+          return scope.onOpen().then(() => filterResolvedComments())
+        }
       }
-    }
 
-    scope.resolvedComments = []
+      scope.resolvedComments = []
 
-    scope.handleUnresolve = function (threadId) {
-      scope.onUnresolve({ threadId })
-      return (scope.resolvedComments = scope.resolvedComments.filter(
-        c => c.threadId !== threadId
-      ))
-    }
+      scope.handleUnresolve = function (threadId) {
+        scope.onUnresolve({ threadId })
+        return (scope.resolvedComments = scope.resolvedComments.filter(
+          c => c.threadId !== threadId
+        ))
+      }
 
-    scope.handleDelete = function (entryId, docId, threadId) {
-      scope.onDelete({ entryId, docId, threadId })
-      return (scope.resolvedComments = scope.resolvedComments.filter(
-        c => c.threadId !== threadId
-      ))
-    }
+      scope.handleDelete = function (entryId, docId, threadId) {
+        scope.onDelete({ entryId, docId, threadId })
+        return (scope.resolvedComments = scope.resolvedComments.filter(
+          c => c.threadId !== threadId
+        ))
+      }
 
-    const getDocNameById = function (docId) {
-      const doc = _.find(scope.docs, doc => doc.doc.id === docId)
-      if (doc != null) {
-        return doc.path
-      } else {
-        return null
+      const getDocNameById = function (docId) {
+        const doc = _.find(scope.docs, doc => doc.doc.id === docId)
+        if (doc != null) {
+          return doc.path
+        } else {
+          return null
+        }
       }
-    }
 
-    return (filterResolvedComments = function () {
-      scope.resolvedComments = []
+      return (filterResolvedComments = function () {
+        scope.resolvedComments = []
 
-      return (() => {
-        const result = []
-        for (const docId in scope.entries) {
-          const docEntries = scope.entries[docId]
-          result.push(
-            (() => {
-              const result1 = []
-              for (const entryId in docEntries) {
-                const entry = docEntries[entryId]
-                if (
-                  entry.type === 'comment' &&
-                  (scope.threads[entry.thread_id] != null
-                    ? scope.threads[entry.thread_id].resolved
-                    : undefined) != null
-                ) {
-                  const resolvedComment = angular.copy(
-                    scope.threads[entry.thread_id]
-                  )
+        return (() => {
+          const result = []
+          for (const docId in scope.entries) {
+            const docEntries = scope.entries[docId]
+            result.push(
+              (() => {
+                const result1 = []
+                for (const entryId in docEntries) {
+                  const entry = docEntries[entryId]
+                  if (
+                    entry.type === 'comment' &&
+                    (scope.threads[entry.thread_id] != null
+                      ? scope.threads[entry.thread_id].resolved
+                      : undefined) != null
+                  ) {
+                    const resolvedComment = angular.copy(
+                      scope.threads[entry.thread_id]
+                    )
 
-                  resolvedComment.content = entry.content
-                  resolvedComment.threadId = entry.thread_id
-                  resolvedComment.entryId = entryId
-                  resolvedComment.docId = docId
-                  resolvedComment.docName = getDocNameById(docId)
+                    resolvedComment.content = entry.content
+                    resolvedComment.threadId = entry.thread_id
+                    resolvedComment.entryId = entryId
+                    resolvedComment.docId = docId
+                    resolvedComment.docName = getDocNameById(docId)
 
-                  result1.push(scope.resolvedComments.push(resolvedComment))
-                } else {
-                  result1.push(undefined)
+                    result1.push(scope.resolvedComments.push(resolvedComment))
+                  } else {
+                    result1.push(undefined)
+                  }
                 }
-              }
-              return result1
-            })()
-          )
-        }
-        return result
-      })()
-    })
-  },
-}))
+                return result1
+              })()
+            )
+          }
+          return result
+        })()
+      })
+    },
+  }
+})

+ 21 - 19
services/web/frontend/js/ide/review-panel/directives/reviewPanelCollapseHeight.js

@@ -12,26 +12,28 @@ import App from '../../../base'
 
 export default App.directive('reviewPanelCollapseHeight', [
   '$parse',
-  $parse => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return scope.$watch(
-        () => $parse(attrs.reviewPanelCollapseHeight)(scope),
-        function (shouldCollapse) {
-          const neededHeight = element.prop('scrollHeight')
-          if (neededHeight > 0) {
-            if (shouldCollapse) {
-              return element.animate({ height: 0 }, 150)
+  function ($parse) {
+    return {
+      restrict: 'A',
+      link(scope, element, attrs) {
+        return scope.$watch(
+          () => $parse(attrs.reviewPanelCollapseHeight)(scope),
+          function (shouldCollapse) {
+            const neededHeight = element.prop('scrollHeight')
+            if (neededHeight > 0) {
+              if (shouldCollapse) {
+                return element.animate({ height: 0 }, 150)
+              } else {
+                return element.animate({ height: neededHeight }, 150)
+              }
             } else {
-              return element.animate({ height: neededHeight }, 150)
-            }
-          } else {
-            if (shouldCollapse) {
-              return element.height(0)
+              if (shouldCollapse) {
+                return element.height(0)
+              }
             }
           }
-        }
-      )
-    },
-  }),
+        )
+      },
+    }
+  },
 ])

+ 242 - 238
services/web/frontend/js/ide/review-panel/directives/reviewPanelSorted.js

@@ -16,285 +16,289 @@
 import App from '../../../base'
 import { debugConsole } from '@/utils/debugging'
 
-export default App.directive('reviewPanelSorted', () => ({
-  link(scope, element, attrs) {
-    let previous_focused_entry_index = 0
+export default App.directive('reviewPanelSorted', function () {
+  return {
+    link(scope, element, attrs) {
+      let previous_focused_entry_index = 0
 
-    const applyEntryVisibility = function (entry) {
-      const visible = entry.scope.entry.screenPos
-      if (visible) {
-        entry.$wrapper_el.removeClass('rp-entry-hidden')
-      } else {
-        entry.$wrapper_el.addClass('rp-entry-hidden')
-      }
-      return visible
-    }
-
-    const layout = function (animate) {
-      let entry,
-        height,
-        i,
-        original_top,
-        overflowTop,
-        screenPosHeight,
-        OVERVIEW_TOGGLE_HEIGHT,
-        PADDING,
-        TOOLBAR_HEIGHT,
-        top
-      if (animate == null) {
-        animate = true
-      }
-      if (animate) {
-        element.removeClass('no-animate')
-      } else {
-        element.addClass('no-animate')
-      }
-      if (scope.ui.reviewPanelOpen) {
-        PADDING = 8
-        TOOLBAR_HEIGHT = 38
-        OVERVIEW_TOGGLE_HEIGHT = 57
-      } else {
-        PADDING = 4
-        TOOLBAR_HEIGHT = 4
-        OVERVIEW_TOGGLE_HEIGHT = 0
+      const applyEntryVisibility = function (entry) {
+        const visible = entry.scope.entry.screenPos
+        if (visible) {
+          entry.$wrapper_el.removeClass('rp-entry-hidden')
+        } else {
+          entry.$wrapper_el.addClass('rp-entry-hidden')
+        }
+        return visible
       }
 
-      const entries = []
-      for (const el of Array.from(element.find('.rp-entry-wrapper'))) {
-        entry = {
-          $wrapper_el: $(el),
-          $indicator_el: $(el).find('.rp-entry-indicator'),
-          $box_el: $(el).find('.rp-entry'),
-          $callout_el: $(el).find('.rp-entry-callout'),
-          scope: angular.element(el).scope(),
+      const layout = function (animate) {
+        let entry,
+          height,
+          i,
+          original_top,
+          overflowTop,
+          screenPosHeight,
+          OVERVIEW_TOGGLE_HEIGHT,
+          PADDING,
+          TOOLBAR_HEIGHT,
+          top
+        if (animate == null) {
+          animate = true
+        }
+        if (animate) {
+          element.removeClass('no-animate')
+        } else {
+          element.addClass('no-animate')
         }
         if (scope.ui.reviewPanelOpen) {
-          entry.$layout_el = entry.$box_el
+          PADDING = 8
+          TOOLBAR_HEIGHT = 38
+          OVERVIEW_TOGGLE_HEIGHT = 57
         } else {
-          entry.$layout_el = entry.$indicator_el
+          PADDING = 4
+          TOOLBAR_HEIGHT = 4
+          OVERVIEW_TOGGLE_HEIGHT = 0
         }
-        entry.height = entry.$layout_el.height() // Do all of our DOM reads first for performance, see http://wilsonpage.co.uk/preventing-layout-thrashing/
-        entries.push(entry)
-      }
-      entries.sort((a, b) => a.scope.entry.offset - b.scope.entry.offset)
 
-      if (entries.length === 0) {
-        return
-      }
+        const entries = []
+        for (const el of Array.from(element.find('.rp-entry-wrapper'))) {
+          entry = {
+            $wrapper_el: $(el),
+            $indicator_el: $(el).find('.rp-entry-indicator'),
+            $box_el: $(el).find('.rp-entry'),
+            $callout_el: $(el).find('.rp-entry-callout'),
+            scope: angular.element(el).scope(),
+          }
+          if (scope.ui.reviewPanelOpen) {
+            entry.$layout_el = entry.$box_el
+          } else {
+            entry.$layout_el = entry.$indicator_el
+          }
+          entry.height = entry.$layout_el.height() // Do all of our DOM reads first for performance, see http://wilsonpage.co.uk/preventing-layout-thrashing/
+          entries.push(entry)
+        }
+        entries.sort((a, b) => a.scope.entry.offset - b.scope.entry.offset)
 
-      const line_height = scope.reviewPanel.rendererData.lineHeight
+        if (entries.length === 0) {
+          return
+        }
 
-      let focused_entry_index = Math.min(
-        previous_focused_entry_index,
-        entries.length - 1
-      )
-      for (i = 0; i < entries.length; i++) {
-        entry = entries[i]
-        if (entry.scope.entry.focused) {
-          focused_entry_index = i
-          break
+        const line_height = scope.reviewPanel.rendererData.lineHeight
+
+        let focused_entry_index = Math.min(
+          previous_focused_entry_index,
+          entries.length - 1
+        )
+        for (i = 0; i < entries.length; i++) {
+          entry = entries[i]
+          if (entry.scope.entry.focused) {
+            focused_entry_index = i
+            break
+          }
         }
-      }
 
-      const focused_entry = entries[focused_entry_index]
-      const focusedEntryVisible = applyEntryVisibility(focused_entry)
+        const focused_entry = entries[focused_entry_index]
+        const focusedEntryVisible = applyEntryVisibility(focused_entry)
 
-      // If the focused entry has no screenPos, we can't position other entries
-      // relative to it, so we position all other entries as though the focused
-      // entry is at the top and they all follow it
-      const entries_after = focusedEntryVisible
-        ? entries.slice(focused_entry_index + 1)
-        : [...entries]
-      const entries_before = focusedEntryVisible
-        ? entries.slice(0, focused_entry_index)
-        : []
-      previous_focused_entry_index = focused_entry_index
+        // If the focused entry has no screenPos, we can't position other entries
+        // relative to it, so we position all other entries as though the focused
+        // entry is at the top and they all follow it
+        const entries_after = focusedEntryVisible
+          ? entries.slice(focused_entry_index + 1)
+          : [...entries]
+        const entries_before = focusedEntryVisible
+          ? entries.slice(0, focused_entry_index)
+          : []
+        previous_focused_entry_index = focused_entry_index
 
-      debugConsole.log('focused_entry_index', focused_entry_index)
+        debugConsole.log('focused_entry_index', focused_entry_index)
 
-      const positionLayoutEl = function (
-        $callout_el,
-        original_top,
-        top,
-        height
-      ) {
-        if (original_top <= top) {
-          $callout_el.removeClass('rp-entry-callout-inverted')
-          return $callout_el.css({
-            top: original_top + height - 1,
-            height: top - original_top,
-          })
-        } else {
-          $callout_el.addClass('rp-entry-callout-inverted')
-          return $callout_el.css({
-            top: top + height,
-            height: original_top - top,
-          })
+        const positionLayoutEl = function (
+          $callout_el,
+          original_top,
+          top,
+          height
+        ) {
+          if (original_top <= top) {
+            $callout_el.removeClass('rp-entry-callout-inverted')
+            return $callout_el.css({
+              top: original_top + height - 1,
+              height: top - original_top,
+            })
+          } else {
+            $callout_el.addClass('rp-entry-callout-inverted')
+            return $callout_el.css({
+              top: top + height,
+              height: original_top - top,
+            })
+          }
         }
-      }
-
-      // Put the focused entry as close to where it wants to be as possible
-      let focused_entry_top = 0
-      let previousBottom = 0
 
-      if (focusedEntryVisible) {
-        const focusedEntryScreenPos = focused_entry.scope.entry.screenPos
-        focused_entry_top = Math.max(focusedEntryScreenPos.y, TOOLBAR_HEIGHT)
-        focused_entry.$box_el.css({
-          top: focused_entry_top,
-          // The entry element is invisible by default, to avoid flickering when positioning for
-          // the first time. Here we make sure it becomes visible after having a "top" value.
-          visibility: 'visible',
-        })
-        focused_entry.$indicator_el.css({ top: focused_entry_top })
-        // use screenPos.height if set
-        screenPosHeight = focusedEntryScreenPos.height ?? line_height
-        positionLayoutEl(
-          focused_entry.$callout_el,
-          focusedEntryScreenPos.y,
-          focused_entry_top,
-          screenPosHeight
-        )
-        previousBottom = focused_entry_top + focused_entry.$layout_el.height()
-      }
+        // Put the focused entry as close to where it wants to be as possible
+        let focused_entry_top = 0
+        let previousBottom = 0
 
-      for (entry of entries_after) {
-        const entryVisible = applyEntryVisibility(entry)
-        if (entryVisible) {
-          original_top = entry.scope.entry.screenPos.y
-          // use screenPos.height if set
-          screenPosHeight = entry.scope.entry.screenPos.height ?? line_height
-          ;({ height } = entry)
-          top = Math.max(original_top, previousBottom + PADDING)
-          previousBottom = top + height
-          entry.$box_el.css({
-            top,
+        if (focusedEntryVisible) {
+          const focusedEntryScreenPos = focused_entry.scope.entry.screenPos
+          focused_entry_top = Math.max(focusedEntryScreenPos.y, TOOLBAR_HEIGHT)
+          focused_entry.$box_el.css({
+            top: focused_entry_top,
             // The entry element is invisible by default, to avoid flickering when positioning for
             // the first time. Here we make sure it becomes visible after having a "top" value.
             visibility: 'visible',
           })
-          entry.$indicator_el.css({ top })
-          positionLayoutEl(
-            entry.$callout_el,
-            original_top,
-            top,
-            screenPosHeight
-          )
-        }
-        debugConsole.log('ENTRY', { entry: entry.scope.entry, top })
-      }
-
-      let previousTop = focused_entry_top
-      entries_before.reverse() // Work through backwards, starting with the one just above
-      for (entry of entries_before) {
-        const entryVisible = applyEntryVisibility(entry)
-        if (entryVisible) {
-          original_top = entry.scope.entry.screenPos.y
+          focused_entry.$indicator_el.css({ top: focused_entry_top })
           // use screenPos.height if set
-          screenPosHeight = entry.scope.entry.screenPos.height ?? line_height
-          ;({ height } = entry)
-          const original_bottom = original_top + height
-          const bottom = Math.min(original_bottom, previousTop - PADDING)
-          top = bottom - height
-          previousTop = top
-          entry.$box_el.css({
-            top,
-            // The entry element is invisible by default, to avoid flickering when positioning for
-            // the first time. Here we make sure it becomes visible after having a "top" value.
-            visibility: 'visible',
-          })
-          entry.$indicator_el.css({ top })
+          screenPosHeight = focusedEntryScreenPos.height ?? line_height
           positionLayoutEl(
-            entry.$callout_el,
-            original_top,
-            top,
+            focused_entry.$callout_el,
+            focusedEntryScreenPos.y,
+            focused_entry_top,
             screenPosHeight
           )
+          previousBottom = focused_entry_top + focused_entry.$layout_el.height()
         }
-        debugConsole.log('ENTRY', { entry: entry.scope.entry, top })
-      }
 
-      const lastTop = top
-      if (lastTop < TOOLBAR_HEIGHT) {
-        overflowTop = -lastTop + TOOLBAR_HEIGHT
-      } else {
-        overflowTop = 0
-      }
+        for (entry of entries_after) {
+          const entryVisible = applyEntryVisibility(entry)
+          if (entryVisible) {
+            original_top = entry.scope.entry.screenPos.y
+            // use screenPos.height if set
+            screenPosHeight = entry.scope.entry.screenPos.height ?? line_height
+            ;({ height } = entry)
+            top = Math.max(original_top, previousBottom + PADDING)
+            previousBottom = top + height
+            entry.$box_el.css({
+              top,
+              // The entry element is invisible by default, to avoid flickering when positioning for
+              // the first time. Here we make sure it becomes visible after having a "top" value.
+              visibility: 'visible',
+            })
+            entry.$indicator_el.css({ top })
+            positionLayoutEl(
+              entry.$callout_el,
+              original_top,
+              top,
+              screenPosHeight
+            )
+          }
+          debugConsole.log('ENTRY', { entry: entry.scope.entry, top })
+        }
 
-      return scope.$emit('review-panel:sizes', {
-        overflowTop,
-        height: previousBottom + OVERVIEW_TOGGLE_HEIGHT,
-      })
-    }
+        let previousTop = focused_entry_top
+        entries_before.reverse() // Work through backwards, starting with the one just above
+        for (entry of entries_before) {
+          const entryVisible = applyEntryVisibility(entry)
+          if (entryVisible) {
+            original_top = entry.scope.entry.screenPos.y
+            // use screenPos.height if set
+            screenPosHeight = entry.scope.entry.screenPos.height ?? line_height
+            ;({ height } = entry)
+            const original_bottom = original_top + height
+            const bottom = Math.min(original_bottom, previousTop - PADDING)
+            top = bottom - height
+            previousTop = top
+            entry.$box_el.css({
+              top,
+              // The entry element is invisible by default, to avoid flickering when positioning for
+              // the first time. Here we make sure it becomes visible after having a "top" value.
+              visibility: 'visible',
+            })
+            entry.$indicator_el.css({ top })
+            positionLayoutEl(
+              entry.$callout_el,
+              original_top,
+              top,
+              screenPosHeight
+            )
+          }
+          debugConsole.log('ENTRY', { entry: entry.scope.entry, top })
+        }
 
-    scope.$applyAsync(() => layout())
+        const lastTop = top
+        if (lastTop < TOOLBAR_HEIGHT) {
+          overflowTop = -lastTop + TOOLBAR_HEIGHT
+        } else {
+          overflowTop = 0
+        }
 
-    scope.$on('review-panel:layout', function (e, animate) {
-      if (animate == null) {
-        animate = true
+        return scope.$emit('review-panel:sizes', {
+          overflowTop,
+          height: previousBottom + OVERVIEW_TOGGLE_HEIGHT,
+        })
       }
-      return scope.$applyAsync(() => layout(animate))
-    })
-
-    scope.$watch('reviewPanel.rendererData.lineHeight', () => layout())
 
-    // # Scroll lock with Ace
-    const scroller = element
-    const list = element.find('.rp-entry-list-inner')
+      scope.$applyAsync(() => layout())
 
-    // If we listen for scroll events in the review panel natively, then with a Mac trackpad
-    // the scroll is very smooth (natively done I'd guess), but we don't get polled regularly
-    // enough to keep Ace in step, and it noticeably lags. If instead, we borrow the manual
-    // mousewheel/trackpad scrolling behaviour from Ace, and turn mousewheel events into
-    // scroll events ourselves, then it makes the review panel slightly less smooth (barely)
-    // noticeable, but keeps it perfectly in step with Ace.
-    ace
-      .require('ace/lib/event')
-      .addMouseWheelListener(scroller[0], function (e) {
-        const deltaY = e.wheelY
-        const old_top = parseInt(list.css('top'))
-        const top = old_top - deltaY * 4
-        scrollAce(-top)
-        dispatchScrollEvent(deltaY * 4)
-        return e.preventDefault()
+      scope.$on('review-panel:layout', function (e, animate) {
+        if (animate == null) {
+          animate = true
+        }
+        return scope.$applyAsync(() => layout(animate))
       })
 
-    // We always scroll by telling Ace to scroll and then updating the
-    // review panel. This lets Ace manage the size of the scroller and
-    // when it overflows.
-    let ignoreNextAceEvent = false
+      scope.$watch('reviewPanel.rendererData.lineHeight', () => layout())
 
-    const scrollPanel = function (scrollTop, height) {
-      if (ignoreNextAceEvent) {
-        return (ignoreNextAceEvent = false)
-      } else {
-        list.height(height)
-        return list.css({ top: -scrollTop })
-      }
-    }
+      // # Scroll lock with Ace
+      const scroller = element
+      const list = element.find('.rp-entry-list-inner')
 
-    const scrollAce = scrollTop =>
-      scope.reviewPanelEventsBridge.emit('externalScroll', scrollTop)
+      // If we listen for scroll events in the review panel natively, then with a Mac trackpad
+      // the scroll is very smooth (natively done I'd guess), but we don't get polled regularly
+      // enough to keep Ace in step, and it noticeably lags. If instead, we borrow the manual
+      // mousewheel/trackpad scrolling behaviour from Ace, and turn mousewheel events into
+      // scroll events ourselves, then it makes the review panel slightly less smooth (barely)
+      // noticeable, but keeps it perfectly in step with Ace.
+      ace
+        .require('ace/lib/event')
+        .addMouseWheelListener(scroller[0], function (e) {
+          const deltaY = e.wheelY
+          const old_top = parseInt(list.css('top'))
+          const top = old_top - deltaY * 4
+          scrollAce(-top)
+          dispatchScrollEvent(deltaY * 4)
+          return e.preventDefault()
+        })
 
-    scope.reviewPanelEventsBridge.on('aceScroll', scrollPanel)
-    scope.$on('$destroy', () => scope.reviewPanelEventsBridge.off('aceScroll'))
+      // We always scroll by telling Ace to scroll and then updating the
+      // review panel. This lets Ace manage the size of the scroller and
+      // when it overflows.
+      let ignoreNextAceEvent = false
 
-    // receive the scroll position from the CodeMirror 6 track changes extension
-    window.addEventListener('editor:scroll', event => {
-      const { scrollTop, height, paddingTop } = event.detail
+      const scrollPanel = function (scrollTop, height) {
+        if (ignoreNextAceEvent) {
+          return (ignoreNextAceEvent = false)
+        } else {
+          list.height(height)
+          return list.css({ top: -scrollTop })
+        }
+      }
 
-      scrollPanel(scrollTop - paddingTop, height)
-    })
+      const scrollAce = scrollTop =>
+        scope.reviewPanelEventsBridge.emit('externalScroll', scrollTop)
 
-    // Send scroll delta to the CodeMirror 6 track changes extension
-    const dispatchScrollEvent = scrollTopDelta => {
-      window.dispatchEvent(
-        new CustomEvent('review-panel:event', {
-          detail: { type: 'scroll', payload: scrollTopDelta },
-        })
+      scope.reviewPanelEventsBridge.on('aceScroll', scrollPanel)
+      scope.$on('$destroy', () =>
+        scope.reviewPanelEventsBridge.off('aceScroll')
       )
-    }
 
-    return scope.reviewPanelEventsBridge.emit('refreshScrollPosition')
-  },
-}))
+      // receive the scroll position from the CodeMirror 6 track changes extension
+      window.addEventListener('editor:scroll', event => {
+        const { scrollTop, height, paddingTop } = event.detail
+
+        scrollPanel(scrollTop - paddingTop, height)
+      })
+
+      // Send scroll delta to the CodeMirror 6 track changes extension
+      const dispatchScrollEvent = scrollTopDelta => {
+        window.dispatchEvent(
+          new CustomEvent('review-panel:event', {
+            detail: { type: 'scroll', payload: scrollTopDelta },
+          })
+        )
+      }
+
+      return scope.reviewPanelEventsBridge.emit('refreshScrollPosition')
+    },
+  }
+})

+ 30 - 28
services/web/frontend/js/ide/review-panel/directives/reviewPanelToggle.js

@@ -12,40 +12,42 @@
  */
 import App from '../../../base'
 
-export default App.directive('reviewPanelToggle', () => ({
-  restrict: 'E',
-  scope: {
-    onToggle: '&',
-    ngModel: '=',
-    valWhenUndefined: '=?',
-    isDisabled: '=?',
-    onDisabledClick: '&?',
-    description: '@',
-  },
-  link(scope) {
-    if (scope.disabled == null) {
-      scope.disabled = false
-    }
-    scope.onChange = (...args) => scope.onToggle({ isOn: scope.localModel })
-    scope.handleClick = function () {
-      if (scope.disabled && scope.onDisabledClick != null) {
-        return scope.onDisabledClick()
+export default App.directive('reviewPanelToggle', function () {
+  return {
+    restrict: 'E',
+    scope: {
+      onToggle: '&',
+      ngModel: '=',
+      valWhenUndefined: '=?',
+      isDisabled: '=?',
+      onDisabledClick: '&?',
+      description: '@',
+    },
+    link(scope) {
+      if (scope.disabled == null) {
+        scope.disabled = false
       }
-    }
-    scope.localModel = scope.ngModel
-    return scope.$watch('ngModel', function (value) {
-      if (scope.valWhenUndefined != null && value == null) {
-        value = scope.valWhenUndefined
+      scope.onChange = (...args) => scope.onToggle({ isOn: scope.localModel })
+      scope.handleClick = function () {
+        if (scope.disabled && scope.onDisabledClick != null) {
+          return scope.onDisabledClick()
+        }
       }
-      return (scope.localModel = value)
-    })
-  },
+      scope.localModel = scope.ngModel
+      return scope.$watch('ngModel', function (value) {
+        if (scope.valWhenUndefined != null && value == null) {
+          value = scope.valWhenUndefined
+        }
+        return (scope.localModel = value)
+      })
+    },
 
-  template: `\
+    template: `\
 <fieldset class="input-switch" ng-click="handleClick();">
 <legend class="sr-only">{{description}}</legend>
 <input id="input-switch-{{$id}}" ng-disabled="isDisabled" type="checkbox" class="input-switch-hidden-input" ng-model="localModel" ng-change="onChange()" />
 <label for="input-switch-{{$id}}" class="input-switch-btn"></label>
 </fieldset>\
 `,
-}))
+  }
+})