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

[web] Add eslint rules for angularjs components

GitOrigin-RevId: 1343d584368faeb912f04c5879228bcbd07a042a
This commit is contained in:
Mathias Jakobsen 2023-10-17 09:36:46 +01:00 committed by Copybot
parent 4b6b9c3bef
commit 1a92e1b664
33 changed files with 1846 additions and 1719 deletions

View file

@ -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

View file

@ -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
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
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
})
const validateCaptchaIfEnabled = function (callback) {
scope.$applyAsync(() => {
scope[attrs.name].inflight = true
})
if (attrs.captchaActionName) {
validateCaptchaV3(attrs.captchaActionName)
}
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
if (attrs.captchaActionName) {
validateCaptchaV3(attrs.captchaActionName)
}
if (attrs.captcha != null) {
validateCaptcha(callback)
} else {
callback()
}
}
if (grecaptchaResponse) {
formData['g-recaptcha-response'] = grecaptchaResponse
}
const _submitRequest = function (grecaptchaResponse) {
const formData = {}
for (const data of Array.from(element.serializeArray())) {
formData[data.name] = data.value
}
// clear the response object which may be referenced downstream
Object.keys(response).forEach(field => delete response[field])
if (grecaptchaResponse) {
formData['g-recaptcha-response'] = grecaptchaResponse
}
// 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
// clear the response object which may be referenced downstream
Object.keys(response).forEach(field => delete response[field])
const onSuccessHandler = scope[attrs.onSuccess]
if (onSuccessHandler) {
onSuccessHandler(httpResponse)
return
}
// 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
if (data.redir) {
ga('send', 'event', formName, 'success')
return (window.location = data.redir)
} else if (data.message) {
response.message = data.message
const onSuccessHandler = scope[attrs.onSuccess]
if (onSuccessHandler) {
onSuccessHandler(httpResponse)
return
}
if (data.message.type === 'error') {
response.success = false
response.error = true
return ga('send', 'event', formName, 'failure', data.message)
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
}
let responseMessage
if (data.message && data.message.text) {
responseMessage = data.message.text
} else {
return ga('send', 'event', formName, 'success')
responseMessage = data.message
}
} 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
}
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',
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',
}
}
} 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)
})
}
const submit = () =>
validateCaptchaIfEnabled(response => _submitRequest(response))
const _httpRequestFn = (method = 'post') => {
const $HTTP_FNS = {
post: $http.post,
get: $http.get,
ga('send', 'event', formName, 'failure', data.message)
})
}
return $HTTP_FNS[method.toLowerCase()]
}
element.on('submit', function (e) {
e.preventDefault()
submit()
})
const submit = () =>
validateCaptchaIfEnabled(response => _submitRequest(response))
if (attrs.autoSubmit) {
submit()
}
},
}),
const _httpRequestFn = (method = 'post') => {
const $HTTP_FNS = {
post: $http.post,
get: $http.get,
}
return $HTTP_FNS[method.toLowerCase()]
}
element.on('submit', function (e) {
e.preventDefault()
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',
},
}
})

View file

@ -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)
}
})
}
},
}
},
])

View file

@ -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
})
},
}
})

View file

@ -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)
})
)
},
}
})

View file

@ -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 (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)
)
}
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)
}
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)
)
}
},
}),
},
}
},
])

View file

@ -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)
},
}
})

View file

@ -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 => ({
restrict: 'A',
link(scope, element, attrs) {
return scope.$watch(attrs.selectNameWhen, function (value) {
if (value) {
return $timeout(() => selectName(element))
}
})
},
}),
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', () => ({
restrict: 'A',
link(scope, element, attrs) {
return scope.$on(attrs.selectNameOn, () => selectName(element))
},
}))
App.directive('selectNameOn', function () {
return {
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) {

View file

@ -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 })
}
})
},
}
})

View file

@ -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()
}
})
)
})

View file

@ -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)
})
},
}
})

View file

@ -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)
)
return scope.$on(attrs.updateScrollBottomOn, () =>
$timeout(
() =>
element.scrollTop(
element[0].scrollHeight - element[0].clientHeight - scrollBottom
),
0
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
)
)
},
}
},
])

View file

@ -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')
this.clearSelectAllState = () => $scope.$broadcast('select-all:clear')
},
],
link(scope, element, attrs) {},
}
})
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
})
},
],
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('selectIndividual', function () {
return {
require: '^selectAllList',
scope: {
ngModel: '=',
},
link(scope, element, attrs, selectAllListController) {
let ignoreChanges = false
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
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: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) {
scope.$watch('ngModel', function (value) {
if (value != null && !ignoreChanges) {
return selectAllListController.clearSelectAllState()
}
})
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'))
},
}))
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)
})
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)
})
},
}
})
export default App.directive('selectRow', function () {
return {
scope: true,
link(scope, element, attrs) {
return element.on('click', e =>
scope.$broadcast('select-all:row-clicked')
)
},
}
})

View file

@ -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())
},
}
})

View file

@ -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()
}
}
}
)
},
}),
)
},
}
},
])

View file

@ -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.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),
},
}
// 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
if (attrs.spacingOpen != null) {
spacingOpen = parseInt(attrs.spacingOpen, 10)
} else {
spacingOpen = 7
}
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
}
}
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,
})
}
if (attrs.spacingClosed != null) {
spacingClosed = parseInt(attrs.spacingClosed, 10)
} else {
spacingClosed = 7
}
}
function repositionCustomToggler() {
if (customTogglerEl == null) {
return
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),
},
}
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)
}
}
// Someone moved the resizer
function onInternalResize() {
state = layout.readState()
scope.$broadcast(`layout:${name}:resize`, state)
repositionControls()
if (hasCustomToggler) {
repositionCustomToggler()
}
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)
// 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
}
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
}
}
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 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
)
}
}
ide.$timeout(() => {
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 (hasCustomToggler) {
state = layout.readState()
const customTogglerScope = scope.$new()
customTogglerScope.isOpen = true
customTogglerScope.isVisible = true
if (
(state[customTogglerPane] != null
? state[customTogglerPane].initClosed
: undefined) === true
) {
customTogglerScope.isOpen = false
function resetOpenStates() {
state = layout.readState()
if (attrs.openEast != null && state.east != null) {
const openEast = $parse(attrs.openEast)
return openEast.assign(scope, !state.east.initClosed)
}
}
customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
customTogglerScope.tooltipMsgWhenClosed = customTogglerMsgWhenClosed
customTogglerScope.tooltipPlacement =
customTogglerPane === 'east' ? 'left' : 'right'
customTogglerScope.handleClick = function () {
layout.toggle(customTogglerPane)
return repositionCustomToggler()
// Someone moved the resizer
function onInternalResize() {
state = layout.readState()
scope.$broadcast(`layout:${name}:resize`, state)
repositionControls()
if (hasCustomToggler) {
repositionCustomToggler()
}
return resetOpenStates()
}
customTogglerEl = $compile(`\
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
}
}
ide.$timeout(() => {
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 (hasCustomToggler) {
state = layout.readState()
const customTogglerScope = scope.$new()
customTogglerScope.isOpen = true
customTogglerScope.isVisible = true
if (
(state[customTogglerPane] != null
? state[customTogglerPane].initClosed
: undefined) === true
) {
customTogglerScope.isOpen = false
}
customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
customTogglerScope.tooltipMsgWhenClosed =
customTogglerMsgWhenClosed
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)
}
function onPaneOpen(pane) {
if (!hasCustomToggler || pane !== customTogglerPane) {
return
element.append(customTogglerEl)
}
return customTogglerEl
.scope()
.$applyAsync(() => (customTogglerEl.scope().isOpen = true))
}
function onPaneClose(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 = 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())
})
function onPaneClose(pane) {
if (!hasCustomToggler || pane !== customTogglerPane) {
return
}
return customTogglerEl
.scope()
.$applyAsync(() => (customTogglerEl.scope().isOpen = false))
}
// 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)
})
// 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.openEast != null) {
scope.$watch(attrs.openEast, function (value, oldValue) {
if (value != null && value !== oldValue) {
if (value) {
layout.open('east')
} else {
layout.close('east')
// 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
})
}
}
if (hasCustomToggler && customTogglerPane === 'east') {
repositionCustomToggler()
customTogglerEl.scope().$applyAsync(function () {
customTogglerEl.scope().isOpen = 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)
})
}
}
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)
},
}
},
}
},
])

View file

@ -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))
},
}
})

View file

@ -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
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)
}
})
}
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 disableResizer() {
if (layoutHandle.resizers && layoutHandle.resizers.south) {
layoutHandle.resizers.south.addClass(resizerDisabledClass)
}
}
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 (hiddenExternally) {
ide.$scope.$on(hiddenExternally, (e, open) => {
if (open) {
layoutHandle.show('south')
} else {
layoutHandle.hide('south')
}
})
}
if (resizeOn) {
ide.$scope.$on(resizeOn, () => {
ide.$timeout(() => {
layoutHandle.resizeAll()
if (name) {
const storageKey = `vertical-resizable:${name}:south-size`
storedSize = localStorage(storageKey)
$(window).unload(() => {
if (storedSize) {
localStorage(storageKey, storedSize)
}
})
})
}
}
if (maxSize) {
layoutOptions.south.maxSize = maxSize
}
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%',
},
}
if (minSize) {
layoutOptions.south.minSize = minSize
}
const toggledExternally =
attrs.verticalResizablePanesToggledExternallyOn
const hiddenExternally = attrs.verticalResizablePanesHiddenExternallyOn
const hiddenInitially = attrs.verticalResizablePanesHiddenInitially
const resizeOn = attrs.verticalResizablePanesResizeOn
const resizerDisabledClass = `${layoutOptions.south.resizerClass}-disabled`
if (defaultSize) {
layoutOptions.south.size = defaultSize
}
function enableResizer() {
if (layoutHandle.resizers && layoutHandle.resizers.south) {
layoutHandle.resizers.south.removeClass(resizerDisabledClass)
}
}
// 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
function disableResizer() {
if (layoutHandle.resizers && layoutHandle.resizers.south) {
layoutHandle.resizers.south.addClass(resizerDisabledClass)
}
}
const layoutHandle = element.layout(layoutOptions)
if (hiddenInitially === 'true') {
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 (hiddenExternally) {
ide.$scope.$on(hiddenExternally, (e, open) => {
if (open) {
layoutHandle.show('south')
} else {
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')
}
},
}
},
])

View file

@ -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',
}
})

View file

@ -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>\
`,
}))
}
})

View file

@ -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,
})
},
})
},
}
})

View file

@ -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 })
},
})
},
}
})

View file

@ -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' })
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 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()
}
})
},
}
})

View file

@ -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')) {
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 = ''
}
},
}))
},
}
})

View file

@ -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)
)
},
}
},
])

View file

@ -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())
},
}
})

View file

@ -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)
)
},
}
},
])

View file

@ -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.saveEdit = function (comment) {
comment.editing = false
return scope.onSaveEdit({ comment })
}
scope.confirmDelete = function (comment) {
comment.deleting = true
return setTimeout(() => scope.$emit('review-panel:layout'))
}
scope.cancelDelete = function (comment) {
comment.deleting = false
return setTimeout(() => scope.$emit('review-panel:layout'))
}
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)
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.saveEdit = function (comment) {
comment.editing = false
return scope.onSaveEdit({ comment })
}
scope.confirmDelete = function (comment) {
comment.deleting = true
return setTimeout(() => scope.$emit('review-panel:layout'))
}
scope.cancelDelete = function (comment) {
comment.deleting = false
return setTimeout(() => scope.$emit('review-panel:layout'))
}
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)
}
})
},
}
},
])

View file

@ -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)
)
},
}
})

View file

@ -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.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
))
}
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 (() => {
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]
)
scope.handleUnresolve = function (threadId) {
scope.onUnresolve({ threadId })
return (scope.resolvedComments = scope.resolvedComments.filter(
c => c.threadId !== threadId
))
}
resolvedComment.content = entry.content
resolvedComment.threadId = entry.thread_id
resolvedComment.entryId = entryId
resolvedComment.docId = docId
resolvedComment.docName = getDocNameById(docId)
scope.handleDelete = function (entryId, docId, threadId) {
scope.onDelete({ entryId, docId, threadId })
return (scope.resolvedComments = scope.resolvedComments.filter(
c => c.threadId !== threadId
))
}
result1.push(scope.resolvedComments.push(resolvedComment))
} else {
result1.push(undefined)
}
}
return result1
})()
)
const getDocNameById = function (docId) {
const doc = _.find(scope.docs, doc => doc.doc.id === docId)
if (doc != null) {
return doc.path
} else {
return null
}
return result
})()
})
},
}))
}
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]
)
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)
}
}
return result1
})()
)
}
return result
})()
})
},
}
})

View file

@ -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)
}
}
}
}
)
},
}),
)
},
}
},
])

View file

@ -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 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 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 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
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,
})
if (entries.length === 0) {
return
}
}
// Put the focused entry as close to where it wants to be as possible
let focused_entry_top = 0
let previousBottom = 0
const line_height = scope.reviewPanel.rendererData.lineHeight
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
let focused_entry_index = Math.min(
previous_focused_entry_index,
entries.length - 1
)
previousBottom = focused_entry_top + focused_entry.$layout_el.height()
}
for (i = 0; i < entries.length; i++) {
entry = entries[i]
if (entry.scope.entry.focused) {
focused_entry_index = i
break
}
}
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,
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
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,
})
}
}
// 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',
})
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.$applyAsync(() => layout(animate))
})
scope.$watch('reviewPanel.rendererData.lineHeight', () => layout())
// # Scroll lock with Ace
const scroller = element
const list = element.find('.rp-entry-list-inner')
// 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()
})
// 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
const scrollPanel = function (scrollTop, height) {
if (ignoreNextAceEvent) {
return (ignoreNextAceEvent = false)
} else {
list.height(height)
return list.css({ top: -scrollTop })
}
}
const scrollAce = scrollTop =>
scope.reviewPanelEventsBridge.emit('externalScroll', scrollTop)
scope.reviewPanelEventsBridge.on('aceScroll', scrollPanel)
scope.$on('$destroy', () => scope.reviewPanelEventsBridge.off('aceScroll'))
// 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.$emit('review-panel:sizes', {
overflowTop,
height: previousBottom + OVERVIEW_TOGGLE_HEIGHT,
})
)
}
}
return scope.reviewPanelEventsBridge.emit('refreshScrollPosition')
},
}))
scope.$applyAsync(() => layout())
scope.$on('review-panel:layout', function (e, animate) {
if (animate == null) {
animate = true
}
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')
// 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()
})
// 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
const scrollPanel = function (scrollTop, height) {
if (ignoreNextAceEvent) {
return (ignoreNextAceEvent = false)
} else {
list.height(height)
return list.css({ top: -scrollTop })
}
}
const scrollAce = scrollTop =>
scope.reviewPanelEventsBridge.emit('externalScroll', scrollTop)
scope.reviewPanelEventsBridge.on('aceScroll', scrollPanel)
scope.$on('$destroy', () =>
scope.reviewPanelEventsBridge.off('aceScroll')
)
// 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')
},
}
})

View file

@ -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>\
`,
}))
}
})