mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 12:40:21 +03:00
refactor: заменить shiki на самописный highlighter и обновить архитектуру
- Удалён shiki (9.5→0 МБ), создан regex-токенизатор для html/css/xml - CLI переведён с аргументов на конфиг-файл svg-sprites.config.ts - Превью переработано: React-приложение вместо инлайн HTML - Добавлен футер с названием пакета и ссылкой на репозиторий - Исправлена загрузка dev-data.js для Vite 8 - Футер прижат к низу, содержимое центрировано
This commit is contained in:
108
preview/src/App.tsx
Normal file
108
preview/src/App.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTheme } from './infrastructure/theme'
|
||||
import { Banner } from './ui/banner'
|
||||
import { SearchInput } from './ui/search-input'
|
||||
import { IconGrid } from './ui/icon-grid'
|
||||
import { IconModal } from './ui/icon-modal'
|
||||
import type { SpritesData, IconData } from './shared/types'
|
||||
import styles from './app/styles/app.module.css'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SPRITES_DATA__?: SpritesData
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_DATA: SpritesData = {
|
||||
groups: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Корневой компонент превью SVG-спрайтов.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения всех иконок из сгенерированных спрайтов
|
||||
* - просмотра деталей иконки и кода использования
|
||||
*/
|
||||
export const App = () => {
|
||||
const { toggle } = useTheme()
|
||||
const data = window.__SPRITES_DATA__ ?? MOCK_DATA
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIconId, setSelectedIconId] = useState<string | null>(null)
|
||||
|
||||
const isFileProtocol = location.protocol === 'file:'
|
||||
|
||||
const totalIcons = useMemo(
|
||||
() => data.groups.reduce((sum, g) => sum + g.icons.length, 0),
|
||||
[data.groups],
|
||||
)
|
||||
|
||||
const selectedIcon = useMemo((): IconData | null => {
|
||||
if (!selectedIconId) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const group of data.groups) {
|
||||
const found = group.icons.find((i) => i.id === selectedIconId)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [selectedIconId, data.groups])
|
||||
|
||||
const handleCloseModal = (): void => {
|
||||
setSelectedIconId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFileProtocol && (
|
||||
<Banner variant="warn">
|
||||
<strong>file://</strong> — Preview opened from local file. External SVG
|
||||
references won't work in code snippets. Use a local server for full
|
||||
functionality.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>SVG Sprites</h1>
|
||||
<span className={styles.count}>{totalIcons} icons</span>
|
||||
<div className={styles.toolbar}>
|
||||
<SearchInput
|
||||
placeholder="Search icons..."
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<button type="button" className={styles.themeButton} onClick={toggle}>
|
||||
◑
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<IconGrid
|
||||
groups={data.groups}
|
||||
searchQuery={searchQuery}
|
||||
onIconSelect={setSelectedIconId}
|
||||
/>
|
||||
|
||||
<IconModal
|
||||
icon={selectedIcon}
|
||||
defaultSprite={data.groups[0]?.name}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<span className={styles.footerText}>@gromlab/svg-sprites</span>
|
||||
<a
|
||||
className={styles.footerLink}
|
||||
href="https://gromlab.ru/gromov/svg-sprites"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Repository
|
||||
</a>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
64
preview/src/app/styles/app.module.css
Normal file
64
preview/src/app/styles/app.module.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.themeButton {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-card-bg);
|
||||
color: var(--color-fg);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.themeButton:hover {
|
||||
background: var(--color-card-hover);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footerText {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footerLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
26
preview/src/app/styles/globals.css
Normal file
26
preview/src/app/styles/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import './variables.css';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
60
preview/src/app/styles/variables.css
Normal file
60
preview/src/app/styles/variables.css
Normal file
@@ -0,0 +1,60 @@
|
||||
:root {
|
||||
--color-bg: #f0f0f3;
|
||||
--color-fg: #1a1a1a;
|
||||
--color-card-bg: #ffffff;
|
||||
--color-card-hover: #eaeaed;
|
||||
--color-border: #d8d8d8;
|
||||
--color-accent: #3b82f6;
|
||||
--color-muted: #888888;
|
||||
--color-code-bg: #f5f5f5;
|
||||
|
||||
--radius-1: 4px;
|
||||
--radius-2: 8px;
|
||||
--radius-3: 12px;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
--icon-size: 128px;
|
||||
|
||||
--checkerboard: conic-gradient(
|
||||
var(--color-checker-a) 25%, var(--color-checker-b) 0 50%,
|
||||
var(--color-checker-a) 0 75%, var(--color-checker-b) 0
|
||||
);
|
||||
--checkerboard-size: 8px 8px;
|
||||
--color-checker-a: #e9e9e9;
|
||||
--color-checker-b: #ffffff;
|
||||
|
||||
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--color-bg: #1a1a1a;
|
||||
--color-fg: #e5e5e5;
|
||||
--color-card-bg: #2a2a2a;
|
||||
--color-card-hover: #333333;
|
||||
--color-border: #404040;
|
||||
--color-code-bg: #2a2a2a;
|
||||
--color-checker-a: #333333;
|
||||
--color-checker-b: #2a2a2a;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--color-bg: #1a1a1a;
|
||||
--color-fg: #e5e5e5;
|
||||
--color-card-bg: #2a2a2a;
|
||||
--color-card-hover: #333333;
|
||||
--color-border: #404040;
|
||||
--color-code-bg: #2a2a2a;
|
||||
--color-checker-a: #333333;
|
||||
--color-checker-b: #2a2a2a;
|
||||
}
|
||||
|
||||
40
preview/src/infrastructure/theme/hooks/use-theme.hook.ts
Normal file
40
preview/src/infrastructure/theme/hooks/use-theme.hook.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { Theme } from '../types/theme.type'
|
||||
|
||||
const getSystemTheme = (): Theme => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* Управляет темой приложения: авто-определение по системе + ручное переключение.
|
||||
*/
|
||||
export const useTheme = () => {
|
||||
const [theme, setTheme] = useState<Theme>(getSystemTheme)
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next: Theme = prev === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.dataset.theme = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем data-theme при инициализации, чтобы CSS-селекторы
|
||||
// (включая подсветку кода) работали сразу, а не только после ручного переключения.
|
||||
document.documentElement.dataset.theme = getSystemTheme()
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const handleChange = (): void => {
|
||||
const next = getSystemTheme()
|
||||
document.documentElement.dataset.theme = next
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return { theme, toggle }
|
||||
}
|
||||
2
preview/src/infrastructure/theme/index.ts
Normal file
2
preview/src/infrastructure/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useTheme } from './hooks/use-theme.hook'
|
||||
export type { Theme } from './types/theme.type'
|
||||
2
preview/src/infrastructure/theme/styles/theme.module.css
Normal file
2
preview/src/infrastructure/theme/styles/theme.module.css
Normal file
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
4
preview/src/infrastructure/theme/theme.infra.tsx
Normal file
4
preview/src/infrastructure/theme/theme.infra.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Theme infrastructure module — реэкспортирует хук.
|
||||
* Компонент не требуется, тема управляется через data-theme на documentElement.
|
||||
*/
|
||||
11
preview/src/infrastructure/theme/types/theme.type.ts
Normal file
11
preview/src/infrastructure/theme/types/theme.type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Тема приложения.
|
||||
*/
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
/**
|
||||
* Параметры Theme.
|
||||
*/
|
||||
export type ThemeParams = {}
|
||||
|
||||
export type ThemeProps = ThemeParams
|
||||
28
preview/src/main.tsx
Normal file
28
preview/src/main.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './app/styles/globals.css'
|
||||
import { App } from './App'
|
||||
|
||||
function loadDevScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/dev-data.js'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDevData(): Promise<void> {
|
||||
if (import.meta.env.DEV && !window.__SPRITES_DATA__) {
|
||||
await loadDevScript()
|
||||
}
|
||||
}
|
||||
|
||||
loadDevData().then(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
})
|
||||
16
preview/src/shared/lib/rgb-to-hex.util.ts
Normal file
16
preview/src/shared/lib/rgb-to-hex.util.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Конвертирует CSS-цвет (rgb или hex) в hex-формат.
|
||||
*/
|
||||
export const rgbToHex = (color: string): string => {
|
||||
if (color.startsWith('#')) {
|
||||
return color
|
||||
}
|
||||
|
||||
const match = color.match(/\d+/g)
|
||||
|
||||
if (match && match.length >= 3) {
|
||||
return '#' + match.slice(0, 3).map((c) => parseInt(c).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
return '#000000'
|
||||
}
|
||||
6
preview/src/shared/types/index.ts
Normal file
6
preview/src/shared/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
IconVar,
|
||||
IconData,
|
||||
SpriteGroup,
|
||||
SpritesData,
|
||||
} from './sprites-data.type'
|
||||
63
preview/src/shared/types/sprites-data.type.ts
Normal file
63
preview/src/shared/types/sprites-data.type.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* CSS-переменная иконки.
|
||||
*/
|
||||
export type IconVar = {
|
||||
/** Имя CSS-переменной. */
|
||||
varName: string
|
||||
/** Fallback-значение. */
|
||||
fallback: string
|
||||
/** HEX-значение для color picker. */
|
||||
hex: string
|
||||
/** Является ли fallback значением currentColor. */
|
||||
isCurrentColor: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Размеры viewBox иконки.
|
||||
*/
|
||||
export type IconViewBox = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Данные одной иконки.
|
||||
*/
|
||||
export type IconData = {
|
||||
/** Идентификатор иконки. */
|
||||
id: string
|
||||
/** Имя группы (папки спрайта). */
|
||||
group: string
|
||||
/** Режим спрайта. */
|
||||
mode: 'stack' | 'symbol'
|
||||
/** Относительный путь к файлу спрайта. */
|
||||
spriteFile: string
|
||||
/** Размеры viewBox иконки. */
|
||||
viewBox: IconViewBox | null
|
||||
/** CSS-переменные иконки. */
|
||||
vars: IconVar[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Группа спрайтов.
|
||||
*/
|
||||
export type SpriteGroup = {
|
||||
/** Имя группы. */
|
||||
name: string
|
||||
/** Режим спрайта. */
|
||||
mode: 'stack' | 'symbol'
|
||||
/** Относительный путь к файлу спрайта. */
|
||||
spriteFile: string
|
||||
/** Иконки в группе. */
|
||||
icons: IconData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Данные для рендера превью.
|
||||
*/
|
||||
export type SpritesData = {
|
||||
/** Группы спрайтов. */
|
||||
groups: SpriteGroup[]
|
||||
}
|
||||
20
preview/src/ui/banner/banner.ui.tsx
Normal file
20
preview/src/ui/banner/banner.ui.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { BannerProps } from './types/banner.type'
|
||||
import styles from './styles/banner.module.css'
|
||||
|
||||
/**
|
||||
* Баннер-уведомление.
|
||||
*
|
||||
* Используется для:
|
||||
* - предупреждений о file:// протоколе
|
||||
* - информационных сообщений
|
||||
*/
|
||||
export const Banner = (props: BannerProps) => {
|
||||
const { children, className, variant = 'warn', ...htmlAttr } = props
|
||||
|
||||
return (
|
||||
<div {...htmlAttr} className={cl(styles.root, variant && styles[`_${variant}`], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
preview/src/ui/banner/index.ts
Normal file
1
preview/src/ui/banner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Banner } from './banner.ui'
|
||||
29
preview/src/ui/banner/styles/banner.module.css
Normal file
29
preview/src/ui/banner/styles/banner.module.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 10px var(--space-4);
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 13px;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
._warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] ._warn {
|
||||
background: #451a03;
|
||||
color: #fde68a;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) ._warn {
|
||||
background: #451a03;
|
||||
color: #fde68a;
|
||||
border-color: #92400e;
|
||||
}
|
||||
}
|
||||
14
preview/src/ui/banner/types/banner.type.ts
Normal file
14
preview/src/ui/banner/types/banner.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры Banner.
|
||||
*/
|
||||
export type BannerParams = {
|
||||
/** Вариант отображения. */
|
||||
variant?: 'warn' | 'info'
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type BannerProps = RootAttrs & BannerParams
|
||||
33
preview/src/ui/code-block/code-block.ui.tsx
Normal file
33
preview/src/ui/code-block/code-block.ui.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import cl from 'clsx'
|
||||
import { highlight } from './lib/highlight.lib'
|
||||
import type { CodeBlockProps } from './types/code-block.type'
|
||||
import styles from './styles/code-block.module.css'
|
||||
|
||||
export const CodeBlock = (props: CodeBlockProps) => {
|
||||
const { code, language = 'html', copyable = true, className, ...htmlAttr } = props
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const highlightedHtml = useMemo(() => highlight(code, language), [code, language])
|
||||
|
||||
const handleCopy = (): void => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...htmlAttr} className={cl(styles.root, className)}>
|
||||
<pre className={styles.pre}>
|
||||
<code className={cl(styles.code, styles[language])} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
||||
</pre>
|
||||
{copyable && (
|
||||
<button type="button" className={styles.copyButton} onClick={handleCopy}>
|
||||
{isCopied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
preview/src/ui/code-block/index.ts
Normal file
1
preview/src/ui/code-block/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CodeBlock } from './code-block.ui'
|
||||
68
preview/src/ui/code-block/lib/highlight.lib.ts
Normal file
68
preview/src/ui/code-block/lib/highlight.lib.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const ESCAPE_RE = /[&<>"]/g
|
||||
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
||||
|
||||
const escape = (str: string): string => str.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c])
|
||||
|
||||
type Token = { kind: string; match: string }
|
||||
type Rule = [RegExp, string]
|
||||
|
||||
const HTML_RULES: Rule[] = [
|
||||
[/<!--[\s\S]*?-->/g, 'comment'],
|
||||
[/<\/?[\w-]+/g, 'tag'],
|
||||
[/<\/?/g, 'punctuation'],
|
||||
[/\/?>/g, 'punctuation'],
|
||||
[/[\w-]+(?=\s*=)/g, 'attr'],
|
||||
[/=/g, 'punctuation'],
|
||||
[/"[^"]*"|'[^']*'/g, 'string'],
|
||||
]
|
||||
|
||||
const CSS_RULES: Rule[] = [
|
||||
[/\/\*[\s\S]*?\*\//g, 'comment'],
|
||||
[/\.[\w-]+/g, 'selector'],
|
||||
[/[\w-]+(?=\s*:)/g, 'property'],
|
||||
[/[:;]/g, 'punctuation'],
|
||||
[/\{|\}/g, 'punctuation'],
|
||||
[/'[^']*'|"[^"]*"/g, 'string'],
|
||||
[/#[\da-fA-F]{3,8}\b/g, 'color'],
|
||||
[/\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|deg|s|ms)?/g, 'number'],
|
||||
[/url\([^)]*\)/g, 'function'],
|
||||
]
|
||||
|
||||
const RULES: Record<string, Rule[]> = { html: HTML_RULES, css: CSS_RULES, xml: HTML_RULES }
|
||||
|
||||
const tokenize = (code: string, lang: string): Token[] => {
|
||||
const rules = RULES[lang] ?? []
|
||||
const tokens: Token[] = []
|
||||
let pos = 0
|
||||
|
||||
while (pos < code.length) {
|
||||
let matched = false
|
||||
for (const [re, kind] of rules) {
|
||||
re.lastIndex = pos
|
||||
const m = re.exec(code)
|
||||
if (m && m.index === pos) {
|
||||
tokens.push({ kind, match: m[0] })
|
||||
pos += m[0].length
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
const last = tokens[tokens.length - 1]
|
||||
const ch = code[pos]
|
||||
if (last && last.kind === 'plain') {
|
||||
last.match += ch
|
||||
} else {
|
||||
tokens.push({ kind: 'plain', match: ch })
|
||||
}
|
||||
pos++
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
export const highlight = (code: string, lang: string): string =>
|
||||
tokenize(code, lang)
|
||||
.map((t) => (t.kind === 'plain' ? escape(t.match) : `<span class="hl-${t.kind}">${escape(t.match)}</span>`))
|
||||
.join('')
|
||||
85
preview/src/ui/code-block/styles/code-block.module.css
Normal file
85
preview/src/ui/code-block/styles/code-block.module.css
Normal file
@@ -0,0 +1,85 @@
|
||||
.root {
|
||||
position: relative;
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
background: var(--color-code-bg);
|
||||
}
|
||||
|
||||
.pre {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
:global(:root[data-theme='light']) .code {
|
||||
--hl-tag: #116329;
|
||||
--hl-attr: #0550ae;
|
||||
--hl-string: #0a3069;
|
||||
--hl-comment: #8b949e;
|
||||
--hl-punctuation: #6639ba;
|
||||
--hl-selector: #6639ba;
|
||||
--hl-property: #0550ae;
|
||||
--hl-color: #0a3069;
|
||||
--hl-number: #0550ae;
|
||||
--hl-function: #0550ae;
|
||||
}
|
||||
|
||||
:global(:root[data-theme='dark']) .code {
|
||||
--hl-tag: #7ee787;
|
||||
--hl-attr: #79c0ff;
|
||||
--hl-string: #a5d6ff;
|
||||
--hl-comment: #8b949e;
|
||||
--hl-punctuation: #d2a8ff;
|
||||
--hl-selector: #d2a8ff;
|
||||
--hl-property: #79c0ff;
|
||||
--hl-color: #a5d6ff;
|
||||
--hl-number: #79c0ff;
|
||||
--hl-function: #d2a8ff;
|
||||
}
|
||||
|
||||
.code :global(.hl-tag),
|
||||
.code :global(.hl-attr),
|
||||
.code :global(.hl-string),
|
||||
.code :global(.hl-comment),
|
||||
.code :global(.hl-punctuation),
|
||||
.code :global(.hl-selector),
|
||||
.code :global(.hl-property),
|
||||
.code :global(.hl-color),
|
||||
.code :global(.hl-number),
|
||||
.code :global(.hl-function) {
|
||||
color: var(--hl-tag);
|
||||
}
|
||||
|
||||
.code :global(.hl-attr) { color: var(--hl-attr); }
|
||||
.code :global(.hl-string) { color: var(--hl-string); }
|
||||
.code :global(.hl-comment) { color: var(--hl-comment); }
|
||||
.code :global(.hl-punctuation) { color: var(--hl-punctuation); }
|
||||
.code :global(.hl-selector) { color: var(--hl-selector); }
|
||||
.code :global(.hl-property) { color: var(--hl-property); }
|
||||
.code :global(.hl-color) { color: var(--hl-color); }
|
||||
.code :global(.hl-number) { color: var(--hl-number); }
|
||||
.code :global(.hl-function) { color: var(--hl-function); }
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 11px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
18
preview/src/ui/code-block/types/code-block.type.ts
Normal file
18
preview/src/ui/code-block/types/code-block.type.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры CodeBlock.
|
||||
*/
|
||||
export type CodeBlockParams = {
|
||||
/** Код для отображения. */
|
||||
code: string
|
||||
/** Язык подсветки синтаксиса. */
|
||||
language?: 'html' | 'css' | 'xml'
|
||||
/** Показывать кнопку копирования. */
|
||||
copyable?: boolean
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type CodeBlockProps = RootAttrs & CodeBlockParams
|
||||
74
preview/src/ui/color-picker/color-picker.ui.tsx
Normal file
74
preview/src/ui/color-picker/color-picker.ui.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { HexColorPicker } from 'react-colorful'
|
||||
import cl from 'clsx'
|
||||
import type { ColorPickerProps } from './types/color-picker.type'
|
||||
import styles from './styles/color-picker.module.css'
|
||||
|
||||
/**
|
||||
* Выбор цвета с попапом react-colorful и HEX-инпутом.
|
||||
*
|
||||
* Используется для:
|
||||
* - настройки CSS-переменных цвета иконок
|
||||
* - визуального подбора цвета в модалке
|
||||
*/
|
||||
export const ColorPicker = (props: ColorPickerProps) => {
|
||||
const { value, onValueChange, label, className, ...htmlAttr } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const swatchRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
const target = e.target as Node
|
||||
|
||||
if (
|
||||
popoverRef.current && !popoverRef.current.contains(target) &&
|
||||
swatchRef.current && !swatchRef.current.contains(target)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const handleHexInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const hex = e.target.value
|
||||
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
onValueChange(hex)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...htmlAttr} className={cl(styles.root, className)}>
|
||||
<button
|
||||
ref={swatchRef}
|
||||
type="button"
|
||||
className={styles.swatch}
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
{label && <code className={styles.label}>{label}</code>}
|
||||
{isOpen && (
|
||||
<div ref={popoverRef} className={styles.popover}>
|
||||
<HexColorPicker color={value} onChange={onValueChange} />
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hexInput}
|
||||
value={value}
|
||||
onChange={handleHexInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
preview/src/ui/color-picker/index.ts
Normal file
1
preview/src/ui/color-picker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ColorPicker } from './color-picker.ui'
|
||||
53
preview/src/ui/color-picker/styles/color-picker.module.css
Normal file
53
preview/src/ui/color-picker/styles/color-picker.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.hexInput {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-fg);
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-1);
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hexInput:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
18
preview/src/ui/color-picker/types/color-picker.type.ts
Normal file
18
preview/src/ui/color-picker/types/color-picker.type.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры ColorPicker.
|
||||
*/
|
||||
export type ColorPickerParams = {
|
||||
/** Текущий цвет в HEX. */
|
||||
value: string
|
||||
/** Обработчик изменения цвета. */
|
||||
onValueChange: (color: string) => void
|
||||
/** Подпись под picker-ом. */
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>
|
||||
|
||||
export type ColorPickerProps = RootAttrs & ColorPickerParams
|
||||
29
preview/src/ui/icon-card/icon-card.ui.tsx
Normal file
29
preview/src/ui/icon-card/icon-card.ui.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import cl from 'clsx'
|
||||
import type { IconCardProps } from './types/icon-card.type'
|
||||
import styles from './styles/icon-card.module.css'
|
||||
|
||||
/**
|
||||
* Карточка иконки в сетке превью.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения иконки из спрайта с именем
|
||||
* - открытия модалки деталей по клику
|
||||
*/
|
||||
export const IconCard = (props: IconCardProps) => {
|
||||
const { iconId, onSelect, className, ...htmlAttr } = props
|
||||
|
||||
const handleClick = (): void => {
|
||||
onSelect?.(iconId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...htmlAttr} className={cl(styles.root, className)} onClick={handleClick}>
|
||||
<div className={styles.iconWrap}>
|
||||
<svg className={styles.icon}>
|
||||
<use href={`#${iconId}`} />
|
||||
</svg>
|
||||
</div>
|
||||
<span className={styles.name}>{iconId}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
preview/src/ui/icon-card/index.ts
Normal file
1
preview/src/ui/icon-card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { IconCard } from './icon-card.ui'
|
||||
37
preview/src/ui/icon-card/styles/icon-card.module.css
Normal file
37
preview/src/ui/icon-card/styles/icon-card.module.css
Normal file
@@ -0,0 +1,37 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-2);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-card-bg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
background: var(--color-card-hover);
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--checkerboard);
|
||||
background-size: var(--checkerboard-size);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
16
preview/src/ui/icon-card/types/icon-card.type.ts
Normal file
16
preview/src/ui/icon-card/types/icon-card.type.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры IconCard.
|
||||
*/
|
||||
export type IconCardParams = {
|
||||
/** Идентификатор иконки. */
|
||||
iconId: string
|
||||
/** Обработчик клика по карточке. */
|
||||
onSelect?: (iconId: string) => void
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'>
|
||||
|
||||
export type IconCardProps = RootAttrs & IconCardParams
|
||||
51
preview/src/ui/icon-grid/icon-grid.ui.tsx
Normal file
51
preview/src/ui/icon-grid/icon-grid.ui.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import cl from 'clsx'
|
||||
import { IconCard } from '../icon-card'
|
||||
import type { IconGridProps } from './types/icon-grid.type'
|
||||
import styles from './styles/icon-grid.module.css'
|
||||
|
||||
/**
|
||||
* Сетка иконок, сгруппированная по спрайтам.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения всех иконок из всех спрайтов
|
||||
* - фильтрации иконок по поисковому запросу
|
||||
*/
|
||||
export const IconGrid = (props: IconGridProps) => {
|
||||
const { groups, searchQuery = '', onIconSelect, className, ...htmlAttr } = props
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
|
||||
return (
|
||||
<div {...htmlAttr} className={cl(styles.root, className)}>
|
||||
{groups.map((group) => {
|
||||
const filteredIcons = query
|
||||
? group.icons.filter((icon) => icon.id.includes(query))
|
||||
: group.icons
|
||||
|
||||
const isGroupHidden = filteredIcons.length === 0
|
||||
|
||||
return (
|
||||
<section
|
||||
key={group.name}
|
||||
className={cl(styles.group, isGroupHidden && styles._hidden)}
|
||||
>
|
||||
<h2 className={styles.groupHeader}>
|
||||
{group.name}
|
||||
<span className={styles.badge}>{group.mode}</span>
|
||||
<span className={styles.count}>{group.icons.length}</span>
|
||||
</h2>
|
||||
<div className={styles.grid}>
|
||||
{filteredIcons.map((icon) => (
|
||||
<IconCard
|
||||
key={icon.id}
|
||||
iconId={icon.id}
|
||||
onSelect={onIconSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
preview/src/ui/icon-grid/index.ts
Normal file
1
preview/src/ui/icon-grid/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { IconGrid } from './icon-grid.ui'
|
||||
42
preview/src/ui/icon-grid/styles/icon-grid.module.css
Normal file
42
preview/src/ui/icon-grid/styles/icon-grid.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.root {
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: var(--space-10);
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 10px;
|
||||
background: var(--color-accent);
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
._hidden {
|
||||
display: none;
|
||||
}
|
||||
19
preview/src/ui/icon-grid/types/icon-grid.type.ts
Normal file
19
preview/src/ui/icon-grid/types/icon-grid.type.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import type { SpriteGroup } from '../../../shared/types'
|
||||
|
||||
/**
|
||||
* Параметры IconGrid.
|
||||
*/
|
||||
export type IconGridParams = {
|
||||
/** Группы спрайтов для отображения. */
|
||||
groups: SpriteGroup[]
|
||||
/** Строка поиска для фильтрации. */
|
||||
searchQuery?: string
|
||||
/** Обработчик выбора иконки. */
|
||||
onIconSelect?: (iconId: string) => void
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type IconGridProps = RootAttrs & IconGridParams
|
||||
235
preview/src/ui/icon-modal/icon-modal.ui.tsx
Normal file
235
preview/src/ui/icon-modal/icon-modal.ui.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import cl from 'clsx'
|
||||
import { CodeBlock } from '../code-block'
|
||||
import { ColorPicker } from '../color-picker'
|
||||
import { rgbToHex } from '../../shared/lib/rgb-to-hex.util'
|
||||
import type { IconModalProps } from './types/icon-modal.type'
|
||||
import styles from './styles/icon-modal.module.css'
|
||||
|
||||
type TabId = 'react' | 'svg' | 'img' | 'css'
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: 'react', label: 'React' },
|
||||
{ id: 'svg', label: 'SVG' },
|
||||
{ id: 'img', label: 'IMG' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Модалка деталей иконки с превью, color pickers и табами кода.
|
||||
*
|
||||
* Превью иконки рендерится тем же способом, что выбран в табе:
|
||||
* - React / SVG — через <svg><use href>
|
||||
* - IMG — через <img src>
|
||||
* - CSS — через mask-image на <div>
|
||||
*/
|
||||
export const IconModal = (props: IconModalProps) => {
|
||||
const { icon, onClose, defaultSprite, className, ...htmlAttr } = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('react')
|
||||
const [colors, setColors] = useState<Record<string, string>>({})
|
||||
const [cssColor, setCssColor] = useState('#000000')
|
||||
const iconWrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [icon, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!icon) return
|
||||
|
||||
const fg = getComputedStyle(document.documentElement).getPropertyValue('--color-fg').trim()
|
||||
const fgHex = rgbToHex(fg)
|
||||
|
||||
const initialColors: Record<string, string> = {}
|
||||
icon.vars.forEach((v) => {
|
||||
initialColors[v.varName] = v.isCurrentColor ? fgHex : v.hex
|
||||
})
|
||||
|
||||
setColors(initialColors)
|
||||
setCssColor(fgHex)
|
||||
setActiveTab('react')
|
||||
|
||||
if (iconWrapRef.current) {
|
||||
iconWrapRef.current.removeAttribute('style')
|
||||
}
|
||||
}, [icon])
|
||||
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
|
||||
const spriteRef = `${icon.spriteFile}#${icon.id}`
|
||||
|
||||
const isDefaultSprite = icon.group === defaultSprite
|
||||
const codeReact = isDefaultSprite
|
||||
? `<SvgSprite icon="${icon.id}" />`
|
||||
: `<SvgSprite icon="${icon.id}" sprite="${icon.group}" />`
|
||||
const codeSvg = `<svg width="24" height="24">\n <use href="${spriteRef}"/>\n</svg>`
|
||||
const codeImg = `<img src="${spriteRef}" width="24" height="24" alt="${icon.id}">`
|
||||
const codeCss = `.${icon.id} {\n width: 24px;\n height: 24px;\n mask: url('${spriteRef}') no-repeat center / contain;\n -webkit-mask: url('${spriteRef}') no-repeat center / contain;\n background-color: ${cssColor};\n}`
|
||||
|
||||
const codeByTab: Record<TabId, { code: string; language: 'html' | 'css' }> = {
|
||||
react: { code: codeReact, language: 'html' },
|
||||
svg: { code: codeSvg, language: 'html' },
|
||||
img: { code: codeImg, language: 'html' },
|
||||
css: { code: codeCss, language: 'css' },
|
||||
}
|
||||
|
||||
const handleColorChange = (varName: string, value: string): void => {
|
||||
setColors((prev) => ({ ...prev, [varName]: value }))
|
||||
iconWrapRef.current?.style.setProperty(varName, value)
|
||||
}
|
||||
|
||||
const renderPreview = () => {
|
||||
switch (activeTab) {
|
||||
case 'react':
|
||||
case 'svg':
|
||||
return (
|
||||
<svg className={styles.icon}>
|
||||
<use href={`#${icon.id}`} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'img':
|
||||
return (
|
||||
<img
|
||||
className={styles.iconImg}
|
||||
src={`${icon.spriteFile}#${icon.id}`}
|
||||
width={96}
|
||||
height={96}
|
||||
alt={icon.id}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'css':
|
||||
return (
|
||||
<div
|
||||
className={styles.iconCss}
|
||||
style={{
|
||||
mask: `url('${icon.spriteFile}#${icon.id}') no-repeat center / contain`,
|
||||
WebkitMask: `url('${icon.spriteFile}#${icon.id}') no-repeat center / contain`,
|
||||
backgroundColor: cssColor,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const supportsColorChange = activeTab === 'react' || activeTab === 'svg'
|
||||
const isMono = icon.vars.length > 0 && icon.vars.every((v) => v.isCurrentColor)
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div {...htmlAttr} className={cl(styles.root, className)}>
|
||||
<button type="button" className={styles.closeButton} onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div ref={iconWrapRef} className={styles.iconWrap}>
|
||||
<div className={styles.iconViewBox}>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.nameRow}>
|
||||
<div className={styles.name}>{icon.id}</div>
|
||||
{icon.viewBox && (
|
||||
<span className={styles.viewBoxBadge}>
|
||||
{icon.viewBox.width} × {icon.viewBox.height}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.varsSection}>
|
||||
{icon.vars.length > 0 && supportsColorChange ? (
|
||||
<>
|
||||
<div className={styles.colorHint}>
|
||||
{isMono ? (
|
||||
<span>
|
||||
Иконка наследует цвет текста или задаётся точечно через CSS-переменную.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Многоцветная иконка — каждый цвет задаётся отдельной CSS-переменной.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.varsTitle}>CSS Variables</div>
|
||||
{icon.vars.map((v) => (
|
||||
<ColorPicker
|
||||
key={v.varName}
|
||||
value={colors[v.varName] || v.hex}
|
||||
onValueChange={(color) => handleColorChange(v.varName, color)}
|
||||
label={`${v.varName}: ${v.fallback}`}
|
||||
className={styles.varRow}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : activeTab === 'img' ? (
|
||||
<div className={styles.hint}>
|
||||
Управление цветом в режиме IMG невозможно. <img> изолирует SVG — CSS-переменные
|
||||
и currentColor не проникают внутрь. Подходит для многоцветных изображений с фиксированными цветами.
|
||||
</div>
|
||||
) : activeTab === 'css' ? (
|
||||
<>
|
||||
<div className={styles.hint}>
|
||||
В режиме CSS mask иконка монохромная — цвет задаётся через background-color.
|
||||
CSS-переменные спрайта не поддерживаются.
|
||||
</div>
|
||||
<ColorPicker
|
||||
value={cssColor}
|
||||
onValueChange={setCssColor}
|
||||
label="background-color"
|
||||
className={styles.cssColorRow}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.noVars}>No color variables</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.tabs}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cl(styles.tab, activeTab === tab.id && styles._active)}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cl(styles.tabContent, activeTab === tab.id && styles._active)}
|
||||
>
|
||||
<CodeBlock
|
||||
code={codeByTab[tab.id].code}
|
||||
language={codeByTab[tab.id].language}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
preview/src/ui/icon-modal/index.ts
Normal file
1
preview/src/ui/icon-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { IconModal } from './icon-modal.ui'
|
||||
211
preview/src/ui/icon-modal/styles/icon-modal.module.css
Normal file
211
preview/src/ui/icon-modal/styles/icon-modal.module.css
Normal file
@@ -0,0 +1,211 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) 0;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
padding: var(--space-6);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-3);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-fg);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-4);
|
||||
background: var(--color-card-bg);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.iconViewBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--checkerboard);
|
||||
background-size: var(--checkerboard-size);
|
||||
}
|
||||
|
||||
.icon,
|
||||
.iconImg,
|
||||
.iconCss {
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.iconImg {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.iconCss {
|
||||
background-color: var(--color-fg);
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewBoxBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 10px;
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.varsSection {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.varsTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.varRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.noVars {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-card-bg);
|
||||
border-radius: var(--radius-1);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.colorHint {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-card-bg);
|
||||
border-radius: var(--radius-1);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.colorHintTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.colorHintRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.colorHintCode {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 2px var(--space-1);
|
||||
background: var(--color-bg);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.colorHintDesc {
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.cssColorRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-muted);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tab._active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabContent._active {
|
||||
display: block;
|
||||
}
|
||||
19
preview/src/ui/icon-modal/types/icon-modal.type.ts
Normal file
19
preview/src/ui/icon-modal/types/icon-modal.type.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import type { IconData } from '../../../shared/types'
|
||||
|
||||
/**
|
||||
* Параметры IconModal.
|
||||
*/
|
||||
export type IconModalParams = {
|
||||
/** Данные иконки для отображения. null — модалка закрыта. */
|
||||
icon: IconData | null
|
||||
/** Имя спрайта по умолчанию (первый из конфига). */
|
||||
defaultSprite?: string
|
||||
/** Обработчик закрытия модалки. */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type IconModalProps = RootAttrs & IconModalParams
|
||||
1
preview/src/ui/search-input/index.ts
Normal file
1
preview/src/ui/search-input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SearchInput } from './search-input.ui'
|
||||
27
preview/src/ui/search-input/search-input.ui.tsx
Normal file
27
preview/src/ui/search-input/search-input.ui.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import cl from 'clsx'
|
||||
import type { SearchInputProps } from './types/search-input.type'
|
||||
import styles from './styles/search-input.module.css'
|
||||
|
||||
/**
|
||||
* Поле поиска.
|
||||
*
|
||||
* Используется для:
|
||||
* - фильтрации иконок по имени
|
||||
*/
|
||||
export const SearchInput = (props: SearchInputProps) => {
|
||||
const { className, onValueChange, ...htmlAttr } = props
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onValueChange?.(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
{...htmlAttr}
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
onChange={handleChange}
|
||||
className={cl(styles.root, className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
preview/src/ui/search-input/styles/search-input.module.css
Normal file
14
preview/src/ui/search-input/styles/search-input.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.root {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-card-bg);
|
||||
color: var(--color-fg);
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.root:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
14
preview/src/ui/search-input/types/search-input.type.ts
Normal file
14
preview/src/ui/search-input/types/search-input.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры SearchInput.
|
||||
*/
|
||||
export type SearchInputParams = {
|
||||
/** Обработчик изменения значения поиска. */
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
/** HTML-атрибуты корневого элемента. */
|
||||
type RootAttrs = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'>
|
||||
|
||||
export type SearchInputProps = RootAttrs & SearchInputParams
|
||||
6
preview/src/vite-env.d.ts
vendored
Normal file
6
preview/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: { readonly [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
Reference in New Issue
Block a user