Scroll projector to a given line

This commit is contained in:
FinnStutzenstein 2018-04-18 13:24:30 +02:00 committed by Emanuel Schütze
parent 011ec56b88
commit 252ba02e86
7 changed files with 164 additions and 53 deletions

View File

@ -7,6 +7,9 @@ https://openslides.org/
Version 2.3 (unreleased) Version 2.3 (unreleased)
======================== ========================
Motions:
- New feature to schroll the projector to a specific line [#3748].
Version 2.2 (2018-06-06) Version 2.2 (2018-06-06)
======================== ========================

View File

@ -485,7 +485,7 @@ angular.module('OpenSlidesApp.core', [
deregisterCallbacks: function () { deregisterCallbacks: function () {
_.forEach(arguments, this.deregisterCallback); _.forEach(arguments, this.deregisterCallback);
}, },
notify: function(eventName, params, users, channels) { notify: function(eventName, params, users, channels, projectors) {
if (eventNameRegex.test(eventName)) { if (eventNameRegex.test(eventName)) {
if (!params || typeof params !== 'object') { if (!params || typeof params !== 'object') {
params = {}; params = {};
@ -497,6 +497,7 @@ angular.module('OpenSlidesApp.core', [
params: params, params: params,
users: users, users: users,
replyChannels: channels, replyChannels: channels,
projectors: projectors,
}; };
if (!operator.user) { if (!operator.user) {
if (!anonymousTrackId) { if (!anonymousTrackId) {

View File

@ -21,13 +21,40 @@ angular.module('OpenSlidesApp.motions.projector', [
'Motion', 'Motion',
'MotionChangeRecommendation', 'MotionChangeRecommendation',
'User', 'User',
function($scope, Motion, MotionChangeRecommendation, User) { 'Notify',
'ProjectorID',
function($scope, Motion, MotionChangeRecommendation, User, Notify, ProjectorID) {
// Attention! Each object that is used here has to be dealt on server side. // Attention! Each object that is used here has to be dealt on server side.
// Add it to the coresponding get_requirements method of the ProjectorElement // Add it to the coresponding get_requirements method of the ProjectorElement
// class. // class.
var id = $scope.element.id; var id = $scope.element.id;
$scope.mode = $scope.element.mode || 'original'; $scope.mode = $scope.element.mode || 'original';
var notifyNamePrefix = 'projector_' + ProjectorID() + '_motion_line_';
var callbackId = Notify.registerCallback(notifyNamePrefix + 'request', function (params) {
var line = params.params.line;
if (!line) {
return;
}
var scrollTop = null;
$('.line-number-' + line).each(function() {
var top = $(this).offset().top;
if (scrollTop === null || top < scrollTop) {
scrollTop = top;
}
});
if (scrollTop) {
scrollTop += (-$scope.scroll); // Add the (reversed) scrolling ontop
var scroll = Math.floor((scrollTop/250) - 0.2);
var channel = params.senderReplyChannelName;
Notify.notify(notifyNamePrefix + 'answer', {scroll: scroll}, null, [channel], null);
}
});
$scope.$on('$destroy', function () {
Notify.deregisterCallback(callbackId);
});
Motion.bindOne(id, $scope, 'motion'); Motion.bindOne(id, $scope, 'motion');
User.bindAll({}, $scope, 'users'); User.bindAll({}, $scope, 'users');

View File

@ -1415,6 +1415,7 @@ angular.module('OpenSlidesApp.motions.site', [
'MotionBlock', 'MotionBlock',
'MotionPdfExport', 'MotionPdfExport',
'PersonalNoteManager', 'PersonalNoteManager',
'Notify',
'WebpageTitle', 'WebpageTitle',
'EditingWarning', 'EditingWarning',
function($scope, $http, $timeout, $window, $filter, operator, ngDialog, gettextCatalog, function($scope, $http, $timeout, $window, $filter, operator, ngDialog, gettextCatalog,
@ -1422,7 +1423,7 @@ angular.module('OpenSlidesApp.motions.site', [
MotionStateAndRecommendationParser, MotionChangeRecommendation, Motion, MotionComment, MotionStateAndRecommendationParser, MotionChangeRecommendation, Motion, MotionComment,
Category, Mediafile, Tag, User, Workflow, Config, motionId, MotionInlineEditing, Category, Mediafile, Tag, User, Workflow, Config, motionId, MotionInlineEditing,
MotionCommentsInlineEditing, Editor, Projector, ProjectionDefault, MotionBlock, MotionCommentsInlineEditing, Editor, Projector, ProjectionDefault, MotionBlock,
MotionPdfExport, PersonalNoteManager, WebpageTitle, EditingWarning) { MotionPdfExport, PersonalNoteManager, Notify, WebpageTitle, EditingWarning) {
var motion = Motion.get(motionId); var motion = Motion.get(motionId);
Category.bindAll({}, $scope, 'categories'); Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles'); Mediafile.bindAll({}, $scope, 'mediafiles');
@ -1538,13 +1539,16 @@ angular.module('OpenSlidesApp.motions.site', [
Motion.bindOne(motion.parent_id, $scope, 'parent'); Motion.bindOne(motion.parent_id, $scope, 'parent');
} }
$scope.scrollToLine = 0;
$scope.highlight = 0; $scope.highlight = 0;
$scope.linesForProjector = false;
$scope.scrollToAndHighlight = function (line) { $scope.scrollToAndHighlight = function (line) {
$scope.scrollToLine = line;
$scope.highlight = line; $scope.highlight = line;
// The same line number can occur twice in diff view; we scroll to the first one in this case // The same line number can occur twice in diff view; we scroll to the first one in this case
var scrollTop = null; var scrollTop = null;
$(".line-number-" + line).each(function() { $('.line-number-' + line).each(function() {
var top = $(this).offset().top; var top = $(this).offset().top;
if (top > 0 && (scrollTop === null || top < scrollTop)) { if (top > 0 && (scrollTop === null || top < scrollTop)) {
scrollTop = top; scrollTop = top;
@ -1561,6 +1565,29 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.highlight = 0; $scope.highlight = 0;
}, 2000); }, 2000);
} }
$scope.scrollProjectorToLine(line);
};
$scope.scrollProjectorToLine = function (line) {
var projectorIds = $scope.motion.isProjected();
if (!$scope.linesForProjector || !line || !projectorIds.length) {
return;
}
var projectorId = projectorIds[0];
var notifyNamePrefix = 'projector_' + projectorId + '_motion_line_';
// register callback
var callbackId = Notify.registerCallback(notifyNamePrefix + 'answer', function (params) {
Notify.deregisterCallback(callbackId);
$http.post('/rest/core/projector/' + projectorId + '/set_scroll/', params.params.scroll);
});
// Query all projectors
Notify.notify(notifyNamePrefix + 'request', {line: line}, null, null, [projectorId]);
};
$scope.toggleLinesForProjector = function () {
$scope.linesForProjector = !$scope.linesForProjector;
$scope.scrollProjectorToLine($scope.scrollToLine);
}; };
// open edit dialog // open edit dialog

View File

@ -79,14 +79,20 @@
e-formclass="small-form" e-formclass="small-form"
onaftersave="scrollToAndHighlight(gotoLinenumber)"> onaftersave="scrollToAndHighlight(gotoLinenumber)">
</span> </span>
<button type="button" class="btn btn-sm btn-default" <div class="btn-group" ng-if="lineNumberMode != 'none'">
ng-click="lineNumberForm.$show()" <button type="button" class="btn btn-sm btn-default" ng-click="lineNumberForm.$show()">
ng-if="lineNumberMode != 'none'" <i class="fa fa-share"></i>
uib-tooltip="{{ 'Jump to a given line number' | translate }}" <translate>go</translate>
tooltip-placement="bottom"> </button>
<i class="fa fa-share"></i> <button type="button" class="btn btn-sm btn-default"
<translate>go</translate> ng-if="lineNumberMode != 'none' && motion.isProjected().length &&
</button> operator.hasPerms('core.can_manage_projector')"
ng-click="toggleLinesForProjector()"
uib-tooltip="{{ 'Show highlighted line also on projector.' | translate }}">
<i class="fa" ng-class="linesForProjector ? 'fa-check-square-o' : 'fa-square-o'"></i>&nbsp;
<i class="fa fa-video-camera"></i>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,12 +7,14 @@ from openslides.utils.autoupdate import (
ws_add_site, ws_add_site,
ws_disconnect_projector, ws_disconnect_projector,
ws_disconnect_site, ws_disconnect_site,
ws_receive_projector,
ws_receive_site, ws_receive_site,
) )
projector_routing = [ projector_routing = [
route("websocket.connect", ws_add_projector), route("websocket.connect", ws_add_projector),
route("websocket.disconnect", ws_disconnect_projector), route("websocket.disconnect", ws_disconnect_projector),
route("websocket.receive", ws_receive_projector),
] ]
site_routing = [ site_routing = [

View File

@ -123,9 +123,8 @@ def ws_disconnect_site(message: Any) -> None:
@channel_session_user @channel_session_user
def ws_receive_site(message: Any) -> None: def ws_receive_site(message: Any) -> None:
""" """
This function is called if a message from a client comes in. The message If we recieve something from the client we currently just interpret this
should be a list. Every item is broadcasted to the given users (or all as a notify message.
users if no user list is given) if it is a notify element.
The server adds the sender's user id (0 for anonymous) and reply The server adds the sender's user id (0 for anonymous) and reply
channel name so that a receiver client may reply to the sender or to all channel name so that a receiver client may reply to the sender or to all
@ -138,51 +137,76 @@ def ws_receive_site(message: Any) -> None:
pass pass
else: else:
if isinstance(incomming, list): if isinstance(incomming, list):
# Parse all items notify(
receivers_users = defaultdict(list) # type: Dict[int, List[Any]] incomming,
receivers_reply_channels = defaultdict(list) # type: Dict[str, List[Any]] senderReplyChannelName=message.reply_channel.name,
items_for_all = [] senderUserId=message.user.id or 0)
for item in incomming:
if item.get('collection') == 'notify':
use_receivers_dict = False
item['senderReplyChannelName'] = message.reply_channel.name
item['senderUserId'] = message.user.id or 0
# Force the params to be a dict
if not isinstance(item.get('params'), dict):
item['params'] = {}
users = item.get('users') def notify(incomming: List[Dict[str, Any]], **attributes: Any) -> None:
if isinstance(users, list): """
# Send this item only to all reply channels of some site users. The incomming should be a list of notify elements. Every item is broadcasted
for user_id in users: to the given users, channels or projectors. If none is given, the message is
receivers_users[user_id].append(item) send to each site client.
use_receivers_dict = True """
# Parse all items
receivers_users = defaultdict(list) # type: Dict[int, List[Any]]
receivers_projectors = defaultdict(list) # type: Dict[int, List[Any]]
receivers_reply_channels = defaultdict(list) # type: Dict[str, List[Any]]
items_for_all = []
for item in incomming:
if item.get('collection') == 'notify':
use_receivers_dict = False
reply_channels = item.get('replyChannels') for key, value in attributes.items():
if isinstance(reply_channels, list): item[key] = value
# Send this item only to some reply channels.
for reply_channel_name in reply_channels:
receivers_reply_channels[reply_channel_name].append(item)
use_receivers_dict = True
if not use_receivers_dict: # Force the params to be a dict
# Send this item to all reply channels. if not isinstance(item.get('params'), dict):
items_for_all.append(item) item['params'] = {}
# Send all items users = item.get('users')
for user_id, channel_names in websocket_user_cache.get_all().items(): if isinstance(users, list):
output = receivers_users[user_id] # Send this item only to all reply channels of some site users.
if len(output) > 0: for user_id in users:
for channel_name in channel_names: receivers_users[user_id].append(item)
send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) use_receivers_dict = True
for channel_name, output in receivers_reply_channels.items(): projectors = item.get('projectors')
if len(output) > 0: if isinstance(projectors, list):
send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) # Send this item only to all reply channels of some site users.
for projector_id in projectors:
receivers_projectors[projector_id].append(item)
use_receivers_dict = True
if len(items_for_all) > 0: reply_channels = item.get('replyChannels')
send_or_wait(Group('site').send, {'text': json.dumps(items_for_all)}) if isinstance(reply_channels, list):
# Send this item only to some reply channels.
for reply_channel_name in reply_channels:
receivers_reply_channels[reply_channel_name].append(item)
use_receivers_dict = True
if not use_receivers_dict:
# Send this item to all reply channels.
items_for_all.append(item)
# Send all items
for user_id, channel_names in websocket_user_cache.get_all().items():
output = receivers_users[user_id]
if len(output) > 0:
for channel_name in channel_names:
send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)})
for channel_name, output in receivers_reply_channels.items():
if len(output) > 0:
send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)})
for projector_id, output in receivers_projectors.items():
if len(output) > 0:
send_or_wait(Group('projector-{}'.format(projector_id)).send, {'text': json.dumps(output)})
if len(items_for_all) > 0:
send_or_wait(Group('site').send, {'text': json.dumps(items_for_all)})
@channel_session_user_from_http @channel_session_user_from_http
@ -247,6 +271,27 @@ def ws_disconnect_projector(message: Any, projector_id: int) -> None:
Group('projector-all').discard(message.reply_channel) Group('projector-all').discard(message.reply_channel)
def ws_receive_projector(message: Any, projector_id: int) -> None:
"""
If we recieve something from the client we currently just interpret this
as a notify message.
The server adds the sender's projector id and reply channel name so that
a receiver client may reply to the sender or to all sender's instances.
"""
try:
incomming = json.loads(message.content['text'])
except ValueError:
# Message content is invalid. Just do nothing.
pass
else:
if isinstance(incomming, list):
notify(
incomming,
senderReplyChannelName=message.reply_channel.name,
senderProjectorId=projector_id)
def send_data_projector(message: ChannelMessageFormat) -> None: def send_data_projector(message: ChannelMessageFormat) -> None:
""" """
Informs all projector clients about changed data. Informs all projector clients about changed data.