Переключение тем
Диаграмма, которая следует внешнему виду страницы: светлая, тёмная и собственная палитра, выбираемые пользователем, запоминаемые и по умолчанию совпадающие с предпочтением операционной системы. В Vue и Nuxt один переключатель перекрашивает сразу все диаграммы на странице.
Что здесь используется:
- Темы —
lightTheme,darkThemeиdefineTheme. - Общая конфигурация — одна реактивная конфигурация для всех диаграмм приложения.
- Структура конфигурации —
setConfigменяет работающую диаграмму.
Чистый JavaScript
<div class="toolbar" id="toolbar"></div>
<div id="diagram">
<div class="card" id="a" style="left: 20px">Source</div>
<div class="card" id="b" style="left: 380px">Target</div>
</div>Собственные цвета страницы — CSS-переменные, которые переключает атрибут data-theme; цвета диаграммы берутся из темы.
body {
margin: 0;
padding: 16px;
font:
14px system-ui,
sans-serif;
background: var(--page);
color: var(--text);
transition: background 0.2s;
}
body[data-theme='light'] {
--page: #f8fafc;
--card: #fff;
--text: #1c1e2b;
--border: #cbd5e1;
}
body[data-theme='dark'],
body[data-theme='midnight'] {
--page: #14162a;
--card: #1b1d2b;
--text: #e6e8f2;
--border: #3b3f5c;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
button {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
cursor: pointer;
}
button[aria-pressed='true'] {
border-color: #6366f1;
}
#diagram {
position: relative;
height: 200px;
}
.card {
position: absolute;
top: 60px;
width: 140px;
padding: 14px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
}import {
createVisualLinker,
darkTheme,
defineTheme,
lightTheme,
} from '@macrulez/visual-linker-core'
const themes = {
light: lightTheme,
dark: darkTheme,
midnight: defineTheme(
{
line: '#8b9bff',
lineHover: '#c4ccff',
lineSelected: '#ffd479',
portFill: '#14162a',
portStroke: '#8b9bff',
labelBorder: '#8b9bff',
},
darkTheme,
),
}
type ThemeName = keyof typeof themes
const stored = localStorage.getItem('diagram-theme') as ThemeName | null
const preferred: ThemeName = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const diagram = document.querySelector<HTMLElement>('#diagram')!
const linker = createVisualLinker(diagram, {
theme: themes[stored && stored in themes ? stored : preferred],
lines: { curve: 'bezier', hover: { width: 3 } },
interaction: { hover: true },
markers: { end: { shape: 'arrow' } },
labels: { fontSize: 12 },
})
linker.setBlocks([
{ id: 'a', el: document.querySelector<HTMLElement>('#a')! },
{ id: 'b', el: document.querySelector<HTMLElement>('#b')! },
])
linker.setConnections([
{
id: 'a-b',
from: { blockId: 'a' },
to: { blockId: 'b' },
labels: [{ id: 'l', position: 'middle', text: 'sync' }],
},
])
const toolbar = document.querySelector<HTMLElement>('#toolbar')!
function apply(name: ThemeName) {
document.body.dataset.theme = name
linker.setConfig({ theme: themes[name] })
localStorage.setItem('diagram-theme', name)
toolbar
.querySelectorAll('button')
.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.theme === name)))
}
toolbar.innerHTML = (Object.keys(themes) as ThemeName[])
.map((n) => `<button data-theme="${n}">${n}</button>`)
.join('')
toolbar.addEventListener('click', (e) => {
const name = (e.target as HTMLElement).dataset.theme as ThemeName | undefined
if (name) apply(name)
})
apply(stored && stored in themes ? stored : preferred)Vue
Установите общую конфигурацию один раз, плагином. Каждый <VisualLinker> ниже начинает с неё, сливая под собственным config.
import { createApp } from 'vue'
import { VisualLinkerPlugin, lightTheme } from '@macrulez/visual-linker-vue'
import App from './ThemedPage.vue'
createApp(App)
.use(VisualLinkerPlugin, { config: { theme: lightTheme } })
.mount('#app')Выбор хранит composable и записывает его в общую конфигурацию. Присваивание shared.theme перерисовывает все диаграммы на странице.
import { ref, watch } from 'vue'
import {
darkTheme,
defineTheme,
lightTheme,
useVisualLinkerConfig,
} from '@macrulez/visual-linker-vue'
const themes = {
light: lightTheme,
dark: darkTheme,
midnight: defineTheme(
{
line: '#8b9bff',
lineHover: '#c4ccff',
lineSelected: '#ffd479',
portFill: '#14162a',
portStroke: '#8b9bff',
labelBorder: '#8b9bff',
},
darkTheme,
),
}
export type ThemeName = keyof typeof themes
export function useDiagramTheme() {
const shared = useVisualLinkerConfig()
const stored = localStorage.getItem('diagram-theme') as ThemeName | null
const preferred: ThemeName = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const name = ref<ThemeName>(stored && stored in themes ? stored : preferred)
watch(
name,
(value) => {
shared.theme = themes[value]
document.body.dataset.theme = value
localStorage.setItem('diagram-theme', value)
},
{ immediate: true },
)
return { name, names: Object.keys(themes) as ThemeName[] }
}Страница просто использует его. У второй диаграммы собственный config для кривой, и она всё равно следует теме.
<script setup lang="ts">
import { VisualLinker, vVlBlock } from '@macrulez/visual-linker-vue'
import { useDiagramTheme } from './useDiagramTheme'
const { name, names } = useDiagramTheme()
const connections = [
{
id: 'a-b',
from: { blockId: 'a' },
to: { blockId: 'b' },
labels: [{ id: 'l', position: 'middle', text: 'sync' }],
},
]
const second = [{ id: 'c-d', from: { blockId: 'c' }, to: { blockId: 'd' } }]
</script>
<template>
<div class="toolbar">
<button v-for="n in names" :key="n" :aria-pressed="name === n" @click="name = n">
{{ n }}
</button>
</div>
<VisualLinker class="diagram" :connections="connections">
<div v-vl-block="'a'" class="card" style="left: 20px">Source</div>
<div v-vl-block="'b'" class="card" style="left: 380px">Target</div>
</VisualLinker>
<VisualLinker class="diagram" :connections="second" :config="{ lines: { curve: 'smoothstep' } }">
<div v-vl-block="'c'" class="card" style="left: 20px">Queue</div>
<div v-vl-block="'d'" class="card" style="left: 380px">Worker</div>
</VisualLinker>
</template>
<style>
body {
margin: 0;
padding: 16px;
font:
14px system-ui,
sans-serif;
background: var(--page);
color: var(--text);
}
body[data-theme='light'] {
--page: #f8fafc;
--card: #fff;
--text: #1c1e2b;
--border: #cbd5e1;
}
body[data-theme='dark'],
body[data-theme='midnight'] {
--page: #14162a;
--card: #1b1d2b;
--text: #e6e8f2;
--border: #3b3f5c;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.toolbar button {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
cursor: pointer;
}
.toolbar button[aria-pressed='true'] {
border-color: #6366f1;
}
.diagram {
position: relative;
height: 150px;
}
.card {
position: absolute;
top: 40px;
width: 140px;
padding: 14px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
}
</style>Nuxt
Подключите модуль как обычно; общая конфигурация устанавливается сама.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@macrulez/visual-linker-nuxt'],
})Composable темы отличается в двух местах: выбор хранится в useState, чтобы сервер и клиент были согласны, а сохранённое предпочтение читается в onMounted, поскольку на сервере нет localStorage и matchMedia. Темы и useVisualLinkerConfig импортируются из @macrulez/visual-linker-vue, поэтому добавьте его прямой зависимостью проекта.
import {
darkTheme,
defineTheme,
lightTheme,
useVisualLinkerConfig,
} from '@macrulez/visual-linker-vue'
const themes = {
light: lightTheme,
dark: darkTheme,
midnight: defineTheme(
{
line: '#8b9bff',
lineHover: '#c4ccff',
lineSelected: '#ffd479',
portFill: '#14162a',
portStroke: '#8b9bff',
labelBorder: '#8b9bff',
},
darkTheme,
),
}
export type ThemeName = keyof typeof themes
export function useDiagramTheme() {
const shared = useVisualLinkerConfig()
const name = useState<ThemeName>('diagram-theme', () => 'light')
onMounted(() => {
const stored = localStorage.getItem('diagram-theme') as ThemeName | null
const preferred: ThemeName = matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
name.value = stored && stored in themes ? stored : preferred
})
watch(
name,
(value) => {
shared.theme = themes[value]
if (import.meta.client) {
document.body.dataset.theme = value
localStorage.setItem('diagram-theme', value)
}
},
{ immediate: true },
)
return { name, names: Object.keys(themes) as ThemeName[] }
}Страница использует composable как любой другой. Компоненту и директиве импорт не нужен.
<script setup lang="ts">
const { name, names } = useDiagramTheme()
const connections = [{ id: 'a-b', from: { blockId: 'a' }, to: { blockId: 'b' } }]
</script>
<template>
<div class="toolbar">
<button v-for="n in names" :key="n" :aria-pressed="name === n" @click="name = n">
{{ n }}
</button>
</div>
<VisualLinker class="diagram" :connections="connections">
<div v-vl-block="'a'" class="card" style="left: 20px">Source</div>
<div v-vl-block="'b'" class="card" style="left: 380px">Target</div>
</VisualLinker>
</template>Как это работает
- Токены, а не стили. Тема — набор цветовых токенов, записанных в CSS-переменные диаграммы, поэтому её переключение перекрашивает линии, порты и подписи сразу. Маркеры следуют цвету своей линии.
- Чистый JavaScript.
linker.setConfig({ theme })меняет работающую диаграмму. Пересоздавать ничего не нужно. - Vue и Nuxt.
useVisualLinkerConfig()возвращает общую конфигурацию приложения — реактивный объект. Присваиваниеshared.themeперерисовывает все диаграммы под ним. - Своя палитра.
defineTheme(overrides, base)копирует базовую тему и заменяет указанные токены. Рамка подписи — отдельный токенlabelBorder, поэтому палитра, перекрашивающая линию, обычно задаёт и его. - Присваивание заменяет тему.
shared.theme = themes[value]заменяет объект целиком, включая токены, заданные раньше, например вnuxt.config.ts. Чтобы изменить один токен, присвойте его:shared.theme = { ...shared.theme, line: '#8b9bff' }. - Первая отрисовка. Сохранённый выбор применяется сразу после монтирования, поэтому пользователь, предпочитающий другую тему, видит один кадр темы по умолчанию. Чтобы этого избежать, выставляйте
data-themeмаленьким инлайн-скриптом в<head>до загрузки приложения.
Варианты
- Следовать системе вживую. Слушайте
changeуmatchMedia('(prefers-color-scheme: dark)')и обновляйте выбор, пока пользователь не выбрал тему явно. - Только CSS. Поскольку тема записывает CSS-переменные,
@media (prefers-color-scheme: dark)может задать--vl-line-colorи остальные в таблице стилей без скрипта. Маркеры не следуют цвету, заданному только в стилях; см. Темы.