mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
feat: Новый кросс платформенный вьювер
This commit is contained in:
@@ -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}
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
◑
|
||||
</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': '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
18
src/viewer-element.ts
Normal 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
14
src/viewer.ts
Normal 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'
|
||||
@@ -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
650
src/viewer/element.ts
Normal 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}
|
||||
>✕</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}
|
||||
>◑</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
107
src/viewer/source.ts
Normal 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
|
||||
}
|
||||
@@ -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
68
src/viewer/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user