feat: comprehensive theme.json config with live hot-reload for editor styling

This commit is contained in:
lucaronin
2026-02-16 08:04:06 +01:00
parent 9afc13811e
commit 405d5f39fc
4 changed files with 371 additions and 1 deletions

View File

@@ -5,7 +5,9 @@ import { createReactInlineContentSpec, useCreateBlockNote, SuggestionMenuControl
import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/mantine/style.css'
import type { VaultEntry } from '../types'
import { useEditorTheme } from '../hooks/useTheme'
import './Editor.css'
import './EditorTheme.css'
interface Tab {
entry: VaultEntry
@@ -163,6 +165,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
const [, body] = useMemo(() => splitFrontmatter(content), [content])
const navigateRef = useRef(onNavigateWikilink)
navigateRef.current = onNavigateWikilink
const { cssVars } = useEditorTheme()
const editor = useCreateBlockNote({ schema })
@@ -217,7 +220,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') !== 'light'
return (
<div className="editor__blocknote-container">
<div className="editor__blocknote-container" style={cssVars as React.CSSProperties}>
<BlockNoteView
editor={editor}
theme={isDark ? 'dark' : 'light'}

View File

@@ -0,0 +1,179 @@
/* ==============================================
EditorTheme.css — BlockNote theme overrides
Driven by CSS custom properties from theme.json
============================================== */
/* --- Editor root --- */
.editor__blocknote-container .bn-editor {
font-family: var(--editor-font-family);
font-size: var(--editor-font-size);
line-height: var(--editor-line-height);
max-width: var(--editor-max-width);
padding: var(--editor-padding-vertical) var(--editor-padding-horizontal);
color: var(--colors-text);
caret-color: var(--colors-cursor);
}
.editor__blocknote-container .bn-editor ::selection {
background: var(--colors-selection);
}
/* --- Paragraph spacing --- */
.editor__blocknote-container .bn-block-content[data-content-type="paragraph"] {
margin-bottom: var(--editor-paragraph-spacing);
}
/* --- Headings --- */
.editor__blocknote-container [data-content-type="heading"][data-level="1"] .bn-inline-content {
font-size: var(--headings-h1-font-size);
font-weight: var(--headings-h1-font-weight);
line-height: var(--headings-h1-line-height);
color: var(--headings-h1-color);
letter-spacing: var(--headings-h1-letter-spacing);
}
.editor__blocknote-container [data-content-type="heading"][data-level="1"] {
margin-top: var(--headings-h1-margin-top);
margin-bottom: var(--headings-h1-margin-bottom);
}
.editor__blocknote-container [data-content-type="heading"][data-level="2"] .bn-inline-content {
font-size: var(--headings-h2-font-size);
font-weight: var(--headings-h2-font-weight);
line-height: var(--headings-h2-line-height);
color: var(--headings-h2-color);
letter-spacing: var(--headings-h2-letter-spacing);
}
.editor__blocknote-container [data-content-type="heading"][data-level="2"] {
margin-top: var(--headings-h2-margin-top);
margin-bottom: var(--headings-h2-margin-bottom);
}
.editor__blocknote-container [data-content-type="heading"][data-level="3"] .bn-inline-content {
font-size: var(--headings-h3-font-size);
font-weight: var(--headings-h3-font-weight);
line-height: var(--headings-h3-line-height);
color: var(--headings-h3-color);
letter-spacing: var(--headings-h3-letter-spacing);
}
.editor__blocknote-container [data-content-type="heading"][data-level="3"] {
margin-top: var(--headings-h3-margin-top);
margin-bottom: var(--headings-h3-margin-bottom);
}
.editor__blocknote-container [data-content-type="heading"][data-level="4"] .bn-inline-content {
font-size: var(--headings-h4-font-size);
font-weight: var(--headings-h4-font-weight);
line-height: var(--headings-h4-line-height);
color: var(--headings-h4-color);
letter-spacing: var(--headings-h4-letter-spacing);
}
.editor__blocknote-container [data-content-type="heading"][data-level="4"] {
margin-top: var(--headings-h4-margin-top);
margin-bottom: var(--headings-h4-margin-bottom);
}
/* --- Bullet lists --- */
.editor__blocknote-container [data-content-type="bulletListItem"] {
padding-left: var(--lists-padding-left);
margin-bottom: var(--lists-item-spacing);
}
/* --- Numbered lists --- */
.editor__blocknote-container [data-content-type="numberedListItem"] {
padding-left: var(--lists-padding-left);
margin-bottom: var(--lists-item-spacing);
}
/* --- Checkbox / check list items --- */
.editor__blocknote-container [data-content-type="checkListItem"] {
margin-bottom: var(--lists-item-spacing);
}
/* --- Inline: bold --- */
.editor__blocknote-container .bn-editor strong {
font-weight: var(--inline-styles-bold-font-weight);
color: var(--inline-styles-bold-color);
}
/* --- Inline: italic --- */
.editor__blocknote-container .bn-editor em {
font-style: var(--inline-styles-italic-font-style);
color: var(--inline-styles-italic-color);
}
/* --- Inline: strikethrough --- */
.editor__blocknote-container .bn-editor s,
.editor__blocknote-container .bn-editor del {
color: var(--inline-styles-strikethrough-color);
text-decoration: var(--inline-styles-strikethrough-text-decoration);
}
/* --- Inline: code --- */
.editor__blocknote-container .bn-editor code {
font-family: var(--inline-styles-code-font-family);
font-size: var(--inline-styles-code-font-size);
background-color: var(--inline-styles-code-background-color);
padding: var(--inline-styles-code-padding-vertical) var(--inline-styles-code-padding-horizontal);
border-radius: var(--inline-styles-code-border-radius);
color: var(--inline-styles-code-color);
}
/* --- Inline: links --- */
.editor__blocknote-container .bn-editor a {
color: var(--inline-styles-link-color);
text-decoration: var(--inline-styles-link-text-decoration);
}
/* --- Wikilinks (override from Editor.css with theme vars) --- */
.editor__blocknote-container .wikilink {
color: var(--inline-styles-wikilink-color);
text-decoration: var(--inline-styles-wikilink-text-decoration);
border-bottom: var(--inline-styles-wikilink-border-bottom);
cursor: var(--inline-styles-wikilink-cursor);
}
/* --- Code blocks --- */
.editor__blocknote-container [data-content-type="codeBlock"] {
font-family: var(--code-blocks-font-family);
font-size: var(--code-blocks-font-size);
line-height: var(--code-blocks-line-height);
background-color: var(--code-blocks-background-color);
padding: var(--code-blocks-padding-vertical) var(--code-blocks-padding-horizontal);
border-radius: var(--code-blocks-border-radius);
margin-top: var(--code-blocks-margin-vertical);
margin-bottom: var(--code-blocks-margin-vertical);
}
/* --- Blockquote --- */
.editor__blocknote-container [data-content-type="blockquote"],
.editor__blocknote-container blockquote {
border-left: var(--blockquote-border-left-width) solid var(--blockquote-border-left-color);
padding-left: var(--blockquote-padding-left);
margin-top: var(--blockquote-margin-vertical);
margin-bottom: var(--blockquote-margin-vertical);
color: var(--blockquote-color);
font-style: var(--blockquote-font-style);
}
/* --- Table --- */
.editor__blocknote-container [data-content-type="table"] table {
border-color: var(--table-border-color);
font-size: var(--table-font-size);
}
.editor__blocknote-container [data-content-type="table"] th {
background: var(--table-header-background);
padding: var(--table-cell-padding-vertical) var(--table-cell-padding-horizontal);
}
.editor__blocknote-container [data-content-type="table"] td {
padding: var(--table-cell-padding-vertical) var(--table-cell-padding-horizontal);
border-color: var(--table-border-color);
}
/* --- Horizontal rule --- */
.editor__blocknote-container hr {
border: none;
border-top: var(--horizontal-rule-thickness) solid var(--horizontal-rule-color);
margin: var(--horizontal-rule-margin-vertical) 0;
}

51
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,51 @@
import { useMemo } from 'react'
import themeConfig from '../theme.json'
type ThemeValue = string | number | Record<string, unknown> | unknown[]
/** Convert a nested theme config object into a flat map of CSS custom properties */
function flattenTheme(
obj: Record<string, ThemeValue>,
prefix = '--'
): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(obj)) {
const cssKey = `${prefix}${camelToKebab(key)}`
if (value === null || value === undefined) continue
if (Array.isArray(value)) continue // skip arrays (e.g. nestedBulletSymbols)
if (typeof value === 'object') {
Object.assign(result, flattenTheme(value as Record<string, ThemeValue>, `${cssKey}-`))
} else if (typeof value === 'number') {
// Numbers that look like px values get 'px' suffix; ratios/weights don't
// These are unitless values; everything else gets 'px'
const isUnitless = /weight|lineHeight|opacity/i.test(key) ||
cssKey.includes('line-height') ||
cssKey.includes('font-weight')
const needsPx = !isUnitless
result[cssKey] = needsPx ? `${value}px` : String(value)
} else {
result[cssKey] = String(value)
}
}
return result
}
function camelToKebab(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
export function useEditorTheme() {
const { cssVars, styleString } = useMemo(() => {
const vars = flattenTheme(themeConfig as Record<string, ThemeValue>)
const str = Object.entries(vars)
.map(([k, v]) => `${k}: ${v};`)
.join('\n')
return { cssVars: vars, styleString: str }
}, [])
return { themeConfig, cssVars, styleString }
}

137
src/theme.json Normal file
View File

@@ -0,0 +1,137 @@
{
"editor": {
"fontFamily": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"fontSize": 16,
"lineHeight": 1.7,
"maxWidth": 720,
"paddingHorizontal": 40,
"paddingVertical": 20,
"paragraphSpacing": 8
},
"headings": {
"h1": {
"fontSize": 32,
"fontWeight": 700,
"lineHeight": 1.2,
"marginTop": 32,
"marginBottom": 12,
"color": "var(--text-heading)",
"letterSpacing": -0.5
},
"h2": {
"fontSize": 24,
"fontWeight": 600,
"lineHeight": 1.3,
"marginTop": 28,
"marginBottom": 10,
"color": "var(--text-heading)",
"letterSpacing": -0.3
},
"h3": {
"fontSize": 20,
"fontWeight": 600,
"lineHeight": 1.4,
"marginTop": 24,
"marginBottom": 8,
"color": "var(--text-heading)",
"letterSpacing": 0
},
"h4": {
"fontSize": 17,
"fontWeight": 600,
"lineHeight": 1.4,
"marginTop": 20,
"marginBottom": 6,
"color": "var(--text-heading)",
"letterSpacing": 0
}
},
"lists": {
"bulletSymbol": "•",
"bulletSize": 14,
"bulletColor": "var(--text-tertiary)",
"indentSize": 24,
"itemSpacing": 4,
"paddingLeft": 8,
"nestedBulletSymbols": ["•", "◦", "▪"]
},
"checkboxes": {
"size": 16,
"borderRadius": 3,
"checkedColor": "var(--accent-blue)",
"uncheckedBorderColor": "var(--text-muted)"
},
"inlineStyles": {
"bold": {
"fontWeight": 700,
"color": "var(--text-primary)"
},
"italic": {
"fontStyle": "italic",
"color": "var(--text-primary)"
},
"strikethrough": {
"color": "var(--text-tertiary)",
"textDecoration": "line-through"
},
"code": {
"fontFamily": "'SF Mono', 'Fira Code', monospace",
"fontSize": 14,
"backgroundColor": "var(--bg-hover-subtle)",
"paddingHorizontal": 4,
"paddingVertical": 2,
"borderRadius": 3,
"color": "var(--text-secondary)"
},
"link": {
"color": "var(--accent-blue)",
"textDecoration": "underline"
},
"wikilink": {
"color": "var(--accent-blue)",
"textDecoration": "none",
"borderBottom": "1px dotted var(--accent-blue)",
"cursor": "pointer"
}
},
"codeBlocks": {
"fontFamily": "'SF Mono', 'Fira Code', monospace",
"fontSize": 13,
"lineHeight": 1.5,
"backgroundColor": "var(--bg-card)",
"paddingHorizontal": 16,
"paddingVertical": 12,
"borderRadius": 6,
"marginVertical": 12
},
"blockquote": {
"borderLeftWidth": 3,
"borderLeftColor": "var(--accent-blue)",
"paddingLeft": 16,
"marginVertical": 12,
"color": "var(--text-secondary)",
"fontStyle": "italic"
},
"table": {
"borderColor": "var(--border-primary)",
"headerBackground": "var(--bg-card)",
"cellPaddingHorizontal": 12,
"cellPaddingVertical": 8,
"fontSize": 14
},
"horizontalRule": {
"color": "var(--border-primary)",
"marginVertical": 24,
"thickness": 1
},
"colors": {
"background": "var(--bg-primary)",
"text": "var(--text-primary)",
"textSecondary": "var(--text-secondary)",
"textMuted": "var(--text-muted)",
"heading": "var(--text-heading)",
"accent": "var(--accent-blue)",
"selection": "var(--bg-selected)",
"cursor": "var(--text-primary)"
}
}