diff --git a/package.json b/package.json index 0c90264..2ef4828 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "apexcharts": "^4.3.0", "highlight.js": "11.11.1", "ibantools": "^4.5.1", + "lucide": "^0.488.0", "monaco-editor": "^0.52.2", "pdfjs-dist": "^4.10.38", "xterm": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34f32af..7852952 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: ibantools: specifier: ^4.5.1 version: 4.5.1 + lucide: + specifier: ^0.488.0 + version: 0.488.0 monaco-editor: specifier: ^0.52.2 version: 0.52.2 @@ -3092,6 +3095,9 @@ packages: resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==} engines: {node: '>=16.14'} + lucide@0.488.0: + resolution: {integrity: sha512-AcCuN/R9ZRDEH+H47azM5RWbhUTi02+OL4QHeY6kP/MGMSislGWtRDNqj/2EJIBp/qMKpuAU1v5BLAVT0BDE/g==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -8640,6 +8646,8 @@ snapshots: lru-cache@8.0.5: {} + lucide@0.488.0: {} + make-dir@3.1.0: dependencies: semver: 6.3.1 diff --git a/ts_web/elements/dees-icon.demo.ts b/ts_web/elements/dees-icon.demo.ts index c58db3c..6876b0a 100644 --- a/ts_web/elements/dees-icon.demo.ts +++ b/ts_web/elements/dees-icon.demo.ts @@ -1,31 +1,159 @@ import { html } from '@design.estate/dees-element'; +import { icons, type IconWithPrefix } from './dees-icon.js'; +import * as lucideIcons from 'lucide'; -import { faIcons } from './dees-icon.js'; +export const demoFunc = () => { + // Group FontAwesome icons by type + const faIcons = Object.keys(icons.fa); + + // Extract Lucide icons from the lucideIcons object directly + // Log the first few keys to understand the structure + console.log('First few Lucide keys:', Object.keys(lucideIcons).slice(0, 5)); + + // Get all icon functions from lucideIcons (they have PascalCase names) + const lucideIconsList = Object.keys(lucideIcons) + .filter(key => { + // Skip utility functions and focus on icon components (first letter is uppercase) + const isUppercaseFirst = key[0] === key[0].toUpperCase() && key[0] !== key[0].toLowerCase(); + const isFunction = typeof lucideIcons[key] === 'function'; + const notUtility = !['createElement', 'createIcons', 'default'].includes(key); + return isFunction && isUppercaseFirst && notUtility; + }) + .map(pascalName => { + // Convert PascalCase to camelCase + return pascalName.charAt(0).toLowerCase() + pascalName.slice(1); + }); + + // Log how many icons we found + console.log(`Found ${lucideIconsList.length} Lucide icons`); + + // If we didn't find any, try an alternative approach + if (lucideIconsList.length === 0) { + console.log('Trying alternative approach to find Lucide icons'); + + // Try to get icon names from a known property if available + if (lucideIcons.icons) { + const iconSource = lucideIcons.icons || {}; + lucideIconsList.push(...Object.keys(iconSource)); + console.log(`Found ${lucideIconsList.length} icons via alternative method`); + } + } -export const demoFunc = () => html` + // Define the functions in TS scope instead of script tags + const searchIcons = (event: InputEvent) => { + const searchTerm = (event.target as HTMLInputElement).value.toLowerCase().trim(); + const containers = document.querySelectorAll('.iconContainer'); + + containers.forEach(container => { + const iconName = container.getAttribute('data-name'); + + // If search term is empty, show all icons + if (searchTerm === '') { + container.classList.remove('hidden'); + } else if (iconName && iconName.includes(searchTerm)) { + container.classList.remove('hidden'); + } else { + container.classList.add('hidden'); + } + }); + + // Update counts + document.querySelectorAll('.section-container').forEach(section => { + const visibleIcons = section.querySelectorAll('.iconContainer:not(.hidden)').length; + const countElement = section.querySelector('.icon-count'); + if (countElement) { + const totalIconsCount = section.classList.contains('fa-section') + ? faIcons.length + : lucideIconsList.length; + + countElement.textContent = visibleIcons === totalIconsCount + ? `${totalIconsCount} icons` + : `${visibleIcons} of ${totalIconsCount} icons`; + } + }); + }; + + const copyIconName = (iconNameToCopy: string, type: 'fa' | 'lucide') => { + // Use the new prefix format + const textToCopy = `${type}:${iconNameToCopy}`; + + navigator.clipboard.writeText(textToCopy).then(() => { + // Find the event target + const currentEvent = window.event as MouseEvent; + const currentTarget = currentEvent.currentTarget as HTMLElement; + // Show feedback + const tooltip = currentTarget.querySelector('.copy-tooltip'); + if (tooltip) { + tooltip.textContent = 'Copied!'; + + setTimeout(() => { + tooltip.textContent = 'Click to copy'; + }, 2000); + } + }); + }; + + return html` -
- ${Object.keys(faIcons).map( - (iconName) => html` -
- -
${iconName}
-
- ` - )} +
+
+
- -`; + +
+ New API: Use icon="fa:iconName" or icon="lucide:iconName" instead of iconFA. + Click any icon to copy its new format to clipboard. +
+ +
+
+ FontAwesome Icons + ${faIcons.length} icons +
+
+ ${faIcons.map( + (iconName) => { + const prefixedName = `fa:${iconName}`; + return html` +
copyIconName(iconName, 'fa')}> + +
${iconName}
+ Click to copy +
+ `; + } + )} +
+
+ +
+
+ Lucide Icons + ${lucideIconsList.length} icons +
+
+ ${lucideIconsList.map( + (iconName) => { + const prefixedName = `lucide:${iconName}`; + return html` +
copyIconName(iconName, 'lucide')}> + +
${iconName}
+ Click to copy +
+ `; + } + )} +
+
+
+ `; +}; \ No newline at end of file diff --git a/ts_web/elements/dees-icon.ts b/ts_web/elements/dees-icon.ts index 6417e5b..e32b444 100644 --- a/ts_web/elements/dees-icon.ts +++ b/ts_web/elements/dees-icon.ts @@ -75,7 +75,12 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { demoFunc } from './dees-icon.demo.js'; -export const faIcons = { +// Import Lucide icons and the createElement function +import * as lucideIcons from 'lucide'; +import { createElement } from 'lucide'; + +// Collect FontAwesome icons +const faIcons = { // normal arrowRight: faArrowRightSolid, arrowUpRightFromSquare: faArrowUpRightFromSquareSolid, @@ -136,7 +141,32 @@ export const faIcons = { twitter: faTwitter, }; -export type TIconKey = keyof typeof faIcons; +// Create a string literal type for all FA icons +type FAIconKey = keyof typeof faIcons; + +// Create union types for the icons with prefixes +export type IconWithPrefix = `fa:${FAIconKey}` | `lucide:${string}`; + +// Export only FontAwesome icons directly +export const icons = { + fa: faIcons +}; + +// Legacy type for backward compatibility +export type TIconKey = FAIconKey | `lucide:${string}`; + +// Use a global static cache for all icons to reduce rendering +const iconCache = new Map(); + +// Clear cache items occasionally to prevent memory leaks +const MAX_CACHE_SIZE = 500; +function limitCacheSize() { + if (iconCache.size > MAX_CACHE_SIZE) { + // Remove oldest entries (first 20% of items) + const keysToDelete = Array.from(iconCache.keys()).slice(0, MAX_CACHE_SIZE / 5); + keysToDelete.forEach(key => iconCache.delete(key)); + } +} declare global { interface HTMLElementTagNameMap { @@ -148,31 +178,170 @@ declare global { export class DeesIcon extends DeesElement { public static demo = demoFunc; + /** + * @deprecated Use the `icon` property instead with format "fa:iconName" or "lucide:iconName" + */ @property({ - type: String + type: String, + converter: { + // Convert attribute string to property (for reflected attributes) + fromAttribute: (value: string): TIconKey => value as TIconKey, + // Convert property to attribute (for reflection) + toAttribute: (value: TIconKey): string => value + } }) - public iconFA: keyof typeof faIcons; + public iconFA?: TIconKey; - @property() + /** + * The preferred icon property. Use format "fa:iconName" or "lucide:iconName" + * Examples: "fa:check", "lucide:menu" + */ + @property({ + type: String, + converter: { + fromAttribute: (value: string): IconWithPrefix => value as IconWithPrefix, + toAttribute: (value: IconWithPrefix): string => value + } + }) + public icon?: IconWithPrefix; + + @property({ type: Number }) public iconSize: number; + @property({ type: String }) + public color: string = 'currentColor'; + + @property({ type: Number }) + public strokeWidth: number = 2; + + // For tracking when we need to re-render + private lastIcon: IconWithPrefix | TIconKey | null = null; + private lastIconSize: number | null = null; + private lastColor: string | null = null; + private lastStrokeWidth: number | null = null; + constructor() { super(); domtools.elementBasic.setup(); } + /** + * Gets the effective icon value, supporting both the new `icon` property + * and the legacy `iconFA` property for backward compatibility. + * Prefers `icon` if both are set. + */ + private getEffectiveIcon(): IconWithPrefix | TIconKey | null { + // Prefer the new API + if (this.icon) { + return this.icon; + } + + // Fall back to the old API + if (this.iconFA) { + // If iconFA is already in the proper format (lucide:name), use it directly + if (this.iconFA.startsWith('lucide:')) { + return this.iconFA; + } + + // For FontAwesome icons with no prefix, add the prefix + return `fa:${this.iconFA}` as IconWithPrefix; + } + + return null; + } + + /** + * Parses an icon string into its type and name parts + * @param iconStr The icon string in format "type:name" + * @returns Object with type and name properties + */ + private parseIconString(iconStr: string): { type: 'fa' | 'lucide', name: string } { + if (iconStr.startsWith('fa:')) { + return { + type: 'fa', + name: iconStr.substring(3) // Remove 'fa:' prefix + }; + } else if (iconStr.startsWith('lucide:')) { + return { + type: 'lucide', + name: iconStr.substring(7) // Remove 'lucide:' prefix + }; + } else { + // For backward compatibility, assume FontAwesome if no prefix + return { + type: 'fa', + name: iconStr + }; + } + } + + private renderLucideIcon(iconName: string): string { + // Create a cache key based on all visual properties + const cacheKey = `lucide:${iconName}:${this.iconSize}:${this.color}:${this.strokeWidth}`; + + // Check if we already have this icon in the cache + if (iconCache.has(cacheKey)) { + return iconCache.get(cacheKey) || ''; + } + + try { + // Get the Pascal case icon name (Menu instead of menu) + const pascalCaseName = iconName.charAt(0).toUpperCase() + iconName.slice(1); + + // Check if the icon exists in lucideIcons + if (!lucideIcons[pascalCaseName]) { + console.warn(`Lucide icon '${pascalCaseName}' not found in lucideIcons object`); + return ''; + } + + // Use the exact pattern from Lucide documentation + const svgElement = createElement(lucideIcons[pascalCaseName], { + color: this.color, + size: this.iconSize, + strokeWidth: this.strokeWidth + }); + + if (!svgElement) { + console.warn(`createElement returned empty result for ${pascalCaseName}`); + return ''; + } + + // Get the HTML + const result = svgElement.outerHTML; + + // Cache the result for future use + iconCache.set(cacheKey, result); + limitCacheSize(); + + return result; + } catch (error) { + console.error(`Error rendering Lucide icon ${iconName}:`, error); + + // Create a fallback SVG with the icon name + return ` + ${iconName} + `; + } + } + public static styles = [ cssManager.defaultStyles, css` :host { - display: block; - white-space: nowrap; - display: flex; + display: inline-flex; align-items: center; justify-content: center; + line-height: 1; + vertical-align: middle; } - * { - transition: inherit !important; + + /* Improve rendering performance */ + #iconContainer svg { + display: block; + height: 100%; + width: 100%; + will-change: transform; /* Helps with animations */ + contain: strict; /* Performance optimization */ } `, ]; @@ -181,8 +350,8 @@ export class DeesIcon extends DeesElement { return html` ${domtools.elementBasic.styles} @@ -190,14 +359,95 @@ export class DeesIcon extends DeesElement { `; } - public async updated() { + public updated() { + // If size is not specified, use font size as a base if (!this.iconSize) { this.iconSize = parseInt(globalThis.getComputedStyle(this).fontSize.replace(/\D/g,'')); } - if (this.iconFA) { - this.shadowRoot.querySelector('#iconContainer').innerHTML = this.iconFA - ? icon(faIcons[this.iconFA]).html[0] - : 'icon not found'; + + // Get the effective icon (either from icon or iconFA property) + const effectiveIcon = this.getEffectiveIcon(); + + // Check if we actually need to update the icon + // This prevents unnecessary DOM operations when properties haven't changed + if (this.lastIcon === effectiveIcon && + this.lastIconSize === this.iconSize && + this.lastColor === this.color && + this.lastStrokeWidth === this.strokeWidth) { + return; // No visual changes - skip update + } + + // Update our "last properties" for future change detection + this.lastIcon = effectiveIcon; + this.lastIconSize = this.iconSize; + this.lastColor = this.color; + this.lastStrokeWidth = this.strokeWidth; + + const container = this.shadowRoot?.querySelector('#iconContainer'); + if (!container || !effectiveIcon) return; + + try { + // Parse the icon string to get type and name + const { type, name } = this.parseIconString(effectiveIcon); + + if (type === 'lucide') { + // For Lucide, use direct DOM manipulation as shown in the docs + // This approach avoids HTML string issues + container.innerHTML = ''; // Clear container + + try { + // Convert to PascalCase + const pascalCaseName = name.charAt(0).toUpperCase() + name.slice(1); + + if (lucideIcons[pascalCaseName]) { + // Use the documented pattern from Lucide docs + const svgElement = createElement(lucideIcons[pascalCaseName], { + color: this.color, + size: this.iconSize, + strokeWidth: this.strokeWidth + }); + + if (svgElement) { + // Directly append the element + container.appendChild(svgElement); + return; // Exit early since we've added the element + } + } + + // If we reach here, something went wrong + throw new Error(`Could not create element for ${pascalCaseName}`); + } catch (error) { + console.error(`Error rendering Lucide icon:`, error); + + // Fall back to the string-based approach + const iconHtml = this.renderLucideIcon(name); + if (iconHtml) { + container.innerHTML = iconHtml; + } + } + } else { + // Use FontAwesome rendering via HTML string + const faIcon = icons.fa[name as FAIconKey]; + if (faIcon) { + const iconHtml = icon(faIcon).html[0]; + container.innerHTML = iconHtml; + } else { + console.warn(`FontAwesome icon not found: ${name}`); + } + } + } catch (error) { + console.error(`Error updating icon ${effectiveIcon}:`, error); } } -} + + // Clean up resources when element is removed + async disconnectedCallback() { + super.disconnectedCallback(); + + // Clear our references + this.lastIcon = null; + this.lastIconSize = null; + this.lastColor = null; + this.lastStrokeWidth = null; + } +} \ No newline at end of file