diff --git a/README.md b/README.md
index c5910322..20de600b 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,22 @@ as Uglify, then this value will reflect the minified size of your code.
This is the size of running the parsed bundles/modules through gzip compression.
+
Selecting Which Chunks to Display
+
+When opened, the report displays all of the Webpack chunks for your project. It's possible to filter to a more specific list of chunks by using the sidebar or the chunk context menu.
+
+### Sidebar
+
+The Sidebar Menu can be opened by clicking the `>` button at the top left of the report. You can select or deselect chunks to display under the "Show chunks" heading there.
+
+### Chunk Context Menu
+
+The Chunk Context Menu can be opened by right-clicking or `Ctrl`-clicking on a specific chunk in the report. It provides the following options:
+
+ * **Hide chunk:** Hides the selected chunk
+ * **Hide all other chunks:** Hides all chunks besides the selected one
+ * **Show all chunks:** Un-hides any hidden chunks, returning the report to its initial, unfiltered view
+
Troubleshooting
### I can't see all the dependencies in a chunk
diff --git a/client/components/ContextMenu.css b/client/components/ContextMenu.css
new file mode 100644
index 00000000..b8441dfe
--- /dev/null
+++ b/client/components/ContextMenu.css
@@ -0,0 +1,18 @@
+.container {
+ font: var(--main-font);
+ position: absolute;
+ padding: 0;
+ border-radius: 4px;
+ background: #fff;
+ border: 1px solid #aaa;
+ list-style: none;
+ opacity: 1;
+ white-space: nowrap;
+ visibility: visible;
+ transition: opacity .2s ease, visibility .2s ease;
+}
+
+.hidden {
+ opacity: 0;
+ visibility: hidden;
+}
diff --git a/client/components/ContextMenu.jsx b/client/components/ContextMenu.jsx
new file mode 100644
index 00000000..0a5db3a8
--- /dev/null
+++ b/client/components/ContextMenu.jsx
@@ -0,0 +1,121 @@
+/** @jsx h */
+import {h} from 'preact';
+import cls from 'classnames';
+import ContextMenuItem from './ContextMenuItem';
+import PureComponent from '../lib/PureComponent';
+import {store} from '../store';
+import {elementIsOutside} from '../utils';
+
+import s from './ContextMenu.css';
+
+export default class ContextMenu extends PureComponent {
+ componentDidMount() {
+ this.boundingRect = this.node.getBoundingClientRect();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.visible && !prevProps.visible) {
+ document.addEventListener('mousedown', this.handleDocumentMousedown, true);
+ } else if (prevProps.visible && !this.props.visible) {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown, true);
+ }
+ }
+
+ render() {
+ const {visible} = this.props;
+ const containerClassName = cls({
+ [s.container]: true,
+ [s.hidden]: !visible
+ });
+ const multipleChunksSelected = store.selectedChunks.length > 1;
+ return (
+
+
+ Hide chunk
+
+
+ Hide all other chunks
+
+
+
+ Show all chunks
+
+
+ );
+ }
+
+ handleClickHideChunk = () => {
+ const {chunk: selectedChunk} = this.props;
+ if (selectedChunk && selectedChunk.label) {
+ const filteredChunks = store.selectedChunks.filter(chunk => chunk.label !== selectedChunk.label);
+ store.selectedChunks = filteredChunks;
+ }
+ this.hide();
+ }
+
+ handleClickFilterToChunk = () => {
+ const {chunk: selectedChunk} = this.props;
+ if (selectedChunk && selectedChunk.label) {
+ const filteredChunks = store.allChunks.filter(chunk => chunk.label === selectedChunk.label);
+ store.selectedChunks = filteredChunks;
+ }
+ this.hide();
+ }
+
+ handleClickShowAllChunks = () => {
+ store.selectedChunks = store.allChunks;
+ this.hide();
+ }
+
+ /**
+ * Handle document-wide `mousedown` events to detect clicks
+ * outside the context menu.
+ * @param {MouseEvent} e - DOM mouse event object
+ * @returns {void}
+ */
+ handleDocumentMousedown = (e) => {
+ const isSecondaryClick = e.ctrlKey || e.button === 2;
+ if (!isSecondaryClick && elementIsOutside(e.target, this.node)) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.hide();
+ }
+ }
+
+ hide() {
+ if (this.props.onHide) {
+ this.props.onHide();
+ }
+ }
+
+ saveNode = node => (this.node = node);
+
+ getStyle() {
+ const {boundingRect} = this;
+
+ // Upon the first render of this component, we don't yet know
+ // its dimensions, so can't position it yet
+ if (!boundingRect) return;
+
+ const {coords} = this.props;
+
+ const pos = {
+ left: coords.x,
+ top: coords.y
+ };
+
+ if (pos.left + boundingRect.width > window.innerWidth) {
+ // Shifting horizontally
+ pos.left = window.innerWidth - boundingRect.width;
+ }
+
+ if (pos.top + boundingRect.height > window.innerHeight) {
+ // Flipping vertically
+ pos.top = coords.y - boundingRect.height;
+ }
+ return pos;
+ }
+}
diff --git a/client/components/ContextMenuItem.css b/client/components/ContextMenuItem.css
new file mode 100644
index 00000000..a33bc962
--- /dev/null
+++ b/client/components/ContextMenuItem.css
@@ -0,0 +1,19 @@
+.item {
+ cursor: pointer;
+ margin: 0;
+ padding: 8px 14px;
+ user-select: none;
+}
+
+.item:hover {
+ background: #ffefd7;
+}
+
+.disabled {
+ cursor: default;
+ color: gray;
+}
+
+.item.disabled:hover {
+ background: transparent;
+}
diff --git a/client/components/ContextMenuItem.jsx b/client/components/ContextMenuItem.jsx
new file mode 100644
index 00000000..e03e6486
--- /dev/null
+++ b/client/components/ContextMenuItem.jsx
@@ -0,0 +1,17 @@
+/** @jsx h */
+import {h} from 'preact';
+import cls from 'classnames';
+import s from './ContextMenuItem.css';
+
+function noop() {
+ return false;
+}
+
+export default function ContextMenuItem({children, disabled, onClick}) {
+ const className = cls({
+ [s.item]: true,
+ [s.disabled]: disabled
+ });
+ const handler = disabled ? noop : onClick;
+ return ({children});
+}
diff --git a/client/components/ModulesTreemap.jsx b/client/components/ModulesTreemap.jsx
index 20af498f..1b2489a2 100644
--- a/client/components/ModulesTreemap.jsx
+++ b/client/components/ModulesTreemap.jsx
@@ -11,6 +11,7 @@ import Switcher from './Switcher';
import Sidebar from './Sidebar';
import Checkbox from './Checkbox';
import CheckboxList from './CheckboxList';
+import ContextMenu from './ContextMenu';
import s from './ModulesTreemap.css';
import Search from './Search';
@@ -26,13 +27,23 @@ const SIZE_SWITCH_ITEMS = [
@observer
export default class ModulesTreemap extends Component {
state = {
+ selectedChunk: null,
+ selectedMouseCoords: {x: 0, y: 0},
sidebarPinned: false,
+ showChunkContextMenu: false,
showTooltip: false,
tooltipContent: null
};
render() {
- const {sidebarPinned, showTooltip, tooltipContent} = this.state;
+ const {
+ selectedChunk,
+ selectedMouseCoords,
+ sidebarPinned,
+ showChunkContextMenu,
+ showTooltip,
+ tooltipContent
+ } = this.state;
return (
@@ -97,10 +108,16 @@ export default class ModulesTreemap extends Component {
highlightGroups={this.highlightedModules}
weightProp={store.activeSize}
onMouseLeave={this.handleMouseLeaveTreemap}
- onGroupHover={this.handleTreemapGroupHover}/>
+ onGroupHover={this.handleTreemapGroupHover}
+ onGroupSecondaryClick={this.handleTreemapGroupSecondaryClick}
+ onResize={this.handleResize}/>
{tooltipContent}
+
);
}
@@ -180,6 +197,22 @@ export default class ModulesTreemap extends Component {
store.showConcatenatedModulesContent = flag;
}
+ handleChunkContextMenuHide = () => {
+ this.setState({
+ showChunkContextMenu: false
+ });
+ }
+
+ handleResize = () => {
+ // Close any open context menu when the report is resized,
+ // so it doesn't show in an incorrect position
+ if (this.state.showChunkContextMenu) {
+ this.setState({
+ showChunkContextMenu: false
+ });
+ }
+ }
+
handleSidebarToggle = () => {
if (this.state.sidebarPinned) {
setTimeout(() => this.treemap.resize());
@@ -211,6 +244,25 @@ export default class ModulesTreemap extends Component {
this.setState({showTooltip: false});
};
+ handleTreemapGroupSecondaryClick = event => {
+ const {group, x, y} = event;
+ if (group && group.isAsset) {
+ this.setState({
+ selectedChunk: group,
+ selectedMouseCoords: {
+ x,
+ y
+ },
+ showChunkContextMenu: true
+ });
+ } else {
+ this.setState({
+ selectedChunk: null,
+ showChunkContextMenu: false
+ });
+ }
+ };
+
handleTreemapGroupHover = event => {
const {group} = event;
@@ -245,6 +297,12 @@ export default class ModulesTreemap extends Component {
{module.path &&
Path: {module.path}
}
+ {module.isAsset &&
+
+
+ Right-click to view options related to this chunk
+
+ }
);
}
diff --git a/client/components/Treemap.jsx b/client/components/Treemap.jsx
index 9ccf6705..5e83124f 100644
--- a/client/components/Treemap.jsx
+++ b/client/components/Treemap.jsx
@@ -89,8 +89,18 @@ export default class Treemap extends Component {
};
}
},
+ /**
+ * Handle Foamtree's "group clicked" event
+ * @param {FoamtreeEvent} event - Foamtree event object
+ * (see https://get.carrotsearch.com/foamtree/demo/api/index.html#event-details)
+ * @returns {void}
+ */
onGroupClick(event) {
preventDefault(event);
+ if ((event.ctrlKey || event.secondary) && props.onGroupSecondaryClick) {
+ props.onGroupSecondaryClick.call(component, event);
+ return;
+ }
component.zoomOutDisabled = false;
this.zoom(event.group);
},
@@ -145,7 +155,12 @@ export default class Treemap extends Component {
}
resize = () => {
+ const {props} = this;
this.treemap.resize();
+
+ if (props.onResize) {
+ props.onResize();
+ }
}
}
diff --git a/client/utils.js b/client/utils.js
index ef5290b5..9ef1cbed 100644
--- a/client/utils.js
+++ b/client/utils.js
@@ -13,3 +13,7 @@ export function walkModules(modules, cb) {
}
}
}
+
+export function elementIsOutside(elem, container) {
+ return !(elem === container || container.contains(elem));
+}
diff --git a/src/analyzer.js b/src/analyzer.js
index 62ac9968..1d1317a8 100644
--- a/src/analyzer.js
+++ b/src/analyzer.js
@@ -94,6 +94,7 @@ function getViewerData(bundleStats, bundleDir, opts) {
return _.transform(assets, (result, asset, filename) => {
result.push({
label: filename,
+ isAsset: true,
// Not using `asset.size` here provided by Webpack because it can be very confusing when `UglifyJsPlugin` is used.
// In this case all module sizes from stats file will represent unminified module sizes, but `asset.size` will
// be the size of minified bundle.