Skip to content

Commit 35fcffd

Browse files
committed
Add extension point for custom stored Font Awesome icons
Allow FontAwesomeIcon to resolve non-native stored icon strings through a new StoredIconResolving event. This keeps native `name;solid` icon strings unchanged while giving packages a package-safe way to provide custom IFontAwesomeIcon implementations for extended descriptors. Also extend the FontAwesome picker callback contract with optional stored value and preview HTML arguments, and update IconFormField and trophy badge preview handling to use those optional values when provided.
1 parent c1394c8 commit 35fcffd

7 files changed

Lines changed: 142 additions & 33 deletions

File tree

com.woltlab.wcf/templates/shared_iconFormField.tpl

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,43 @@
1313
{if $__iconFormFieldIncludeJavaScript}
1414
{include file='shared_fontAwesomeJavaScript'}
1515
{/if}
16-
16+
1717
<script data-relocate="true">
1818
require(['WoltLabSuite/Core/Ui/Style/FontAwesome'], (UiStyleFontAwesome) => {
1919
const iconContainer = document.getElementById('{unsafe:$field->getPrefixedId()|encodeJS}_icon');
2020
const input = document.getElementById('{unsafe:$field->getPrefixedId()|encodeJS}');
2121
const buttonRemoveIcon = document.getElementById('{unsafe:$field->getPrefixedId()|encodeJS}_removeIcon');
22-
23-
const callback = (iconName, forceSolid) => {
24-
input.value = `${ iconName };${ forceSolid }`;
2522
23+
const renderNativePreview = (iconName, forceSolid) => {
2624
let icon = iconContainer.querySelector("fa-icon");
27-
if (icon) {
28-
icon.setIcon(iconName, forceSolid);
29-
} else {
25+
if (!icon || icon.parentElement !== iconContainer || iconContainer.childElementCount !== 1) {
26+
iconContainer.replaceChildren();
27+
3028
icon = document.createElement("fa-icon");
3129
icon.size = 64;
32-
icon.setIcon(iconName, forceSolid);
3330
iconContainer.append(icon);
3431
}
3532
33+
icon.setIcon(iconName, forceSolid);
34+
};
35+
36+
const callback = (iconName, forceSolid, value, previewHtml) => {
37+
input.value = typeof value === 'string' ? value : `${ iconName };${ forceSolid }`;
38+
39+
if (typeof previewHtml === 'string') {
40+
iconContainer.innerHTML = previewHtml;
41+
} else {
42+
renderNativePreview(iconName, forceSolid);
43+
}
44+
3645
buttonRemoveIcon.hidden = false;
3746
};
38-
47+
3948
const button = document.getElementById('{unsafe:$field->getPrefixedId()|encodeJS}_openIconDialog');
4049
button.addEventListener('click', () => UiStyleFontAwesome.open(callback));
4150
buttonRemoveIcon.addEventListener("click", () => {
4251
input.value = "";
43-
iconContainer.querySelector("fa-icon")?.remove();
52+
iconContainer.replaceChildren();
4453
4554
buttonRemoveIcon.hidden = true;
4655
});

ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,33 @@ import DomUtil from "../../../Dom/Util";
1313

1414
const badgeContainer = document.getElementById("badgeContainer")!;
1515
const previewWrapper = badgeContainer.querySelector(".trophyIcon") as HTMLElement;
16-
const previewIcon = previewWrapper.querySelector("fa-icon")!;
16+
17+
function renderNativePreview(icon: string, forceSolid: boolean): void {
18+
let previewIcon = previewWrapper.querySelector("fa-icon");
19+
if (!(previewIcon instanceof HTMLElement) || previewIcon.parentElement !== previewWrapper) {
20+
previewWrapper.replaceChildren();
21+
22+
previewIcon = document.createElement("fa-icon");
23+
previewIcon.size = 64;
24+
previewWrapper.append(previewIcon);
25+
}
26+
27+
previewIcon.setIcon(icon, forceSolid);
28+
}
1729

1830
function setupChangeIcon(): void {
1931
const button = badgeContainer.querySelector('.trophyIconEditButton[data-value="icon"]') as HTMLButtonElement;
2032
const input = badgeContainer.querySelector('input[name="iconName"]') as HTMLInputElement;
2133

2234
button.addEventListener("click", () => {
23-
openFontAwesomePicker((icon, forceSolid) => {
24-
previewIcon.setIcon(icon, forceSolid);
25-
input.value = `${icon};${String(forceSolid)}`;
35+
openFontAwesomePicker((icon, forceSolid, value, previewHtml) => {
36+
input.value = value ?? `${icon};${String(forceSolid)}`;
37+
38+
if (typeof previewHtml === "string") {
39+
previewWrapper.innerHTML = previewHtml;
40+
} else {
41+
renderNativePreview(icon, forceSolid);
42+
}
2643
});
2744
});
2845
}

ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getPhrase } from "WoltLabSuite/Core/Language";
1111
import UiItemListFilter from "../ItemList/Filter";
1212
import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
1313

14-
type CallbackSelect = (icon: string, forceSolid: boolean) => void;
14+
type CallbackSelect = (icon: string, forceSolid: boolean, value?: string, previewHtml?: string) => void;
1515
type IconData = { icon: string; forceSolid: boolean };
1616

1717
function createIconList(): HTMLElement {
@@ -92,7 +92,9 @@ function setupListeners(): void {
9292

9393
/**
9494
* Shows the FontAwesome selection dialog, supplied callback will be
95-
* invoked with the selection icon's name as the only argument.
95+
* invoked with the selected icon's native data. Replacement
96+
* implementations may pass an explicit stored value and preview html
97+
* for non-native icons.
9698
*/
9799
export function open(callback: CallbackSelect): void {
98100
const dialog = dialogFactory().fromElement(getContent()).asConfirmation();

wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.js

Lines changed: 18 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Style/FontAwesome.js

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace wcf\event\style;
4+
5+
use wcf\event\IPsr14Event;
6+
use wcf\system\style\IFontAwesomeIcon;
7+
8+
/**
9+
* Resolves a stored icon string to a renderable icon instance.
10+
*
11+
* @author Sascha Greuel
12+
* @copyright 2001-2026 WoltLab GmbH
13+
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
14+
* @since 6.2
15+
*/
16+
final class StoredIconResolving implements IPsr14Event
17+
{
18+
public ?IFontAwesomeIcon $icon = null;
19+
20+
public function __construct(
21+
public readonly string $iconData
22+
) {
23+
}
24+
}

wcfsetup/install/files/lib/system/style/FontAwesomeIcon.class.php

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace wcf\system\style;
44

5+
use wcf\event\style\StoredIconResolving;
6+
use wcf\system\event\EventHandler;
57
use wcf\system\style\exception\InvalidIconFormat;
68
use wcf\system\style\exception\InvalidIconSize;
79
use wcf\system\style\exception\UnknownIcon;
@@ -28,9 +30,13 @@ final class FontAwesomeIcon implements IFontAwesomeIcon, \Stringable
2830

2931
private function __construct(
3032
private readonly string $name,
31-
private readonly bool $forceSolid
33+
private readonly bool $forceSolid,
34+
private readonly ?string $iconData = null,
35+
private readonly ?IFontAwesomeIcon $icon = null
3236
) {
33-
self::validateName($name);
37+
if ($this->icon === null) {
38+
self::validateName($name);
39+
}
3440
}
3541

3642
/**
@@ -39,6 +45,10 @@ private function __construct(
3945
*/
4046
public function __toString(): string
4147
{
48+
if ($this->iconData !== null) {
49+
return $this->iconData;
50+
}
51+
4252
return \sprintf(
4353
"%s;%s",
4454
$this->name,
@@ -55,6 +65,10 @@ public function toHtml(int $size = 16): string
5565
throw new InvalidIconSize($size);
5666
}
5767

68+
if ($this->icon !== null) {
69+
return $this->icon->toHtml($size);
70+
}
71+
5872
if ($this->forceSolid) {
5973
return \sprintf(
6074
'<fa-icon size="%d" name="%s" solid></fa-icon>',
@@ -79,18 +93,21 @@ public function toHtml(int $size = 16): string
7993
*/
8094
public static function fromString(string $iconData): self
8195
{
82-
if (!\str_contains($iconData, ';')) {
83-
throw new InvalidIconFormat();
96+
$icon = self::parseIconData($iconData);
97+
if ($icon !== null && self::isValidName($icon['name'])) {
98+
return self::fromValues($icon['name'], $icon['forceSolid']);
8499
}
85100

86-
[$name, $solid] = \explode(';', $iconData, 2);
87-
if ($solid !== 'true' && $solid !== 'false') {
88-
throw new InvalidIconFormat();
101+
$resolvedIcon = self::resolveStoredIcon($iconData);
102+
if ($resolvedIcon !== null) {
103+
return new self('question', true, $iconData, $resolvedIcon);
89104
}
90105

91-
$forceSolid = $solid === 'true';
106+
if ($icon === null) {
107+
throw new InvalidIconFormat();
108+
}
92109

93-
return self::fromValues($name, $forceSolid);
110+
return self::fromValues($icon['name'], $icon['forceSolid']);
94111
}
95112

96113
public static function fromValues(string $name, bool $forceSolid = false): self
@@ -99,17 +116,41 @@ public static function fromValues(string $name, bool $forceSolid = false): self
99116
}
100117

101118
public static function isValidString(string $iconData): bool
119+
{
120+
$icon = self::parseIconData($iconData);
121+
if ($icon !== null && self::isValidName($icon['name'])) {
122+
return true;
123+
}
124+
125+
return self::resolveStoredIcon($iconData) !== null;
126+
}
127+
128+
/**
129+
* @return ?array{name: string, forceSolid: bool}
130+
*/
131+
private static function parseIconData(string $iconData): ?array
102132
{
103133
if (!\str_contains($iconData, ';')) {
104-
return false;
134+
return null;
105135
}
106136

107137
[$name, $solid] = \explode(';', $iconData, 2);
108138
if ($solid !== 'true' && $solid !== 'false') {
109-
return false;
139+
return null;
110140
}
111141

112-
return self::isValidName($name);
142+
return [
143+
'name' => $name,
144+
'forceSolid' => $solid === 'true',
145+
];
146+
}
147+
148+
private static function resolveStoredIcon(string $iconData): ?IFontAwesomeIcon
149+
{
150+
$event = new StoredIconResolving($iconData);
151+
EventHandler::getInstance()->fire($event);
152+
153+
return $event->icon;
113154
}
114155

115156
public static function isValidName(string $name): bool

0 commit comments

Comments
 (0)