Add ThemePropertyEditor component that surfaces all customizable properties from theme.json — typography, headings, lists, code blocks, blockquote, table, and horizontal rule — organized into collapsible sections with appropriate input types (number, color, select, text). - themeSchema.ts: derives flat property list from theme.json with auto-detected input types, units, and select options - ThemePropertyEditor.tsx: sectioned editor with collapsible sections, keyboard-accessible toggles, debounced live updates - ThemeManager: add updateThemeProperty() and activeThemeContent - SettingsPanel: show property editor below theme list when active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
6.3 KiB
TypeScript
194 lines
6.3 KiB
TypeScript
import themeConfig from '../theme.json'
|
|
import { isValidCssColor } from './colorUtils'
|
|
|
|
export type InputType = 'number' | 'color' | 'text' | 'select'
|
|
|
|
export interface ThemeProperty {
|
|
/** Flat kebab-case key used in frontmatter, e.g. "editor-font-size" */
|
|
cssVar: string
|
|
/** Human-readable label, e.g. "Font Size" */
|
|
label: string
|
|
/** Default value from theme.json */
|
|
defaultValue: string | number
|
|
inputType: InputType
|
|
/** Unit label shown next to numeric inputs (e.g. "px"). Absent for unitless. */
|
|
unit?: string
|
|
/** Options for select inputs (e.g. font weights). */
|
|
options?: string[]
|
|
/** Minimum allowed value for numeric inputs. */
|
|
min?: number
|
|
}
|
|
|
|
export interface ThemeSubsection {
|
|
id: string
|
|
label: string
|
|
properties: ThemeProperty[]
|
|
}
|
|
|
|
export interface ThemeSection {
|
|
id: string
|
|
label: string
|
|
properties: ThemeProperty[]
|
|
subsections: ThemeSubsection[]
|
|
}
|
|
|
|
const SECTION_LABELS: Record<string, string> = {
|
|
editor: 'Typography',
|
|
headings: 'Headings',
|
|
lists: 'Lists',
|
|
checkboxes: 'Checkboxes',
|
|
inlineStyles: 'Inline Styles',
|
|
codeBlocks: 'Code Blocks',
|
|
blockquote: 'Blockquote',
|
|
table: 'Table',
|
|
horizontalRule: 'Horizontal Rule',
|
|
colors: 'Colors',
|
|
}
|
|
|
|
const SUBSECTION_LABELS: Record<string, string> = {
|
|
h1: 'Heading 1',
|
|
h2: 'Heading 2',
|
|
h3: 'Heading 3',
|
|
h4: 'Heading 4',
|
|
bold: 'Bold',
|
|
italic: 'Italic',
|
|
strikethrough: 'Strikethrough',
|
|
code: 'Inline Code',
|
|
link: 'Link',
|
|
wikilink: 'Wiki Link',
|
|
}
|
|
|
|
/** Keys where the numeric value is unitless (ratios, weights). */
|
|
const UNITLESS_KEYS = /weight|lineHeight|opacity/i
|
|
|
|
/** Keys that should use a select input with predefined options. */
|
|
const SELECT_OPTIONS: Record<string, string[]> = {
|
|
fontWeight: ['400', '500', '600', '700'],
|
|
fontStyle: ['normal', 'italic'],
|
|
textDecoration: ['none', 'underline', 'line-through'],
|
|
cursor: ['default', 'pointer', 'text'],
|
|
}
|
|
|
|
function camelToKebab(str: string): string {
|
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
}
|
|
|
|
function camelToTitle(str: string): string {
|
|
return str
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
.replace(/^./, c => c.toUpperCase())
|
|
}
|
|
|
|
function isColorValue(value: unknown, key: string): boolean {
|
|
if (typeof value !== 'string') return false
|
|
if (value.startsWith('#') || value.startsWith('var(--')) return isColorKeyHint(key) || isValidCssColor(value)
|
|
return false
|
|
}
|
|
|
|
function isColorKeyHint(key: string): boolean {
|
|
const lower = key.toLowerCase()
|
|
return lower === 'color' || lower.endsWith('color') || lower === 'background'
|
|
|| lower.endsWith('background') || lower === 'fill' || lower === 'tint'
|
|
}
|
|
|
|
function deriveInputType(key: string, value: unknown): { inputType: InputType; unit?: string; options?: string[]; min?: number } {
|
|
// Select options take priority
|
|
for (const [pattern, opts] of Object.entries(SELECT_OPTIONS)) {
|
|
if (key === pattern || key.endsWith(pattern.charAt(0).toUpperCase() + pattern.slice(1))) {
|
|
// Check if current key ends with the select key (e.g. "fontWeight" matches "boldFontWeight")
|
|
if (key === pattern || key.toLowerCase().endsWith(pattern.toLowerCase())) {
|
|
return { inputType: 'select', options: opts }
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof value === 'number') {
|
|
const isUnitless = UNITLESS_KEYS.test(key)
|
|
return { inputType: 'number', unit: isUnitless ? undefined : 'px', min: 0 }
|
|
}
|
|
|
|
if (isColorValue(value, key)) {
|
|
return { inputType: 'color' }
|
|
}
|
|
|
|
return { inputType: 'text' }
|
|
}
|
|
|
|
function buildProperty(parentPrefix: string, key: string, value: string | number): ThemeProperty {
|
|
const cssVar = `${parentPrefix}${camelToKebab(key)}`
|
|
const { inputType, unit, options, min } = deriveInputType(key, value)
|
|
return { cssVar, label: camelToTitle(key), defaultValue: value, inputType, unit, options, min }
|
|
}
|
|
|
|
/** Build the full theme schema from theme.json, grouped by section. */
|
|
export function buildThemeSchema(): ThemeSection[] {
|
|
const sections: ThemeSection[] = []
|
|
|
|
for (const [sectionKey, sectionValue] of Object.entries(themeConfig)) {
|
|
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) continue
|
|
const sectionObj = sectionValue as Record<string, unknown>
|
|
const sectionPrefix = `${camelToKebab(sectionKey)}-`
|
|
|
|
const section: ThemeSection = {
|
|
id: sectionKey,
|
|
label: SECTION_LABELS[sectionKey] ?? camelToTitle(sectionKey),
|
|
properties: [],
|
|
subsections: [],
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(sectionObj)) {
|
|
if (Array.isArray(value)) continue // skip arrays like nestedBulletSymbols
|
|
|
|
if (typeof value === 'object' && value !== null) {
|
|
// Subsection (e.g. headings.h1, inlineStyles.bold)
|
|
const subPrefix = `${sectionPrefix}${camelToKebab(key)}-`
|
|
const subProperties: ThemeProperty[] = []
|
|
for (const [subKey, subValue] of Object.entries(value as Record<string, unknown>)) {
|
|
if (typeof subValue === 'string' || typeof subValue === 'number') {
|
|
subProperties.push(buildProperty(subPrefix, subKey, subValue))
|
|
}
|
|
}
|
|
if (subProperties.length > 0) {
|
|
section.subsections.push({
|
|
id: key,
|
|
label: SUBSECTION_LABELS[key] ?? camelToTitle(key),
|
|
properties: subProperties,
|
|
})
|
|
}
|
|
} else if (typeof value === 'string' || typeof value === 'number') {
|
|
section.properties.push(buildProperty(sectionPrefix, key, value))
|
|
}
|
|
}
|
|
|
|
if (section.properties.length > 0 || section.subsections.length > 0) {
|
|
sections.push(section)
|
|
}
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
/** Format a value for storage in theme note frontmatter. */
|
|
export function formatValueForFrontmatter(value: string | number, property: ThemeProperty): string {
|
|
if (property.inputType === 'number' && property.unit && typeof value === 'number') {
|
|
return `${value}${property.unit}`
|
|
}
|
|
return String(value)
|
|
}
|
|
|
|
/** Parse a frontmatter value back to its editable form (strip unit suffix). */
|
|
export function parseValueFromFrontmatter(raw: string, property: ThemeProperty): string | number {
|
|
if (property.inputType === 'number') {
|
|
const numeric = parseFloat(raw)
|
|
if (!isNaN(numeric)) return numeric
|
|
}
|
|
return raw
|
|
}
|
|
|
|
/** Cached schema — built once from theme.json. */
|
|
let cachedSchema: ThemeSection[] | null = null
|
|
export function getThemeSchema(): ThemeSection[] {
|
|
if (!cachedSchema) cachedSchema = buildThemeSchema()
|
|
return cachedSchema
|
|
}
|