Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
536ba5e0
Commit
536ba5e0
authored
May 25, 2016
by
Nick Stenning
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #3285 from hypothesis/virtualize-thread-list
New threading 4/N - Virtualize the thread list
parents
d59f8070
d0876bbd
Changes
15
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
554 additions
and
69 deletions
+554
-69
annotation-viewer-controller.js
h/static/scripts/annotation-viewer-controller.js
+8
-0
app.js
h/static/scripts/app.js
+1
-0
annotation.js
h/static/scripts/directive/annotation.js
+11
-20
annotation-test.js
h/static/scripts/directive/test/annotation-test.js
+11
-11
root-thread.js
h/static/scripts/root-thread.js
+22
-16
stream-controller.coffee
h/static/scripts/stream-controller.coffee
+8
-0
annotation-viewer-controller-test.js
h/static/scripts/test/annotation-viewer-controller-test.js
+9
-3
stream-controller-test.coffee
h/static/scripts/test/stream-controller-test.coffee
+8
-3
virtual-thread-list-test.js
h/static/scripts/test/virtual-thread-list-test.js
+181
-0
widget-controller-test.js
h/static/scripts/test/widget-controller-test.js
+39
-10
virtual-thread-list.js
h/static/scripts/virtual-thread-list.js
+162
-0
widget-controller.js
h/static/scripts/widget-controller.js
+75
-2
thread.scss
h/static/styles/thread.scss
+8
-1
top-bar.scss
h/static/styles/top-bar.scss
+4
-0
viewer.html
h/templates/client/viewer.html
+7
-3
No files found.
h/static/scripts/annotation-viewer-controller.js
View file @
536ba5e0
...
...
@@ -17,6 +17,14 @@ function AnnotationViewerController (
$location
.
path
(
'/stream'
).
search
(
'q'
,
query
);
};
rootThread
.
on
(
'changed'
,
function
(
thread
)
{
$scope
.
virtualThreadList
=
{
visibleThreads
:
thread
.
children
,
offscreenUpperHeight
:
'0px'
,
offscreenLowerHeight
:
'0px'
,
};
});
$scope
.
rootThread
=
function
()
{
return
rootThread
.
thread
();
};
...
...
h/static/scripts/app.js
View file @
536ba5e0
...
...
@@ -183,6 +183,7 @@ module.exports = angular.module('h', [
.
value
(
'AnnotationUISync'
,
require
(
'./annotation-ui-sync'
))
.
value
(
'Discovery'
,
require
(
'./discovery'
))
.
value
(
'ExcerptOverflowMonitor'
,
require
(
'./directive/excerpt-overflow-monitor'
))
.
value
(
'VirtualThreadList'
,
require
(
'./virtual-thread-list'
))
.
value
(
'raven'
,
require
(
'./raven'
))
.
value
(
'settings'
,
settings
)
.
value
(
'time'
,
require
(
'./time'
))
...
...
h/static/scripts/directive/annotation.js
View file @
536ba5e0
...
...
@@ -268,15 +268,12 @@ function AnnotationController(
// are empty
$rootScope
.
$on
(
events
.
BEFORE_ANNOTATION_CREATED
,
deleteIfNewAndEmpty
);
// Call `onDestroy()` when th
is AnnotationController's scope is remov
ed.
// Call `onDestroy()` when th
e component is destroy
ed.
$scope
.
$on
(
'$destroy'
,
onDestroy
);
// Call `onGroupFocused()` whenever the currently-focused group changes.
$scope
.
$on
(
events
.
GROUP_FOCUSED
,
onGroupFocused
);
// Call `onUserChanged()` whenever the user logs in or out.
$scope
.
$on
(
events
.
USER_CHANGED
,
onUserChanged
);
// New annotations (just created locally by the client, rather then
// received from the server) have some fields missing. Add them.
domainModel
.
user
=
domainModel
.
user
||
session
.
state
.
userid
;
...
...
@@ -322,34 +319,28 @@ function AnnotationController(
}
function
onDestroy
()
{
// If the annotation component is destroyed whilst the annotation is being
// edited, persist temporary state so that we can restore it if the
// annotation editor is later recreated.
//
// The annotation component may be destroyed when switching accounts,
// when switching groups or when the component is scrolled off-screen.
if
(
vm
.
editing
())
{
saveToDrafts
(
drafts
,
domainModel
,
vm
);
}
if
(
vm
.
cancelTimestampRefresh
)
{
vm
.
cancelTimestampRefresh
();
}
}
function
onGroupFocused
()
{
if
(
vm
.
editing
())
{
saveToDrafts
(
drafts
,
domainModel
,
vm
);
}
// New annotations move to the new group, when a new group is focused.
if
(
isNew
(
domainModel
))
{
domainModel
.
group
=
groups
.
focused
().
id
;
}
}
function
onUserChanged
(
event
,
args
)
{
// If the user creates an annotation while signed out and then signs in
// we want those annotations to still be in the sidebar after sign in.
// So we need to save a draft of the annotation here on sign in because
// app.coffee / the routing code is about to destroy all the
// AnnotationController instances and only the ones that have saved drafts
// will be re-created.
if
(
vm
.
editing
()
&&
session
.
state
.
userid
)
{
saveToDrafts
(
drafts
,
domainModel
,
vm
);
}
}
/** Save this annotation if it's a new highlight.
*
* The highlight will be saved to the server if the user is logged in,
...
...
h/static/scripts/directive/test/annotation-test.js
View file @
536ba5e0
...
...
@@ -1271,7 +1271,7 @@ describe('annotation', function() {
});
});
describe
(
'
onGroupFocused()'
,
function
()
{
describe
(
'
when component is destroyed'
,
function
()
{
it
(
'if the annotation is being edited it updates drafts'
,
function
()
{
var
parts
=
createDirective
();
parts
.
controller
.
isPrivate
=
true
;
...
...
@@ -1283,25 +1283,25 @@ describe('annotation', function() {
});
fakeDrafts
.
update
=
sinon
.
stub
();
$rootScope
.
$broadcast
(
events
.
GROUP_FOCUSED
);
parts
.
scope
.
$broadcast
(
'$destroy'
);
assert
.
calledWith
(
fakeDrafts
.
update
,
parts
.
annotation
,
{
isPrivate
:
true
,
tags
:[],
text
:
'unsaved-text'
});
});
it
(
'if the annotation isn
\'
t being edited it doesn
\'
t update drafts'
,
function
()
{
var
parts
=
createDirective
();
parts
.
controller
.
isPrivate
=
true
;
fakeDrafts
.
update
=
sinon
.
stub
();
it
(
'if the annotation isn
\'
t being edited it doesn
\'
t update drafts'
,
function
()
{
var
parts
=
createDirective
();
parts
.
controller
.
isPrivate
=
true
;
fakeDrafts
.
update
=
sinon
.
stub
();
$rootScope
.
$broadcast
(
events
.
GROUP_FOCUSED
);
parts
.
scope
.
$broadcast
(
'$destroy'
);
assert
.
notCalled
(
fakeDrafts
.
update
);
}
);
assert
.
notCalled
(
fakeDrafts
.
update
);
});
}
);
describe
(
'onGroupFocused()'
,
function
()
{
it
(
'updates domainModel.group if the annotation is new'
,
function
()
{
var
annotation
=
fixtures
.
newAnnotation
();
annotation
.
group
=
'old-group-id'
;
...
...
h/static/scripts/root-thread.js
View file @
536ba5e0
'use strict'
;
var
EventEmitter
=
require
(
'tiny-emitter'
);
var
inherits
=
require
(
'inherits'
);
var
buildThread
=
require
(
'./build-thread'
);
var
events
=
require
(
'./events'
);
var
metadata
=
require
(
'./annotation-metadata'
);
...
...
@@ -37,7 +40,8 @@ var sortFns = {
* The root thread is then displayed by viewer.html
*/
// @ngInject
module
.
exports
=
function
(
$rootScope
,
annotationUI
,
searchFilter
,
viewFilter
)
{
function
RootThread
(
$rootScope
,
annotationUI
,
searchFilter
,
viewFilter
)
{
var
self
=
this
;
var
thread
;
/**
...
...
@@ -72,6 +76,7 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
sortCompareFn
:
sortFn
,
filterFn
:
filterFn
,
});
self
.
emit
(
'changed'
,
thread
);
}
rebuildRootThread
();
annotationUI
.
subscribe
(
rebuildRootThread
);
...
...
@@ -112,19 +117,20 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
annotationUI
.
removeAnnotations
(
annotations
);
});
return
{
/**
* Rebuild the conversation thread based on the currently loaded annotations
* and search/sort/filter settings.
*/
rebuild
:
rebuildRootThread
,
/**
* Returns the current root conversation thread.
* @return {Thread}
*/
thread
:
function
()
{
return
thread
;
},
/**
* Rebuild the conversation thread based on the currently loaded annotations
* and search/sort/filter settings.
*/
this
.
rebuild
=
rebuildRootThread
;
/**
* Returns the current root conversation thread.
* @return {Thread}
*/
this
.
thread
=
function
()
{
return
thread
;
};
};
}
inherits
(
RootThread
,
EventEmitter
);
module
.
exports
=
RootThread
;
h/static/scripts/stream-controller.coffee
View file @
536ba5e0
...
...
@@ -56,6 +56,14 @@ module.exports = class StreamController
$scope
.
forceVisible
=
(
id
)
->
annotationUI
.
setForceVisible
(
id
,
true
)
rootThread
.
on
(
'changed'
,
(
thread
)
->
$scope
.
virtualThreadList
=
{
visibleThreads
:
thread
.
children
,
offscreenUpperHeight
:
'0px'
,
offscreenLowerHeight
:
'0px'
,
};
);
$scope
.
isStream
=
true
$scope
.
sortOptions
=
[
'Newest'
,
'Oldest'
]
$scope
.
sort
.
name
=
'Newest'
...
...
h/static/scripts/test/annotation-viewer-controller-test.js
View file @
536ba5e0
'use strict'
;
var
angular
=
require
(
'angular'
);
var
EventEmitter
=
require
(
'tiny-emitter'
);
var
inherits
=
require
(
'inherits'
);
function
FakeRootThread
()
{
this
.
thread
=
sinon
.
stub
();
}
inherits
(
FakeRootThread
,
EventEmitter
);
describe
(
'AnnotationViewerController'
,
function
()
{
...
...
@@ -30,9 +37,7 @@ describe('AnnotationViewerController', function () {
search
:
{},
},
annotationUI
:
{},
rootThread
:
{
thread
:
sinon
.
stub
(),
},
rootThread
:
new
FakeRootThread
(),
streamer
:
opts
.
streamer
||
{
setConfig
:
function
()
{}
},
store
:
opts
.
store
||
{
AnnotationResource
:
{
get
:
sinon
.
spy
()
},
...
...
@@ -52,6 +57,7 @@ describe('AnnotationViewerController', function () {
},
annotationMapper
:
opts
.
annotationMapper
||
{
loadAnnotations
:
sinon
.
spy
()
},
};
inherits
(
locals
.
rootThread
,
EventEmitter
);
locals
.
ctrl
=
getControllerService
()(
'AnnotationViewerController'
,
locals
);
return
locals
;
...
...
h/static/scripts/test/stream-controller-test.coffee
View file @
536ba5e0
EventEmitter
=
require
(
'tiny-emitter'
)
inherits
=
require
(
'inherits'
)
{
module
,
inject
}
=
angular
.
mock
class
FakeRootThread
extends
EventEmitter
constructor
:
()
->
this
.
thread
=
sinon
.
stub
()
describe
'StreamController'
,
->
$controller
=
null
$scope
=
null
...
...
@@ -70,9 +77,7 @@ describe 'StreamController', ->
getFilter
:
sandbox
.
stub
()
}
fakeRootThread
=
{
thread
:
sandbox
.
stub
()
}
fakeRootThread
=
new
FakeRootThread
()
$provide
.
value
'annotationMapper'
,
fakeAnnotationMapper
$provide
.
value
'annotationUI'
,
fakeAnnotationUI
...
...
h/static/scripts/test/virtual-thread-list-test.js
0 → 100644
View file @
536ba5e0
'use strict'
;
var
proxyquire
=
require
(
'proxyquire'
);
var
VirtualThreadList
=
proxyquire
(
'../virtual-thread-list'
,
{
'lodash.debounce'
:
function
(
fn
)
{
// Make debounced functions execute immediately
return
fn
;
},
});
var
util
=
require
(
'./util'
);
var
unroll
=
util
.
unroll
;
describe
(
'VirtualThreadList'
,
function
()
{
var
lastState
;
var
threadList
;
var
fakeScope
;
var
fakeWindow
;
function
idRange
(
start
,
end
)
{
var
ary
=
[];
for
(
var
i
=
start
;
i
<=
end
;
i
++
)
{
ary
.
push
(
't'
+
i
.
toString
());
}
return
ary
;
}
function
threadIDs
(
threads
)
{
return
threads
.
map
(
function
(
thread
)
{
return
thread
.
id
;
});
}
function
generateRootThread
(
count
)
{
return
{
annotation
:
undefined
,
children
:
idRange
(
0
,
count
-
1
).
map
(
function
(
id
)
{
return
{
id
:
id
,
annotation
:
undefined
,
children
:
[]};
}),
};
}
beforeEach
(
function
()
{
fakeScope
=
{
$digest
:
sinon
.
stub
()};
fakeWindow
=
{
listeners
:
{},
addEventListener
:
function
(
event
,
listener
)
{
this
.
listeners
[
event
]
=
this
.
listeners
[
event
]
||
[];
this
.
listeners
[
event
].
push
(
listener
);
},
removeEventListener
:
function
(
event
,
listener
)
{
this
.
listeners
[
event
]
=
this
.
listeners
[
event
].
filter
(
function
(
fn
)
{
return
fn
!==
listener
;
});
},
trigger
:
function
(
event
)
{
this
.
listeners
[
event
].
forEach
(
function
(
cb
)
{
cb
();
});
},
innerHeight
:
100
,
pageYOffset
:
0
,
};
var
rootThread
=
{
annotation
:
undefined
,
children
:
[]};
threadList
=
new
VirtualThreadList
(
fakeScope
,
fakeWindow
,
rootThread
);
threadList
.
on
(
'changed'
,
function
(
state
)
{
lastState
=
state
;
});
});
unroll
(
'generates expected state when #when'
,
function
(
testCase
)
{
var
thread
=
generateRootThread
(
testCase
.
threads
);
fakeWindow
.
pageYOffset
=
testCase
.
scrollOffset
;
fakeWindow
.
innerHeight
=
testCase
.
windowHeight
;
threadList
.
setRootThread
(
thread
);
var
visibleIDs
=
threadIDs
(
lastState
.
visibleThreads
);
assert
.
deepEqual
(
visibleIDs
,
testCase
.
expectedVisibleThreads
);
assert
.
equal
(
lastState
.
offscreenUpperHeight
,
testCase
.
expectedHeightAbove
);
assert
.
equal
(
lastState
.
offscreenLowerHeight
,
testCase
.
expectedHeightBelow
);
},[{
when
:
'window is scrolled to top of list'
,
threads
:
100
,
scrollOffset
:
0
,
windowHeight
:
300
,
expectedVisibleThreads
:
idRange
(
0
,
5
),
expectedHeightAbove
:
0
,
expectedHeightBelow
:
18800
,
},{
when
:
'window is scrolled to middle of list'
,
threads
:
100
,
scrollOffset
:
2000
,
windowHeight
:
300
,
expectedVisibleThreads
:
idRange
(
5
,
15
),
expectedHeightAbove
:
1000
,
expectedHeightBelow
:
16800
,
},{
when
:
'window is scrolled to bottom of list'
,
threads
:
100
,
scrollOffset
:
18800
,
windowHeight
:
300
,
expectedVisibleThreads
:
idRange
(
89
,
99
),
expectedHeightAbove
:
17800
,
expectedHeightBelow
:
0
,
}]);
unroll
(
'recalculates when a window.#event occurs'
,
function
(
testCase
)
{
lastState
=
null
;
fakeWindow
.
trigger
(
testCase
.
event
);
assert
.
ok
(
lastState
);
},[{
event
:
'resize'
,
},{
event
:
'scroll'
,
}]);
it
(
'recalculates when root thread changes'
,
function
()
{
threadList
.
setRootThread
({
annotation
:
undefined
,
children
:
[]});
assert
.
ok
(
lastState
);
});
describe
(
'#setThreadHeight'
,
function
()
{
unroll
(
'affects visible threads'
,
function
(
testCase
)
{
var
thread
=
generateRootThread
(
10
);
fakeWindow
.
innerHeight
=
500
;
fakeWindow
.
pageYOffset
=
0
;
idRange
(
0
,
10
).
forEach
(
function
(
id
)
{
threadList
.
setThreadHeight
(
id
,
testCase
.
threadHeight
);
});
threadList
.
setRootThread
(
thread
);
assert
.
deepEqual
(
threadIDs
(
lastState
.
visibleThreads
),
testCase
.
expectedVisibleThreads
);
},[{
threadHeight
:
1000
,
expectedVisibleThreads
:
idRange
(
0
,
1
),
},{
threadHeight
:
300
,
expectedVisibleThreads
:
idRange
(
0
,
4
),
}]);
});
describe
(
'#detach'
,
function
()
{
unroll
(
'stops listening to window.#event events'
,
function
(
testCase
)
{
threadList
.
detach
();
lastState
=
null
;
fakeWindow
.
trigger
(
testCase
.
event
);
assert
.
isNull
(
lastState
);
},[{
event
:
'resize'
,
},{
event
:
'scroll'
,
}]);
});
describe
(
'#yOffsetOf'
,
function
()
{
unroll
(
'returns #offset as the Y offset of the #nth thread'
,
function
(
testCase
)
{
var
thread
=
generateRootThread
(
10
);
threadList
.
setRootThread
(
thread
);
idRange
(
0
,
10
).
forEach
(
function
(
id
)
{
threadList
.
setThreadHeight
(
id
,
100
);
});
var
id
=
idRange
(
testCase
.
index
,
testCase
.
index
)[
0
];
assert
.
equal
(
threadList
.
yOffsetOf
(
id
),
testCase
.
offset
);
},
[{
nth
:
'first'
,
index
:
0
,
offset
:
0
,
},{
nth
:
'second'
,
index
:
1
,
offset
:
100
,
},{
nth
:
'last'
,
index
:
9
,
offset
:
900
,
}]);
});
});
h/static/scripts/test/widget-controller-test.js
View file @
536ba5e0
...
...
@@ -26,6 +26,23 @@ function FakeSearchClient(resource, opts) {
}
inherits
(
FakeSearchClient
,
EventEmitter
);
function
FakeRootThread
()
{
this
.
thread
=
sinon
.
stub
().
returns
({
totalChildren
:
0
,
});
}
inherits
(
FakeRootThread
,
EventEmitter
);
function
FakeVirtualThreadList
()
{
this
.
setRootThread
=
sinon
.
stub
();
this
.
setThreadHeight
=
sinon
.
stub
();
this
.
detach
=
sinon
.
stub
();
this
.
yOffsetOf
=
function
()
{
return
100
;
};
}
inherits
(
FakeVirtualThreadList
,
EventEmitter
);
describe
(
'WidgetController'
,
function
()
{
var
$rootScope
;
var
$scope
;
...
...
@@ -88,13 +105,7 @@ describe('WidgetController', function () {
focus
:
sinon
.
stub
(),
};
fakeRootThread
=
{
thread
:
sinon
.
stub
().
returns
({
totalChildren
:
0
,
}),
setSearchQuery
:
sinon
.
stub
(),
sortBy
:
sinon
.
stub
(),
};
fakeRootThread
=
new
FakeRootThread
();
fakeSettings
=
{
annotations
:
'test'
,
...
...
@@ -104,6 +115,7 @@ describe('WidgetController', function () {
SearchResource
:
{},
};
$provide
.
value
(
'VirtualThreadList'
,
FakeVirtualThreadList
);
$provide
.
value
(
'annotationMapper'
,
fakeAnnotationMapper
);
$provide
.
value
(
'annotationUI'
,
annotationUI
);
$provide
.
value
(
'crossframe'
,
fakeCrossFrame
);
...
...
@@ -273,6 +285,21 @@ describe('WidgetController', function () {
});
describe
(
'when a new annotation is created'
,
function
()
{
var
windowScroll
;
var
cardListTopEl
;
beforeEach
(
function
()
{
$scope
.
clearSelection
=
sinon
.
stub
();
windowScroll
=
sinon
.
stub
(
window
,
'scroll'
);
cardListTopEl
=
$
(
'<div class="js-thread-list-top"></div>'
);
cardListTopEl
.
appendTo
(
document
.
body
);
});
afterEach
(
function
()
{
windowScroll
.
restore
();
cardListTopEl
.
remove
();
});
/**
* It should clear any selection that exists in the sidebar before
* creating a new annotation. Otherwise the new annotation with its
...
...
@@ -280,24 +307,26 @@ describe('WidgetController', function () {
* not part of the selection.
*/
it
(
'clears the selection'
,
function
()
{
$scope
.
clearSelection
=
sinon
.
stub
();
$rootScope
.
$emit
(
'beforeAnnotationCreated'
,
{});
assert
.
called
(
$scope
.
clearSelection
);
});
it
(
'does not clear the selection if the new annotation is a highlight'
,
function
()
{
$scope
.
clearSelection
=
sinon
.
stub
();
$rootScope
.
$emit
(
'beforeAnnotationCreated'
,
{
$highlight
:
true
});
assert
.
notCalled
(
$scope
.
clearSelection
);
});
it
(
'does not clear the selection if the new annotation is a reply'
,
function
()
{
$scope
.
clearSelection
=
sinon
.
stub
();
$rootScope
.
$emit
(
'beforeAnnotationCreated'
,
{
references
:
[
'parent-id'
]
});
assert
.
notCalled
(
$scope
.
clearSelection
);
});
it
(
'scrolls the viewport to the new annotation'
,
function
()
{
$rootScope
.
$emit
(
'beforeAnnotationCreated'
,
{
$
$tag
:
'123'
});
assert
.
called
(
windowScroll
);
});
});
describe
(
'direct linking messages'
,
function
()
{
...
...
h/static/scripts/virtual-thread-list.js
0 → 100644
View file @
536ba5e0
'use strict'
;
var
EventEmitter
=
require
(
'tiny-emitter'
);
var
debounce
=
require
(
'lodash.debounce'
);
var
inherits
=
require
(
'inherits'
);
/**
* VirtualThreadList is a helper for virtualizing the annotation thread list.
*
* 'Virtualizing' the thread list improves UI performance by only creating
* annotation cards for annotations which are either in or near the viewport.
*
* Reducing the number of annotation cards that are actually created optimizes
* the initial population of the list, since annotation cards are big components
* that are expensive to create and consume a lot of memory. For Angular
* applications this also helps significantly with UI responsiveness by limiting
* the number of watchers (functions created by template expressions or
* '$scope.$watch' calls) that have to be run on every '$scope.$digest()' cycle.
*
* @param {Window} container - The Window displaying the list of annotation threads.
* @param {Thread} rootThread - The initial Thread object for the top-level
* threads.
*/
function
VirtualThreadList
(
$scope
,
window_
,
rootThread
)
{
var
self
=
this
;
this
.
_rootThread
=
rootThread
;
// Cache of thread ID -> last-seen height
this
.
_heights
=
{};
this
.
window
=
window_
;
var
debouncedUpdate
=
debounce
(
function
()
{
self
.
_updateVisibleThreads
();
$scope
.
$digest
();
},
20
);
this
.
window
.
addEventListener
(
'scroll'
,
debouncedUpdate
);
this
.
window
.
addEventListener
(
'resize'
,
debouncedUpdate
);
this
.
_detach
=
function
()
{
this
.
window
.
removeEventListener
(
'scroll'
,
debouncedUpdate
);
this
.
window
.
removeEventListener
(
'resize'
,
debouncedUpdate
);
};
}
inherits
(
VirtualThreadList
,
EventEmitter
);
/**
* Detach event listeners and clear any pending timeouts.
*
* This should be invoked when the UI view presenting the virtual thread list
* is torn down.
*/
VirtualThreadList
.
prototype
.
detach
=
function
()
{
this
.
_detach
();
};
/**
* Sets the root thread containing all conversations matching the current
* filters.
*
* This should be called with the current Thread object whenever the set of
* matching annotations changes.
*/
VirtualThreadList
.
prototype
.
setRootThread
=
function
(
thread
)
{
this
.
_rootThread
=
thread
;
this
.
_updateVisibleThreads
();
};
/**
* Sets the actual height for a thread.
*
* When calculating the amount of space required for offscreen threads,
* the actual or 'last-seen' height is used if known. Otherwise an estimate
* is used.
*
* @param {string} id - The annotation ID or $$tag
* @param {number?} height - The height of the annotation or undefined to
* revert to the default height for this thread.
*/
VirtualThreadList
.
prototype
.
setThreadHeight
=
function
(
id
,
height
)
{
this
.
_heights
[
id
]
=
height
;
};
VirtualThreadList
.
prototype
.
_height
=
function
(
id
)
{
// Default guess of the height required for a threads that have not been
// measured
var
DEFAULT_HEIGHT
=
200
;
return
this
.
_heights
[
id
]
||
DEFAULT_HEIGHT
;
};
/** Return the vertical offset of an annotation card from the top of the list. */
VirtualThreadList
.
prototype
.
yOffsetOf
=
function
(
id
)
{
var
self
=
this
;
var
allThreads
=
this
.
_rootThread
.
children
;
var
matchIndex
=
allThreads
.
findIndex
(
function
(
thread
)
{
return
thread
.
id
===
id
;
});
if
(
matchIndex
===
-
1
)
{
return
0
;
}
return
allThreads
.
slice
(
0
,
matchIndex
).
reduce
(
function
(
offset
,
thread
)
{
return
offset
+
self
.
_height
(
thread
.
id
);
},
0
);
};
/**
* Recalculates the set of visible threads and estimates of the amount of space
* required for offscreen threads above and below the viewport.
*
* Emits a `changed` event with the recalculated set of visible threads.
*/
VirtualThreadList
.
prototype
.
_updateVisibleThreads
=
function
()
{
// Space above the viewport in pixels which should be considered 'on-screen'
// when calculating the set of visible threads
var
MARGIN_ABOVE
=
800
;
// Same as MARGIN_ABOVE but for the space below the viewport
var
MARGIN_BELOW
=
800
;
// Estimated height in pixels of annotation cards which are below the
// viewport and not actually created. This is used to create an empty spacer
// element below visible cards in order to give the list's scrollbar the
// correct dimensions.
var
offscreenLowerHeight
=
0
;
// Same as offscreenLowerHeight but for cards above the viewport.
var
offscreenUpperHeight
=
0
;
// List of annotations which are in or near the viewport and need to
// actually be created.
var
visibleThreads
=
[];
var
allThreads
=
this
.
_rootThread
.
children
;
var
visibleHeight
=
this
.
window
.
innerHeight
;
var
usedHeight
=
0
;
var
thread
;
for
(
var
i
=
0
;
i
<
allThreads
.
length
;
i
++
)
{
thread
=
allThreads
[
i
];
var
threadHeight
=
this
.
_height
(
thread
.
id
);
if
(
usedHeight
+
threadHeight
<
this
.
window
.
pageYOffset
-
MARGIN_ABOVE
)
{
// Thread is above viewport
offscreenUpperHeight
+=
threadHeight
;
}
else
if
(
usedHeight
<
this
.
window
.
pageYOffset
+
visibleHeight
+
MARGIN_BELOW
)
{
// Thread is either in or close to the viewport
visibleThreads
.
push
(
allThreads
[
i
]);
}
else
{
// Thread is below viewport
offscreenLowerHeight
+=
threadHeight
;
}
usedHeight
+=
threadHeight
;
}
this
.
emit
(
'changed'
,
{
offscreenLowerHeight
:
offscreenLowerHeight
,
offscreenUpperHeight
:
offscreenUpperHeight
,
visibleThreads
:
visibleThreads
,
});
};
module
.
exports
=
VirtualThreadList
;
h/static/scripts/widget-controller.js
View file @
536ba5e0
'use strict'
;
var
SearchClient
=
require
(
'./search-client'
);
var
events
=
require
(
'./events'
);
var
memoize
=
require
(
'./util/memoize'
);
var
SearchClient
=
require
(
'./search-clien
t'
);
var
scopeTimeout
=
require
(
'./util/scope-timeou
t'
);
function
firstKey
(
object
)
{
for
(
var
k
in
object
)
{
...
...
@@ -32,8 +33,49 @@ function groupIDFromSelection(selection, results) {
// @ngInject
module
.
exports
=
function
WidgetController
(
$scope
,
$rootScope
,
annotationUI
,
crossframe
,
annotationMapper
,
drafts
,
groups
,
rootThread
,
settings
,
streamer
,
streamFilter
,
store
drafts
,
groups
,
rootThread
,
settings
,
streamer
,
streamFilter
,
store
,
VirtualThreadList
)
{
function
getThreadHeight
(
id
)
{
var
threadElement
=
document
.
getElementById
(
id
);
if
(
!
threadElement
)
{
return
;
}
// Get the height of the element inside the border-box, excluding
// top and bottom margins.
var
elementHeight
=
threadElement
.
getBoundingClientRect
().
height
;
var
style
=
window
.
getComputedStyle
(
threadElement
);
// Get the bottom margin of the element. style.margin{Side} will return
// values of the form 'Npx', from which we extract 'N'.
var
marginHeight
=
parseFloat
(
style
.
marginTop
)
+
parseFloat
(
style
.
marginBottom
);
return
elementHeight
+
marginHeight
;
}
var
visibleThreads
=
new
VirtualThreadList
(
$scope
,
window
,
rootThread
.
thread
());
visibleThreads
.
on
(
'changed'
,
function
(
state
)
{
$scope
.
virtualThreadList
=
{
visibleThreads
:
state
.
visibleThreads
,
offscreenUpperHeight
:
state
.
offscreenUpperHeight
+
'px'
,
offscreenLowerHeight
:
state
.
offscreenLowerHeight
+
'px'
,
};
scopeTimeout
(
$scope
,
function
()
{
state
.
visibleThreads
.
forEach
(
function
(
thread
)
{
visibleThreads
.
setThreadHeight
(
thread
.
id
,
getThreadHeight
(
thread
.
id
));
});
},
50
);
});
rootThread
.
on
(
'changed'
,
function
(
thread
)
{
visibleThreads
.
setRootThread
(
thread
);
});
$scope
.
$on
(
'$destroy'
,
function
()
{
visibleThreads
.
detach
();
});
$scope
.
sortOptions
=
[
'Newest'
,
'Oldest'
,
'Location'
];
...
...
@@ -291,10 +333,41 @@ module.exports = function WidgetController(
return
rootThread
.
thread
().
totalChildren
;
};
/**
* Return the offset between the top of the window and the top of the
* first annotation card.
*/
function
cardListYOffset
()
{
var
cardListTopEl
=
document
.
querySelector
(
'.js-thread-list-top'
);
return
cardListTopEl
.
getBoundingClientRect
().
top
+
window
.
pageYOffset
;
}
/** Scroll the annotation with a given ID or $$tag into view. */
function
scrollIntoView
(
id
)
{
var
estimatedYOffset
=
visibleThreads
.
yOffsetOf
(
id
);
var
estimatedPos
=
estimatedYOffset
-
cardListYOffset
();
window
.
scroll
(
0
,
estimatedPos
);
// As a result of scrolling the sidebar, the heights of some of the cards
// above `id` might change because the initial estimate will be replaced by
// the actual known height after a card is rendered.
//
// So we wait briefly after the view is scrolled then check whether the
// estimated Y offset changed and if so, trigger scrolling again.
scopeTimeout
(
$scope
,
function
()
{
var
newYOffset
=
visibleThreads
.
yOffsetOf
(
id
);
if
(
newYOffset
!==
estimatedYOffset
)
{
scrollIntoView
(
id
);
}
},
200
);
}
$rootScope
.
$on
(
events
.
BEFORE_ANNOTATION_CREATED
,
function
(
event
,
data
)
{
if
(
data
.
$highlight
||
(
data
.
references
&&
data
.
references
.
length
>
0
))
{
return
;
}
$scope
.
clearSelection
();
scrollIntoView
(
data
.
$$tag
);
});
};
h/static/styles/thread.scss
View file @
536ba5e0
$thread-padding
:
$annotation-card-left-padding
;
.
stream
-list
{
.
thread
-list
{
&
>
*
{
margin-bottom
:
.72em
;
}
...
...
@@ -10,6 +10,13 @@ $thread-padding: $annotation-card-left-padding;
}
}
.thread-list__spacer
{
// This is a hidden element which is used to reserve space for off-screen
// threads, so it should not occupy any space other than that set via its
// 'height' inline style property.
margin
:
0
;
}
.annotation-unavailable-message
{
display
:
flex
;
flex-direction
:
column
;
...
...
h/static/styles/top-bar.scss
View file @
536ba5e0
...
...
@@ -11,6 +11,10 @@
right
:
0
;
top
:
0
;
z-index
:
5
;
// Force top-bar onto a new compositor layer so that it does not judder when
// the window is scrolled.
transform
:
translate3d
(
0
,
0
,
0
);
}
.top-bar__inner
{
...
...
h/templates/client/viewer.html
View file @
536ba5e0
...
...
@@ -2,7 +2,7 @@
(See gh2642 for rationale for 'ng-show="true"')
-->
<ul
class=
"
stream
-list ng-hide"
<ul
class=
"
thread
-list ng-hide"
ng-show=
"true"
window-scroll=
"loadMore(20)"
>
<search-status-bar
...
...
@@ -30,13 +30,15 @@
You do not have permission to see this annotation
</p>
</li>
<li
id=
"{{vm.id}}"
<li
class=
"thread-list__spacer js-thread-list-top"
ng-style=
"{height: virtualThreadList.offscreenUpperHeight}"
></li>
<li
id=
"{{child.id}}"
class=
"annotation-card thread"
ng-class=
"{'js-hover': hasFocus(child.annotation)}"
ng-mouseenter=
"focus(child.annotation)"
ng-click=
"scrollTo(child.annotation)"
ng-mouseleave=
"focus()"
ng-repeat=
"child in
rootThread().children
track by child.id"
>
ng-repeat=
"child in
virtualThreadList.visibleThreads
track by child.id"
>
<annotation-thread
thread=
"child"
show-document-info=
"::!isSidebar"
...
...
@@ -44,6 +46,8 @@
on-force-visible=
"forceVisible(thread)"
>
</annotation-thread>
</li>
<li
class=
"thread-list__spacer"
ng-style=
"{height: virtualThreadList.offscreenLowerHeight}"
></li>
<loggedout-message
ng-if=
"isSidebar && shouldShowLoggedOutMessage()"
on-login=
"login()"
ng-cloak
>
</loggedout-message>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment