feat: Новый кросс платформенный вьювер

This commit is contained in:
2026-07-14 09:54:36 +03:00
parent c596f9f1c3
commit 1280eb6fcd
46 changed files with 1440 additions and 785 deletions

View File

@@ -1,317 +0,0 @@
import { HexColorInput, HexColorPicker } from 'react-colorful'
import { useEffect, useId, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import {
generateViewerCode,
highlightViewerCode,
normalizeHexColor,
tabsForFormat,
viewBoxSize,
} from './sprite-viewer-code.js'
import type { SpriteViewerTab } from './sprite-viewer-code.js'
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
type SpriteViewerDialogProps = {
manifest: SpriteManifest
icon: SpriteManifestIcon
colorTheme: 'light' | 'dark'
onClose: () => void
}
type ColorControlProps = {
label: string
value: string
onChange: (value: string) => void
}
function ColorControl({ label, value, onChange }: ColorControlProps) {
const [open, setOpen] = useState(false)
const swatchRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const popoverId = useId()
useEffect(() => {
if (!open) return
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node
const insideSwatch = swatchRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideSwatch && !insidePopover) setOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [open])
return (
<div className="gromlab-sprite-viewer__color-row">
<button
ref={swatchRef}
className="gromlab-sprite-viewer__swatch"
type="button"
style={{ backgroundColor: value }}
aria-label={`Изменить цвет ${label}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
title={`Изменить цвет ${label}`}
onClick={() => setOpen((current) => !current)}
/>
<code className="gromlab-sprite-viewer__color-label">{label}</code>
{open && (
<div
ref={popoverRef}
id={popoverId}
className="gromlab-sprite-viewer__color-popover"
role="dialog"
aria-label={`Выбор цвета ${label}`}
onKeyDown={(event) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
setOpen(false)
}}
>
<HexColorPicker color={value} onChange={onChange} />
<HexColorInput
className="gromlab-sprite-viewer__hex-input"
color={value}
onChange={onChange}
prefixed
aria-label={`HEX-значение ${label}`}
/>
</div>
)}
</div>
)
}
function initialColors(icon: SpriteManifestIcon, currentColor: string) {
return Object.fromEntries(icon.colors.map(({ variable, fallback }) => [
variable,
normalizeHexColor(fallback, currentColor),
]))
}
export function SpriteViewerDialog({ manifest, icon, colorTheme, onClose }: SpriteViewerDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
const tabsId = useId()
const tabs = tabsForFormat(manifest.format)
const [activeTab, setActiveTab] = useState<SpriteViewerTab>('react')
const themeColor = colorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a'
const [colors, setColors] = useState<Record<string, string>>(() => initialColors(icon, themeColor))
const [colorOverrides, setColorOverrides] = useState<Record<string, string>>({})
const [cssColor, setCssColor] = useState(themeColor)
const [cssColorOverridden, setCssColorOverridden] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
dialog.showModal()
return () => {
if (dialog.open) dialog.close()
}
}, [icon])
useEffect(() => {
setColors({ ...initialColors(icon, themeColor), ...colorOverrides })
if (!cssColorOverridden) setCssColor(themeColor)
}, [colorTheme, icon])
useEffect(() => {
if (!copied) return
const timeout = setTimeout(() => setCopied(false), 1500)
return () => clearTimeout(timeout)
}, [copied])
const previewStyle = Object.fromEntries(
Object.entries(colors).map(([variable, color]) => [variable, color]),
) as CSSProperties
const href = `${manifest.spriteUrl}#${icon.id}`
const dimensions = viewBoxSize(icon.viewBox)
const code = generateViewerCode({ manifest, icon, tab: activeTab, colorOverrides, cssColor })
function handleColorChange(variable: string, color: string) {
const normalized = normalizeHexColor(color)
setColors((current) => ({ ...current, [variable]: normalized }))
setColorOverrides((current) => ({ ...current, [variable]: normalized }))
}
function handleCssColorChange(color: string) {
setCssColor(normalizeHexColor(color))
setCssColorOverridden(true)
}
function handleBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
if (event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom
if (outside) onClose()
}
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, tabIndex: number) {
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (tabIndex + 1) % tabs.length
if (event.key === 'ArrowLeft') nextIndex = (tabIndex - 1 + tabs.length) % tabs.length
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = tabs.length - 1
if (nextIndex === null) return
event.preventDefault()
setActiveTab(tabs[nextIndex].id)
const buttons = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function copyCode() {
if (!globalThis.navigator?.clipboard) return
try {
await globalThis.navigator.clipboard.writeText(code.code)
setCopied(true)
} catch {
setCopied(false)
}
}
function renderPreview() {
if (activeTab === 'img') {
return <img className="gromlab-sprite-viewer__dialog-img" src={href} alt={icon.name} />
}
if (activeTab === 'css') {
return (
<div
className="gromlab-sprite-viewer__dialog-mask"
role="img"
aria-label={icon.name}
style={{
backgroundColor: cssColor,
mask: `url('${href}') no-repeat center / contain`,
WebkitMask: `url('${href}') no-repeat center / contain`,
}}
/>
)
}
return (
<svg
className="gromlab-sprite-viewer__dialog-icon"
viewBox={icon.viewBox ?? undefined}
aria-label={icon.name}
role="img"
>
<use href={href} />
</svg>
)
}
return (
<dialog
ref={dialogRef}
className="gromlab-sprite-viewer__dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClick={handleBackdropClick}
>
<div className="gromlab-sprite-viewer__dialog-shell">
<button
className="gromlab-sprite-viewer__close"
type="button"
aria-label="Закрыть"
autoFocus
onClick={onClose}
>
&#x2715;
</button>
<div className="gromlab-sprite-viewer__dialog-preview" style={previewStyle}>
<div className="gromlab-sprite-viewer__dialog-preview-canvas">
{renderPreview()}
</div>
</div>
<div className="gromlab-sprite-viewer__dialog-heading">
<h2 id={titleId} className="gromlab-sprite-viewer__dialog-title">{icon.name}</h2>
{dimensions && <span className="gromlab-sprite-viewer__viewbox">{dimensions}</span>}
</div>
<p className="gromlab-sprite-viewer__dialog-meta">
{manifest.name} · {manifest.format} · {manifest.target}
</p>
<div className="gromlab-sprite-viewer__colors">
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length > 0 && (
<>
<p className="gromlab-sprite-viewer__hint">
Цвета применяются к превью через CSS-переменные и попадут в пример кода.
</p>
<h3 className="gromlab-sprite-viewer__colors-title">CSS Variables</h3>
{icon.colors.map(({ variable, fallback }) => (
<ColorControl
key={variable}
value={colors[variable]}
label={`${variable}: ${fallback}`}
onChange={(color) => handleColorChange(variable, color)}
/>
))}
</>
)}
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length === 0 && (
<p className="gromlab-sprite-viewer__hint">У иконки нет настраиваемых цветовых переменных.</p>
)}
{activeTab === 'img' && (
<p className="gromlab-sprite-viewer__hint">
IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются.
</p>
)}
{activeTab === 'css' && (
<>
<p className="gromlab-sprite-viewer__hint">
CSS mask отображает иконку одним цветом через background-color.
</p>
<ColorControl label="background-color" value={cssColor} onChange={handleCssColorChange} />
</>
)}
</div>
<div className="gromlab-sprite-viewer__tabs" role="tablist" aria-label="Способ подключения">
{tabs.map((tab, tabIndex) => (
<button
id={`${tabsId}-${tab.id}-tab`}
key={tab.id}
className="gromlab-sprite-viewer__tab"
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`${tabsId}-panel`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => handleTabKeyDown(event, tabIndex)}
>
{tab.label}
</button>
))}
</div>
<div
id={`${tabsId}-panel`}
className="gromlab-sprite-viewer__code"
role="tabpanel"
aria-labelledby={`${tabsId}-${activeTab}-tab`}
>
<pre><code dangerouslySetInnerHTML={{ __html: highlightViewerCode(code.code, code.language) }} /></pre>
<button className="gromlab-sprite-viewer__copy" type="button" onClick={() => void copyCode()}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
</div>
</dialog>
)
}

View File

@@ -1,19 +1,10 @@
'use client'
import { useDeferredValue, useEffect, useState } from 'react'
import { createElement, useEffect, useRef } from 'react'
import type { CSSProperties, ReactElement } from 'react'
import { SpriteViewerDialog } from './sprite-viewer-dialog.js'
import { SPRITE_VIEWER_STYLES } from './sprite-viewer-styles.js'
import type {
SpriteManifest,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
import type { SpriteViewerColorTheme, SpriteViewerSources } from '../viewer/types.js'
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
export type { SpriteViewerColorTheme } from '../viewer/types.js'
export type SpriteViewerProps = {
sources: SpriteViewerSources
@@ -26,96 +17,17 @@ export type SpriteViewerProps = {
style?: CSSProperties
}
type SelectedIcon = {
key: string
manifest: SpriteManifest
icon: SpriteManifestIcon
type ViewerElement = HTMLElement & {
sources: SpriteViewerSources
viewerTitle: string
colorTheme: SpriteViewerColorTheme
themeControlled: boolean
showThemeToggle: boolean
}
const manifestLoaderCache = new WeakMap<SpriteManifestLoader, Promise<SpriteManifest>>()
type ThemeChangeEvent = CustomEvent<{ theme: SpriteViewerColorTheme }>
function compareManifests(left: SpriteManifest, right: SpriteManifest): number {
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
}
function sourceArray(sources: SpriteViewerSources): readonly SpriteViewerSource[] {
return Array.isArray(sources)
? sources
: Object.values(sources as Readonly<Record<string, SpriteViewerSource>>)
}
function isSpriteManifest(value: unknown): value is SpriteManifest {
if (!value || typeof value !== 'object') return false
const manifest = value as Partial<SpriteManifest>
return manifest.schemaVersion === 1
&& manifest.generator === '@gromlab/svg-sprites'
&& typeof manifest.name === 'string'
&& typeof manifest.componentName === 'string'
&& typeof manifest.spriteUrl === 'string'
&& Array.isArray(manifest.icons)
}
function manifestFromModule(value: SpriteManifest | SpriteManifestModule): SpriteManifest {
if (isSpriteManifest(value)) return value
if (isSpriteManifest(value.default)) return value.default
if (isSpriteManifest(value.spriteManifest)) return value.spriteManifest
throw new Error('The loaded module does not export a valid SVG sprite manifest.')
}
async function resolveSource(source: SpriteViewerSource): Promise<SpriteManifest> {
if (typeof source !== 'function') return source
const cached = manifestLoaderCache.get(source)
if (cached) return cached
const pending = Promise.resolve().then(source).then(manifestFromModule)
manifestLoaderCache.set(source, pending)
void pending.catch(() => {
if (manifestLoaderCache.get(source) === pending) manifestLoaderCache.delete(source)
})
return pending
}
function directManifests(sources: SpriteViewerSources): SpriteManifest[] {
return sourceArray(sources)
.filter((source): source is SpriteManifest => typeof source !== 'function')
.sort(compareManifests)
}
function countLabel(value: number, forms: readonly [string, string, string]): string {
const modulo100 = value % 100
const modulo10 = value % 10
const form = modulo100 >= 11 && modulo100 <= 19
? forms[2]
: modulo10 === 1
? forms[0]
: modulo10 >= 2 && modulo10 <= 4
? forms[1]
: forms[2]
return `${value} ${form}`
}
function currentSystemTheme(): 'light' | 'dark' {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function useSystemTheme(): 'light' | 'dark' {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(currentSystemTheme)
useEffect(() => {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
if (!media) return
const update = () => setSystemTheme(media.matches ? 'dark' : 'light')
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return systemTheme
}
/** Интерактивный каталог локальных React- и Next.js-спрайтов. */
/** React bridge к единому framework-neutral Viewer Web Component. */
export function SpriteViewer({
sources,
title = 'SVG Sprites',
@@ -124,178 +36,42 @@ export function SpriteViewer({
className,
style,
}: SpriteViewerProps): ReactElement {
const [manifests, setManifests] = useState<SpriteManifest[]>(() => directManifests(sources))
const [errors, setErrors] = useState<string[]>([])
const [loading, setLoading] = useState(() => sourceArray(sources).some((source) => typeof source === 'function'))
const [query, setQuery] = useState('')
const [localColorTheme, setLocalColorTheme] = useState<SpriteViewerColorTheme>('auto')
const [selected, setSelected] = useState<SelectedIcon | null>(null)
const systemTheme = useSystemTheme()
const activeColorTheme = colorTheme ?? localColorTheme
const resolvedColorTheme = activeColorTheme === 'auto' ? systemTheme : activeColorTheme
const canToggleTheme = colorTheme === undefined || onColorThemeChange !== undefined
const deferredQuery = useDeferredValue(query.trim().toLowerCase())
const elementRef = useRef<ViewerElement | null>(null)
useEffect(() => {
let active = true
const allSources = sourceArray(sources)
const direct = directManifests(sources)
const loaders = allSources.filter((source) => typeof source === 'function')
let element: ViewerElement | null = null
setManifests(direct)
setErrors([])
setLoading(loaders.length > 0)
setSelected(null)
const handleThemeChange = (event: Event) => {
onColorThemeChange?.((event as ThemeChangeEvent).detail.theme)
}
if (loaders.length === 0) return () => { active = false }
void import('../viewer-element.js').then(async ({ defineSpriteViewerElement }) => {
defineSpriteViewerElement()
await customElements.whenDefined('gromlab-sprite-viewer')
if (!active || !elementRef.current) return
Promise.allSettled(loaders.map(resolveSource)).then((results) => {
if (!active) return
const loaded: SpriteManifest[] = []
const failures: string[] = []
for (const result of results) {
if (result.status === 'fulfilled') loaded.push(result.value)
else failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
}
setManifests([...direct, ...loaded].sort(compareManifests))
setErrors(failures)
setLoading(false)
element = elementRef.current
const wasThemeControlled = element.themeControlled
element.sources = sources
element.viewerTitle = title
if (colorTheme !== undefined) element.colorTheme = colorTheme
else if (wasThemeControlled) element.colorTheme = 'auto'
element.themeControlled = colorTheme !== undefined
element.showThemeToggle = colorTheme === undefined || onColorThemeChange !== undefined
element.addEventListener('color-theme-change', handleThemeChange)
})
return () => { active = false }
}, [sources])
return () => {
active = false
element?.removeEventListener('color-theme-change', handleThemeChange)
}
}, [colorTheme, onColorThemeChange, sources, title])
const visibleGroups = manifests
.map((manifest) => ({
manifest,
icons: manifest.icons.filter((icon) => (
deferredQuery === ''
|| icon.name.toLowerCase().includes(deferredQuery)
|| manifest.name.toLowerCase().includes(deferredQuery)
)),
}))
.filter((group) => group.icons.length > 0)
const totalIcons = manifests.reduce((total, manifest) => total + manifest.iconCount, 0)
const visibleIcons = visibleGroups.reduce((total, group) => total + group.icons.length, 0)
const rootClassName = ['gromlab-sprite-viewer', className].filter(Boolean).join(' ')
function toggleTheme() {
const nextTheme = resolvedColorTheme === 'dark' ? 'light' : 'dark'
if (colorTheme === undefined) setLocalColorTheme(nextTheme)
onColorThemeChange?.(nextTheme)
}
return (
<section
className={rootClassName}
style={style}
data-sprite-viewer=""
data-theme={activeColorTheme === 'auto' ? undefined : activeColorTheme}
>
<style>{SPRITE_VIEWER_STYLES}</style>
<header className="gromlab-sprite-viewer__header">
<h1 className="gromlab-sprite-viewer__title">{title}</h1>
<span className="gromlab-sprite-viewer__summary">
{countLabel(manifests.length, ['спрайт', 'спрайта', 'спрайтов'])}
{' · '}
{countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])}
{deferredQuery && ` · найдено ${visibleIcons}`}
</span>
<div className="gromlab-sprite-viewer__toolbar">
<input
className="gromlab-sprite-viewer__search"
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Найти иконку"
aria-label="Поиск иконок"
/>
{canToggleTheme && (
<button
className="gromlab-sprite-viewer__theme"
type="button"
aria-label="Переключить тему"
title="Переключить тему"
onClick={toggleTheme}
>
&#x25D1;
</button>
)}
</div>
</header>
{errors.length > 0 && (
<div className="gromlab-sprite-viewer__errors" role="alert">
{errors.map((error, index) => <div key={`${index}:${error}`}>{error}</div>)}
</div>
)}
{visibleGroups.map(({ manifest, icons }) => (
<section className="gromlab-sprite-viewer__group" key={`${manifest.name}:${manifest.spriteUrl}`}>
<div className="gromlab-sprite-viewer__group-header">
<h2 className="gromlab-sprite-viewer__group-title">{manifest.name}</h2>
<span className="gromlab-sprite-viewer__badge">{manifest.format}</span>
<span className="gromlab-sprite-viewer__group-count">{icons.length}</span>
{manifest.description && (
<p className="gromlab-sprite-viewer__description">{manifest.description}</p>
)}
</div>
<div className="gromlab-sprite-viewer__grid">
{icons.map((icon) => {
const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}`
return (
<button
className="gromlab-sprite-viewer__card"
type="button"
key={key}
data-icon-name={icon.name}
onClick={() => setSelected({ key, manifest, icon })}
title={`Открыть ${icon.name}`}
>
<span className="gromlab-sprite-viewer__icon-wrap">
<svg
className="gromlab-sprite-viewer__icon"
viewBox={icon.viewBox ?? undefined}
aria-hidden="true"
>
<use href={`${manifest.spriteUrl}#${icon.id}`} />
</svg>
</span>
<span className="gromlab-sprite-viewer__icon-name">{icon.name}</span>
</button>
)
})}
</div>
</section>
))}
{visibleGroups.length === 0 && (!loading || manifests.length > 0) && (
<div className="gromlab-sprite-viewer__status">
{manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'}
</div>
)}
{loading && manifests.length === 0 && (
<div className="gromlab-sprite-viewer__status">Загрузка спрайтов...</div>
)}
<footer className="gromlab-sprite-viewer__footer">
<span>@gromlab/svg-sprites</span>
<a href="https://github.com/gromlab-ru/svg-sprites" target="_blank" rel="noreferrer">Repository</a>
</footer>
{selected && (
<SpriteViewerDialog
key={selected.key}
manifest={selected.manifest}
icon={selected.icon}
colorTheme={resolvedColorTheme}
onClose={() => setSelected(null)}
/>
)}
</section>
)
return createElement('gromlab-sprite-viewer', {
ref: elementRef,
className,
style,
'data-sprite-viewer-host': '',
})
}

View File

@@ -1,41 +1,24 @@
import type { NextAssetTarget, ReactAssetTarget, ReactSpriteMode } from '../targets/types.js'
import type {
SpriteViewerManifest,
SpriteViewerManifestColor,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from '../viewer/types.js'
export type SpriteManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type SpriteManifestColor = SpriteViewerManifestColor
export type SpriteManifestIcon = SpriteViewerManifestIcon
export type SpriteManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly SpriteManifestColor[]
}
/** Публичные данные одного сгенерированного React-спрайта. */
export type SpriteManifest = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
/** Публичные данные одного сгенерированного React- или Next.js-спрайта. */
export type SpriteManifest = SpriteViewerManifest & {
componentName: string
mode?: ReactSpriteMode | NextAssetTarget
target: ReactAssetTarget | NextAssetTarget
format: 'stack' | 'symbol'
iconCount: number
spriteUrl: string
icons: readonly SpriteManifestIcon[]
}
export type SpriteManifestModule = {
default?: SpriteManifest
spriteManifest?: SpriteManifest
}
export type SpriteManifestLoader = () => Promise<SpriteManifest | SpriteManifestModule>
export type SpriteViewerSource = SpriteManifest | SpriteManifestLoader
/** Массив источников либо результат import.meta.glob. */
export type SpriteViewerSources =
| readonly SpriteViewerSource[]
| Readonly<Record<string, SpriteViewerSource>>
export type SpriteManifestModule = SpriteViewerManifestModule
export type SpriteManifestLoader = SpriteViewerManifestLoader
export type { SpriteViewerSource, SpriteViewerSources }

18
src/viewer-element.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineSpriteViewerElement } from './viewer/element.js'
defineSpriteViewerElement()
export { defineSpriteViewerElement } from './viewer/element.js'
export type {
SpriteViewerColorTheme,
SpriteViewerElement,
SpriteViewerManifest,
SpriteViewerManifestColor,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerManifestModule,
SpriteViewerManifestUsage,
SpriteViewerRemoteSource,
SpriteViewerSource,
SpriteViewerSources,
} from './viewer/types.js'

14
src/viewer.ts Normal file
View File

@@ -0,0 +1,14 @@
export { defineSpriteViewerElement } from './viewer/element.js'
export type {
SpriteViewerColorTheme,
SpriteViewerElement,
SpriteViewerManifest,
SpriteViewerManifestColor,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerManifestModule,
SpriteViewerManifestUsage,
SpriteViewerRemoteSource,
SpriteViewerSource,
SpriteViewerSources,
} from './viewer/types.js'

View File

@@ -1,19 +1,29 @@
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
import type { SpriteViewerManifest, SpriteViewerManifestIcon } from './types.js'
export type SpriteViewerTab = 'react' | 'svg' | 'img' | 'css'
export type SpriteViewerTab = 'react' | 'vue' | 'svg' | 'img' | 'css'
export type SpriteViewerCodeLanguage = 'tsx' | 'html' | 'css'
export const SPRITE_VIEWER_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
{ id: 'react', label: 'React' },
const COMMON_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
{ id: 'svg', label: 'SVG' },
{ id: 'img', label: 'IMG' },
{ id: 'css', label: 'CSS' },
]
export function tabsForFormat(format: SpriteManifest['format']) {
return format === 'stack'
? SPRITE_VIEWER_TABS
: SPRITE_VIEWER_TABS.filter((tab) => tab.id === 'react' || tab.id === 'svg')
function usageForManifest(manifest: SpriteViewerManifest): { framework: 'react' | 'vue'; componentName: string } | null {
if (manifest.usage) return manifest.usage
if (manifest.componentName) return { framework: 'react', componentName: manifest.componentName }
return null
}
export function tabsForManifest(manifest: SpriteViewerManifest): ReadonlyArray<{ id: SpriteViewerTab; label: string }> {
const usage = usageForManifest(manifest)
const frameworkTab = usage
? [{ id: usage.framework, label: usage.framework === 'react' ? 'React' : 'Vue' } as const]
: []
const tabs = [...frameworkTab, ...COMMON_TABS]
return manifest.format === 'stack'
? tabs
: tabs.filter((tab) => tab.id === 'react' || tab.id === 'vue' || tab.id === 'svg')
}
export function viewBoxSize(viewBox: string | null): string | null {
@@ -76,8 +86,8 @@ function cssUrl(value: string): string {
}
export function generateViewerCode(options: {
manifest: SpriteManifest
icon: SpriteManifestIcon
manifest: SpriteViewerManifest
icon: SpriteViewerManifestIcon
tab: SpriteViewerTab
colorOverrides: Readonly<Record<string, string>>
cssColor: string
@@ -86,16 +96,29 @@ export function generateViewerCode(options: {
const href = `${manifest.spriteUrl}#${icon.id}`
const overrides = styleLines(colorOverrides)
if (tab === 'react') {
if (tab === 'react' || tab === 'vue') {
const usage = usageForManifest(manifest)
if (!usage) throw new Error(`The ${tab} code tab requires component metadata.`)
if (tab === 'vue') {
const style = Object.entries(colorOverrides)
.map(([variable, color]) => `${JSON.stringify(variable)}: ${JSON.stringify(color)}`)
.join(', ')
return {
code: `<${usage.componentName} icon=${JSON.stringify(icon.name)}${style ? ` :style="{ ${htmlAttribute(style)} }"` : ''} />`,
language: 'html',
}
}
if (overrides.length === 0) {
return {
code: `<${manifest.componentName} icon=${JSON.stringify(icon.name)} />`,
code: `<${usage.componentName} icon=${JSON.stringify(icon.name)} />`,
language: 'tsx',
}
}
return {
code: [
`<${manifest.componentName}`,
`<${usage.componentName}`,
` icon=${JSON.stringify(icon.name)}`,
' style={{',
...overrides,

650
src/viewer/element.ts Normal file
View File

@@ -0,0 +1,650 @@
import { LitElement, html, nothing, unsafeCSS } from 'lit'
import type { PropertyValues, TemplateResult } from 'lit'
import { ifDefined } from 'lit/directives/if-defined.js'
import { styleMap } from 'lit/directives/style-map.js'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
import {
generateViewerCode,
highlightViewerCode,
normalizeHexColor,
tabsForManifest,
viewBoxSize,
} from './code.js'
import type { SpriteViewerTab } from './code.js'
import {
compareManifests,
isRemoteSource,
normalizeSpriteViewerManifest,
resolveViewerSource,
sourceArray,
} from './source.js'
import { SPRITE_VIEWER_STYLES } from './styles.js'
import type {
SpriteViewerColorTheme,
SpriteViewerManifest,
SpriteViewerManifestIcon,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
const ELEMENT_NAME = 'gromlab-sprite-viewer'
const COLOR_PICKER_NAME = 'gromlab-hex-color-picker'
const COLOR_INPUT_NAME = 'gromlab-hex-input'
let colorElementsPromise: Promise<void> | undefined
function defineColorElements(): Promise<void> {
if (customElements.get(COLOR_PICKER_NAME) && customElements.get(COLOR_INPUT_NAME)) return Promise.resolve()
if (colorElementsPromise) return colorElementsPromise
colorElementsPromise = Promise.all([
import('vanilla-colorful/lib/entrypoints/hex'),
import('vanilla-colorful/lib/entrypoints/hex-input'),
]).then(([{ HexBase }, { HexInputBase }]) => {
if (!customElements.get(COLOR_PICKER_NAME)) {
customElements.define(COLOR_PICKER_NAME, class extends HexBase {})
}
if (!customElements.get(COLOR_INPUT_NAME)) {
customElements.define(COLOR_INPUT_NAME, class extends HexInputBase {})
}
})
return colorElementsPromise
}
function countLabel(value: number, forms: readonly [string, string, string]): string {
const modulo100 = value % 100
const modulo10 = value % 10
const form = modulo100 >= 11 && modulo100 <= 19
? forms[2]
: modulo10 === 1
? forms[0]
: modulo10 >= 2 && modulo10 <= 4
? forms[1]
: forms[2]
return `${value} ${form}`
}
function currentSystemTheme(): 'light' | 'dark' {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function initialColors(icon: SpriteViewerManifestIcon, currentColor: string): Record<string, string> {
return Object.fromEntries(icon.colors.map(({ variable, fallback }) => [
variable,
normalizeHexColor(fallback, currentColor),
]))
}
type SelectedIcon = {
key: string
manifest: SpriteViewerManifest
icon: SpriteViewerManifestIcon
}
type ColorChangedEvent = CustomEvent<{ value: string }>
class GromlabSpriteViewerElement extends LitElement {
static properties = {
sources: { attribute: false },
viewerTitle: { attribute: 'viewer-title' },
colorTheme: { attribute: 'color-theme' },
themeControlled: { attribute: false },
showThemeToggle: { attribute: false },
manifestUrl: { attribute: 'manifest-url' },
spriteUrl: { attribute: 'sprite-url' },
_manifests: { state: true },
_errors: { state: true },
_loading: { state: true },
_query: { state: true },
_systemTheme: { state: true },
_selected: { state: true },
_activeTab: { state: true },
_colors: { state: true },
_colorOverrides: { state: true },
_cssColor: { state: true },
_cssColorOverridden: { state: true },
_openColorControl: { state: true },
_copied: { state: true },
}
static styles = unsafeCSS(SPRITE_VIEWER_STYLES)
declare sources: SpriteViewerSources
declare viewerTitle: string
declare colorTheme: SpriteViewerColorTheme
declare themeControlled: boolean
declare showThemeToggle: boolean
declare manifestUrl?: string
declare spriteUrl?: string
declare private _manifests: SpriteViewerManifest[]
declare private _errors: string[]
declare private _loading: boolean
declare private _query: string
declare private _systemTheme: 'light' | 'dark'
declare private _selected: SelectedIcon | null
declare private _activeTab: SpriteViewerTab
declare private _colors: Record<string, string>
declare private _colorOverrides: Record<string, string>
declare private _cssColor: string
declare private _cssColorOverridden: boolean
declare private _openColorControl: string | null
declare private _copied: boolean
private _loadVersion = 0
private _copyTimeout?: ReturnType<typeof setTimeout>
private _media?: MediaQueryList
constructor() {
super()
this.sources = []
this.viewerTitle = 'SVG Sprites'
this.colorTheme = 'auto'
this.themeControlled = false
this.showThemeToggle = true
this._manifests = []
this._errors = []
this._loading = false
this._query = ''
this._systemTheme = currentSystemTheme()
this._selected = null
this._activeTab = 'svg'
this._colors = {}
this._colorOverrides = {}
this._cssColor = '#1a1a1a'
this._cssColorOverridden = false
this._openColorControl = null
this._copied = false
}
private readonly _handleMediaChange = () => {
this._systemTheme = this._media?.matches ? 'dark' : 'light'
this._syncThemeColors()
}
private readonly _handleDocumentPointerDown = (event: PointerEvent) => {
if (!this._openColorControl) return
const insideCurrentControl = event.composedPath().some((node) => (
node instanceof HTMLElement
&& (node.dataset.colorTrigger === this._openColorControl
|| node.dataset.colorPopover === this._openColorControl)
))
if (!insideCurrentControl) this._openColorControl = null
}
connectedCallback(): void {
super.connectedCallback()
void defineColorElements()
this._media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
this._systemTheme = this._media?.matches ? 'dark' : 'light'
this._media?.addEventListener('change', this._handleMediaChange)
globalThis.document?.addEventListener('pointerdown', this._handleDocumentPointerDown, true)
}
disconnectedCallback(): void {
this._media?.removeEventListener('change', this._handleMediaChange)
globalThis.document?.removeEventListener('pointerdown', this._handleDocumentPointerDown, true)
if (this._copyTimeout) clearTimeout(this._copyTimeout)
super.disconnectedCallback()
}
protected updated(changed: PropertyValues<this>): void {
if (changed.has('sources') || changed.has('manifestUrl') || changed.has('spriteUrl')) {
void this._loadSources()
}
if (changed.has('colorTheme')) this._syncThemeColors()
}
private get _resolvedColorTheme(): 'light' | 'dark' {
return this.colorTheme === 'auto' ? this._systemTheme : this.colorTheme
}
private get _themeColor(): string {
return this._resolvedColorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a'
}
private _effectiveSources(): readonly SpriteViewerSource[] {
const configured = sourceArray(this.sources)
if (configured.length > 0) return configured
if (this.manifestUrl && this.spriteUrl) {
return [{ manifestUrl: this.manifestUrl, spriteUrl: this.spriteUrl }]
}
return []
}
private async _loadSources(): Promise<void> {
const version = ++this._loadVersion
const sources = this._effectiveSources()
const direct: SpriteViewerManifest[] = []
const asynchronous: SpriteViewerSource[] = []
const errors: string[] = []
if (this.manifestUrl && !this.spriteUrl && sourceArray(this.sources).length === 0) {
errors.push('The sprite-url attribute is required with manifest-url.')
}
for (const source of sources) {
if (typeof source === 'function' || isRemoteSource(source)) {
asynchronous.push(source)
continue
}
try {
direct.push(normalizeSpriteViewerManifest(source))
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error))
}
}
this._manifests = direct.sort(compareManifests)
this._errors = errors
this._loading = asynchronous.length > 0
this._selected = null
if (asynchronous.length === 0) return
const results = await Promise.allSettled(asynchronous.map(resolveViewerSource))
if (version !== this._loadVersion) return
const loaded: SpriteViewerManifest[] = []
for (const result of results) {
if (result.status === 'fulfilled') loaded.push(result.value)
else errors.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
}
this._manifests = [...direct, ...loaded].sort(compareManifests)
this._errors = [...errors]
this._loading = false
}
private _syncThemeColors(): void {
if (!this._selected) return
this._colors = {
...initialColors(this._selected.icon, this._themeColor),
...this._colorOverrides,
}
if (!this._cssColorOverridden) this._cssColor = this._themeColor
}
private _toggleTheme(): void {
const theme: Exclude<SpriteViewerColorTheme, 'auto'> = this._resolvedColorTheme === 'dark' ? 'light' : 'dark'
if (!this.themeControlled) this.colorTheme = theme
this.dispatchEvent(new CustomEvent('color-theme-change', {
bubbles: true,
composed: true,
detail: { theme },
}))
}
private async _selectIcon(manifest: SpriteViewerManifest, icon: SpriteViewerManifestIcon): Promise<void> {
const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}`
this._selected = { key, manifest, icon }
this._activeTab = tabsForManifest(manifest)[0]?.id ?? 'svg'
this._colorOverrides = {}
this._colors = initialColors(icon, this._themeColor)
this._cssColor = this._themeColor
this._cssColorOverridden = false
this._openColorControl = null
this._copied = false
await this.updateComplete
const dialog = this.renderRoot.querySelector<HTMLDialogElement>('dialog')
if (dialog && !dialog.open) dialog.showModal()
}
private _closeDialog(): void {
const dialog = this.renderRoot.querySelector<HTMLDialogElement>('dialog')
if (dialog?.open) dialog.close()
this._selected = null
this._openColorControl = null
}
private _handleBackdropClick(event: MouseEvent): void {
if (!(event.currentTarget instanceof HTMLDialogElement) || event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom
if (outside) this._closeDialog()
}
private _handleTabKeyDown(event: KeyboardEvent, tabIndex: number): void {
if (!this._selected) return
const tabs = tabsForManifest(this._selected.manifest)
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (tabIndex + 1) % tabs.length
if (event.key === 'ArrowLeft') nextIndex = (tabIndex - 1 + tabs.length) % tabs.length
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = tabs.length - 1
if (nextIndex === null) return
event.preventDefault()
this._activeTab = tabs[nextIndex].id
const buttons = this.renderRoot.querySelectorAll<HTMLButtonElement>('[role="tab"]')
void this.updateComplete.then(() => buttons[nextIndex]?.focus())
}
private _handleColorChange(variable: string, color: string): void {
const normalized = normalizeHexColor(color)
this._colors = { ...this._colors, [variable]: normalized }
this._colorOverrides = { ...this._colorOverrides, [variable]: normalized }
}
private _handleCssColorChange(color: string): void {
this._cssColor = normalizeHexColor(color)
this._cssColorOverridden = true
}
private async _copyCode(code: string): Promise<void> {
if (!globalThis.navigator?.clipboard) return
try {
await globalThis.navigator.clipboard.writeText(code)
this._copied = true
if (this._copyTimeout) clearTimeout(this._copyTimeout)
this._copyTimeout = setTimeout(() => { this._copied = false }, 1500)
} catch {
this._copied = false
}
}
private _renderColorControl(label: string, value: string, key: string, onChange: (value: string) => void): TemplateResult {
const open = this._openColorControl === key
const popoverId = `color-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`
return html`
<div class="gromlab-sprite-viewer__color-row">
<button
class="gromlab-sprite-viewer__swatch"
type="button"
data-color-trigger=${key}
style=${styleMap({ backgroundColor: value })}
aria-label=${`Изменить цвет ${label}`}
aria-expanded=${String(open)}
aria-controls=${ifDefined(open ? popoverId : undefined)}
title=${`Изменить цвет ${label}`}
@click=${() => { this._openColorControl = open ? null : key }}
></button>
<code class="gromlab-sprite-viewer__color-label">${label}</code>
${open ? html`
<div
id=${popoverId}
class="gromlab-sprite-viewer__color-popover"
data-color-popover=${key}
role="dialog"
aria-label=${`Выбор цвета ${label}`}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
this._openColorControl = null
}}
>
<gromlab-hex-color-picker
class="gromlab-sprite-viewer__color-picker"
.color=${value}
@color-changed=${(event: ColorChangedEvent) => onChange(event.detail.value)}
></gromlab-hex-color-picker>
<gromlab-hex-input
class="gromlab-sprite-viewer__hex-input"
.color=${value}
.prefixed=${true}
aria-label=${`HEX-значение ${label}`}
@color-changed=${(event: ColorChangedEvent) => onChange(event.detail.value)}
></gromlab-hex-input>
</div>
` : nothing}
</div>
`
}
private _renderDialogPreview(manifest: SpriteViewerManifest, icon: SpriteViewerManifestIcon): TemplateResult {
const href = `${manifest.spriteUrl}#${icon.id}`
if (this._activeTab === 'img') {
return html`<img class="gromlab-sprite-viewer__dialog-img" src=${href} alt=${icon.name}>`
}
if (this._activeTab === 'css') {
return html`
<div
class="gromlab-sprite-viewer__dialog-mask"
role="img"
aria-label=${icon.name}
style=${styleMap({
backgroundColor: this._cssColor,
mask: `url('${href}') no-repeat center / contain`,
'-webkit-mask': `url('${href}') no-repeat center / contain`,
})}
></div>
`
}
return html`
<svg
class="gromlab-sprite-viewer__dialog-icon"
viewBox=${ifDefined(icon.viewBox ?? undefined)}
aria-label=${icon.name}
role="img"
>
<use href=${href}></use>
</svg>
`
}
private _renderDialog(): TemplateResult | typeof nothing {
if (!this._selected) return nothing
const { manifest, icon } = this._selected
const tabs = tabsForManifest(manifest)
const activeTab = tabs.some((tab) => tab.id === this._activeTab) ? this._activeTab : tabs[0].id
const code = generateViewerCode({
manifest,
icon,
tab: activeTab,
colorOverrides: this._colorOverrides,
cssColor: this._cssColor,
})
const dimensions = viewBoxSize(icon.viewBox)
const colorsVisible = activeTab === 'react' || activeTab === 'vue' || activeTab === 'svg'
return html`
<dialog
class="gromlab-sprite-viewer__dialog"
aria-labelledby="viewer-dialog-title"
@cancel=${(event: Event) => { event.preventDefault(); this._closeDialog() }}
@click=${this._handleBackdropClick}
>
<div class="gromlab-sprite-viewer__dialog-shell">
<button
class="gromlab-sprite-viewer__close"
type="button"
aria-label="Закрыть"
autofocus
@click=${this._closeDialog}
>&#x2715;</button>
<div class="gromlab-sprite-viewer__dialog-preview" style=${styleMap(this._colors)}>
<div class="gromlab-sprite-viewer__dialog-preview-canvas">
${this._renderDialogPreview(manifest, icon)}
</div>
</div>
<div class="gromlab-sprite-viewer__dialog-heading">
<h2 id="viewer-dialog-title" class="gromlab-sprite-viewer__dialog-title">${icon.name}</h2>
${dimensions ? html`<span class="gromlab-sprite-viewer__viewbox">${dimensions}</span>` : nothing}
</div>
<p class="gromlab-sprite-viewer__dialog-meta">${manifest.name} · ${manifest.format} · ${manifest.target}</p>
<div class="gromlab-sprite-viewer__colors">
${colorsVisible && icon.colors.length > 0 ? html`
<p class="gromlab-sprite-viewer__hint">
Цвета применяются к превью через CSS-переменные и попадут в пример кода.
</p>
<h3 class="gromlab-sprite-viewer__colors-title">CSS Variables</h3>
${icon.colors.map(({ variable, fallback }) => this._renderColorControl(
`${variable}: ${fallback}`,
this._colors[variable],
variable,
(color) => this._handleColorChange(variable, color),
))}
` : nothing}
${colorsVisible && icon.colors.length === 0 ? html`
<p class="gromlab-sprite-viewer__hint">У иконки нет настраиваемых цветовых переменных.</p>
` : nothing}
${activeTab === 'img' ? html`
<p class="gromlab-sprite-viewer__hint">
IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются.
</p>
` : nothing}
${activeTab === 'css' ? html`
<p class="gromlab-sprite-viewer__hint">
CSS mask отображает иконку одним цветом через background-color.
</p>
${this._renderColorControl(
'background-color',
this._cssColor,
'background-color',
(color) => this._handleCssColorChange(color),
)}
` : nothing}
</div>
<div class="gromlab-sprite-viewer__tabs" role="tablist" aria-label="Способ подключения">
${tabs.map((tab, tabIndex) => html`
<button
id=${`viewer-${tab.id}-tab`}
class="gromlab-sprite-viewer__tab"
type="button"
role="tab"
aria-selected=${String(activeTab === tab.id)}
aria-controls="viewer-code-panel"
tabindex=${activeTab === tab.id ? 0 : -1}
@click=${() => { this._activeTab = tab.id }}
@keydown=${(event: KeyboardEvent) => this._handleTabKeyDown(event, tabIndex)}
>${tab.label}</button>
`)}
</div>
<div
id="viewer-code-panel"
class="gromlab-sprite-viewer__code"
role="tabpanel"
aria-labelledby=${`viewer-${activeTab}-tab`}
>
<pre><code>${unsafeHTML(highlightViewerCode(code.code, code.language))}</code></pre>
<button
class="gromlab-sprite-viewer__copy"
type="button"
@click=${() => { void this._copyCode(code.code) }}
>${this._copied ? 'Скопировано' : 'Копировать'}</button>
</div>
</div>
</dialog>
`
}
protected render(): TemplateResult {
const normalizedQuery = this._query.trim().toLowerCase()
const visibleGroups = this._manifests
.map((manifest) => ({
manifest,
icons: manifest.icons.filter((icon) => (
normalizedQuery === ''
|| icon.name.toLowerCase().includes(normalizedQuery)
|| manifest.name.toLowerCase().includes(normalizedQuery)
)),
}))
.filter((group) => group.icons.length > 0)
const totalIcons = this._manifests.reduce((total, manifest) => total + manifest.iconCount, 0)
const visibleIcons = visibleGroups.reduce((total, group) => total + group.icons.length, 0)
return html`
<section
class="gromlab-sprite-viewer"
data-sprite-viewer
data-theme=${ifDefined(this.colorTheme === 'auto' ? undefined : this.colorTheme)}
>
<header class="gromlab-sprite-viewer__header">
<h1 class="gromlab-sprite-viewer__title">${this.viewerTitle}</h1>
<span class="gromlab-sprite-viewer__summary">
${countLabel(this._manifests.length, ['спрайт', 'спрайта', 'спрайтов'])}
· ${countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])}
${normalizedQuery ? ` · найдено ${visibleIcons}` : nothing}
</span>
<div class="gromlab-sprite-viewer__toolbar">
<input
class="gromlab-sprite-viewer__search"
type="search"
.value=${this._query}
@input=${(event: InputEvent) => { this._query = (event.currentTarget as HTMLInputElement).value }}
placeholder="Найти иконку"
aria-label="Поиск иконок"
>
${this.showThemeToggle ? html`
<button
class="gromlab-sprite-viewer__theme"
type="button"
aria-label="Переключить тему"
title="Переключить тему"
@click=${this._toggleTheme}
>&#x25D1;</button>
` : nothing}
</div>
</header>
${this._errors.length > 0 ? html`
<div class="gromlab-sprite-viewer__errors" role="alert">
${this._errors.map((error) => html`<div>${error}</div>`)}
</div>
` : nothing}
${visibleGroups.map(({ manifest, icons }) => html`
<section class="gromlab-sprite-viewer__group">
<div class="gromlab-sprite-viewer__group-header">
<h2 class="gromlab-sprite-viewer__group-title">${manifest.name}</h2>
<span class="gromlab-sprite-viewer__badge">${manifest.format}</span>
<span class="gromlab-sprite-viewer__group-count">${icons.length}</span>
${manifest.description ? html`
<p class="gromlab-sprite-viewer__description">${manifest.description}</p>
` : nothing}
</div>
<div class="gromlab-sprite-viewer__grid">
${icons.map((icon) => html`
<button
class="gromlab-sprite-viewer__card"
type="button"
data-icon-name=${icon.name}
title=${`Открыть ${icon.name}`}
@click=${() => { void this._selectIcon(manifest, icon) }}
>
<span class="gromlab-sprite-viewer__icon-wrap">
<svg
class="gromlab-sprite-viewer__icon"
viewBox=${ifDefined(icon.viewBox ?? undefined)}
aria-hidden="true"
>
<use href=${`${manifest.spriteUrl}#${icon.id}`}></use>
</svg>
</span>
<span class="gromlab-sprite-viewer__icon-name">${icon.name}</span>
</button>
`)}
</div>
</section>
`)}
${visibleGroups.length === 0 && (!this._loading || this._manifests.length > 0) ? html`
<div class="gromlab-sprite-viewer__status">
${this._manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'}
</div>
` : nothing}
${this._loading && this._manifests.length === 0 ? html`
<div class="gromlab-sprite-viewer__status">Загрузка спрайтов...</div>
` : nothing}
<footer class="gromlab-sprite-viewer__footer">
<span>@gromlab/svg-sprites</span>
<a href="https://github.com/gromlab-ru/svg-sprites" target="_blank" rel="noreferrer">Repository</a>
</footer>
${this._renderDialog()}
</section>
`
}
}
export function defineSpriteViewerElement(): void {
if (!customElements.get(ELEMENT_NAME)) customElements.define(ELEMENT_NAME, GromlabSpriteViewerElement)
}

107
src/viewer/source.ts Normal file
View File

@@ -0,0 +1,107 @@
import type {
SpriteViewerManifest,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerRemoteSource,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
const manifestLoaderCache = new WeakMap<SpriteViewerManifestLoader, Promise<SpriteViewerManifest>>()
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
}
function isManifestIcon(value: unknown): value is SpriteViewerManifestIcon {
if (!isRecord(value)) return false
return typeof value.name === 'string'
&& typeof value.id === 'string'
&& (value.viewBox === null || typeof value.viewBox === 'string')
&& Array.isArray(value.colors)
}
export function isSpriteViewerManifest(value: unknown): value is SpriteViewerManifest {
if (!isRecord(value)) return false
return value.schemaVersion === 1
&& value.generator === '@gromlab/svg-sprites'
&& typeof value.name === 'string'
&& typeof value.target === 'string'
&& (value.format === 'stack' || value.format === 'symbol')
&& typeof value.iconCount === 'number'
&& typeof value.spriteUrl === 'string'
&& Array.isArray(value.icons)
&& value.icons.every(isManifestIcon)
}
function isManifestData(value: unknown): value is Omit<SpriteViewerManifest, 'spriteUrl'> {
return isRecord(value)
&& value.schemaVersion === 1
&& value.generator === '@gromlab/svg-sprites'
&& typeof value.name === 'string'
&& typeof value.target === 'string'
&& (value.format === 'stack' || value.format === 'symbol')
&& typeof value.iconCount === 'number'
&& Array.isArray(value.icons)
&& value.icons.every(isManifestIcon)
}
function manifestCandidate(value: unknown): unknown {
if (!isRecord(value)) return value
if (isSpriteViewerManifest(value) || isManifestData(value)) return value
return value.default ?? value.spriteManifest ?? value
}
export function normalizeSpriteViewerManifest(value: unknown, spriteUrl?: string): SpriteViewerManifest {
const candidate = manifestCandidate(value)
if (isSpriteViewerManifest(candidate)) return candidate
if (spriteUrl && isManifestData(candidate)) return { ...candidate, spriteUrl }
throw new Error('The loaded source does not contain a valid SVG sprite manifest.')
}
export function sourceArray(sources: SpriteViewerSources): readonly SpriteViewerSource[] {
return Array.isArray(sources)
? sources
: Object.values(sources as Readonly<Record<string, SpriteViewerSource>>)
}
export function isRemoteSource(source: SpriteViewerSource): source is SpriteViewerRemoteSource {
if (!isRecord(source)) return false
const candidate = source as Record<string, unknown>
return typeof candidate.manifestUrl === 'string'
&& typeof candidate.spriteUrl === 'string'
}
export function directManifests(sources: SpriteViewerSources): SpriteViewerManifest[] {
return sourceArray(sources)
.filter((source) => typeof source !== 'function' && !isRemoteSource(source))
.map((source) => normalizeSpriteViewerManifest(source))
.sort(compareManifests)
}
export function compareManifests(left: SpriteViewerManifest, right: SpriteViewerManifest): number {
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
}
async function loadRemoteSource(source: SpriteViewerRemoteSource): Promise<SpriteViewerManifest> {
const response = await fetch(source.manifestUrl)
if (!response.ok) {
throw new Error(`Cannot load SVG sprite manifest: ${response.status} ${response.statusText}`)
}
return normalizeSpriteViewerManifest(await response.json(), source.spriteUrl)
}
export async function resolveViewerSource(source: SpriteViewerSource): Promise<SpriteViewerManifest> {
if (isRemoteSource(source)) return loadRemoteSource(source)
if (typeof source !== 'function') return normalizeSpriteViewerManifest(source)
const cached = manifestLoaderCache.get(source)
if (cached) return cached
const pending = Promise.resolve().then(source).then((value) => normalizeSpriteViewerManifest(value))
manifestLoaderCache.set(source, pending)
void pending.catch(() => {
if (manifestLoaderCache.get(source) === pending) manifestLoaderCache.delete(source)
})
return pending
}

View File

@@ -1,4 +1,5 @@
export const SPRITE_VIEWER_STYLES = `
:host { display: block; }
.gromlab-sprite-viewer {
--sv-bg: #f0f0f3;
--sv-text: #1a1a1a;
@@ -271,9 +272,10 @@ export const SPRITE_VIEWER_STYLES = `
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
}
.gromlab-sprite-viewer__color-popover .react-colorful { width: 198px; max-width: 100%; height: 160px; }
.gromlab-sprite-viewer__hex-input { display: block; width: 100%; height: 32px; margin-top: 8px; padding: 0 8px; color: var(--sv-text); text-align: center; background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 4px; outline: none; font: 12px/1 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__hex-input:focus { border-color: var(--sv-accent); }
.gromlab-sprite-viewer__color-picker { display: flex; flex-direction: column; width: 198px; max-width: 100%; height: 160px; }
.gromlab-sprite-viewer__hex-input { display: block; width: 100%; margin-top: 8px; }
.gromlab-sprite-viewer__hex-input::part(input) { box-sizing: border-box; display: block; width: 100%; height: 32px; padding: 0 8px; color: var(--sv-text); text-align: center; background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 4px; outline: none; font: 12px/1 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__hex-input::part(input):focus { border-color: var(--sv-accent); }
.gromlab-sprite-viewer__tabs { display: flex; overflow-x: auto; margin-bottom: 12px; border-bottom: 1px solid var(--sv-border); }
.gromlab-sprite-viewer__tab { flex: 0 0 auto; padding: 8px 16px; color: var(--sv-muted); background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
.gromlab-sprite-viewer__tab[aria-selected="true"] { color: var(--sv-accent); border-bottom-color: var(--sv-accent); }

68
src/viewer/types.ts Normal file
View File

@@ -0,0 +1,68 @@
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
export type SpriteViewerManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type SpriteViewerManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly SpriteViewerManifestColor[]
}
export type SpriteViewerManifestUsage = {
framework: 'react' | 'vue'
componentName: string
}
/** Нормализованный manifest, который может отобразить framework-neutral Viewer. */
export type SpriteViewerManifest = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
mode?: string
target: string
format: 'stack' | 'symbol'
iconCount: number
spriteUrl: string
icons: readonly SpriteViewerManifestIcon[]
/** Текущее поле React/Next manifests. */
componentName?: string
/** Расширяемое описание framework-specific примера. */
usage?: SpriteViewerManifestUsage
}
export type SpriteViewerManifestModule = {
default?: SpriteViewerManifest
spriteManifest?: SpriteViewerManifest
}
export type SpriteViewerManifestLoader = () => Promise<SpriteViewerManifest | SpriteViewerManifestModule>
export type SpriteViewerRemoteSource = {
manifestUrl: string
spriteUrl: string
}
export type SpriteViewerSource =
| SpriteViewerManifest
| SpriteViewerManifestModule
| SpriteViewerManifestLoader
| SpriteViewerRemoteSource
export type SpriteViewerSources =
| readonly SpriteViewerSource[]
| Readonly<Record<string, SpriteViewerSource>>
export type SpriteViewerElement = HTMLElement & {
sources: SpriteViewerSources
viewerTitle: string
colorTheme: SpriteViewerColorTheme
themeControlled: boolean
showThemeToggle: boolean
manifestUrl?: string
spriteUrl?: string
}