mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
feat: выпустить версию 1.0.0
- добавлены отдельные режимы генерации для React, Next.js и legacy - добавлены SpriteViewer, типизированные компоненты и безопасный codegen - перенесена сборка AI-скила и обновлена документация - добавлены migration guide, лицензии и проверки публикации
This commit is contained in:
196
src/react/sprite-viewer-code.ts
Normal file
196
src/react/sprite-viewer-code.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
|
||||
|
||||
export type SpriteViewerTab = 'react' | 'svg' | 'img' | 'css'
|
||||
export type SpriteViewerCodeLanguage = 'tsx' | 'html' | 'css'
|
||||
|
||||
export const SPRITE_VIEWER_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
|
||||
{ id: 'react', label: 'React' },
|
||||
{ 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')
|
||||
}
|
||||
|
||||
export function viewBoxSize(viewBox: string | null): string | null {
|
||||
if (!viewBox) return null
|
||||
const values = viewBox.trim().split(/[\s,]+/).map(Number)
|
||||
return values.length === 4 && values.every(Number.isFinite)
|
||||
? `${values[2]} × ${values[3]}`
|
||||
: null
|
||||
}
|
||||
|
||||
export function normalizeHexColor(value: string, currentColor = '#1a1a1a'): string {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'currentcolor') return normalizeHexColor(currentColor)
|
||||
|
||||
const shortHex = normalized.match(/^#([\da-f])([\da-f])([\da-f])(?:[\da-f])?$/i)
|
||||
if (shortHex) return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`
|
||||
|
||||
const longHex = normalized.match(/^#([\da-f]{6})(?:[\da-f]{2})?$/i)
|
||||
if (longHex) return `#${longHex[1]}`
|
||||
|
||||
const rgb = normalized.match(/^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/)
|
||||
if (rgb) {
|
||||
return `#${rgb.slice(1, 4).map((part) => Math.min(255, Number(part)).toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
const named: Record<string, string> = {
|
||||
black: '#000000',
|
||||
blue: '#0000ff',
|
||||
cyan: '#00ffff',
|
||||
gray: '#808080',
|
||||
green: '#008000',
|
||||
grey: '#808080',
|
||||
magenta: '#ff00ff',
|
||||
orange: '#ffa500',
|
||||
pink: '#ffc0cb',
|
||||
purple: '#800080',
|
||||
red: '#ff0000',
|
||||
white: '#ffffff',
|
||||
yellow: '#ffff00',
|
||||
}
|
||||
return named[normalized] ?? normalizeHexColor(currentColor === value ? '#1a1a1a' : currentColor)
|
||||
}
|
||||
|
||||
function styleLines(overrides: Readonly<Record<string, string>>): string[] {
|
||||
return Object.entries(overrides).map(([variable, color]) => ` ${JSON.stringify(variable)}: ${JSON.stringify(color)},`)
|
||||
}
|
||||
|
||||
function htmlAttribute(value: string): string {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
}
|
||||
return value.replace(/[&"<>]/g, (character) => entities[character])
|
||||
}
|
||||
|
||||
function cssUrl(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||||
}
|
||||
|
||||
export function generateViewerCode(options: {
|
||||
manifest: SpriteManifest
|
||||
icon: SpriteManifestIcon
|
||||
tab: SpriteViewerTab
|
||||
colorOverrides: Readonly<Record<string, string>>
|
||||
cssColor: string
|
||||
}): { code: string; language: SpriteViewerCodeLanguage } {
|
||||
const { manifest, icon, tab, colorOverrides, cssColor } = options
|
||||
const href = `${manifest.spriteUrl}#${icon.id}`
|
||||
const overrides = styleLines(colorOverrides)
|
||||
|
||||
if (tab === 'react') {
|
||||
if (overrides.length === 0) {
|
||||
return {
|
||||
code: `<${manifest.componentName} icon=${JSON.stringify(icon.name)} />`,
|
||||
language: 'tsx',
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: [
|
||||
`<${manifest.componentName}`,
|
||||
` icon=${JSON.stringify(icon.name)}`,
|
||||
' style={{',
|
||||
...overrides,
|
||||
' }}',
|
||||
'/>',
|
||||
].join('\n'),
|
||||
language: 'tsx',
|
||||
}
|
||||
}
|
||||
|
||||
if (tab === 'svg') {
|
||||
const style = Object.entries(colorOverrides)
|
||||
.map(([variable, color]) => `${variable}: ${color}`)
|
||||
.join('; ')
|
||||
return {
|
||||
code: `<svg width="24" height="24"${style ? ` style="${htmlAttribute(style)}"` : ''}>\n <use href="${htmlAttribute(href)}" />\n</svg>`,
|
||||
language: 'html',
|
||||
}
|
||||
}
|
||||
|
||||
if (tab === 'img') {
|
||||
return {
|
||||
code: `<img src="${htmlAttribute(href)}" width="24" height="24" alt="${htmlAttribute(icon.name)}">`,
|
||||
language: 'html',
|
||||
}
|
||||
}
|
||||
|
||||
const className = `icon-${icon.name.replace(/[^a-zA-Z0-9_-]+/g, '-') || 'sprite'}`
|
||||
const escapedHref = cssUrl(href)
|
||||
return {
|
||||
code: [
|
||||
`.${className} {`,
|
||||
' width: 24px;',
|
||||
' height: 24px;',
|
||||
` mask: url('${escapedHref}') no-repeat center / contain;`,
|
||||
` -webkit-mask: url('${escapedHref}') no-repeat center / contain;`,
|
||||
` background-color: ${cssColor};`,
|
||||
'}',
|
||||
].join('\n'),
|
||||
language: 'css',
|
||||
}
|
||||
}
|
||||
|
||||
const ESCAPE_RE = /[&<>"]/g
|
||||
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
||||
type HighlightRule = [RegExp, string]
|
||||
|
||||
const MARKUP_RULES: HighlightRule[] = [
|
||||
[/<!--[\s\S]*?-->/g, 'comment'],
|
||||
[/<\/?[\w.-]+/g, 'tag'],
|
||||
[/[\w-]+(?=\s*=)/g, 'attr'],
|
||||
[/"[^"]*"|'[^']*'/g, 'string'],
|
||||
]
|
||||
|
||||
const CSS_RULES: HighlightRule[] = [
|
||||
[/\/\*[\s\S]*?\*\//g, 'comment'],
|
||||
[/\.[\w-]+/g, 'selector'],
|
||||
[/[\w-]+(?=\s*:)/g, 'property'],
|
||||
[/'[^']*'|"[^"]*"/g, 'string'],
|
||||
[/#[\da-fA-F]{3,8}\b/g, 'color'],
|
||||
[/\d+(?:\.\d+)?(?:px|em|rem|%)?/g, 'number'],
|
||||
]
|
||||
|
||||
export function highlightViewerCode(code: string, language: SpriteViewerCodeLanguage): string {
|
||||
const rules = language === 'css' ? CSS_RULES : MARKUP_RULES
|
||||
const tokens: Array<{ kind: string; value: string }> = []
|
||||
let position = 0
|
||||
|
||||
while (position < code.length) {
|
||||
let match: RegExpExecArray | null = null
|
||||
let kind = ''
|
||||
|
||||
for (const [rule, ruleKind] of rules) {
|
||||
rule.lastIndex = position
|
||||
const candidate = rule.exec(code)
|
||||
if (candidate?.index === position) {
|
||||
match = candidate
|
||||
kind = ruleKind
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
tokens.push({ kind, value: match[0] })
|
||||
position += match[0].length
|
||||
} else {
|
||||
const previous = tokens.at(-1)
|
||||
if (previous?.kind === '') previous.value += code[position]
|
||||
else tokens.push({ kind: '', value: code[position] })
|
||||
position++
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.map(({ kind, value }) => {
|
||||
const escaped = value.replace(ESCAPE_RE, (character) => ESCAPE_MAP[character])
|
||||
return kind ? `<span class="hl-${kind}">${escaped}</span>` : escaped
|
||||
}).join('')
|
||||
}
|
||||
317
src/react/sprite-viewer-dialog.tsx
Normal file
317
src/react/sprite-viewer-dialog.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
332
src/react/sprite-viewer-styles.ts
Normal file
332
src/react/sprite-viewer-styles.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
export const SPRITE_VIEWER_STYLES = `
|
||||
.gromlab-sprite-viewer {
|
||||
--sv-bg: #f0f0f3;
|
||||
--sv-text: #1a1a1a;
|
||||
--sv-card: #ffffff;
|
||||
--sv-card-hover: #eaeaed;
|
||||
--sv-border: #d8d8d8;
|
||||
--sv-accent: #3b82f6;
|
||||
--sv-muted: #777777;
|
||||
--sv-code: #f5f5f5;
|
||||
--sv-checker-a: #e9e9e9;
|
||||
--sv-checker-b: #ffffff;
|
||||
--sv-danger: #b42332;
|
||||
box-sizing: border-box;
|
||||
min-height: 320px;
|
||||
padding: 24px;
|
||||
color: var(--sv-text);
|
||||
color-scheme: light;
|
||||
background: var(--sv-bg);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.gromlab-sprite-viewer[data-theme="dark"] {
|
||||
--sv-bg: #1a1a1a;
|
||||
--sv-text: #e5e5e5;
|
||||
--sv-card: #2a2a2a;
|
||||
--sv-card-hover: #333333;
|
||||
--sv-border: #404040;
|
||||
--sv-muted: #a3a3a3;
|
||||
--sv-code: #242424;
|
||||
--sv-checker-a: #333333;
|
||||
--sv-checker-b: #2a2a2a;
|
||||
--sv-danger: #ff9ba6;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.gromlab-sprite-viewer:not([data-theme]) {
|
||||
--sv-bg: #1a1a1a;
|
||||
--sv-text: #e5e5e5;
|
||||
--sv-card: #2a2a2a;
|
||||
--sv-card-hover: #333333;
|
||||
--sv-border: #404040;
|
||||
--sv-muted: #a3a3a3;
|
||||
--sv-code: #242424;
|
||||
--sv-checker-a: #333333;
|
||||
--sv-checker-b: #2a2a2a;
|
||||
--sv-danger: #ff9ba6;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
.gromlab-sprite-viewer *,
|
||||
.gromlab-sprite-viewer *::before,
|
||||
.gromlab-sprite-viewer *::after { box-sizing: border-box; }
|
||||
.gromlab-sprite-viewer button,
|
||||
.gromlab-sprite-viewer input { font: inherit; }
|
||||
.gromlab-sprite-viewer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.gromlab-sprite-viewer__title { margin: 0; font-size: 24px; line-height: 1.2; font-weight: 700; }
|
||||
.gromlab-sprite-viewer__summary { color: var(--sv-muted); font-size: 13px; }
|
||||
.gromlab-sprite-viewer__toolbar { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
.gromlab-sprite-viewer__search {
|
||||
width: 220px;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
color: var(--sv-text);
|
||||
background: var(--sv-card);
|
||||
border: 1px solid var(--sv-border);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.gromlab-sprite-viewer__search:focus { border-color: var(--sv-accent); box-shadow: 0 0 0 3px rgba(59, 130, 246, .18); }
|
||||
.gromlab-sprite-viewer__search::placeholder { color: var(--sv-muted); }
|
||||
.gromlab-sprite-viewer__theme {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
color: var(--sv-text);
|
||||
background: var(--sv-card);
|
||||
border: 1px solid var(--sv-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.gromlab-sprite-viewer__theme:hover { background: var(--sv-card-hover); }
|
||||
.gromlab-sprite-viewer__theme:focus-visible,
|
||||
.gromlab-sprite-viewer__card:focus-visible,
|
||||
.gromlab-sprite-viewer__close:focus-visible,
|
||||
.gromlab-sprite-viewer__tab:focus-visible,
|
||||
.gromlab-sprite-viewer__copy:focus-visible,
|
||||
.gromlab-sprite-viewer__swatch:focus-visible {
|
||||
outline: 2px solid var(--sv-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gromlab-sprite-viewer__errors {
|
||||
margin: 0 0 24px;
|
||||
padding: 12px 14px;
|
||||
color: var(--sv-danger);
|
||||
background: rgba(180, 35, 50, .08);
|
||||
border: 1px solid rgba(180, 35, 50, .28);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.gromlab-sprite-viewer__group { margin-bottom: 40px; }
|
||||
.gromlab-sprite-viewer__group-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.gromlab-sprite-viewer__group-title { margin: 0; font-size: 18px; line-height: 1.3; font-weight: 600; }
|
||||
.gromlab-sprite-viewer__badge {
|
||||
padding: 2px 8px;
|
||||
color: #ffffff;
|
||||
background: var(--sv-accent);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.gromlab-sprite-viewer__group-count,
|
||||
.gromlab-sprite-viewer__description { color: var(--sv-muted); font-size: 13px; font-weight: 400; }
|
||||
.gromlab-sprite-viewer__description { flex-basis: 100%; margin: -2px 0 0; }
|
||||
.gromlab-sprite-viewer__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
||||
.gromlab-sprite-viewer__card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 16px 8px;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
background: var(--sv-card);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background .15s, transform .15s;
|
||||
}
|
||||
.gromlab-sprite-viewer__card:hover { background: var(--sv-card-hover); transform: translateY(-1px); }
|
||||
.gromlab-sprite-viewer__icon-wrap,
|
||||
.gromlab-sprite-viewer__dialog-preview-canvas {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: conic-gradient(
|
||||
var(--sv-checker-a) 25%, var(--sv-checker-b) 0 50%,
|
||||
var(--sv-checker-a) 0 75%, var(--sv-checker-b) 0
|
||||
);
|
||||
background-size: 8px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.gromlab-sprite-viewer__icon-wrap { width: 128px; height: 128px; }
|
||||
.gromlab-sprite-viewer__icon { display: block; width: 128px; height: 128px; color: var(--sv-text); overflow: visible; }
|
||||
.gromlab-sprite-viewer__icon-name {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--sv-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gromlab-sprite-viewer__status {
|
||||
padding: 40px 20px;
|
||||
color: var(--sv-muted);
|
||||
text-align: center;
|
||||
background: var(--sv-card);
|
||||
border: 1px dashed var(--sv-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.gromlab-sprite-viewer__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 40px;
|
||||
padding-top: 16px;
|
||||
color: var(--sv-muted);
|
||||
border-top: 1px solid var(--sv-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.gromlab-sprite-viewer__footer a { color: var(--sv-accent); text-decoration: none; }
|
||||
.gromlab-sprite-viewer__footer a:hover { text-decoration: underline; }
|
||||
.gromlab-sprite-viewer__dialog {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 0;
|
||||
color: var(--sv-text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.gromlab-sprite-viewer__dialog::backdrop { background: rgba(0, 0, 0, .55); backdrop-filter: blur(2px); }
|
||||
.gromlab-sprite-viewer__dialog-shell {
|
||||
position: relative;
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
color: var(--sv-text);
|
||||
background: var(--sv-bg);
|
||||
border: 1px solid var(--sv-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, .28);
|
||||
}
|
||||
.gromlab-sprite-viewer__close {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: var(--sv-text);
|
||||
background: var(--sv-bg);
|
||||
border: 1px solid var(--sv-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gromlab-sprite-viewer__close:hover { background: var(--sv-card-hover); }
|
||||
.gromlab-sprite-viewer__dialog-preview { display: flex; align-items: center; justify-content: center; padding: 24px; margin-bottom: 16px; background: var(--sv-card); border-radius: 8px; }
|
||||
.gromlab-sprite-viewer__dialog-preview-canvas { width: 256px; height: 256px; }
|
||||
.gromlab-sprite-viewer__dialog-icon,
|
||||
.gromlab-sprite-viewer__dialog-img,
|
||||
.gromlab-sprite-viewer__dialog-mask { display: block; width: 256px; height: 256px; }
|
||||
.gromlab-sprite-viewer__dialog-icon { color: var(--sv-text); overflow: visible; }
|
||||
.gromlab-sprite-viewer__dialog-img { object-fit: contain; }
|
||||
.gromlab-sprite-viewer__dialog-heading { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.gromlab-sprite-viewer__dialog-title { margin: 0; font: 600 16px/1.3 ui-monospace, "SFMono-Regular", Consolas, monospace; }
|
||||
.gromlab-sprite-viewer__viewbox {
|
||||
padding: 2px 8px;
|
||||
color: var(--sv-muted);
|
||||
background: var(--sv-card);
|
||||
border: 1px solid var(--sv-border);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gromlab-sprite-viewer__dialog-meta { margin: 0 0 20px; color: var(--sv-muted); text-align: center; font-size: 12px; }
|
||||
.gromlab-sprite-viewer__colors { margin-bottom: 20px; }
|
||||
.gromlab-sprite-viewer__hint {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 12px;
|
||||
color: var(--sv-muted);
|
||||
background: var(--sv-card);
|
||||
border-left: 3px solid var(--sv-accent);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gromlab-sprite-viewer__colors-title { margin: 0 0 8px; color: var(--sv-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.gromlab-sprite-viewer__color-row { position: relative; display: flex; align-items: center; gap: 8px; min-height: 32px; margin-bottom: 6px; }
|
||||
.gromlab-sprite-viewer__swatch { width: 26px; height: 26px; flex: 0 0 auto; padding: 0; border: 1px solid var(--sv-border); border-radius: 4px; cursor: pointer; }
|
||||
.gromlab-sprite-viewer__color-label { min-width: 0; overflow-wrap: anywhere; color: var(--sv-muted); font: 12px/1.4 ui-monospace, "SFMono-Regular", Consolas, monospace; }
|
||||
.gromlab-sprite-viewer__color-popover {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 224px;
|
||||
max-width: calc(100vw - 64px);
|
||||
padding: 12px;
|
||||
background: var(--sv-bg);
|
||||
border: 1px solid var(--sv-border);
|
||||
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__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); }
|
||||
.gromlab-sprite-viewer__code { position: relative; overflow: hidden; background: var(--sv-code); border-radius: 8px; }
|
||||
.gromlab-sprite-viewer__code pre { min-height: 72px; margin: 0; padding: 16px 70px 16px 16px; overflow-x: auto; font: 12px/1.6 ui-monospace, "SFMono-Regular", Consolas, monospace; }
|
||||
.gromlab-sprite-viewer__copy { position: absolute; top: 8px; right: 8px; padding: 4px 8px; color: var(--sv-muted); background: var(--sv-bg); border: 1px solid var(--sv-border); border-radius: 4px; cursor: pointer; font-size: 11px; }
|
||||
.gromlab-sprite-viewer__copy:hover { color: var(--sv-text); }
|
||||
.gromlab-sprite-viewer__code .hl-tag { color: #116329; }
|
||||
.gromlab-sprite-viewer__code .hl-attr,
|
||||
.gromlab-sprite-viewer__code .hl-number,
|
||||
.gromlab-sprite-viewer__code .hl-property { color: #0550ae; }
|
||||
.gromlab-sprite-viewer__code .hl-string,
|
||||
.gromlab-sprite-viewer__code .hl-color { color: #0a3069; }
|
||||
.gromlab-sprite-viewer__code .hl-comment { color: #8b949e; }
|
||||
.gromlab-sprite-viewer__code .hl-punctuation,
|
||||
.gromlab-sprite-viewer__code .hl-selector { color: #6639ba; }
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-tag { color: #7ee787; }
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-attr,
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-number,
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-property { color: #79c0ff; }
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-string,
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-color { color: #a5d6ff; }
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-punctuation,
|
||||
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-selector { color: #d2a8ff; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-tag { color: #7ee787; }
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-attr,
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-number,
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-property { color: #79c0ff; }
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-string,
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-color { color: #a5d6ff; }
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-punctuation,
|
||||
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-selector { color: #d2a8ff; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.gromlab-sprite-viewer { padding: 16px; border-radius: 8px; }
|
||||
.gromlab-sprite-viewer__header { align-items: stretch; }
|
||||
.gromlab-sprite-viewer__toolbar { width: 100%; margin-left: 0; }
|
||||
.gromlab-sprite-viewer__search { width: auto; flex: 1; min-width: 0; }
|
||||
.gromlab-sprite-viewer__grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; }
|
||||
.gromlab-sprite-viewer__icon-wrap,
|
||||
.gromlab-sprite-viewer__icon { width: 104px; height: 104px; }
|
||||
.gromlab-sprite-viewer__dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 20px); }
|
||||
.gromlab-sprite-viewer__dialog-shell { max-height: calc(100dvh - 20px); padding: 16px; }
|
||||
.gromlab-sprite-viewer__dialog-preview { padding: 16px; }
|
||||
.gromlab-sprite-viewer__dialog-preview-canvas,
|
||||
.gromlab-sprite-viewer__dialog-icon,
|
||||
.gromlab-sprite-viewer__dialog-img,
|
||||
.gromlab-sprite-viewer__dialog-mask { width: min(256px, calc(100vw - 86px)); height: min(256px, calc(100vw - 86px)); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gromlab-sprite-viewer *,
|
||||
.gromlab-sprite-viewer *::before,
|
||||
.gromlab-sprite-viewer *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
`
|
||||
301
src/react/sprite-viewer.tsx
Normal file
301
src/react/sprite-viewer.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { useDeferredValue, useEffect, useState } 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'
|
||||
|
||||
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
|
||||
|
||||
export type SpriteViewerProps = {
|
||||
sources: SpriteViewerSources
|
||||
title?: string
|
||||
/** Тема Viewer. По умолчанию следует prefers-color-scheme. */
|
||||
colorTheme?: SpriteViewerColorTheme
|
||||
/** Вызывается встроенным переключателем в controlled-режиме. */
|
||||
onColorThemeChange?: (theme: SpriteViewerColorTheme) => void
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
type SelectedIcon = {
|
||||
key: string
|
||||
manifest: SpriteManifest
|
||||
icon: SpriteManifestIcon
|
||||
}
|
||||
|
||||
const manifestLoaderCache = new WeakMap<SpriteManifestLoader, Promise<SpriteManifest>>()
|
||||
|
||||
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-спрайтов. */
|
||||
export function SpriteViewer({
|
||||
sources,
|
||||
title = 'SVG Sprites',
|
||||
colorTheme,
|
||||
onColorThemeChange,
|
||||
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())
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const allSources = sourceArray(sources)
|
||||
const direct = directManifests(sources)
|
||||
const loaders = allSources.filter((source) => typeof source === 'function')
|
||||
|
||||
setManifests(direct)
|
||||
setErrors([])
|
||||
setLoading(loaders.length > 0)
|
||||
setSelected(null)
|
||||
|
||||
if (loaders.length === 0) return () => { active = false }
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
return () => { active = false }
|
||||
}, [sources])
|
||||
|
||||
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://gromlab.ru/gromov/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>
|
||||
)
|
||||
}
|
||||
40
src/react/types.ts
Normal file
40
src/react/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SpriteAssetTarget } from '../targets/types.js'
|
||||
|
||||
export type SpriteManifestColor = {
|
||||
variable: `--icon-color-${number}`
|
||||
fallback: string
|
||||
}
|
||||
|
||||
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
|
||||
componentName: string
|
||||
target: SpriteAssetTarget
|
||||
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>>
|
||||
Reference in New Issue
Block a user