Templarian / MaterialDesign

✒7000+ Material Design Icons from the Community
https://materialdesignicons.com
Other
11.01k stars 720 forks source link

Angular Material SVG loading full icon library #4913

Closed elizabeth-dev closed 1 year ago

elizabeth-dev commented 4 years ago

When using this library with Angular Material, through the @mdi/angular-material package, the getting started guide suggests copying the mdi.svg file to the assets folder, and loading the icon set through the MatIconRegistry. This works fine, but it has a downside: the full mdi.svg file (1.8mb) is always loaded, even if only one icon is used.

Is there any workaround for this? When using @mdi/js (with a custom component, as suggested for plain Angular stacks) only the necessary icons are loaded. Can this be adapted for use with Angular Material?

Templarian commented 4 years ago

I would ask on their community as I'm not familiar with the project. Still find it amazing people still use @mdi/angular-material instead of @mdi/js. It's very much a legacy package we just publish it since they asked for it.

I'm in the process of coding a @mdi/angular component at the moment that will be our recommended approach. It will function very similar to @mdi/react does for the react community.

Most likely we'll deprecate @mdi/angular-material once our first party component is complete.

I'm going to leave this open once you reach out to them can you link here so we can see what they say.

philmtd commented 4 years ago

I solved this issue by using the single SVGs and importing them into Angular Material individually. I can quickly describe how I automated this:

extract-svg-icons.js (don't just copy and paste this - it contains filenames and paths you might not have/want):

const fs = require('fs');

function writeSvgDefinitionFile(filePath, constName, basePath, iconNames) {
  let file =
    `// THIS FILE IS GENERATED AUTOMATICALLY BY extract-svg-icons.js
// DO NOT EDIT IT MANUALLY - YOU WILL LOSE YOUR CHANGES
export interface IconSet {
 basePath: string;
 icons: Array<string>;
}

export const ${constName}: IconSet = {
  basePath: '${basePath}',
  icons: [\n`;

  for (let i = 0; i < iconNames.length; i++) {
    file += `    '${iconNames[i]}'`;
    if (i < iconNames.length - 1) {
      file += `,\n`
    }
  }

  file += `\n  ]\n};\n`;

  fs.writeFileSync(filePath, new Uint8Array(Buffer.from(file)));
}

function getSvgIconNamesFromDirectory(path) {
  return fs.readdirSync(path)
    .filter(filename => filename.endsWith('.svg'))
    .map(filename => filename.slice(0, -4));
}

function extractMdiIconSet() {
  const iconNames = getSvgIconNamesFromDirectory('./node_modules/@mdi/svg/svg');
  fs.mkdirSync(`./src/assets/icons/mdi/`, {recursive: true});
  iconNames.forEach(iconName => fs.copyFileSync(`./node_modules/@mdi/svg/svg/${iconName}.svg`, `./src/assets/icons/mdi/${iconName}.svg`));
  writeSvgDefinitionFile('./src/app/configuration/mdi.ts', 'MDI_SVG_ICONS', 'assets/icons/mdi', iconNames);
}

extractMdiIconSet();

What this script does:

How to import the icons:

// somewhere in your code
// this adds the icons in the mdi namespace. you can customise this as you wish or just use matIconRegistry.addSvgIcon(...) if you don't want the icons to reside in a namespace
MDI_SVG_ICONS.icons.forEach(svgIcon => {
    matIconRegistry.addSvgIconInNamespace('mdi', svgIcon, domSanitizer.bypassSecurityTrustResourceUrl(`${MDI_SVG_ICONS.basePath}/${svgIcon}.svg`));
  });

Note: You should add the generated mdi.ts file as well as the icon directory (per default ./src/assets/icons/mdi) to your .gitignore because the files will be generated / copied automatically after npm install/yarn install.

Templarian commented 4 years ago

@philmtd Creative, but one main issue with that approach is you're including all the icon names in the production build. There doesn't seem to be a tree-shaking step.

The way that Angular Material handles icons seems to be a bit flawed or less than ideal. We might want to make sure that our first party component can be used instead and deprecate other approaches.

Templarian commented 4 years ago

Also, we provide @mdi/util to do a lot of things with @mdi/svg. It would simplify your code a bit probably. It's used in our NodeJS libs to interact with the meta.json.

philmtd commented 4 years ago

Absolutely, my solution is rather a quick workaround that makes the issue smaller, but it's far from ideal. The first party component you're developing sounds promising but would not entirely solve the issue for a project I'm currently working on as we use more than one icon set and would want to only use one icon component. Probably the Angular (Material) team would have to provide a fix for this including tree shaking at build time, but I'm not sure whether they see this as an important issue and whether this is so easy to have. I'll take a look at @mdi/util, thanks for the hint!

elizabeth-dev commented 4 years ago

Hmmm, I see, I thought about something like what that script does, but I just thought of it as a workaround, as you said.

Well, I was looking for a more "first-party supported" way to use this with Angular Material, but if @mdi/angular-material should be considered deprecated, I will try to find a way to make things work with @mdi/js or @mdi/svg, at least while the new component isn't out.

The only thing I am concerned about is how native Angular Material components use material icons, and how can them be adapted for use with mdi.

philmtd commented 4 years ago

The only thing I am concerned about is how native Angular Material components use material icons, and how can them be adapted for use with mdi.

I think a proper solution that integrates best into Angular Material needs to come from the Angular Material team. I tried to suggest a possible idea here but to me it seems like they don't understand the problem or don't think of it as a problem. https://github.com/angular/components/issues/18607

elizabeth-dev commented 4 years ago

The only thing I am concerned about is how native Angular Material components use material icons, and how can them be adapted for use with mdi.

I think a proper solution that integrates best into Angular Material needs to come from the Angular Material team. I tried to suggest a possible idea here but to me it seems like they don't understand the problem or don't think of it as a problem. angular/components#18607

I was trying to write a PR modifying the mat-icon component allowing it to take a SVG path string (this way @mdi/js should work)

wall-street-dev commented 4 years ago

Another approach using an SVG sprite, in this case using the IonIcons (https://ionicons.com/) The requirements are:

  1. IonIcons installed and part of your package.json
  2. A script that runs after every npm install and generates the sprite
  3. The code needed to register the Sprite in the MatIconRegistry
  4. Style Adjustments

For the first point just run npm i ionicons. For the second one, make sure to save to following script to the root of your project, I named it postinstall.js but you are free to choose the name you want. Also, add the svgstore package to your dev dependencies by running npm i svgstore -D

// postinstall.js

const fs = require('fs');
const path = require('path');
const svgstore = require('svgstore');

const normalizePath = (folderPath) => path.resolve(path.normalize(folderPath));
const SVG_INPUT_FOLDER = normalizePath('node_modules/ionicons/dist/ionicons/svg');
const SPRITE_OUTPUT_FOLDER = normalizePath('src/assets/images/');
const GENERATED_SPRITE_NAME = path.join(SPRITE_OUTPUT_FOLDER, 'ionic-icons.svg');

const sprite = svgstore();
console.log('Generating SVG Sprite');
fs.readdir(SVG_INPUT_FOLDER, function (err, files) {

  if (err) {
    return console.log('Ups, there was an error reading the Directory: ' + err);
  }
  files
    .filter(el => /\.svg$/.test(el))
    .forEach((file) => {
      sprite.add(file.replace('.svg', ''), fs.readFileSync(`${SVG_INPUT_FOLDER}/${file}`, 'utf8').replace(/<title.*>.*?<\/title>/ig, ''));
    });
  fs.writeFileSync(GENERATED_SPRITE_NAME, sprite);
  console.log('Sprite generated successfully');
});

This script reads all the SVGs contained in the node_modules/ionicons/dist/ionicons/svg folder and saves the sprite as src/assets/images/ionic-icons.svg. As I said before this script shall run during every npm install, this can be achieved by simply editing your package.json file and adding "postinstall": "node postinstall.js" in the scripts section.

Almost there: Registration of the Sprite using MatIconRegistry. In my case, I have decided to create a module named Material and then import it into the app.module.ts

// material.module.ts
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { MatIconModule, MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';

@NgModule({
  declarations: [],
  exports: [MatIconModule],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: configureMatIconRegistry,
      multi: true,
      deps: [MatIconRegistry, DomSanitizer]
    }
  ]
})
export class MaterialModule {}

export function configureMatIconRegistry(
  matIconRegistry: MatIconRegistry,
  domSanitizer: DomSanitizer
): () => Promise<void> {
  return (): Promise<any> => {
    matIconRegistry.addSvgIconSetInNamespace(
      'ionicon',
      domSanitizer.bypassSecurityTrustResourceUrl('assets/images/ionic-icons.svg')
    );
    return Promise.resolve();
  };
}

The relevant part is the configureMatIconRegistry but I think is self-explanatory.

Last but not least: Styles Our mat-icon is ready to be used like this:

<mat-icon svgIcon="ionicon:heart-outline"></mat-icon>

But, due to the way these icons are created (I mean the original SVGs) we need some style adjustments. Just make sure to put this in your global styles to target all mat-icons across the app, don't worry "regular" Material mat-icons will remain unchanged.

// styles.scss
 .mat-icon {
      &[data-mat-icon-namespace="ionicon"] {
        display: inline-block;
        width: 24px;
        height: 24px;
        font-size: 24px;
        line-height: 24px;
        contain: strict;
        fill: currentcolor;
        box-sizing: content-box !important;
        svg {
          stroke: currentcolor;

          .ionicon {
            stroke: currentColor;
          }

          .ionicon-fill-none {
            fill: none;
          }

          .ionicon-stroke-width {
            stroke-width: 32px;
          }
        }
        .ionicon,
        svg {
          display: block;
          height: 100%;
          width: 100%;
        }
      }
    }

And that's pretty much it! Now, with every npm install you will have all the Ionic Icons ready to be used with the <mat-icon>

philmtd commented 4 years ago

@guzmanoj The point of this issue is exactly that we do not want to use an SVG sprite because all icons would always be loaded, even if you only include one in your app.

wall-street-dev commented 4 years ago

@philmtd you're right. My idea with this approach is that you can selectively have a folder with the SVGs you need and then create the sprite. 2 miles away from ideal, I know.

jefbarn commented 2 years ago

Hello to anyone looking at this in 2022. This is how I attacked this on Angular 14:

import * as mdi from './mdi-icons'
import { camelCase } from 'lodash-es'

export class MaterialModule {
  constructor(matIconRegistry: MatIconRegistry, sanitizer: DomSanitizer) {
    for (const [name, path] of Object.entries(mdi)) {
      matIconRegistry.addSvgIconLiteral(
        kebabCase(name),
        sanitizer.bypassSecurityTrustHtml(
          `<svg viewBox="0 0 24 24"><path d="${path}"></path></svg>`
        )
      )
    }
  }

then in the 'mdi-icons.ts' file only export what you need (to avoid importing the whole set):

export { mdiWrench, mdiCalendarCheck } from '@mdi/js'

Then in your component template:

<mat-icon svgIcon="mdi-wrench"></mat-icon>

The only real benefit here is avoiding the need to name everything twice on import using the addSvgIconLiteral method.

Edit: added a little helper directive to yell at me if I use an icon that's not bundled

import { Directive, Input } from '@angular/core'
import { KebabCase } from 'type-fest'
import * as mdi from './mdi-icons'

export type MdiIconName = KebabCase<keyof typeof mdi>

@Directive({ selector: 'mat-icon[svgIcon]' })
export class MdiIconDirective {
  @Input() svgIcon!: MdiIconName
}