1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @constructor
7 * @extends {WebInspector.View}
8 * @implements {WebInspector.TargetManager.Observer}
9 */
10WebInspector.MediaQueryInspector = function()
11{
12    WebInspector.View.call(this);
13    this.element.classList.add("media-inspector-view", "media-inspector-view-empty");
14    this.element.addEventListener("click", this._onMediaQueryClicked.bind(this), false);
15    this.element.addEventListener("contextmenu", this._onContextMenu.bind(this), false);
16    this._mediaThrottler = new WebInspector.Throttler(0);
17
18    this._offset = 0;
19    this._scale = 1;
20    this._lastReportedCount = 0;
21
22    WebInspector.targetManager.observeTargets(this);
23
24    WebInspector.zoomManager.addEventListener(WebInspector.ZoomManager.Events.ZoomChanged, this._renderMediaQueries.bind(this), this);
25}
26
27/**
28 * @enum {number}
29 */
30WebInspector.MediaQueryInspector.Section = {
31    Max: 0,
32    MinMax: 1,
33    Min: 2
34}
35
36WebInspector.MediaQueryInspector.Events = {
37    HeightUpdated: "HeightUpdated",
38    CountUpdated: "CountUpdated"
39}
40
41WebInspector.MediaQueryInspector.prototype = {
42    /**
43     * @param {!WebInspector.Target} target
44     */
45    targetAdded: function(target)
46    {
47        // FIXME: adapt this to multiple targets.
48        if (this._target)
49            return;
50        this._target = target;
51        target.cssModel.addEventListener(WebInspector.CSSStyleModel.Events.StyleSheetAdded, this._scheduleMediaQueriesUpdate, this);
52        target.cssModel.addEventListener(WebInspector.CSSStyleModel.Events.StyleSheetRemoved, this._scheduleMediaQueriesUpdate, this);
53        target.cssModel.addEventListener(WebInspector.CSSStyleModel.Events.StyleSheetChanged, this._scheduleMediaQueriesUpdate, this);
54        target.cssModel.addEventListener(WebInspector.CSSStyleModel.Events.MediaQueryResultChanged, this._scheduleMediaQueriesUpdate, this);
55    },
56
57    /**
58     * @param {!WebInspector.Target} target
59     */
60    targetRemoved: function(target)
61    {
62        if (target !== this._target)
63            return;
64        target.cssModel.removeEventListener(WebInspector.CSSStyleModel.Events.StyleSheetAdded, this._scheduleMediaQueriesUpdate, this);
65        target.cssModel.removeEventListener(WebInspector.CSSStyleModel.Events.StyleSheetRemoved, this._scheduleMediaQueriesUpdate, this);
66        target.cssModel.removeEventListener(WebInspector.CSSStyleModel.Events.StyleSheetChanged, this._scheduleMediaQueriesUpdate, this);
67        target.cssModel.removeEventListener(WebInspector.CSSStyleModel.Events.MediaQueryResultChanged, this._scheduleMediaQueriesUpdate, this);
68    },
69
70    /**
71     * @param {number} offset
72     * @param {number} scale
73     */
74    setAxisTransform: function(offset, scale)
75    {
76        if (this._offset === offset && Math.abs(this._scale - scale) < 1e-8)
77            return;
78        this._offset = offset;
79        this._scale = scale;
80        this._renderMediaQueries();
81    },
82
83    /**
84     * @param {boolean} enabled
85     */
86    setEnabled: function(enabled)
87    {
88        this._enabled = enabled;
89        this._scheduleMediaQueriesUpdate();
90    },
91
92    /**
93     * @param {!Event} event
94     */
95    _onMediaQueryClicked: function(event)
96    {
97        var mediaQueryMarker = event.target.enclosingNodeOrSelfWithClass("media-inspector-marker");
98        if (!mediaQueryMarker)
99            return;
100
101        /**
102         * @param {number} width
103         */
104        function setWidth(width)
105        {
106            WebInspector.overridesSupport.settings.deviceWidth.set(width);
107            WebInspector.overridesSupport.settings.emulateResolution.set(true);
108        }
109
110        var model = mediaQueryMarker._model;
111        if (model.section() === WebInspector.MediaQueryInspector.Section.Max) {
112            setWidth(model.maxWidthExpression().computedLength());
113            return;
114        }
115        if (model.section() === WebInspector.MediaQueryInspector.Section.Min) {
116            setWidth(model.minWidthExpression().computedLength());
117            return;
118        }
119        var currentWidth = WebInspector.overridesSupport.settings.deviceWidth.get();
120        if (currentWidth !== model.minWidthExpression().computedLength())
121            setWidth(model.minWidthExpression().computedLength());
122        else
123            setWidth(model.maxWidthExpression().computedLength());
124    },
125
126    /**
127     * @param {!Event} event
128     */
129    _onContextMenu: function(event)
130    {
131        var mediaQueryMarker = event.target.enclosingNodeOrSelfWithClass("media-inspector-marker");
132        if (!mediaQueryMarker)
133            return;
134
135        var locations = mediaQueryMarker._locations;
136        var uiLocations = new StringMap();
137        for (var i = 0; i < locations.length; ++i) {
138            var uiLocation = WebInspector.cssWorkspaceBinding.rawLocationToUILocation(locations[i]);
139            if (!uiLocation)
140                continue;
141            var descriptor = String.sprintf("%s:%d:%d", uiLocation.uiSourceCode.uri(), uiLocation.lineNumber + 1, uiLocation.columnNumber + 1);
142            uiLocations.set(descriptor, uiLocation);
143        }
144
145        var contextMenuItems = uiLocations.keys().sort();
146        var contextMenu = new WebInspector.ContextMenu(event);
147        var subMenuItem = contextMenu.appendSubMenuItem(WebInspector.UIString(WebInspector.useLowerCaseMenuTitles() ? "Reveal in source code" : "Reveal In Source Code"));
148        for (var i = 0; i < contextMenuItems.length; ++i) {
149            var title = contextMenuItems[i];
150            subMenuItem.appendItem(title, this._revealSourceLocation.bind(this, /** @type {!WebInspector.UILocation} */(uiLocations.get(title))));
151        }
152        contextMenu.show();
153    },
154
155    /**
156     * @param {!WebInspector.UILocation} location
157     */
158    _revealSourceLocation: function(location)
159    {
160        WebInspector.Revealer.reveal(location);
161    },
162
163    _scheduleMediaQueriesUpdate: function()
164    {
165        if (!this._enabled)
166            return;
167        this._mediaThrottler.schedule(this._refetchMediaQueries.bind(this));
168    },
169
170    /**
171     * @param {!WebInspector.Throttler.FinishCallback} finishCallback
172     */
173    _refetchMediaQueries: function(finishCallback)
174    {
175        if (!this._enabled) {
176            finishCallback();
177            return;
178        }
179
180        /**
181         * @param {!Array.<!WebInspector.CSSMedia>} cssMedias
182         * @this {!WebInspector.MediaQueryInspector}
183         */
184        function callback(cssMedias)
185        {
186            this._rebuildMediaQueries(cssMedias);
187            finishCallback();
188        }
189        this._target.cssModel.getMediaQueries(callback.bind(this));
190    },
191
192    /**
193     * @param {!Array.<!WebInspector.MediaQueryInspector.MediaQueryUIModel>} models
194     * @return {!Array.<!WebInspector.MediaQueryInspector.MediaQueryUIModel>}
195     */
196    _squashAdjacentEqual: function(models)
197    {
198        var filtered = [];
199        for (var i = 0; i < models.length; ++i) {
200            var last = filtered.peekLast();
201            if (!last || !last.equals(models[i]))
202                filtered.push(models[i]);
203        }
204        return filtered;
205    },
206
207    /**
208     * @param {!Array.<!WebInspector.CSSMedia>} cssMedias
209     */
210    _rebuildMediaQueries: function(cssMedias)
211    {
212        var queryModels = [];
213        for (var i = 0; i < cssMedias.length; ++i) {
214            var cssMedia = cssMedias[i];
215            if (!cssMedia.mediaList)
216                continue;
217            for (var j = 0; j < cssMedia.mediaList.length; ++j) {
218                var mediaQuery = cssMedia.mediaList[j];
219                var queryModel = WebInspector.MediaQueryInspector.MediaQueryUIModel.createFromMediaQuery(cssMedia, mediaQuery);
220                if (queryModel && queryModel.rawLocation())
221                    queryModels.push(queryModel);
222            }
223        }
224        queryModels.sort(compareModels);
225        queryModels = this._squashAdjacentEqual(queryModels);
226
227        var allEqual = this._cachedQueryModels && this._cachedQueryModels.length == queryModels.length;
228        for (var i = 0; allEqual && i < queryModels.length; ++i)
229            allEqual = allEqual && this._cachedQueryModels[i].equals(queryModels[i]);
230        if (allEqual)
231            return;
232        this._cachedQueryModels = queryModels;
233        this._renderMediaQueries();
234
235        /**
236         * @param {!WebInspector.MediaQueryInspector.MediaQueryUIModel} model1
237         * @param {!WebInspector.MediaQueryInspector.MediaQueryUIModel} model2
238         * @return {number}
239         */
240        function compareModels(model1, model2)
241        {
242            return model1.compareTo(model2);
243        }
244    },
245
246    _renderMediaQueries: function()
247    {
248        if (!this._cachedQueryModels)
249            return;
250
251        var markers = [];
252        var lastMarker = null;
253        for (var i = 0; i < this._cachedQueryModels.length; ++i) {
254            var model = this._cachedQueryModels[i];
255            if (lastMarker && lastMarker.model.dimensionsEqual(model)) {
256                lastMarker.locations.push(model.rawLocation());
257                lastMarker.active = lastMarker.active || model.active();
258            } else {
259                lastMarker = {
260                    active: model.active(),
261                    model: model,
262                    locations: [ model.rawLocation() ]
263                };
264                markers.push(lastMarker);
265            }
266        }
267
268        if (markers.length !== this._lastReportedCount) {
269            this._lastReportedCount = markers.length;
270            this.dispatchEventToListeners(WebInspector.MediaQueryInspector.Events.CountUpdated, markers.length);
271        }
272
273        if (!this.isShowing())
274            return;
275
276        var oldChildrenCount = this.element.children.length;
277        var scrollTop = this.element.scrollTop;
278        this.element.removeChildren();
279
280        var container = null;
281        for (var i = 0; i < markers.length; ++i) {
282            if (!i || markers[i].model.section() !== markers[i - 1].model.section())
283                container = this.element.createChild("div", "media-inspector-marker-container");
284            var marker = markers[i];
285            var bar = this._createElementFromMediaQueryModel(marker.model);
286            bar._model = marker.model;
287            bar._locations = marker.locations;
288            bar.classList.toggle("media-inspector-marker-inactive", !marker.active);
289            container.appendChild(bar);
290        }
291        this.element.scrollTop = scrollTop;
292        this.element.classList.toggle("media-inspector-view-empty", !this.element.children.length);
293        if (this.element.children.length !== oldChildrenCount)
294            this.dispatchEventToListeners(WebInspector.MediaQueryInspector.Events.HeightUpdated);
295    },
296
297    /**
298     * @return {number}
299     */
300    _zoomFactor: function()
301    {
302        return WebInspector.zoomManager.zoomFactor() / this._scale;
303    },
304
305    wasShown: function()
306    {
307        this._renderMediaQueries();
308    },
309
310    /**
311     * @param {!WebInspector.MediaQueryInspector.MediaQueryUIModel} model
312     * @return {!Element}
313     */
314    _createElementFromMediaQueryModel: function(model)
315    {
316        var zoomFactor = this._zoomFactor();
317        var minWidthValue = model.minWidthExpression() ? model.minWidthExpression().computedLength() : 0;
318
319        const styleClassPerSection = [
320            "media-inspector-marker-max-width",
321            "media-inspector-marker-min-max-width",
322            "media-inspector-marker-min-width"
323        ];
324        var markerElement = document.createElementWithClass("div", "media-inspector-marker");
325        var leftPixelValue = minWidthValue ? (minWidthValue - this._offset) / zoomFactor : 0;
326        markerElement.style.left = leftPixelValue + "px";
327        markerElement.classList.add(styleClassPerSection[model.section()]);
328        var widthPixelValue = null;
329        if (model.maxWidthExpression() && model.minWidthExpression())
330            widthPixelValue = (model.maxWidthExpression().computedLength() - minWidthValue) / zoomFactor;
331        else if (model.maxWidthExpression())
332            widthPixelValue = (model.maxWidthExpression().computedLength() - this._offset) / zoomFactor;
333        else
334            markerElement.style.right = "0";
335        if (typeof widthPixelValue === "number")
336            markerElement.style.width = widthPixelValue + "px";
337
338        if (model.minWidthExpression()) {
339            var labelClass = model.section() === WebInspector.MediaQueryInspector.Section.MinMax ? "media-inspector-label-right" : "media-inspector-label-left";
340            var labelContainer = markerElement.createChild("div", "media-inspector-marker-label-container media-inspector-marker-label-container-left");
341            labelContainer.createChild("span", "media-inspector-marker-label " + labelClass).textContent = model.minWidthExpression().value() + model.minWidthExpression().unit();
342        }
343
344        if (model.maxWidthExpression()) {
345            var labelClass = model.section() === WebInspector.MediaQueryInspector.Section.MinMax ? "media-inspector-label-left" : "media-inspector-label-right";
346            var labelContainer = markerElement.createChild("div", "media-inspector-marker-label-container media-inspector-marker-label-container-right");
347            labelContainer.createChild("span", "media-inspector-marker-label " + labelClass).textContent = model.maxWidthExpression().value() + model.maxWidthExpression().unit();
348        }
349        markerElement.title = model.mediaText();
350
351        return markerElement;
352    },
353
354    __proto__: WebInspector.View.prototype
355};
356
357/**
358 * @constructor
359 * @param {!WebInspector.CSSMedia} cssMedia
360 * @param {?WebInspector.CSSMediaQueryExpression} minWidthExpression
361 * @param {?WebInspector.CSSMediaQueryExpression} maxWidthExpression
362 * @param {boolean} active
363 */
364WebInspector.MediaQueryInspector.MediaQueryUIModel = function(cssMedia, minWidthExpression, maxWidthExpression, active)
365{
366    this._cssMedia = cssMedia;
367    this._minWidthExpression = minWidthExpression;
368    this._maxWidthExpression = maxWidthExpression;
369    this._active = active;
370    if (maxWidthExpression && !minWidthExpression)
371        this._section = WebInspector.MediaQueryInspector.Section.Max;
372    else if (minWidthExpression && maxWidthExpression)
373        this._section = WebInspector.MediaQueryInspector.Section.MinMax;
374    else
375        this._section = WebInspector.MediaQueryInspector.Section.Min;
376}
377
378/**
379 * @param {!WebInspector.CSSMedia} cssMedia
380 * @param {!WebInspector.CSSMediaQuery} mediaQuery
381 * @return {?WebInspector.MediaQueryInspector.MediaQueryUIModel}
382 */
383WebInspector.MediaQueryInspector.MediaQueryUIModel.createFromMediaQuery = function(cssMedia, mediaQuery)
384{
385    var maxWidthExpression = null;
386    var maxWidthPixels = Number.MAX_VALUE;
387    var minWidthExpression = null;
388    var minWidthPixels = Number.MIN_VALUE;
389    var expressions = mediaQuery.expressions();
390    for (var i = 0; i < expressions.length; ++i) {
391        var expression = expressions[i];
392        var feature = expression.feature();
393        if (feature.indexOf("width") === -1)
394            continue;
395        var pixels = expression.computedLength();
396        if (feature.startsWith("max-") && pixels < maxWidthPixels) {
397            maxWidthExpression = expression;
398            maxWidthPixels = pixels;
399        } else if (feature.startsWith("min-") && pixels > minWidthPixels) {
400            minWidthExpression = expression;
401            minWidthPixels = pixels;
402        }
403    }
404    if (minWidthPixels > maxWidthPixels || (!maxWidthExpression && !minWidthExpression))
405        return null;
406
407    return new WebInspector.MediaQueryInspector.MediaQueryUIModel(cssMedia, minWidthExpression, maxWidthExpression, mediaQuery.active());
408}
409
410WebInspector.MediaQueryInspector.MediaQueryUIModel.prototype = {
411    /**
412     * @param {!WebInspector.MediaQueryInspector.MediaQueryUIModel} other
413     * @return {boolean}
414     */
415    equals: function(other)
416    {
417        return this.compareTo(other) === 0;
418    },
419
420    /**
421     * @param {!WebInspector.MediaQueryInspector.MediaQueryUIModel} other
422     * @return {boolean}
423     */
424    dimensionsEqual: function(other)
425    {
426        return this.section() === other.section()
427            && (!this.minWidthExpression() || (this.minWidthExpression().computedLength() === other.minWidthExpression().computedLength()))
428            && (!this.maxWidthExpression() || (this.maxWidthExpression().computedLength() === other.maxWidthExpression().computedLength()));
429    },
430
431    /**
432     * @param {!WebInspector.MediaQueryInspector.MediaQueryUIModel} other
433     * @return {number}
434     */
435    compareTo: function(other)
436    {
437        if (this.section() !== other.section())
438            return this.section() - other.section();
439        if (this.dimensionsEqual(other)) {
440            var myLocation = this.rawLocation();
441            var otherLocation = other.rawLocation();
442            if (!myLocation && !otherLocation)
443                return this.mediaText().compareTo(other.mediaText());
444            if (myLocation && !otherLocation)
445                return 1;
446            if (!myLocation && otherLocation)
447                return -1;
448            if (this.active() !== other.active())
449                return this.active() ? -1 : 1;
450            return myLocation.url.compareTo(otherLocation.url) || myLocation.lineNumber - otherLocation.lineNumber || myLocation.columnNumber - otherLocation.columnNumber;
451        }
452        if (this.section() === WebInspector.MediaQueryInspector.Section.Max)
453            return other.maxWidthExpression().computedLength() - this.maxWidthExpression().computedLength();
454        if (this.section() === WebInspector.MediaQueryInspector.Section.Min)
455            return this.minWidthExpression().computedLength() - other.minWidthExpression().computedLength();
456        return this.minWidthExpression().computedLength() - other.minWidthExpression().computedLength() || other.maxWidthExpression().computedLength() - this.maxWidthExpression().computedLength();
457    },
458
459    /**
460     * @return {!WebInspector.MediaQueryInspector.Section}
461     */
462    section: function()
463    {
464        return this._section;
465    },
466
467    /**
468     * @return {string}
469     */
470    mediaText: function()
471    {
472        return this._cssMedia.text;
473    },
474
475    /**
476     * @return {?WebInspector.CSSLocation}
477     */
478    rawLocation: function()
479    {
480        if (!this._rawLocation)
481            this._rawLocation = this._cssMedia.rawLocation();
482        return this._rawLocation;
483    },
484
485    /**
486     * @return {?WebInspector.CSSMediaQueryExpression}
487     */
488    minWidthExpression: function()
489    {
490        return this._minWidthExpression;
491    },
492
493    /**
494     * @return {?WebInspector.CSSMediaQueryExpression}
495     */
496    maxWidthExpression: function()
497    {
498        return this._maxWidthExpression;
499    },
500
501    /**
502     * @return {boolean}
503     */
504    active: function()
505    {
506        return this._active;
507    }
508}
509