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 ( + + ); + } + + 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.