Skip to content

Переключение тем ​

Диаграмма, которая следует внешнему виду страницы: светлая, тёмная и собственная палитра, выбираемые пользователем, запоминаемые и по умолчанию совпадающие с предпочтением операционной системы. В Vue и Nuxt один переключатель перекрашивает сразу все диаграммы на странице.

Что здесь используется:

Чистый JavaScript ​

html
<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; цвета диаграммы берутся из темы.

css
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;
}
ts
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.

ts
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 перерисовывает все диаграммы на странице.

ts
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 для кривой, и она всё равно следует теме.

vue
<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 ​

Подключите модуль как обычно; общая конфигурация устанавливается сама.

ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@macrulez/visual-linker-nuxt'],
})

Composable темы отличается в двух местах: выбор хранится в useState, чтобы сервер и клиент были согласны, а сохранённое предпочтение читается в onMounted, поскольку на сервере нет localStorage и matchMedia. Темы и useVisualLinkerConfig импортируются из @macrulez/visual-linker-vue, поэтому добавьте его прямой зависимостью проекта.

ts
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 как любой другой. Компоненту и директиве импорт не нужен.

vue
<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 и остальные в таблице стилей без скрипта. Маркеры не следуют цвету, заданному только в стилях; см. Темы.