diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a27d5ebb..2485cefb 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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 ( -
+
| unknown[] + +/** Convert a nested theme config object into a flat map of CSS custom properties */ +function flattenTheme( + obj: Record, + prefix = '--' +): Record { + const result: Record = {} + + 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, `${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) + const str = Object.entries(vars) + .map(([k, v]) => `${k}: ${v};`) + .join('\n') + return { cssVars: vars, styleString: str } + }, []) + + return { themeConfig, cssVars, styleString } +} diff --git a/src/theme.json b/src/theme.json new file mode 100644 index 00000000..ec37c55b --- /dev/null +++ b/src/theme.json @@ -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)" + } +}