Title: [291063] trunk/Tools
Revision
291063
Author
[email protected]
Date
2022-03-09 13:05:03 -0800 (Wed, 09 Mar 2022)

Log Message

[results.webkit.org]Add selection box for results database.
https://bugs.webkit.org/show_bug.cgi?id=233958

Reviewed by Jonathan Bedard.

Allow user can drag a selection box to select multiple dots.
Allow user to use cmd + click to select multiple dots.
Allow user to use shift + click to select a row of dots.

* Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/timeline.js:
(TimelineFromEndpoint):
(TimelineFromEndpoint.prototype.update):
(TimelineFromEndpoint.prototype.getTestResultStatus):
(TimelineFromEndpoint.prototype.getTestResultUrl):
(TimelineFromEndpoint.prototype.render):
* Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/tooltip.js:
* Scripts/libraries/resultsdbpy/resultsdbpy/view/static/library/js/components/TimelineComponents.js:
(pointRectCollisionDetect): Detact if a point and a rect have a insertion.
(rectRectCollisionDetect): Detact if two rects have a insertion.
(XScrollableCanvasProvider): Add selection box.
(xScrollStreamRenderFactory):
(Timeline.CanvasSeriesComponent): Add selection box , cmd + click and shift + click.
(prototype.ExpandableSeriesComponent):
(prototype.Timeline.CanvasContainer):

Canonical link: https://commits.webkit.org/248236@main

Modified Paths

Added Paths

Diff

Modified: trunk/Tools/ChangeLog (291062 => 291063)


--- trunk/Tools/ChangeLog	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/ChangeLog	2022-03-09 21:05:03 UTC (rev 291063)
@@ -1,3 +1,30 @@
+2021-12-07  Zhifei Fang  <[email protected]>
+
+        [results.webkit.org]Add selection box for results database.
+        https://bugs.webkit.org/show_bug.cgi?id=233958
+
+        Reviewed by Jonathan Bedard.
+
+        Allow user can drag a selection box to select multiple dots.
+        Allow user to use cmd + click to select multiple dots. 
+        Allow user to use shift + click to select a row of dots.
+
+        * Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/timeline.js:
+        (TimelineFromEndpoint):
+        (TimelineFromEndpoint.prototype.update):
+        (TimelineFromEndpoint.prototype.getTestResultStatus):
+        (TimelineFromEndpoint.prototype.getTestResultUrl):
+        (TimelineFromEndpoint.prototype.render):
+        * Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/tooltip.js:
+        * Scripts/libraries/resultsdbpy/resultsdbpy/view/static/library/js/components/TimelineComponents.js:
+        (pointRectCollisionDetect): Detact if a point and a rect have a insertion.
+        (rectRectCollisionDetect): Detact if two rects have a insertion.
+        (XScrollableCanvasProvider): Add selection box.
+        (xScrollStreamRenderFactory):
+        (Timeline.CanvasSeriesComponent): Add selection box , cmd + click and shift + click.
+        (prototype.ExpandableSeriesComponent):
+        (prototype.Timeline.CanvasContainer):
+
 2022-03-01  Jonathan Bedard  <[email protected]>
 
         [EWS] Support concept of 'blocked' pull requests

Modified: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/controller/api_routes.py (291062 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/controller/api_routes.py	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/controller/api_routes.py	2022-03-09 21:05:03 UTC (rev 291063)
@@ -20,6 +20,7 @@
 # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+from crypt import methods
 import traceback
 
 from flask import abort, jsonify
@@ -30,12 +31,13 @@
 from resultsdbpy.controller.suite_controller import SuiteController
 from resultsdbpy.controller.test_controller import TestController
 from resultsdbpy.controller.upload_controller import UploadController
+from resultsdbpy.controller.bug_tracker_controller import BugTrackerController
 from webkitflaskpy import AuthedBlueprint
 from werkzeug.exceptions import HTTPException
 
 
 class APIRoutes(AuthedBlueprint):
-    def __init__(self, model, import_name=__name__, auth_decorator=None):
+    def __init__(self, model, import_name=__name__, auth_decorator=None, bug_tracker_configs=[]):
         super(APIRoutes, self).__init__('controller', import_name, url_prefix='/api', auth_decorator=auth_decorator)
 
         self.commit_controller = CommitController(commit_context=model.commit_context)
@@ -48,6 +50,8 @@
         self.ci_controller = CIController(ci_context=model.ci_context, upload_context=model.upload_context)
         self.archive_controller = ArchiveController(commit_controller=self.commit_controller, archive_context=model.archive_context, upload_context=model.upload_context)
 
+        self.bug_tracker_controller = BugTrackerController(bug_tracker_configs=bug_tracker_configs, commit_context=model.commit_context)
+
         for code in [400, 404, 405]:
             self.register_error_handler(code, self.error_response)
         self.register_error_handler(500, self.response_500)
@@ -77,6 +81,10 @@
         self.add_url_rule('/urls/queue', 'queue-urls', self.ci_controller.urls_for_queue_endpoint, methods=('GET',))
         self.add_url_rule('/urls', 'build-urls', self.ci_controller.urls_for_builds_endpoint, methods=('GET',))
 
+        if bug_tracker_configs:
+            self.add_url_rule('/bug-trackers', 'bug-trackers', self.bug_tracker_controller.list_trackers, methods=('GET',))
+            self.add_url_rule('/bug-trackers/<path:tracker>/create-bug', 'create-bug', self.bug_tracker_controller.create_bug, methods=('PUT',))
+
     def error_response(self, error):
         response = jsonify(status='error', error=error.name, description=error.description)
         response.status = f'error.{error.name}'

Added: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/controller/bug_tracker_controller.py (0 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/controller/bug_tracker_controller.py	                        (rev 0)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/controller/bug_tracker_controller.py	2022-03-09 21:05:03 UTC (rev 291063)
@@ -0,0 +1,85 @@
+# Copyright (C) 2022 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from pydoc import describe
+from flask import request, abort, jsonify
+from resultsdbpy.controller.commit_controller import HasCommitContext
+
+
+class BugTrackerConfig(object):
+
+    def __init__(self, name):
+        self._name = name
+        self.commit_context = None
+
+    def name(self):
+        return self._name
+
+    def set_commit_context(self, commit_context):
+        self.commit_context = commit_context
+
+    def create_bug(self, **kwargs):
+        raise NotImplementedError
+
+    @classmethod
+    def get_test_result_url(config_params, commit_json, suite, host, protocol='https'):
+        build_params = {}
+        for k, v in config_params.items():
+            build_params[k] = v
+        build_params['suite'] = [suite]
+        build_params['uuid'] = [commit_json['uuid']]
+        build_params['after_time'] = [commit_json['start_time']]
+        build_params['before_time'] = [commit_json['start_time']]
+
+        query = []
+        for k, v in build_params:
+            query.append('{}={}'.format(k, v))
+
+        return '{}://{}/urls/build?{}'.format(protocol, host, '&'.join(query))
+
+
+class BugTrackerController(HasCommitContext):
+
+    def __init__(self, commit_context, bug_tracker_configs=[]):
+        self.bug_tracker_configs = bug_tracker_configs
+        for bug_tracker_config in self.bug_tracker_configs:
+            bug_tracker_config.set_commit_context(commit_context)
+        super(BugTrackerController, self).__init__(commit_context)
+
+    def list_trackers(self):
+        bug_tracker_names = []
+        if self.bug_tracker_configs:
+            for bug_tracker_config in self.bug_tracker_configs:
+                bug_tracker_names.append(bug_tracker_config.name())
+
+        return jsonify(bug_tracker_names)
+
+    def create_bug(self, tracker):
+        content = request.json
+        for bug_tracker_config in self.bug_tracker_configs:
+            if bug_tracker_config.name() == tracker:
+                try:
+                    return jsonify(bug_tracker_config.create_bug(content))
+                except ValueError as e:
+                    abort(400, description=str(e))
+
+        return abort(404, description='No such bug tracker')

Modified: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/model/test_context.py (291062 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/model/test_context.py	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/model/test_context.py	2022-03-09 21:05:03 UTC (rev 291063)
@@ -52,6 +52,9 @@
 
     CRASH, TIMEOUT, IMAGE, AUDIO, TEXT, FAIL, ERROR, WARNING, PASS = STATE_ID_TO_STRING.values()
 
+    FAILURE_TYPES = [WARNING, ERROR, TIMEOUT, CRASH]
+    STATS_FAILURE_TYPES = ['warning', 'failed', 'timedout', 'crashed']
+
     @classmethod
     def string_to_state_ids(cls, string):
         result = set([elm for elm in [cls.STRING_TO_STATE_ID.get(str) for str in string.split(' ')] if elm is not None])
@@ -81,7 +84,56 @@
         for key, value in results.items():
             recurse(key, value)
 
+    @classmethod
+    def get_test_result_status(cls, test_result_json, will_filter_expected=False):
+        failure_type = None
+        failure_number = None
+        if test_result_json.get('stats', None):
+            if test_result_json.get('start_time', None):
+                failure_number = test_result_json['stats']['tests{}failed'.format('_unexpected_' if will_filter_expected else '_')]
+            else:
+                failure_number = test_result_json['stats']['worst_tests{}failed'.format('_unexpected_' if will_filter_expected else '_')]
+            if 'worst_tests_run' in test_result_json and test_result_json['stats']['worst_tests_run'] <= 1:
+                failure_number = None
 
+            for type in Expectations.STATS_FAILURE_TYPES:
+                if test_result_json['stats'].get('tests{}{}'.format('_unexpected_' if will_filter_expected else '_', type.lower()), 0) > 0:
+                    failure_type = type
+        else:
+            resultId = Expectations.string_to_state_ids(test_result_json['actual'])
+            if will_filter_expected:
+                resultId = Expectations.string_to_state_ids(cls.unexpected_results(test_result_json['actual'], test_result_json['expected']))
+            for type in Expectations.FAILURE_TYPES:
+                if Expectations.string_to_state_ids(type) >= resultId:
+                    failure_type = type
+        return failure_type, failure_number
+
+    @classmethod
+    def unexpected_results(cls, results, expectations):
+        """
+        This function is a python translation for expectations.js Expectation.unexpectedResults
+        """
+        r = results.split('.')
+        for expectation in expectations.split(' '):
+            try:
+                i = r.index(expectation)
+                r = r[:i] + r[i + 1:]
+            except ValueError:
+                pass
+            if expectation == cls.FAIL:
+                for expectation in [cls.TEXT, cls.AUDIO, cls.IMAGE]:
+                    try:
+                        i = r.index(expectation)
+                        r = r[:i] + r[i + 1:]
+                    except ValueError:
+                        pass
+        result = cls.PASS
+        for candidate in r:
+            if Expectations.string_to_state_ids(candidate) < Expectations.string_to_state_ids(result):
+                result = candidate
+        return result
+
+
 class TestContext(UploadCallbackContext):
     DEFAULT_LIMIT = 100
 

Modified: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/css/tooltip.css (291062 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/css/tooltip.css	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/css/tooltip.css	2022-03-09 21:05:03 UTC (rev 291063)
@@ -23,31 +23,34 @@
  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
  
-.tooltip {
+ .tooltip {
     z-index: var(--topZIndex);
     position: absolute;
-    opacity: 80%;
-    width: 0;
-    height: 0;
-    border-style: solid;
-    border-width: 15px;
-    -webkit-background-clip: padding-box;
-    background-clip: padding-box;
+    width: var(--largeSize);
+    height: var(--largeSize);
+    transform-origin: center center;
+    -webkit-backdrop-filter: blur(10px) brightness(88%);
+    backdrop-filter: blur(10px) brightness(88%);
+    color: var(--inverseColor);
 }
 
 .tooltip.arrow-up {
-     border-color: transparent transparent #cccd transparent;
+    transform: rotate(225deg);
+    clip-path: polygon(100% 0, 100% 100%, 0 100%);
 }
 
 .tooltip.arrow-down {
-    border-color: #cccd transparent transparent transparent;
+    transform: rotate(45deg);
+    clip-path: polygon(100% 0, 100% 100%, 0 100%);
 }
 
 .tooltip.arrow-left {
-    border-color: transparent transparent transparent #cccd;
+    transform: rotate(45deg);
+    clip-path: polygon(0 0, 100% 100%, 0 100%);
 }
 .tooltip.arrow-right {
-     border-color: transparent #cccd transparent  transparent;
+    transform: rotate(225deg);
+    clip-path: polygon(0 0, 0 100%, 100% 100%);
 }
 
 .tooltip-content {

Modified: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/timeline.js (291062 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/timeline.js	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/timeline.js	2022-03-09 21:05:03 UTC (rev 291063)
@@ -353,6 +353,7 @@
 
         this.configurations = Configuration.fromQuery();
         this.results = {};
+        this.selectedDots = new Map();
 
         // Suite and test can often be implied by the endpoint, but doing so is more confusing then helpful
         this.suite = suite;
@@ -379,7 +380,6 @@
                     element.innerHTML = this.placeholder();
             }
         });
-
         this.commit_callback = () => {
             self.update();
         };
@@ -393,6 +393,8 @@
         });
     }
     update() {
+        if (this.selectedDotsButtonGroupRef)
+            this.selectedDotsButtonGroupRef.setState({show: false});
         const params = queryToParams(document.URL.split('?')[1]);
         const commits = commitsForResults(this.results, params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT, this.allCommits);
         const scale = scaleForCommits(commits);
@@ -503,9 +505,113 @@
             }
         });
 
-        return `<div class="content" ref="${this.ref}"></div>`;
+        return `
+        <div style="position:relative">
+            <div class="content" ref="${this.ref}"></div>
+        </div>`;
     }
 
+    getTestResultStatus(data, willFilterExpected=false) {
+        let failureType = null;
+        let failureNumber = null;
+        if (data.stats) {
+            if (data.start_time)
+                failureNumber = data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}failed`];
+            else
+                failureNumber = data.stats[`worst_tests${willFilterExpected ? '_unexpected_' : '_'}failed`];
+            if (data.stats.worst_tests_run <= 1)
+                failureNumber = null;
+
+            Expectations.failureTypes.forEach(type => {
+                if (data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}${type}`] > 0) {
+                    failureType = type;
+                }
+            });
+        } else {
+            let resultId = Expectations.stringToStateId(data.actual);
+            if (willFilterExpected)
+                resultId = Expectations.stringToStateId(Expectations.unexpectedResults(data.actual, data.expected));
+            Expectations.failureTypes.forEach(type => {
+                if (Expectations.stringToStateId(Expectations.failureTypeMap[type]) >= resultId) {
+                    failureType = type;
+                }
+            });
+        }
+        return {failureType, failureNumber};
+    }
+
+    getTestResultUrl(config, data) {
+        const buildParams = config.toParams();
+        buildParams['suite'] = [this.suite];
+        buildParams['uuid'] = [data.uuid];
+        buildParams['after_time'] = [data.start_time];
+        buildParams['before_time'] = [data.start_time];
+        return `${window.location.protocol}//${window.location.host}/urls/build?${paramsToQuery(buildParams)}`;
+    }
+
+    _renderSelectedDotsButtonGroup(element) {
+        DOM.inject(element, this.bugTrackers.map(bugTracker => {
+            const buttonText = `${bugTracker}`;
+            const buttonRef = REF.createRef({
+                state: {
+                    loading: false
+                },
+                onStateUpdate: (element, stateDiff) => {
+                    if (stateDiff.loading)
+                        element.innerText = 'Waiting...';
+                    else
+                        element.innerText = buttonText;
+                }
+            });
+
+            buttonRef.fromEvent('click').action(e => {
+                const requestPayload = {
+                    selectedRows: [],
+                    willFilterExpected: InvestigateDrawer.willFilterExpected,
+                    repositories: this.repositories,
+                    suite: this.suite,
+                    test: this.test
+                };
+                Array.from(this.selectedDots.keys()).forEach(config => {
+                    const dots = this.selectedDots.get(config);
+                    requestPayload.selectedRows.push({
+                        config,
+                        results: dots
+                    });
+                });
+                buttonRef.setState({loading: true});
+                fetch(`api/bug-trackers/${bugTracker}/create-bug`, {
+                    method: 'PUT',
+                    headers: {
+                        'Content-Type': 'application/json'
+                    },
+                    body: JSON.stringify(requestPayload)
+                }).then(res => {
+                    if (res.ok)
+                        return res.json()
+                    return res.json().then((data) => {
+                        throw new Error(data.description);
+                    });
+                }).then(data ="" {
+                    const bugLinkElement = document.createElement('a');
+                    bugLinkElement.setAttribute('href', data['url']);
+                    bugLinkElement.click();
+                }).catch(e => {
+                    alert(e);
+                }).finally(() => {
+                    buttonRef.setState({loading: false});
+                });
+                
+            });
+
+            return `<div>
+                <button class="button tiny" style="position:absolute; background: var(--purple); color: var(--white)" ref="${buttonRef}">
+                    ${buttonText}
+                </button>
+            </div>`;
+        }).join(''));
+    }
+
     render(limit) {
         const branch = queryToParams(document.URL.split('?')[1]).branch;
         const self = this;
@@ -512,6 +618,59 @@
         const commits = commitsForResults(this.results, limit, this.allCommits);
         const scale = scaleForCommits(commits);
 
+        this.selectedDotsButtonGroupRef = REF.createRef({
+            state: {
+                show: false,
+                top: 0,
+                left: 0,
+            },
+            onElementMount: (element) => {
+                if (this.bugTrackers)
+                    this._renderSelectedDotsButtonGroup(element);
+                fetch('api/bug-trackers').then(res => res.json()).then(bugTrackers => {
+                    this.bugTrackers = bugTrackers;
+                    this._renderSelectedDotsButtonGroup(element);
+                })
+            },
+            onStateUpdate: (element, stateDiff) => {
+                if ('show' in stateDiff) {
+                    if (stateDiff.show)
+                        element.style.display = 'block';
+                    else
+                        element.style.display = 'none';
+                }
+                if ('top' in stateDiff)
+                    element.style.top = `${stateDiff.top}px`;
+                if ('left' in stateDiff) {
+                    const rect = element.getBoundingClientRect();
+                    element.style.left = `${stateDiff.left - rect.width}px`;
+                }
+            }
+        });
+
+        this.radarButtonRef = REF.createRef({
+            state: {
+                show: false,
+                top: 0,
+                left: 0,
+                loading: false,
+            },
+            onStateUpdate: (element, stateDiff) => {
+                if ('show' in stateDiff) {
+                    if (stateDiff.show)
+                        element.style.display = 'block';
+                    else
+                        element.style.display = 'none';
+                }
+                if ('top' in stateDiff)
+                    element.style.top = `${stateDiff.top}px`;
+                if ('left' in stateDiff) {
+                    const rect = element.getBoundingClientRect();
+                    element.style.left = `${stateDiff.left - rect.width}px`;
+                }
+            }
+        });
+
         const colorMap = Expectations.colorMap();
         this.updates = [];
         const options = {
@@ -736,6 +895,12 @@
                         onDotClick: onDotClickFactory(config),
                         onDotEnter: onDotEnterFactory(config),
                         onDotLeave: onDotLeave,
+                        onDotsSelected: dots => {
+                            if (dots.length)
+                                this.selectedDots.set(config, dots);
+                            else
+                                this.selectedDots.delete(config);
+                        },
                         exporter: exporterFactory(resultsForConfig),
                     }));
 
@@ -752,6 +917,12 @@
                                     onDotClick: onDotClickFactory(sdkConfig),
                                     onDotEnter: onDotEnterFactory(sdkConfig),
                                     onDotLeave: onDotLeave,
+                                    onDotsSelected: dots => {
+                                        if (dots.length)
+                                            this.selectedDots.set(sdkConfig, dots);
+                                        else
+                                            this.selectedDots.delete(sdkConfig);
+                                    },
                                     exporter: exporterFactory(resultsByKey[sdkConfig.toKey()]),
                                 })));
                     });
@@ -779,6 +950,12 @@
                         onDotClick: onDotClickFactory(configuration),
                         onDotEnter: onDotEnterFactory(configuration),
                         onDotLeave: onDotLeave,
+                        onDotsSelected: dots => {
+                            if (dots.length)
+                                this.selectedDots.set(configuration, dots);
+                            else
+                                this.selectedDots.delete(configuration);
+                        },
                         exporter: exporterFactory(allResults),
                     })),
                 {expanded: this.configurations.length <= 1},
@@ -816,7 +993,20 @@
             };
             self.notifyRerender = notifyRerender;
         }));
-        return Timeline.CanvasContainer(composer, ...children);
+        return Timeline.CanvasContainer({
+            customizedLayer: `<div class="row" style="position:absolute" ref="${this.selectedDotsButtonGroupRef}"></div>`,
+            onSelecting: (e) => {
+                this.selectedDotsButtonGroupRef.setState({show: false});
+            },
+            onSelect: (dots, selectedDotRect, seriesRect, e) => {
+                // this api will called with selected dots for each series once, and compose the selectedDotRect during the call
+                this.selectedDotsButtonGroupRef.setState({show: true, top: selectedDotRect.bottom, left: selectedDotRect.right});
+            },
+            onSelectionScroll: (dots, selectedDotRect) => {
+                // this api will called with selected dots for each series once, and compose the selectedDotRect during the call
+                this.selectedDotsButtonGroupRef.setState({top: selectedDotRect.bottom, left: selectedDotRect.right});
+            },
+        }, composer, ...children);
     }
 }
 
@@ -936,3 +1126,4 @@
 }
 
 export {Legend, TimelineFromEndpoint, Expectations};
+

Modified: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/tooltip.js (291062 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/tooltip.js	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/js/tooltip.js	2022-03-09 21:05:03 UTC (rev 291063)
@@ -69,6 +69,7 @@
                     const lowerPoint = stateDiff.points.length > 1 && stateDiff.points[1].y > stateDiff.points[0].y ? stateDiff.points[1] : stateDiff.points[0];
                     const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
                     const bounds = element.getBoundingClientRect();
+                    const computedStyle = getComputedStyle(element);
 
                     let direction = 'down';
                     let point = upperPoint;
@@ -79,11 +80,11 @@
                         const rightPoint = stateDiff.points.length > 1 && stateDiff.points[1].x > stateDiff.points[0].x ? stateDiff.points[1] : stateDiff.points[0];
 
                         direction = 'left';
-                        let tipX = leftPoint.x - 12 - bounds.width;
+                        let tipX = leftPoint.x - bounds.width;
                         point = rightPoint;
                         if (tipX < 0 || tipX + bounds.width + (rightPoint.x - leftPoint.x) / 2 < stateDiff.viewport.x + stateDiff.viewport.width / 2) {
                             direction = 'right';
-                            tipX = rightPoint.x + 16;
+                            tipX = rightPoint.x;
                             point = rightPoint;
                         }
                         element.style.left = `${tipX}px`;
@@ -96,11 +97,11 @@
                         element.style.top = `${tipY}px`;
                     } else {
                         // Make an effort to place the tooltip in the center of the viewport.
-                        let tipY = upperPoint.y - 8 - bounds.height;
+                        let tipY = upperPoint.y - bounds.height;
                         point = upperPoint;
                         if (tipY < scrollDelta || tipY + bounds.height + (lowerPoint.y - upperPoint.y) / 2 < scrollDelta + stateDiff.viewport.y + stateDiff.viewport.height / 2) {
                             direction = 'up';
-                            tipY = lowerPoint.y + 16;
+                            tipY = lowerPoint.y + parseFloat(computedStyle.getPropertyValue('--smallSize'));
                             point = lowerPoint;
                         }
                         element.style.top = `${tipY}px`;
@@ -144,19 +145,20 @@
                 }
 
                 element.classList = [`tooltip arrow-${stateDiff.direction}`];
-                
+                const computedStyle = getComputedStyle(element);
+                const {width, height} = computedStyle;
                 if (stateDiff.direction == 'down') {
-                    element.style.left = `${stateDiff.location.x - 15}px`;
-                    element.style.top = `${stateDiff.location.y - 8}px`;
+                    element.style.left = `calc(${stateDiff.location.x}px - ${width} / 2)`;
+                    element.style.top = `calc(${stateDiff.location.y}px - ${height} / 2)`;
                 } else if (stateDiff.direction == 'left') {
-                    element.style.left = `${stateDiff.location.x - 30}px`;
-                    element.style.top = `${stateDiff.location.y - 15}px`;
+                    element.style.left = `calc(${stateDiff.location.y}px - ${width} / 2)`;
+                    element.style.top = `calc(${stateDiff.location.x}px - ${height} / 2)`;
                 } else if (stateDiff.direction == 'right') {
-                    element.style.left = `${stateDiff.location.x - 13}px`;
-                    element.style.top = `${stateDiff.location.y - 15}px`;
+                    element.style.left = `calc(${stateDiff.location.y}px - ${width} / 2)`;
+                    element.style.top = `calc(${stateDiff.location.x}px - ${height} / 2)`;
                 } else {
-                    element.style.left = `${stateDiff.location.x - 15}px`;
-                    element.style.top = `${stateDiff.location.y - 13}px`;
+                    element.style.left = `calc(${stateDiff.location.x}px - ${width} / 2)`;
+                    element.style.top = `calc(${stateDiff.location.y}px - ${height} / 2 + ${computedStyle.getPropertyValue('--smallSize')})`;
                 }
                 element.style.display = null;
             },

Modified: trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/library/js/components/TimelineComponents.js (291062 => 291063)


--- trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/library/js/components/TimelineComponents.js	2022-03-09 20:59:08 UTC (rev 291062)
+++ trunk/Tools/Scripts/libraries/resultsdbpy/resultsdbpy/view/static/library/js/components/TimelineComponents.js	2022-03-09 21:05:03 UTC (rev 291063)
@@ -33,11 +33,22 @@
 }
 
 function pointRectCollisionDetect(point, rect) {
-    const diffX = point.x - rect.topLeftX;
-    const diffY = point.y - rect.topLeftY;
-    return diffX <= rect.width && diffY <= rect.height && diffX >= 0 && diffY >= 0;
+    const x1 = rect.left;
+    const x2 = rect.left + rect.width;
+    const y1 = rect.top;
+    const y2 = rect.top + rect.height;
+    return (point.x > x1 && point.x < x2) && (point.y > y1 && point.y < y2);
 }
 
+function rectRectCollisionDetect(rectA, rectB) {
+    return !(
+        (rectA.left + rectA.width) < rectB.left ||
+        rectA.left > (rectB.left + rectB.width) ||
+        rectA.top > (rectB.top + rectB.height) ||
+        (rectA.top + rectA.height) < rectB.top
+    );
+}
+
 function pointPolygonCollisionDetect(point, polygon) {
     let res = false;
     for (let i = 0, j = 1; i < polygon.length; i++, j = i + 1) {
@@ -103,7 +114,22 @@
     context.scale(dpr, dpr);
 }
 
-function XScrollableCanvasProvider(exporter, ...childrenFunctions) {
+function XScrollableCanvasProvider(props, exporter, ...childrenFunctions) {
+    let drag = false;
+    let initPos = null;
+    const _onSelect_ = 'onSelect' in props ? props.onSelect : null;
+    const _onSelecting_ = 'onSelecting' in props ? props.onSelecting: null;
+    const _onSelectionScroll_ = 'onSelectionScroll' in props ? props.onSelectionScroll: null;
+    const _onScroll_ = 'onScroll' in props ? props.onScroll : null;
+    const customizedLayer = 'customizedLayer' in props ? props.customizedLayer : '';
+    const timeLineMouseUpEventStream = new EventStream();
+    const selectionBoxChangeEventStream = new EventStream();
+    const selectionBoxDoneEventStream = new EventStream();
+    const selectingEventStream = new EventStream();
+    const clickSelectionDoneEventStream = new EventStream();
+    const selectedDotsScrollEventStream = new EventStream();
+    const resizeEventStream = new EventStream();
+
     const containerRef = REF.createRef({
         state: {width: 0},
         onStateUpdate: (element, stateDiff, state) => {
@@ -111,9 +137,16 @@
                 element.style.width = `${stateDiff.width}px`;
         },
     });
-    const scrollRef = REF.createRef({});
+    const containerMouseDownEventStream = containerRef.fromEvent('mousedown');
+    const containerMouseMoveEventStream = containerRef.fromEvent('mousemove');
+    const scrollRef = REF.createRef({
+        onElementMount: (element, stateDiff) => {
+            element.parentElement.addEventListener('mouseup', e => {
+                timeLineMouseUpEventStream.add(e);
+            });
+        }
+    });
     const scrollEventStream = scrollRef.fromEvent('scroll');
-    const resizeEventStream = new EventStream();
     window.addEventListener('resize', () => {
         presenterRef.setState({resize:true});
     });
@@ -138,11 +171,132 @@
     layoutSizeMayChange.action(() => {
         presenterRef.setState({resize:true});
     });
+
+    // Selection box
+    const selectionBoxRef = REF.createRef({
+        state: {x: 0, y: 0, width: 0, height: 0, show: false},
+        onStateUpdate: (element, stateDiff) => {
+            if ('x' in stateDiff)
+                element.style.left = `${stateDiff.x}px`;
+            if ('y' in stateDiff)
+                element.style.top = `${stateDiff.y}px`;
+            if ('width' in stateDiff)
+                element.style.width = `${stateDiff.width}px`;
+            if ('height' in stateDiff)
+                element.style.height = `${stateDiff.height}px`;
+            if ('show' in stateDiff)
+                if (stateDiff.show)
+                    element.style.display = 'block';
+                else
+                    element.style.display = 'none';
+        }
+    });
+    const selectedDotRect = {
+        left: Number.MAX_VALUE,
+        top: Number.MAX_VALUE,
+        right: 0,
+        bottom: 0,
+        width: 0,
+        height: 0
+    };
+
+    const updateSelectedDotsRect = (dots, seriesRect) => {
+        const contentRect = scrollRef.element.getBoundingClientRect();
+        const dotLeft = dots[0]._dotCenter.x - dots[0]._dotRadius + dots[0]._cachedScrollLeft * getDevicePixelRatio();
+        const dotRight = dots[dots.length - 1]._dotCenter.x + dots[dots.length - 1]._dotRadius + dots[dots.length - 1]._cachedScrollLeft * getDevicePixelRatio();
+        const dotTop = seriesRect.top - contentRect.top;
+        const dotBottom = seriesRect.bottom - contentRect.top;
+        if (dotLeft < selectedDotRect.left)
+            selectedDotRect.left = dotLeft;
+        if (dotTop < selectedDotRect.top)
+            selectedDotRect.top = dotTop;
+        if (dotRight > selectedDotRect.right)
+            selectedDotRect.right = dotRight;
+        if (dotBottom > selectedDotRect.bottom)
+            selectedDotRect.bottom = dotBottom;
+        selectedDotRect.width = selectedDotRect.right - selectedDotRect.left;
+        selectedDotRect.height = selectedDotRect.bottom - selectedDotRect.top;
+    };
+
+    scrollEventStream.action(e => {
+        selectedDotRect.left = Number.MAX_VALUE;
+        selectedDotRect.right = 0;
+        selectedDotRect.top = Number.MAX_VALUE;
+        selectedDotRect.bottom = 0;
+        selectedDotRect.width = 0;
+        selectedDotRect.height = 0;
+        if (onScroll)
+            onScroll(e);
+    });
+
+    selectingEventStream.action(() => {
+        if (onSelecting)
+            onSelecting();
+    });
+
+    selectedDotsScrollEventStream.action((dots, seriesRect) => {
+        if (!dots || !dots.length)
+            return;
+        updateSelectedDotsRect(dots, seriesRect);
+        if (onSelectionScroll)
+            onSelectionScroll(dots, selectedDotRect, seriesRect);
+    });
+
+    containerMouseDownEventStream.action(e => {
+        if (e.buttons != 1)
+            return; 
+        drag = true;
+        initPos = getMousePosInCanvas(e, containerRef.element);
+        selectedDotRect.left = Number.MAX_VALUE;
+        selectedDotRect.right = 0;
+        selectedDotRect.top = Number.MAX_VALUE;
+        selectedDotRect.bottom = 0;
+        selectedDotRect.width = 0;
+        selectedDotRect.height = 0;
+    });
+
+    containerMouseMoveEventStream.action(e => {
+        if (!drag)
+            return;
+        selectingEventStream.add(e);
+    });
+
+    const _onSelectionDoneCallback_ = (dots, seriesRect, e) => {
+        if (!dots || !dots.length)
+            return;
+        updateSelectedDotsRect(dots, seriesRect);
+        if (onSelect)
+            onSelect(dots, selectedDotRect, seriesRect, e);
+    };
+    timeLineMouseUpEventStream.action(e => {
+        drag = false;
+        selectionBoxRef.setState({show: false});
+        if (onSelect)
+            selectionBoxDoneEventStream.add(onSelectionDoneCallback, e);
+    });
+    clickSelectionDoneEventStream.action(onSelectionDoneCallback);
+    window.addEventListener('mouseup', e => {
+        drag = false;
+        selectionBoxRef.setState({show: false});
+    });
+    window.addEventListener('mousemove', e => {
+        if (!drag)
+            return;
+        const cursorPos = getMousePosInCanvas(e, containerRef.element);
+        const width = Math.abs(cursorPos.x - initPos.x);
+        const height = Math.abs(cursorPos.y - initPos.y);
+        let x = initPos.x < cursorPos.x ? initPos.x : cursorPos.x;
+        let y = initPos.y < cursorPos.y ? initPos.y : cursorPos.y;
+        const containerBoundingRect = containerRef.element.getBoundingClientRect();
+        // notify with absolute pos
+        selectionBoxChangeEventStream.add({left: x + containerBoundingRect.left, top: y + containerBoundingRect.top, width, height});
+        selectionBoxRef.setState({x, y, width, height, show: true});
+    });
     // Provide parent functions/event to children to use
 
     return `<div class="content" ref="${scrollRef}">
         <div ref="${containerRef}" style="position: relative">
-            <div ref="${presenterRef}" style="position: -webkit-sticky; position:sticky; top:0; left: 0">${
+            <div ref="${presenterRef}" style="position: -webkit-sticky; position:sticky; top:0; left: 0; user-select: none; -webkit-user-select: none;">${
                 ListProvider((updateChildrenFunctions) => {
                     if (exporter) {
                         exporter(
@@ -168,8 +322,15 @@
                             }
                         );
                     }
-                }, [resizeContainerWidth, scrollEventStream, resizeEventStream, layoutSizeMayChange], ...childrenFunctions)
+                }, [
+                    resizeContainerWidth, scrollEventStream, resizeEventStream, 
+                    layoutSizeMayChange, selectionBoxChangeEventStream, 
+                    selectionBoxDoneEventStream, selectedDotsScrollEventStream, 
+                    selectingEventStream, clickSelectionDoneEventStream
+                ], ...childrenFunctions)
             }</div>
+            ${customizedLayer}
+            <div ref="${selectionBoxRef}" style="position:absolute; width: 0; height: 0; border: 1px solid var(--inverseColor)"></div>
         </div>
     </div>`;
 }
@@ -202,7 +363,7 @@
     }
 }
 
-function xScrollStreamRenderFactory(height) {
+function xScrollStreamRenderFactory(height, callback=null) {
     return (redraw, element, stateDiff, state) => {
         const width = typeof stateDiff.width === 'number' ? stateDiff.width : state.width;
         if (width <= 0)
@@ -221,6 +382,8 @@
             }
             element.getContext("2d", {alpha: false}).clearRect(startX, 0, renderWidth, element.logicHeight);
             redraw(startX, renderWidth, element, stateDiff, state);
+            if (callback)
+                callback(element, stateDiff, state);
         });
     }
 }
@@ -243,6 +406,7 @@
 
     // Get configuration
     // Default order is left is biggest
+    const selectedOutLineSize = 3;
     const reversed = typeof option.reversed === "boolean" ? option.reversed : false;
     const getScale = typeof option.getScaleFunc === "function" ? option.getScaleFunc : (a) => a;
     const comp = typeof option.compareFunc === "function" ? option.compareFunc : (a, b) => a - b;
@@ -249,8 +413,9 @@
     const _onDotClick_ = typeof option._onDotClick_ === "function" ? option.onDotClick : null;
     const _onDotEnter_ = typeof option._onDotEnter_ === "function" ? option.onDotEnter : null;
     const _onDotLeave_ = typeof option._onDotLeave_ === "function" ? option.onDotLeave : null;
+    const _onDotsSelected_ = typeof option._onDotsSelected_ === "function" ? option.onDotsSelected : null;
     const tagHeight = defaultFontSize;
-    const height = option.height ? option.height : 2 * radius + tagHeight;
+    const height = option.height ? option.height : 2 * radius + tagHeight + selectedOutLineSize;
     const colorBatchRender = new ColorBatchRender();
     let drawLabelsSeqs = [];
 
@@ -349,15 +514,16 @@
 
     const dotWidth = 2 * (radius + dotMargin);
     const padding = 100 * dotWidth / getDevicePixelRatio();
-    const xScrollStreamRender = xScrollStreamRenderFactory(height);
 
     const redraw = (startX, renderWidth, element, stateDiff, state) => {
         const scrollLeft = typeof stateDiff.scrollLeft === 'number' ? stateDiff.scrollLeft : state.scrollLeft;
         const scales = stateDiff.scales ? stateDiff.scales : state.scales;
         const dots = stateDiff.dots ? stateDiff.dots : state.dots;
+        if ('dots' in stateDiff)
+            dots.forEach((dot, index) => dot._index = index);
         // This color maybe change when switch dark/light mode
         const defaultLineColor = getComputedStyle(document.body).getPropertyValue('--borderColorInlineElement');
-
+        const defaultSelectedBackgroundColor = getComputedStyle(document.body).getPropertyValue('--blue');
         const context = element.getContext("2d", { alpha: false });
         // Clear pervious batchRender
         colorBatchRender.clear();
@@ -369,9 +535,18 @@
             context.strokeStyle = color;
             context.stroke();
         });
+
+        colorBatchRender.lazyCreateColorSeqs(defaultSelectedBackgroundColor, (context) => {
+            context.beginPath();
+        }, (context, color) => {
+            context.lineWidth = selectedOutLineSize;
+            context.strokeStyle = color;
+            context.stroke();
+        });
+
         colorBatchRender.addSeq(defaultLineColor, (context) => {
-            context.moveTo(startX, radius);
-            context.lineTo(startX + renderWidth, radius);
+            context.moveTo(startX, radius + selectedOutLineSize);
+            context.lineTo(startX + renderWidth, radius + selectedOutLineSize);
         });
         if (!scales || !dots || !scales.length || !dots.length) {
             colorBatchRender.batchRender(context);
@@ -406,18 +581,31 @@
         for (let i = startScalesIndex; i <= endScalesIndex; i++) {
             let x = i * dotWidth - scrollLeft;
             if (currentDotIndex < dots.length && comp(scales[i], getScale(dots[currentDotIndex])) === 0) {
-                render(dots[currentDotIndex], context, x, 0);
+                if(dots[currentDotIndex]._selected) {
+                    colorBatchRender.addSeq(defaultSelectedBackgroundColor, (context, color) => {
+                        context.arc(x + dotMargin + radius, radius + selectedOutLineSize, radius, 0, 2 * Math.PI);
+                    });
+                }
+                render(dots[currentDotIndex], context, x, selectedOutLineSize);
                 dots[currentDotIndex]._dotCenter = {x: x + dotMargin + radius, y: radius};
+                dots[currentDotIndex]._dotRadius = radius;
                 dots[currentDotIndex]._cachedScrollLeft = scrollLeft;
                 inCacheDots.push(dots[currentDotIndex]);
                 currentDotIndex += 1;
             } else
-                render(null, context, x, 0);
+                render(null, context, x, selectedOutLineSize);
         }
         colorBatchRender.batchRender(context);
     };
 
-    return ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize) => {
+    return ListProviderReceiver((
+            updateContainerWidth, onContainerScroll, onResize, 
+            layoutSizeMayChange, onSelectionBoxChange, onSelectionBoxDone, 
+            onSelectionScroll, onSelecting, onClickSelectionDone
+    ) => {
+        let selectedDots = [];
+        let cmdKeyDown = null;
+        let shiftKeyDown = null;
         const mouseMove = (e) => {
             let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, canvasRef.element);
             if (dots.length) {
@@ -435,6 +623,10 @@
                 canvasRef.element.style.cursor = "default";
             }
         }
+        const unselectAllDots = () => {
+            selectedDots = [];
+            canvasRef.state.dots.forEach(dot => dot._selected = false);
+        }; 
         const _onScrollAction_ = (e) => {
             canvasRef.setState({scrollLeft: e.target.scrollLeft / getDevicePixelRatio()});
             mouseMove(e);
@@ -443,6 +635,59 @@
             canvasRef.setState({width: width});
         };
 
+        const xScrollStreamRender = xScrollStreamRenderFactory(height, (element, stateDiff, state) => {
+            if (selectedDots && selectedDots.length && 'scrollLeft' in stateDiff) {
+                const canvasBoundingRect = canvasRef.element.getBoundingClientRect();
+                onSelectionScroll.add(selectedDots, canvasBoundingRect);
+            }
+        });
+
+        const _onSelectionBoxChangeAction_ = (selectionBoxRect) => {
+            const canvasBoundingRect = canvasRef.element.getBoundingClientRect();
+            if (!rectRectCollisionDetect(selectionBoxRect, canvasBoundingRect)) {
+                unselectAllDots();
+                canvasRef.setState({hasDotSelected: true});
+                if (onDotsSelected)
+                    onDotsSelected(selectedDots);
+                return;
+            }
+            canvasRef.state.dots.forEach(dot => dot._selected = false);
+            selectedDots = inCacheDots.filter(dot => {
+                // convert to absoulte pos
+                const dotCenter = {
+                    x: dot._dotCenter.x + canvasBoundingRect.left,
+                    y: dot._dotCenter.y + canvasBoundingRect.top,
+                };
+                return pointRectCollisionDetect(dotCenter, selectionBoxRect);
+            });
+            selectedDots.forEach(dot => {
+                dot._selected = true;
+            });
+            if (onDotsSelected)
+                onDotsSelected(selectedDots);
+            canvasRef.setState({hasDotSelected: true});
+        };
+
+        const _onSelectionBoxDoneAction_ = (onSelectionDoneCallback, e) => {
+            if (selectedDots && selectedDots.length) {
+                const canvasBoundingRect = canvasRef.element.getBoundingClientRect();
+                onSelectionDoneCallback(selectedDots, canvasBoundingRect, e);
+            }
+        }
+
+        const globalKeyDownAction =  (e) => {
+            if (e.key === 'Meta')
+                cmdKeyDown = true;
+            else if (e.key === 'Shift')
+                shiftKeyDown = true;
+        };
+        const globalKeyUpAction =(e) => {
+            if (e.key === 'Meta')
+                cmdKeyDown = false;
+            else if (e.key === 'Shift')
+                shiftKeyDown = false;
+        };
+
         const canvasRef = REF.createRef({
             state: {
                 dots: initDots,
@@ -450,17 +695,50 @@
                 scrollLeft: 0,
                 width: 0,
                 onScreen: false,
+                hasDotSelected: false,
             },
             onElementMount: (element) => {
                 setupCanvasHeightWithDpr(element, height);
                 setupCanvasContextScale(element);
-                if (onDotClick) {
-                    element.addEventListener('click', (e) => {
-                        let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, element);
-                        if (dots.length)
-                            onDotClick(dots[0], e);
-                    });
-                }
+                window.addEventListener('keydown',globalKeyDownAction);
+                window.addEventListener('keyup', globalKeyUpAction);
+                element.addEventListener('click', (e) => {
+                    let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, element);
+                    if (!dots.length)
+                        return;
+                    if (onDotClick)
+                        onDotClick(dots[0], e);
+                    if (!shiftKeyDown && cmdKeyDown) {
+                        selectedDots.push(dots[0]);
+                        dots[0]._selected = true;
+                    }
+                    else if (!cmdKeyDown && shiftKeyDown) {
+                        let startDotIndex = c
+                        let endDotIndex = dots[0]._index;
+                        if (selectedDots[0]._index < dost[0]._index) {
+                            if (selectedDots[selectedDots.length - 1]._index > dots[0]._index)
+                                startDotIndex = selectedDots[0]._index;
+                            else
+                                startDotIndex = selectedDots[selectedDots.length - 1]._index;
+                            endDotIndex = dots[0]._index;
+                        } else if (selectedDots[0]._index > dost[0]._index) {
+                            startDotIndex = dost[0]._index;
+                            endDotIndex = selectedDots[0]._index;
+                        }
+                        unselectAllDots();
+                        for(let i = startDotIndex; i <= endDotIndex; i++) {
+                            canvasRef.state.dots[i]._selected = true;
+                            selectedDots.push(canvasRef.state.dots[i]);
+                        }
+                    }
+                    if (shiftKeyDown || cmdKeyDown) {
+                        onSelecting.add(e);
+                        onClickSelectionDone.add(selectedDots, canvasRef.element.getBoundingClientRect(), e);
+                        if (onDotsSelected)
+                            onDotsSelected(selectedDots);
+                        canvasRef.setState({hasDotSelected: true});
+                    }
+                });
 
                 if (onDotClick || onDotEnter || onDotLeave)
                     element.addEventListener('mousemove', mouseMove);
@@ -474,6 +752,10 @@
             onElementUnmount: (element) => {
                 onContainerScroll.stopAction(onScrollAction);
                 onResize.stopAction(onResizeAction);
+                onContainerScroll.stopAction(onScrollAction);
+                onSelectionBoxDone.stopAction(onSelectionBoxDoneAction);
+                window.removeEventListener('keydown',globalKeyDownAction);
+                window.removeEventListener('keyup', globalKeyUpAction);
                 // Clean the canvas, free its memory
                 element.width = 0;
                 element.height = 0;
@@ -481,7 +763,7 @@
             onStateUpdate: (element, stateDiff, state) => {
                 if (!state.onScreen && !stateDiff.onScreen)
                     return;
-                if (stateDiff.scales || stateDiff.dots || typeof stateDiff.scrollLeft === 'number' || typeof stateDiff.width === 'number' || stateDiff.onScreen) {
+                if (stateDiff.scales || stateDiff.dots || typeof stateDiff.scrollLeft === 'number' || typeof stateDiff.width === 'number' || stateDiff.onScreen || stateDiff.hasDotSelected) {
                     if (stateDiff.scales)
                         stateDiff.scales = stateDiff.scales.map(x => x);
                     if (stateDiff.dots)
@@ -503,7 +785,9 @@
             option.exporter(updateData);
         onContainerScroll.action(onScrollAction);
         onResize.action(onResizeAction);
-        return `<div class="series">
+        onSelectionBoxChange.action(onSelectionBoxChangeAction);
+        onSelectionBoxDone.action(onSelectionBoxDoneAction);
+        return `<div class="series" style="user-select: none; -webkit-user-select: none;">
             <canvas ref="${canvasRef}" width="0" height="0">
         </div>`;
     });
@@ -529,12 +813,23 @@
     });
     if (exporter)
         exporter((expanded) => ref.setState({expanded: expanded}));
-    return ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize, layoutSizeMayChange) => {
+    return ListProviderReceiver((
+        updateContainerWidth, onContainerScroll, onResize, 
+        layoutSizeMayChange, onSelectionBoxChange, onSelectionBoxDone, 
+        onSelectionScroll, onSelecting, onClickSelectionDone
+    ) => {
         layoutSizeMayChangeEvent = layoutSizeMayChange;
         return `<div class="groupSeries" ref="${ref}">
             <div class="series" style="display:none;"></div>
-            <div>${mainSeries(updateContainerWidth, onContainerScroll, onResize, layoutSizeMayChange)}</div>
-            <div style="display:none">${subSerieses.map((subSeries) => subSeries(updateContainerWidth, onContainerScroll, onResize, layoutSizeMayChange)).join("")}</div>
+            <div>${mainSeries(updateContainerWidth, onContainerScroll, onResize, 
+                layoutSizeMayChange, onSelectionBoxChange, onSelectionBoxDone, 
+                onSelectionScroll, onSelecting, onClickSelectionDone
+            )}</div>
+            <div style="display:none">${subSerieses.map((subSeries) => subSeries(
+                updateContainerWidth, onContainerScroll, onResize, 
+                layoutSizeMayChange, onSelectionBoxChange, onSelectionBoxDone, 
+                onSelectionScroll, onSelecting, onClickSelectionDone
+            )).join("")}</div>
         </div>`;
     });
 }
@@ -885,7 +1180,7 @@
                 option.exporter(updateData);
             onContainerScroll.action(onScrollAction);
             onResize.action(onResizeAction);
-            return `<div class="x-axis">
+            return `<div class="x-axis" style="user-select: none; -webkit-user-select: none;">
                 <canvas ref="${canvasRef}">
             </div>`;
         }),
@@ -894,7 +1189,7 @@
     };
 }
 
-Timeline.CanvasContainer = (exporter, ...children) => {
+Timeline.CanvasContainer = (props, exporter, ...children) => {
     let headerAxisPlaceHolderHeight = 0;
     let topAxis = true;
     const upackChildren = (children) => {
@@ -929,7 +1224,7 @@
             <div class="header" style="padding-top:${headerAxisPlaceHolderHeight}px">
                 ${ListComponent(composer, ...headers)}
             </div>
-            ${XScrollableCanvasProvider(composer, ...serieses)}
+            ${XScrollableCanvasProvider(props, composer, ...serieses)}
         </div>`
     );
 }
_______________________________________________
webkit-changes mailing list
[email protected]
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to