diff options
| author | Jan-Hendrik Willms <tleilax+studip@gmail.com> | 2025-07-15 12:02:29 +0200 |
|---|---|---|
| committer | Jan-Hendrik Willms <tleilax+studip@gmail.com> | 2025-07-15 12:02:29 +0200 |
| commit | c347c0177209f2c94306189df154b8661f1cf086 (patch) | |
| tree | 2f33be54bf90d252dbd919626f847bd8c895a4bb /resources | |
| parent | 7801dd9d96c8e8e898af08ea3a760b06bee01d6b (diff) | |
rewrite icon loader to handle non svg icons as well, fixes #5725
Closes #5725
Merge request studip/studip!4356
Diffstat (limited to 'resources')
| -rw-r--r-- | resources/assets/javascripts/lib/icon-loader.ts | 52 | ||||
| -rw-r--r-- | resources/vue/components/StudipIcon.vue | 171 |
2 files changed, 136 insertions, 87 deletions
diff --git a/resources/assets/javascripts/lib/icon-loader.ts b/resources/assets/javascripts/lib/icon-loader.ts index fe47b53..0b95e2f 100644 --- a/resources/assets/javascripts/lib/icon-loader.ts +++ b/resources/assets/javascripts/lib/icon-loader.ts @@ -1,14 +1,18 @@ type CacheOption = 'off' | 'session' | 'local'; +type CachedIcon = { + isSvg: boolean, + content: string +}; class IconLoader { - readonly #cacheKey: string = 'studip/svg-icons'; + readonly #cacheKey: string = 'studip/icons'; #baseUrl: string; #useCache: CacheOption = 'off'; #cache: Map<string, string>; - #promises: Map<string, Promise<string>>; + #promises: Map<string, Promise<CachedIcon>>; constructor(baseUrl: string, useCache: CacheOption = 'off') { @@ -16,13 +20,13 @@ class IconLoader this.#useCache = useCache; this.#cache = new Map<string, string>(this.#initialState()); - this.#promises = new Map<string, Promise<string>>(); + this.#promises = new Map<string, Promise<CachedIcon>>(); } - async load(shape: string): Promise<string> + async load(shape: string): Promise<CachedIcon> { if (this.#cache.has(shape)) { - return this.#cache.get(shape)!; + return JSON.parse(this.#cache.get(shape)!); } if (this.#promises.has(shape)) { @@ -37,19 +41,37 @@ class IconLoader try { const response = await fetch(url); if (!response.ok) { - return ''; + throw new Error(`IconLoader: HTTP ${response.status} ${response.statusText}`); } - let svg = await response.text(); - svg = svg.replace(/fill="(?!none)[^"]+"/g, 'fill="currentColor"'); - svg = svg.replace(/(width|height)="[^"]+"/g, ''); + const icon: CachedIcon = { + isSvg: response.headers.get('Content-Type')?.includes('image/svg+xml') ?? false, + content: '' + }; + + if (icon.isSvg) { + let svg = await response.text(); + svg = svg.replace(/fill="(?!none)[^"]+"/g, 'fill="currentColor"'); + svg = svg.replace(/(width|height)="[^"]+"/g, ''); + icon.content = svg; + } else { + const blob = await response.blob(); + icon.content = await (new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + })); + } - this.store(shape, svg); + this.store(shape, icon); - return svg; + return icon; } catch(error) { console.error(`IconLoader: Fehler beim Laden von ${shape}`, error); - return ''; + return { + isSvg: true, + content: '' + } as CachedIcon; } finally { this.#promises.delete(shape); } @@ -60,9 +82,9 @@ class IconLoader return promise; } - store(shape: string, svg: string): void + store(shape: string, icon: CachedIcon): void { - this.#cache.set(shape, svg); + this.#cache.set(shape, JSON.stringify(icon)); this.#getStorage()?.setItem( this.#cacheKey, @@ -96,4 +118,4 @@ class IconLoader const defaultLoader = new IconLoader(window.STUDIP.ASSETS_URL, 'session'); export default defaultLoader; -export { IconLoader }; +export { IconLoader, CachedIcon }; diff --git a/resources/vue/components/StudipIcon.vue b/resources/vue/components/StudipIcon.vue index 4e6eb19..1f68c66 100644 --- a/resources/vue/components/StudipIcon.vue +++ b/resources/vue/components/StudipIcon.vue @@ -1,84 +1,111 @@ <template> - <div - v-if="!name" - v-bind="$attrs" - v-html="svgContent" - :role="ariaRole" - :class="cssClass" - :style="computedStyle" - /> - - <label v-else class="icon-button undecorated"> - <input type="submit" hidden v-bind="$attrs"> - <div v-html="svgContent" :role="ariaRole" :class="cssClass" :style="computedStyle" /> + <label v-if="name" class="icon-button undecorated"> + <input type="submit" hidden v-bind="attrs"> + <IconContent v-bind="iconAttrs" /> <span v-if="text">{{ text }}</span> </label> + + <IconContent v-else v-bind="allAttrs" /> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import iconLoader from "../../assets/javascripts/lib/icon-loader"; +<script lang="ts" setup> +import { + computed, + defineComponent, + h, + ref, + useAttrs, + watch, -export default defineComponent({ - name: 'studip-icon', - props: { - ariaRole: { type: String, required: false }, - name: { type: String, required: false }, - role: { type: String, required: false, default: 'clickable' }, - shape: { type: String, required: true }, - size: { type: Number, required: false, default: null }, - inline: { type: Boolean, default: false }, - text: { type: String, required: false } - }, - data() { - return { svgContent: '' }; + CSSProperties, + PropType +} from 'vue'; +import iconLoader, { CachedIcon } from '../../assets/javascripts/lib/icon-loader'; + +interface IconProps { + ariaRole?: string; + name?: string; + role?: string; + shape: string; + size?: number | null; + inline?: boolean; + text?: string; +} + +const props = withDefaults(defineProps<IconProps>(),{ + role: 'clickable', + size: null, + inline: false +}); + +const isSvg = ref(false); +const content = ref(''); + +const attrs = useAttrs(); +const iconAttrs = computed(() => ({ + ariaRole: props.ariaRole, + class: cssClass.value, + content: content.value, + isSvg: isSvg.value, + style: computedStyle.value, +})); +const allAttrs = computed(() => ({ + ...attrs, + ...iconAttrs.value +})); + +const cssClass = computed(() => [ + 'studip-icon', + props.inline ? 'studip-icon-inline' : '', + `icon-role-${props.role}`, + `icon-shape-${shapeName.value}` +]); + +const computedStyle = computed(() => props.size + ? { width: `${props.size}px`, height: `${props.size}px` } + : {} +); + +const shapeName = computed((): string => { + const containsUrl = (shape: string): boolean => /\bhttps?:\/\/\S+/i.test(shape); + return containsUrl(props.shape) ? (props.shape.split('/').pop()?.replace('.svg', '') ?? '') : props.shape; +}); + +watch( + () => props.shape, + async (shape) => { + const icon: CachedIcon = await iconLoader.load(shape); + isSvg.value = icon.isSvg; + content.value = icon.content; }, - computed: { - color(): string { - const roleColors: Record<string, string> = { - accept: 'green', - attention: 'red', - clickable: 'blue', - info: 'black', - info_alt: 'white', - inactive: 'grey', - navigation: 'blue', - new: 'red', - sort: 'blue', - 'status-green': 'green', - 'status-red': 'red', - 'status-yellow': 'yellow', - }; + { immediate: true } +); - return roleColors[this.role] ?? 'blue'; - }, - cssClass(): string[] { - return [ - 'studip-icon', - this.inline ? 'studip-icon-inline' : '', - `icon-role-${this.role}`, - `icon-shape-${this.shapeName}` - ]; - }, - computedStyle(): Record<string, string> { - return this.size - ? { width: `${this.size}px`, height: `${this.size}px` } - : {}; // Falls size nicht gesetzt ist, greift CSS mit --icon-size-default - }, - shapeName(): string { - const containsUrl = (shape: string): boolean => /\bhttps?:\/\/[^\s]+/i.test(shape); - return containsUrl(this.shape) ? (this.shape.split('/').pop()?.replace('.svg', '') ?? '') : this.shape; - } +const IconContent = defineComponent({ + props: { + isSvg: Boolean, + content: String, + ariaRole: String, + cssClass: String, + computedStyle: Object as PropType<CSSProperties> }, - watch: { - shape: { - immediate: true, - handler(shape) { - iconLoader.load(shape).then((svg: string) => { - this.svgContent = svg; - }); - } - } + setup(props, { attrs }) { + const baseAttrs = { + class: props.cssClass, + role: props.ariaRole, + style: props.computedStyle, + ...attrs + }; + + return () => props.isSvg + ? h('div', { + innerHTML: props.content, + ...baseAttrs + }) + : h('img', { + src: props.content, + ...baseAttrs + }); } }); </script> |
