Add experimental support for `<fa-icon>`
authorAlexander Ebert <ebert@woltlab.com>
Mon, 8 Aug 2022 12:37:39 +0000 (14:37 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Fri, 12 Aug 2022 19:25:55 +0000 (21:25 +0200)
com.woltlab.wcf/templates/headIncludeJavaScript.tpl
com.woltlab.wcf/templates/pageHeaderUser.tpl
ts/WoltLabSuite/WebComponent/fa-icon.ts [new file with mode: 0644]
ts/WoltLabSuite/WebComponent/tsconfig.json [new file with mode: 0644]
tsconfig.json
wcfsetup/install/files/js/WoltLabSuite/WebComponent/fa-icon.js [new file with mode: 0644]
wcfsetup/install/files/lib/system/template/plugin/IconFunctionTemplatePlugin.class.php
wcfsetup/install/files/style/font-awesome/000-woltlab.scss

index 722a5ce6ed7212f85ea6d1afb21bf2b1f4c99480..e7a1d6118a26bba18dea70d90d4bdc297d1dd824 100644 (file)
@@ -26,6 +26,8 @@
        {/if}
 </script>
 
+<script src="{$__wcf->getPath()}js/WoltLabSuite/WebComponent/fa-icon.js?v={TIME_NOW}"></script>
+
 {js application='wcf' file='require' bundle='WoltLabSuite.Core' core='true' hasTiny=true}
 {js application='wcf' file='require.config' bundle='WoltLabSuite.Core' core='true' hasTiny=true}
 {js application='wcf' file='require.linearExecution' bundle='WoltLabSuite.Core' core='true' hasTiny=true}
index cd2c59ec9510c4b9e89c3006f5adc4eda139133a..7efee9e94d1cc0b01fb5d45e2bd1fa48f1244a4a 100644 (file)
                                                aria-haspopup="true"
                                                aria-expanded="false"
                                        >
-                                               <span class="icon icon32 fa-bell-o"></span> <span>{lang}wcf.user.notification.notifications{/lang}</span>{if $__wcf->getUserNotificationHandler()->getNotificationCount()} <span class="badge badgeUpdate">{#$__wcf->getUserNotificationHandler()->getNotificationCount()}</span>{/if}
+                                               {icon size=32 name='bell' type='solid'}
+                                               {icon size=32 name='bell' type='regular'}
+                                               <span class="icon icon32 fa-bell-o"></span>
+                                               {icon size=32 name='500px' type='brand'}
+                                               <span>{lang}wcf.user.notification.notifications{/lang}</span>{if $__wcf->getUserNotificationHandler()->getNotificationCount()} <span class="badge badgeUpdate">{#$__wcf->getUserNotificationHandler()->getNotificationCount()}</span>{/if}
                                        </a>
                                        {if !OFFLINE || $__wcf->session->getPermission('admin.general.canViewPageDuringOfflineMode')}
                                                <script data-relocate="true">
diff --git a/ts/WoltLabSuite/WebComponent/fa-icon.ts b/ts/WoltLabSuite/WebComponent/fa-icon.ts
new file mode 100644 (file)
index 0000000..72702d8
--- /dev/null
@@ -0,0 +1,94 @@
+const Sizes = [16, 24, 32, 48, 64, 96, 128, 144];
+const HeightMap = new Map<number, number>([
+  [16, 14],
+  [24, 18],
+  [32, 28],
+  [48, 42],
+  [64, 56],
+  [96, 84],
+  [128, 112],
+  [144, 130],
+]);
+
+class FaIcon extends HTMLElement {
+  constructor() {
+    performance.mark("icon-init-start");
+    super();
+
+    performance.mark("icon-init-end");
+    performance.measure("iconInit", "icon-init-start", "icon-init-end");
+  }
+
+  connectedCallback() {
+    performance.mark("icon-start");
+
+    this.validate();
+
+    const root = this.prepareRoot();
+    if (this.brand) {
+      const slot = document.createElement("slot");
+      slot.name = "svg";
+      root.append(slot);
+    } else {
+      root.append("\uf0f3");
+    }
+
+    performance.mark("icon-end");
+    performance.measure("iconRendered", "icon-start", "icon-end");
+  }
+
+  private validate(): void {
+    if (this.size === 0) {
+      throw new TypeError("Must provide an icon size.");
+    } else if (!Sizes.includes(this.size)) {
+      throw new TypeError("Must provide a valid icon size.");
+    }
+
+    if (this.brand) {
+      if (this.name !== null) {
+        throw new TypeError("Cannot provide a name for brand icons.");
+      }
+    } else {
+      if (this.name === null) {
+        throw new TypeError("Must provide the name of the icon.");
+      }
+    }
+  }
+
+  private prepareRoot(): ShadowRoot {
+    const size = this.size;
+    const iconHeight = HeightMap.get(size)!;
+
+    const root = this.attachShadow({ mode: "closed" });
+    const style = document.createElement("style");
+    style.textContent = `
+      ::slotted(svg) {
+        fill: currentColor;
+        height: ${iconHeight}px;
+        shape-rendering: geometricprecision;
+      }
+    `;
+    root.append(style);
+
+    return root;
+  }
+
+  get brand(): boolean {
+    return this.hasAttribute("brand");
+  }
+
+  get name(): string | null {
+    return this.getAttribute("name");
+  }
+
+  get size(): number {
+    const size = this.getAttribute("size");
+    if (size === null) {
+      return 0;
+    }
+
+    return parseInt(size);
+  }
+}
+
+window.customElements.define("fa-icon", FaIcon);
diff --git a/ts/WoltLabSuite/WebComponent/tsconfig.json b/ts/WoltLabSuite/WebComponent/tsconfig.json
new file mode 100644 (file)
index 0000000..f103ea0
--- /dev/null
@@ -0,0 +1,11 @@
+{
+    "extends": "../../../tsconfig.json",
+    "include": [
+        "*.ts"
+    ],
+    "exclude": [],
+    "compilerOptions": {
+        "composite": false,
+        "module": "ES2020"
+    }
+}
index e870409cf29fd47f888ba8b0b8ff2e2935c4e016..c73c71fc03af5495902abd3ebe831b4f628f099d 100644 (file)
@@ -3,6 +3,14 @@
     "global.d.ts",
     "ts/**/*"
   ],
+  "exclude": [
+    "ts/WoltLabSuite/WebComponent/*.ts"
+  ],
+  "references": [
+    {
+      "path": "ts/WoltLabSuite/WebComponent"
+    }
+  ],
   "compilerOptions": {
     "allowJs": true,
     "target": "es2019",
diff --git a/wcfsetup/install/files/js/WoltLabSuite/WebComponent/fa-icon.js b/wcfsetup/install/files/js/WoltLabSuite/WebComponent/fa-icon.js
new file mode 100644 (file)
index 0000000..825356b
--- /dev/null
@@ -0,0 +1,81 @@
+const Sizes = [16, 24, 32, 48, 64, 96, 128, 144];
+const HeightMap = new Map([
+    [16, 14],
+    [24, 18],
+    [32, 28],
+    [48, 42],
+    [64, 56],
+    [96, 84],
+    [128, 112],
+    [144, 130],
+]);
+class FaIcon extends HTMLElement {
+    constructor() {
+        performance.mark("icon-init-start");
+        super();
+        performance.mark("icon-init-end");
+        performance.measure("iconInit", "icon-init-start", "icon-init-end");
+    }
+    connectedCallback() {
+        performance.mark("icon-start");
+        this.validate();
+        const root = this.prepareRoot();
+        if (this.brand) {
+            const slot = document.createElement("slot");
+            slot.name = "svg";
+            root.append(slot);
+        }
+        else {
+            root.append("\uf0f3");
+        }
+        performance.mark("icon-end");
+        performance.measure("iconRendered", "icon-start", "icon-end");
+    }
+    validate() {
+        if (this.size === 0) {
+            throw new TypeError("Must provide an icon size.");
+        }
+        else if (!Sizes.includes(this.size)) {
+            throw new TypeError("Must provide a valid icon size.");
+        }
+        if (this.brand) {
+            if (this.name !== null) {
+                throw new TypeError("Cannot provide a name for brand icons.");
+            }
+        }
+        else {
+            if (this.name === null) {
+                throw new TypeError("Must provide the name of the icon.");
+            }
+        }
+    }
+    prepareRoot() {
+        const size = this.size;
+        const iconHeight = HeightMap.get(size);
+        const root = this.attachShadow({ mode: "closed" });
+        const style = document.createElement("style");
+        style.textContent = `
+      ::slotted(svg) {
+        fill: currentColor;
+        height: ${iconHeight}px;
+        shape-rendering: geometricprecision;
+      }
+    `;
+        root.append(style);
+        return root;
+    }
+    get brand() {
+        return this.hasAttribute("brand");
+    }
+    get name() {
+        return this.getAttribute("name");
+    }
+    get size() {
+        const size = this.getAttribute("size");
+        if (size === null) {
+            return 0;
+        }
+        return parseInt(size);
+    }
+}
+window.customElements.define("fa-icon", FaIcon);
index 26c31764b44e62c50395036c829e39748bcb64d6..c59bbd176c2e0120532ae97846d6f247f9304d1c 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace wcf\system\template\plugin;
 
-use wcf\system\exception\SystemException;
 use wcf\system\template\TemplateEngine;
 
 /**
@@ -22,6 +21,8 @@ final class IconFunctionTemplatePlugin implements IFunctionTemplatePlugin
 {
     private const SIZES = [16, 24, 32, 48, 64, 96, 128, 144];
 
+    private const TYPES = ['brand', 'regular', 'solid'];
+
     /**
      * @inheritDoc
      */
@@ -29,25 +30,37 @@ final class IconFunctionTemplatePlugin implements IFunctionTemplatePlugin
     {
         $size = \intval($tagArgs['size'] ?? 0);
         $name = $tagArgs['name'] ?? '';
+        $type = $tagArgs['type'] ?? '';
 
         if (!\in_array($size, self::SIZES)) {
             throw new \InvalidArgumentException("An unsupported size `{$size}` was requested.");
         }
 
         if ($name === '') {
-            throw new \InvalidArgumentException("The `name` attribute must be present and non-empty");
+            throw new \InvalidArgumentException("The `name` attribute must be present and non-empty.");
+        }
+
+        if ($type === '') {
+            throw new \InvalidArgumentException("The `type` attribute must be present and non-empty.");
+        } else if (!\in_array($type, self::TYPES)) {
+            throw new \InvalidArgumentException("An unsupported type `${type}` was specified.");
         }
 
-        $svgFile = \WCF_DIR . "icon/font-awesome/v6/brands/{$name}.svg";
-        if (\file_exists($svgFile)) {
+        if ($type === 'brand') {
+            $svgFile = \WCF_DIR . "icon/font-awesome/v6/brands/{$name}.svg";
+            if (!\file_exists($svgFile)) {
+                throw new \InvalidArgumentException("Unable to locate the icon for brand `${name}`.");
+            }
+
             $content = \file_get_contents($svgFile);
+            $content = \preg_replace('~^<svg~', '<svg slot="svg"', $content);
             return <<<HTML
             <fa-icon size="{$size}" brand>{$content}</fa-icon>
             HTML;
         }
 
         return <<<HTML
-        <fa-icon size="{$size}" name="{$name}"></fa-icon>
+        <fa-icon size="{$size}" name="{$name}" {$type}></fa-icon>
         HTML;
     }
 }
index 97975b4b53658664495684b53754cfccfa7139e9..5dd7138d74e86f9c2ed1f515d027055a33bbc9e4 100644 (file)
@@ -38,6 +38,82 @@ $fa-size-scale-base: 15;
   }
 }
 
+fa-icon {
+  align-items: center;
+  display: flex;
+  height: var(--icon-size);
+  justify-content: center;
+  width: calc(var(--icon-size) * 1.25);
+
+  &:not(:upgraded) {
+    visibility: hidden !important;
+  }
+
+  &[hidden] {
+    display: none;
+  }
+
+  &:not([brand]) {
+    -moz-osx-font-smoothing: grayscale;
+    -webkit-font-smoothing: antialiased;
+
+    font-family: "Font Awesome 6 Free";
+    font-size: var(--font-size);
+    font-style: normal;
+    font-variant: normal;
+    line-height: 1;
+    text-rendering: auto;
+  }
+
+  &[solid] {
+    font-weight: 900;
+  }
+
+  &[regular] {
+    font-weight: 400;
+  }
+
+  &[size="16"] {
+    --font-size: 14px;
+    --icon-size: 16px;
+  }
+
+  &[size="24"] {
+    --font-size: 18px;
+    --icon-size: 24px;
+  }
+
+  &[size="32"] {
+    --font-size: 28px;
+    --icon-size: 32px;
+  }
+
+  &[size="48"] {
+    --font-size: 42px;
+    --icon-size: 48px;
+  }
+
+  &[size="64"] {
+    --font-size: 56px;
+    --icon-size: 64px;
+  }
+
+  &[size="96"] {
+    --font-size: 84px;
+    --icon-size: 96px;
+  }
+
+  &[size="128"] {
+    --font-size: 112px;
+    --icon-size: 128px;
+  }
+
+  &[size="144"] {
+    --font-size: 130px;
+    --icon-size: 144px;
+  }
+}
+
 /* Default icon sizes */
 .icon {
   &.icon16 {