Themes & UI 主题与界面

SXSEditor's UI is driven by a token-based theme system. Every color, spacing, radius, font size, motion duration, and shadow is a CSS custom property (--token) injected on :root. Themes are plain JSON objects that override these tokens. The system lives in src/themes/ and runs in the renderer process.

SXSEditor 的界面由基于 token 的主题系统驱动。每种颜色、间距、圆角、字号、动效时长与阴影都是一个注入到 :root 上的 CSS 自定义属性(--token)。主题是覆盖这些 token 的纯 JSON 对象。该系统位于 src/themes/,运行在渲染进程中。

ℹ️

Three source files form the core: tokenCatalog.js (token metadata + defaults), themeManager.js (registry, activation, history), and themeValidator.js (validation rules). canvasTheme.js bridges tokens to Canvas 2D rendering.

三个源文件构成核心:tokenCatalog.js(token 元数据 + 默认值)、themeManager.js(注册表、激活、历史)、themeValidator.js(校验规则)。canvasTheme.js 将 token 桥接到 Canvas 2D 渲染。

Theme System Overview 主题系统总览

┌─ tokenCatalog.js ─────────────────────────────────────────┐
│  TOKEN_CATALOG: { '--token-name': { layer, group, type,   │
│                    default, label } }                      │
│  buildDefaultTokens(): flat { name: value } from catalog   │
│  Layers: 'global' | 'alias' | 'component'                  │
│  Types:  'color' | 'size' | 'motion' | 'shadow' | 'string' │
└────────────────────────────────────────────────────────────┘
        │ defaults
        ▼
┌─ themeManager.js (renderer) ───────────────────────────────┐
│  registry: Map(id -> { theme, source })                    │
│  activate(id): flattenTheme(extends chain) → injectTokens  │
│  mergeOverrides(patch): transient patch (not persisted)    │
│  pushHistory / undo / redo: 20-step undo stack             │
│  export(id) → JSON string; import(json) → theme object     │
│  Events: 'theme-changed', 'theme-list-changed',            │
│          'theme-overwritten', 'theme-imported'              │
└────────────────────────────────────────────────────────────┘
        │ setProperty('--token', value) on documentElement
        ▼
┌─ DOM :root ────────────────────────────────────────────────┐
│  All CSS in src/renderer.css consumes var(--token)          │
│  Canvas 2D reads via canvasTheme.getCanvasColors()          │
└────────────────────────────────────────────────────────────┘

Token Catalog Token 目录

TOKEN_CATALOG in src/themes/tokenCatalog.js is the single source of truth for every token. Each entry has a layer, group, type, default value, and a Chinese label. Tokens are organized into three layers:

src/themes/tokenCatalog.js 中的 TOKEN_CATALOG 是每个 token 的唯一来源。每个条目包含 layergrouptypedefault 值与中文 label。token 分为三层:

Layer层级 Purpose用途 Example Groups示例分组
global Raw design tokens (scales, primitives)原始设计 token(刻度、原语) color-blue, color-gray, color-ink, color-red, color-green, color-amber, color-purple, color-base, space, radius, font, motion, shadowcolor-bluecolor-graycolor-inkcolor-redcolor-greencolor-ambercolor-purplecolor-basespaceradiusfontmotionshadow
alias Semantic tokens referencing globals引用 global 的语义 token bg, fg, accent, border, status (success/warning/danger/info)bgfgaccentborderstatus(success/warning/danger/info)
component Component-scoped tokens (button, input, panel, tooltip, scrollbar, selection)组件级 token(button、input、panel、tooltip、scrollbar、selection) button-primary-bg, input-border, panel-bg, tooltip-bgbutton-primary-bginput-borderpanel-bgtooltip-bg

Token types drive validation: color (hex/rgb/hsl), size (number + unit), motion (duration), shadow (free-form CSS), string (any non-empty). buildDefaultTokens() produces a flat { name: value } map from the catalog defaults, used as the base when no theme is loaded.

Token 类型驱动校验:color(hex/rgb/hsl)、size(数值 + 单位)、motion(时长)、shadow(自由 CSS)、string(任意非空)。buildDefaultTokens() 从目录默认值生成扁平的 { name: value } 映射,作为未加载主题时的基础。

Built-in Themes 内置主题

Four themes ship with SXSEditor, defined as JSON files in src/themes/builtins/ and aggregated by builtins/index.js into the BUILTIN_THEMES array:

SXSEditor 内置 4 个主题,以 JSON 文件形式定义在 src/themes/builtins/,由 builtins/index.js 聚合为 BUILTIN_THEMES 数组:

IDID Name名称 Dark?暗色? Description描述
dark-aurora Aurora DarkAurora Dark Yes (default)是(默认) Classic blue-purple dark style经典蓝紫暗色风格
light-paper Paper LightPaper Light No Bright white background, eye-friendly明亮白底,护眼清爽
midnight-amber Midnight AmberMidnight Amber Yes Dark with warm amber accent深色琥珀强调,温暖复古
acg ACG LightACG Light No Parchment-style light, teal accent, ACG aesthetic羊皮纸风格亮色,青绿强调,ACG 美学

Built-in themes are imported as ES module exports so webpack bundles them. BUILTIN_THEME_IDS is the array of their ids. They are registered on startup via themeManager.registerBuiltins(BUILTIN_THEMES).

内置主题以 ES 模块导出导入,以便 webpack 打包。BUILTIN_THEME_IDS 是其 id 数组。启动时通过 themeManager.registerBuiltins(BUILTIN_THEMES) 注册。

.theme.json Format .theme.json 格式

A theme file is a JSON object with this shape (example abbreviated from dark-aurora.theme.json):

主题文件是如下结构的 JSON 对象(示例节选自 dark-aurora.theme.json):

{
  "id": "dark-aurora",
  "name": "Aurora Dark",
  "version": "1.0.0",
  "author": "SXSEditor",
  "isDark": true,
  "description": "默认暗色主题,复刻 SXSEditor 经典蓝紫风格",
  "tags": ["builtin", "dark", "default"],
  "extends": null,
  "tokens": {
    "--color-blue-500": "#4a7de0",
    "--bg-app":     "#14141f",
    "--bg-panel":   "#1a1a2a",
    "--fg-primary": "#e0e0f0",
    "--accent":     "#5b8def",
    "--button-primary-bg": "var(--bg-button-primary)",
    ...
  }
}

Fields:

字段说明:

Creating a Custom Theme 创建自定义主题

  1. Create the JSON file — place it at src/themes/builtins/my-theme.theme.json (to bundle it) or load it at runtime via themeManager.import(jsonString).创建 JSON 文件— 放在 src/themes/builtins/my-theme.theme.json(打包)或运行时通过 themeManager.import(jsonString) 加载。
  2. Pick a parent — set extends to "dark-aurora" or "light-paper" so you only override the tokens you care about.选择父主题— 将 extends 设为 "dark-aurora""light-paper",只覆盖关心的 token。
  3. Override tokens — only list the --token keys you want to change. Unknown tokens are accepted as free CSS values but won't appear in the editor UI.覆盖 token— 只列出要改的 --token 键。未知 token 会作为自由 CSS 值接受,但不会出现在编辑器 UI 中。
  4. Register it — for bundled themes, add an import to builtins/index.js and append to BUILTIN_THEMES. For runtime themes, call themeManager.register(themeObj).注册— 打包主题:在 builtins/index.js 加导入并追加到 BUILTIN_THEMES。运行时主题:调用 themeManager.register(themeObj)
  5. Validate — run validate(theme, { getThemeById }) from themeValidator.js. It returns { ok, errors, warnings } and does not throw.校验— 调用 themeValidator.jsvalidate(theme, { getThemeById })。返回 { ok, errors, warnings },不会抛异常。
  6. Activate — call themeManager.activate('my-theme'). Tokens are injected on :root and the 'theme-changed' event fires.激活— 调用 themeManager.activate('my-theme')。token 注入到 :root 并触发 'theme-changed' 事件。
💡

For live preview in the theme editor, use mergeOverrides(patch) to apply a transient token patch without persisting. pushHistory() snapshots the current overrides into a 20-step undo stack (HISTORY_LIMIT=20); undo() / redo() traverse it. Call clearOverrides() to discard the patch and return to the base theme.

在主题编辑器中实时预览,用 mergeOverrides(patch) 应用瞬态 token 补丁而不持久化。pushHistory() 将当前覆盖快照到 20 步撤销栈(HISTORY_LIMIT=20);undo() / redo() 遍历该栈。调用 clearOverrides() 丢弃补丁并回到基础主题。

Theme Manager API 主题管理器 API

The themeManager singleton (renderer process) exposes:

themeManager 单例(渲染进程)暴露:

Method方法 Description说明
register(themeObj) Add a theme to the registry将主题加入注册表
registerBuiltins(arr) Register an array of built-in themes注册内置主题数组
unregister(id) Remove a theme (built-ins cannot be removed)移除主题(内置不可移除)
list() Return [{id,name,isDark,author,version,source}]返回 [{id,name,isDark,author,version,source}]
activate(id, { scope }) Resolve extends chain, inject tokens, fire event解析 extends 链,注入 token,触发事件
current() / currentTokens() Get active id / resolved token map获取活动 id / 已解析 token 映射
export(id) / import(json) Serialize to JSON string / parse and register序列化为 JSON 字符串 / 解析并注册
mergeOverrides(patch) / clearOverrides() Transient patch (not persisted to localStorage)瞬态补丁(不持久化到 localStorage)
pushHistory() / undo() / redo() 20-step undo stack for editor history20 步撤销栈,用于编辑器历史
on(eventName, handler) Subscribe; returns unsubscribe function订阅;返回取消订阅函数

Events: 'theme-changed', 'theme-list-changed', 'theme-overwritten', 'theme-imported'. Persisted overrides use the localStorage key sxseditor-theme-overrides.

事件:'theme-changed''theme-list-changed''theme-overwritten''theme-imported'。持久化的覆盖使用 localStorage 键 sxseditor-theme-overrides

Extends Resolution extends 解析

flattenTheme(theme) walks the extends chain (parent first, child overrides last) and merges all tokens maps into one flat object. The chain depth is capped at MAX_EXTENDS_DEPTH=3; cycles are rejected. If a parent id is not in the registry, an error is thrown.

flattenTheme(theme) 沿 extends 链(父在前、子覆盖在后)将所有 tokens 映射合并为一个扁平对象。链深度上限 MAX_EXTENDS_DEPTH=3;环会被拒绝。若父 id 不在注册表中,则抛错。

// Example: a custom theme extending dark-aurora
{
  "id": "my-theme",
  "name": "My Theme",
  "extends": "dark-aurora",
  "tokens": {
    "--accent": "#a855f7",          // override accent to purple
    "--bg-app":  "#1a0a2a"          // override app background
    // all other tokens inherited from dark-aurora
  }
}

Canvas Theming Canvas 主题化

Canvas 2D rendering cannot read CSS var() directly. src/themes/canvasTheme.js bridges this: getCanvasColors() reads the relevant custom properties from :root (inline style first, then getComputedStyle) and returns a plain object like { bgApp, bgPanel, fgPrimary, accent, success, danger, ... }. Results are cached until invalidateCanvasThemeCache() is called.

Canvas 2D 渲染无法直接读取 CSS var()src/themes/canvasTheme.js 桥接此问题:getCanvasColors():root 读取相关自定义属性(先内联样式,再 getComputedStyle),返回 { bgApp, bgPanel, fgPrimary, accent, success, danger, ... } 等普通对象。结果缓存直到调用 invalidateCanvasThemeCache()

import { getCanvasColors, invalidateCanvasThemeCache } from '../themes/canvasTheme.js';
import themeManager from '../themes/themeManager.js';

themeManager.on('theme-changed', () => invalidateCanvasThemeCache());

function drawPianoRoll(ctx) {
  const c = getCanvasColors();
  ctx.fillStyle = c.bgApp;
  ctx.fillRect(0, 0, width, height);
  ctx.strokeStyle = c.accent;
  // ...
}

Theme Validation 主题校验

themeValidator.js validates theme objects without throwing — it returns { ok, errors, warnings }. Pass getThemeById to enable extends validation (parent existence, depth ≤ 3, no cycles). Validation rules:

themeValidator.js 校验主题对象但不抛异常——返回 { ok, errors, warnings }。传入 getThemeById 可启用 extends 校验(父存在、深度 ≤ 3、无环)。校验规则:

Field字段 Rule规则
id Non-empty, kebab-case (ID_RE), no leading/trailing dash非空、kebab-case(ID_RE)、首尾无连字符
name / version / author / description Optional; if present must be a string (version defaults to 1.0.0)可选;若存在必须为字符串(version 默认 1.0.0)
isDark Optional boolean; else derived from --bg-app可选布尔;否则由 --bg-app 推导
tokens keys Must match TOKEN_NAME_RE = /^--[a-z0-9][a-z0-9-]*$/必须匹配 TOKEN_NAME_RE = /^--[a-z0-9][a-z0-9-]*$/
tokens color values COLOR_VALUE_RE: #hex, rgb()/rgba(), hsl()/hsla(), transparent, currentColor, inheritCOLOR_VALUE_RE#hexrgb()/rgba()hsl()/hsla()transparentcurrentColorinherit
tokens size/motion values SIZE_VALUE_RE: number + px|rem|em|%|vh|vw|s|ms, or 0SIZE_VALUE_RE:数值 + px|rem|em|%|vh|vw|s|ms,或 0
extends Parent must exist, depth ≤ MAX_EXTENDS_DEPTH=3, no cycles父必须存在、深度 ≤ MAX_EXTENDS_DEPTH=3、无环

If validation fails, ThemeValidationError can be constructed from the errors array (it carries error.errors) — but the validator itself never throws, so callers decide how to surface problems.

若校验失败,可由 errors 数组构造 ThemeValidationError(它带有 error.errors)——但校验器本身从不抛异常,由调用方决定如何呈现问题。

⚠️

When a theme is activated, injectTokens sets every token via root.style.setProperty(k, v). Switching themes does not clear previously-injected properties that are absent from the new theme — call clearInjectedTokens(tokenNames) for the previous theme's tokens first, or design themes to override the same superset of tokens. Built-in themes all define the full token set, so switching among them is safe.

激活主题时,injectTokens 通过 root.style.setProperty(k, v) 设置每个 token。切换主题不会清除新主题中缺失的先前注入属性——需先对前一主题的 token 调用 clearInjectedTokens(tokenNames),或将主题设计为覆盖相同的 token 超集。内置主题都定义了完整 token 集,因此它们之间切换是安全的。