diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index e6401a5c..f788d969 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -564,6 +564,12 @@ "title": "Authenticate with GitHub (Unlock Pro Features)", "category": "Demo Time", "icon": "$(github)" + }, + { + "command": "demo-time.openThemeBuilder", + "title": "Open Theme Builder (Pro)", + "category": "Demo Time", + "icon": "$(paintcan)" } ], "menus": { @@ -684,6 +690,10 @@ "command": "demo-time.showSettings", "when": "view == demo-time-scenes" }, + { + "command": "demo-time.openThemeBuilder", + "when": "view == demo-time-scenes" + }, { "command": "demo-time.openSupportTheProject", "when": "view == demo-time-scenes" diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 07f9ec2c..5201df26 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -18,6 +18,7 @@ import { TextTypingService, TerminalService, ResourceService, + ThemeBuilderService, } from './services'; import { DemoPanel } from './panels/DemoPanel'; import { ResourcesPanel } from './panels/ResourcesPanel'; @@ -63,6 +64,7 @@ export async function activate(context: vscode.ExtensionContext) { TerminalService.register(); InputService.registerCommands(); SponsorService.init(context); + ThemeBuilderService.register(); console.log(`${Config.title} is active!`); } diff --git a/apps/vscode-extension/src/models/WebviewType.ts b/apps/vscode-extension/src/models/WebviewType.ts index 6439d4fc..9c382ba9 100644 --- a/apps/vscode-extension/src/models/WebviewType.ts +++ b/apps/vscode-extension/src/models/WebviewType.ts @@ -1 +1 @@ -export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview'; +export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview' | 'themeBuilder'; diff --git a/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts b/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts new file mode 100644 index 00000000..01e8efb5 --- /dev/null +++ b/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts @@ -0,0 +1,368 @@ +import { Uri, ViewColumn, WebviewPanel } from 'vscode'; +import { WebviewType } from '../models'; +import { Extension } from '../services'; +import { Config, WebViewMessages } from '@demotime/common'; +import { BaseWebview } from '../webview/BaseWebviewPanel'; +import { General } from '../constants'; +import { fileExists, readFile, writeFile, sanitizeFileName } from '../utils'; + +export class ThemeBuilderPanel extends BaseWebview { + public static id: WebviewType = 'themeBuilder'; + public static title: string = `${Config.title}: Theme Builder`; + + /** + * Render the theme builder panel + */ + public static async render() { + if (ThemeBuilderPanel.isOpen) { + ThemeBuilderPanel.reveal(); + } else { + await ThemeBuilderPanel.create(); + } + } + + protected static onCreate() { + ThemeBuilderPanel.isDisposed = false; + } + + protected static onDispose() { + ThemeBuilderPanel.isDisposed = true; + } + + protected static async messageListener(message: any) { + const { command, requestId, payload } = message; + + if (!command) { + return; + } + + switch (command) { + case WebViewMessages.toVscode.themeBuilder.getExistingThemes: + await ThemeBuilderPanel.getExistingThemes(command, requestId); + break; + case WebViewMessages.toVscode.themeBuilder.getExistingLayouts: + await ThemeBuilderPanel.getExistingLayouts(command, requestId); + break; + case WebViewMessages.toVscode.themeBuilder.loadTheme: + await ThemeBuilderPanel.loadTheme(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.loadLayout: + await ThemeBuilderPanel.loadLayout(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.saveTheme: + await ThemeBuilderPanel.saveTheme(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.saveLayout: + await ThemeBuilderPanel.saveLayout(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.getPreviewHtml: + await ThemeBuilderPanel.getPreviewHtml(command, requestId, payload); + break; + } + } + + /** + * Get list of existing themes + */ + private static async getExistingThemes(command: string, requestId: string) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + return; + } + + const themeFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'theme'); + const themeFiles: { name: string; path: string }[] = []; + + // Look for CSS files in theme folder + try { + const files = await Extension.getInstance().context.fs.readDirectory(themeFolder); + for (const [file, type] of files) { + if (type === 1 && file.endsWith('.css')) { + // File type + themeFiles.push({ + name: file.replace('.css', ''), + path: file, + }); + } + } + } catch (error) { + // Folder might not exist yet + } + + ThemeBuilderPanel.postRequestMessage(command, requestId, themeFiles); + } catch (error) { + console.error('Error getting existing themes:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + } + } + + /** + * Get list of existing layouts + */ + private static async getExistingLayouts(command: string, requestId: string) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + return; + } + + const layoutsFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'layouts'); + const layoutFiles: { name: string; path: string }[] = []; + + // Look for HBS/Handlebars files in layouts folder + try { + const files = await Extension.getInstance().context.fs.readDirectory(layoutsFolder); + for (const [file, type] of files) { + if (type === 1 && (file.endsWith('.hbs') || file.endsWith('.handlebars'))) { + // File type + layoutFiles.push({ + name: file.replace(/\.(hbs|handlebars)$/, ''), + path: file, + }); + } + } + } catch (error) { + // Folder might not exist yet + } + + ThemeBuilderPanel.postRequestMessage(command, requestId, layoutFiles); + } catch (error) { + console.error('Error getting existing layouts:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + } + } + + /** + * Load a theme file + */ + private static async loadTheme(command: string, requestId: string, payload: { name: string }) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const themePath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + 'theme', + `${payload.name}.css`, + ); + + if (!(await fileExists(themePath))) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const content = await readFile(themePath); + ThemeBuilderPanel.postRequestMessage(command, requestId, { + name: payload.name, + content: content?.toString() || '', + }); + } catch (error) { + console.error('Error loading theme:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + } + } + + /** + * Load a layout file + */ + private static async loadLayout(command: string, requestId: string, payload: { name: string }) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const layoutPath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + 'layouts', + `${payload.name}.hbs`, + ); + + if (!(await fileExists(layoutPath))) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const content = await readFile(layoutPath); + ThemeBuilderPanel.postRequestMessage(command, requestId, { + name: payload.name, + content: content?.toString() || '', + }); + } catch (error) { + console.error('Error loading layout:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + } + } + + /** + * Save a theme file + */ + private static async saveTheme( + command: string, + requestId: string, + payload: { name: string; content: string }, + ) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + return; + } + + // Ensure theme folder exists + const themeFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'theme'); + if (!(await fileExists(themeFolder))) { + await Extension.getInstance().context.fs.createDirectory(themeFolder); + } + + const fileName = sanitizeFileName(payload.name, '.css'); + const themePath = Uri.joinPath(themeFolder, fileName); + + await writeFile(themePath, payload.content); + + ThemeBuilderPanel.postRequestMessage(command, requestId, { + success: true, + path: fileName, + }); + } catch (error) { + console.error('Error saving theme:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + } + } + + /** + * Save a layout file + */ + private static async saveLayout( + command: string, + requestId: string, + payload: { name: string; content: string }, + ) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + return; + } + + // Ensure layouts folder exists + const layoutsFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'layouts'); + if (!(await fileExists(layoutsFolder))) { + await Extension.getInstance().context.fs.createDirectory(layoutsFolder); + } + + const fileName = sanitizeFileName(payload.name, '.hbs'); + const layoutPath = Uri.joinPath(layoutsFolder, fileName); + + await writeFile(layoutPath, payload.content); + + ThemeBuilderPanel.postRequestMessage(command, requestId, { + success: true, + path: fileName, + }); + } catch (error) { + console.error('Error saving layout:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + } + } + + /** + * Get preview HTML for a slide with custom theme/layout + * Note: This generates HTML for preview in a sandboxed iframe. + * The content comes from the authenticated user's own files. + */ + private static async getPreviewHtml( + command: string, + requestId: string, + payload: { css?: string; html?: string }, + ) { + try { + // Sanitize CSS: Remove potentially dangerous URL schemes and patterns + const sanitizedCss = (payload.css || '') + .replace(/@import\s*[^;]*;?/gi, '/* @import blocked */') + .replace(/(javascript|data|vbscript):/gi, '/* $1: blocked */') + .replace(/expression\s*\(/gi, '/* expression() blocked */'); + + // Sanitize HTML: Remove dangerous tags and event handlers + // Note: This is basic sanitization for a trusted Pro user environment. + // The preview is also sandboxed with CSP and iframe sandbox attribute. + // Multiple layers of security: regex sanitization + CSP + iframe sandbox + let sanitizedHtml = (payload.html || '

Preview

Add your HTML content to see the preview

'); + + // Remove script tags iteratively to handle nested or malformed tags + // Matches ... with any attributes and any whitespace in closing tag + let prevHtml = ''; + while (prevHtml !== sanitizedHtml) { + prevHtml = sanitizedHtml; + sanitizedHtml = sanitizedHtml.replace(/]*>[\s\S]*?<\/script[^>]*>/gi, ''); + } + + // Remove iframe tags iteratively + prevHtml = ''; + while (prevHtml !== sanitizedHtml) { + prevHtml = sanitizedHtml; + sanitizedHtml = sanitizedHtml.replace(/]*>[\s\S]*?<\/iframe[^>]*>/gi, ''); + } + + // Remove event handlers iteratively to catch all variations + prevHtml = ''; + while (prevHtml !== sanitizedHtml) { + prevHtml = sanitizedHtml; + sanitizedHtml = sanitizedHtml.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + } + + // Remove dangerous URL schemes + sanitizedHtml = sanitizedHtml.replace(/(javascript|data|vbscript):/gi, ''); + + // Generate a simple preview HTML + const previewHtml = ` + + + + + + + + + +
+ ${sanitizedHtml} +
+ + + `; + + ThemeBuilderPanel.postRequestMessage(command, requestId, { html: previewHtml }); + } catch (error) { + console.error('Error generating preview HTML:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { html: '' }); + } + } +} diff --git a/apps/vscode-extension/src/services/ThemeBuilderService.ts b/apps/vscode-extension/src/services/ThemeBuilderService.ts new file mode 100644 index 00000000..017f4dae --- /dev/null +++ b/apps/vscode-extension/src/services/ThemeBuilderService.ts @@ -0,0 +1,53 @@ +import { commands } from 'vscode'; +import { Subscription } from '../models'; +import { Extension } from './Extension'; +import { SponsorService } from './SponsorService'; +import { Notifications } from './Notifications'; +import { COMMAND } from '@demotime/common'; +import { ThemeBuilderPanel } from '../panels/ThemeBuilderPanel'; + +export class ThemeBuilderService { + /** + * Register the theme builder commands + */ + public static register() { + const subscriptions: Subscription[] = Extension.getInstance().subscriptions; + + subscriptions.push( + commands.registerCommand(COMMAND.openThemeBuilder, ThemeBuilderService.openThemeBuilder), + ); + } + + /** + * Open the theme builder panel (pro feature) + */ + private static async openThemeBuilder() { + // Check if user is a sponsor (pro feature) + const isSponsor = SponsorService.getSponsorStatus(); + + if (!isSponsor) { + const selection = await Notifications.warning( + 'Theme Builder is a Pro feature. Please authenticate with GitHub to unlock it.', + 'Authenticate', + ); + + if (selection === 'Authenticate') { + await commands.executeCommand(COMMAND.authenticate); + + // Check again after authentication + const isNowSponsor = SponsorService.getSponsorStatus(); + if (!isNowSponsor) { + Notifications.info( + 'To access Pro features, please consider sponsoring the project.', + ); + return; + } + } else { + return; + } + } + + // Open the theme builder panel + await ThemeBuilderPanel.render(); + } +} diff --git a/apps/vscode-extension/src/services/index.ts b/apps/vscode-extension/src/services/index.ts index 4bac5dbc..ce1f2391 100644 --- a/apps/vscode-extension/src/services/index.ts +++ b/apps/vscode-extension/src/services/index.ts @@ -29,5 +29,6 @@ export * from './StateManager'; export * from './TemplateCreator'; export * from './TerminalService'; export * from './TextTypingService'; +export * from './ThemeBuilderService'; export * from './UriHandler'; export * from './ZoomService'; diff --git a/apps/webviews/src/components/webviews/ThemeBuilderView.tsx b/apps/webviews/src/components/webviews/ThemeBuilderView.tsx new file mode 100644 index 00000000..d4602029 --- /dev/null +++ b/apps/webviews/src/components/webviews/ThemeBuilderView.tsx @@ -0,0 +1,730 @@ +import { useEffect, useState } from 'react'; +import { Button } from '../ui/Button'; +import { Save, Plus, Eye, Upload, X } from 'lucide-react'; +import { Loader as Spinner } from 'vscrui'; +import { messageHandler } from '@estruyf/vscode/dist/client'; +import { WebViewMessages } from '@demotime/common'; +import '../../styles/config.css'; +import { AppHeader } from '../layout'; + +interface ThemeFile { + name: string; + path: string; +} + +interface ThemeContent { + name: string; + content: string; +} + +interface SlideTemplate { + name: string; + backgroundColor?: string; + backgroundImage?: string; + color?: string; + fontSize?: string; + h1FontSize?: string; + h1Color?: string; + padding?: string; + layout?: 'default' | 'center' | 'flex-start'; +} + +interface ThemeConfig { + name: string; + className: string; + globalBackground?: string; + globalBackgroundImage?: string; + globalColor?: string; + globalFontSize?: string; + globalFontFamily?: string; + slideTemplates: Record; +} + +const DEFAULT_SLIDE_TEMPLATES = [ + 'default', + 'intro', + 'section', + 'quote', + 'image', + 'image-left', + 'image-right', + 'two-columns', + 'video', +]; + +// Helper function to get a valid color value for color picker +const getColorPickerValue = (colorValue: string | undefined, defaultColor: string): string => { + return colorValue?.startsWith('#') ? colorValue : defaultColor; +}; + +const ThemeBuilderView = () => { + const [themes, setThemes] = useState([]); + const [selectedTheme, setSelectedTheme] = useState(''); + const [loading, setLoading] = useState(false); + const [saveStatus, setSaveStatus] = useState<{ + type: 'blank' | 'success' | 'error'; + text: string; + }>({ type: 'blank', text: '' }); + + const [themeConfig, setThemeConfig] = useState({ + name: '', + className: '', + globalBackground: '#ffffff', + globalColor: '#000000', + globalFontSize: '24px', + globalFontFamily: 'Arial, sans-serif', + slideTemplates: {}, + }); + + const [activeTemplate, setActiveTemplate] = useState('default'); + const [previewHtml, setPreviewHtml] = useState(''); + + useEffect(() => { + loadExistingThemes(); + }, []); + + const loadExistingThemes = async () => { + try { + const themesData = await messageHandler.request( + WebViewMessages.toVscode.themeBuilder.getExistingThemes, + ); + setThemes(themesData || []); + } catch (error) { + console.error('Error loading existing themes:', error); + } + }; + + const loadTheme = async (name: string) => { + try { + setLoading(true); + const data = await messageHandler.request( + WebViewMessages.toVscode.themeBuilder.loadTheme, + { name }, + ); + if (data) { + // Parse the CSS to extract theme configuration + // This is a simplified parser - in production, you'd want a more robust solution + const parsedConfig = parseCSSToConfig(data.content, name); + setThemeConfig(parsedConfig); + setSelectedTheme(name); + } + } catch (error) { + console.error('Error loading theme:', error); + } finally { + setLoading(false); + } + }; + + const parseCSSToConfig = (css: string, name: string): ThemeConfig => { + // Enhanced CSS parser to extract theme configuration + // Limitations: This parser handles common patterns but doesn't support: + // - Media queries, nested at-rules + // - Complex selectors (e.g., attribute selectors, pseudo-classes beyond basic ones) + // - CSS variables as values + // - Vendor-prefixed properties + // For complex themes, the parser extracts what it can and defaults the rest. + const config: ThemeConfig = { + name, + className: name, + globalBackground: '#ffffff', + globalColor: '#000000', + globalFontSize: '24px', + globalFontFamily: 'Arial, sans-serif', + slideTemplates: {}, + }; + + // Extract main selector content + const mainSelectorMatch = css.match(/\.slide\.\S+\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s); + if (!mainSelectorMatch) return config; + + const mainContent = mainSelectorMatch[1]; + + // Extract global properties (before any nested selectors) + const globalSection = mainContent.split(/\.[a-z_]+\s*\{/)[0]; + + // Extract background (handle both simple colors and complex values) + const bgMatch = globalSection.match(/background:\s*([^;]+);/); + if (bgMatch) config.globalBackground = bgMatch[1].trim(); + + // Extract color + const colorMatch = globalSection.match(/(?:^|[^-])color:\s*([^;]+);/); + if (colorMatch) config.globalColor = colorMatch[1].trim(); + + // Extract font-size + const fontSizeMatch = globalSection.match(/font-size:\s*([^;]+);/); + if (fontSizeMatch) config.globalFontSize = fontSizeMatch[1].trim(); + + // Extract font-family + const fontFamilyMatch = globalSection.match(/font-family:\s*([^;]+);/); + if (fontFamilyMatch) config.globalFontFamily = fontFamilyMatch[1].trim(); + + // Extract background image from .slide__layout + const layoutBgMatch = css.match(/\.slide__layout\s*\{[^}]*background-image:\s*url\(['"]*([^'")\s]+)['"]*\)/); + if (layoutBgMatch) config.globalBackgroundImage = layoutBgMatch[1]; + + // Extract slide templates using the defined template list + const templateNames = DEFAULT_SLIDE_TEMPLATES.join('|'); + const templateRegex = new RegExp(`\\.(${templateNames})\\s*\\{([^}]*(?:\\{[^}]*\\}[^}]*)*)\\}`, 'g'); + let templateMatch; + + while ((templateMatch = templateRegex.exec(css)) !== null) { + const templateName = templateMatch[1]; + const templateContent = templateMatch[2]; + + const template: SlideTemplate = { + name: templateName, + }; + + // Check for layout type + if (templateContent.includes('align-items: center') && templateContent.includes('justify-content: center')) { + template.layout = 'center'; + } else if (templateContent.includes('align-items: flex-start')) { + template.layout = 'flex-start'; + } + + // Extract padding + const paddingMatch = templateContent.match(/padding:\s*([^;]+);/); + if (paddingMatch) template.padding = paddingMatch[1].trim(); + + // Extract H1 styles + const h1Match = templateContent.match(/h1\s*\{([^}]+)\}/); + if (h1Match) { + const h1Content = h1Match[1]; + const h1SizeMatch = h1Content.match(/font-size:\s*([^;]+);/); + if (h1SizeMatch) template.h1FontSize = h1SizeMatch[1].trim(); + + const h1ColorMatch = h1Content.match(/color:\s*([^;]+);/); + if (h1ColorMatch) template.h1Color = h1ColorMatch[1].trim(); + } + + config.slideTemplates[templateName] = template; + } + + return config; + }; + + const generateCSS = (): string => { + const { className, globalBackground, globalBackgroundImage, globalColor, globalFontSize, globalFontFamily, slideTemplates } = themeConfig; + + let css = `.slide.${className} {\n`; + css += ` background: ${globalBackground};\n`; + css += ` color: ${globalColor};\n`; + css += ` font-size: ${globalFontSize};\n`; + css += ` font-family: ${globalFontFamily};\n\n`; + + if (globalBackgroundImage) { + css += ` .slide__layout {\n`; + css += ` background-image: url("${globalBackgroundImage}");\n`; + css += ` background-repeat: no-repeat;\n`; + css += ` background-size: cover;\n`; + css += ` background-position: center center;\n`; + css += ` }\n\n`; + } + + css += ` .slide__content__inner {\n`; + css += ` padding: calc(var(--spacing) * 8);\n`; + css += ` box-sizing: border-box;\n`; + css += ` > :not([hidden]) ~ :not([hidden]) {\n`; + css += ` margin-top: 15px;\n`; + css += ` }\n`; + css += ` }\n\n`; + + // Add heading styles + for (let i = 1; i <= 5; i++) { + const size = 32 - (i - 1) * 2; + css += ` h${i} {\n`; + if (i === 1) { + css += ` color: ${globalColor};\n`; + } + css += ` font-size: ${size}px;\n`; + if (i === 1) { + css += ` font-weight: 600;\n`; + } + css += ` }\n\n`; + } + + // Add paragraph and list styles + css += ` p {\n`; + css += ` font-size: ${globalFontSize};\n`; + css += ` }\n\n`; + + css += ` ul, ol {\n`; + css += ` font-size: ${globalFontSize};\n`; + css += ` margin-left: 19px;\n`; + css += ` li {\n`; + css += ` margin-bottom: calc(var(--spacing) * 2);\n`; + css += ` }\n`; + css += ` }\n\n`; + + // Add slide template styles + Object.entries(slideTemplates).forEach(([templateName, template]) => { + css += ` .${templateName} {\n`; + + if (template.padding || template.layout) { + css += ` .slide__content__inner {\n`; + + if (template.layout === 'center') { + css += ` display: flex;\n`; + css += ` flex-direction: column;\n`; + css += ` align-items: center;\n`; + css += ` justify-content: center;\n`; + css += ` text-align: center;\n`; + } else if (template.layout === 'flex-start') { + css += ` display: flex;\n`; + css += ` flex-direction: column;\n`; + css += ` align-items: flex-start;\n`; + css += ` justify-content: center;\n`; + } + + if (template.padding) { + css += ` padding: ${template.padding};\n`; + } + + css += ` }\n\n`; + } + + if (template.h1FontSize || template.h1Color) { + css += ` h1 {\n`; + if (template.h1FontSize) { + css += ` font-size: ${template.h1FontSize};\n`; + } + if (template.h1Color) { + css += ` color: ${template.h1Color};\n`; + } + css += ` }\n\n`; + } + + css += ` }\n\n`; + }); + + css += `}\n`; + + return css; + }; + + const saveTheme = async () => { + if (!themeConfig.name.trim() || !themeConfig.className.trim()) { + setSaveStatus({ type: 'error', text: 'Theme name and class name are required' }); + return; + } + + try { + setLoading(true); + const css = generateCSS(); + const result = await messageHandler.request<{ success: boolean; path?: string }>( + WebViewMessages.toVscode.themeBuilder.saveTheme, + { name: themeConfig.className, content: css }, + ); + + if (result?.success) { + setSaveStatus({ type: 'success', text: 'Theme saved successfully!' }); + await loadExistingThemes(); + setTimeout(() => setSaveStatus({ type: 'blank', text: '' }), 3000); + } else { + setSaveStatus({ type: 'error', text: 'Failed to save theme' }); + } + } catch (error) { + console.error('Error saving theme:', error); + setSaveStatus({ type: 'error', text: 'Failed to save theme' }); + } finally { + setLoading(false); + } + }; + + const createNewTheme = () => { + setThemeConfig({ + name: '', + className: '', + globalBackground: '#ffffff', + globalColor: '#000000', + globalFontSize: '24px', + globalFontFamily: 'Arial, sans-serif', + slideTemplates: {}, + }); + setSelectedTheme(''); + }; + + const updateGlobalProperty = (key: keyof ThemeConfig, value: any) => { + setThemeConfig((prev) => ({ ...prev, [key]: value })); + }; + + const updateTemplateProperty = (templateName: string, key: keyof SlideTemplate, value: any) => { + setThemeConfig((prev) => ({ + ...prev, + slideTemplates: { + ...prev.slideTemplates, + [templateName]: { + ...prev.slideTemplates[templateName], + [key]: value, + }, + }, + })); + }; + + const addTemplate = (templateName: string) => { + if (!themeConfig.slideTemplates[templateName]) { + setThemeConfig((prev) => ({ + ...prev, + slideTemplates: { + ...prev.slideTemplates, + [templateName]: { + name: templateName, + }, + }, + })); + } + }; + + const removeTemplate = (templateName: string) => { + setThemeConfig((prev) => { + const newTemplates = { ...prev.slideTemplates }; + delete newTemplates[templateName]; + return { ...prev, slideTemplates: newTemplates }; + }); + }; + + if (loading) { + return ; + } + + return ( +
+ + +
+ {/* Theme Selection */} +
+
+ + +
+
+ + {/* Theme Configuration */} +
+ {/* Basic Info */} +
+

Theme Information

+ +
+ + updateGlobalProperty('name', e.target.value)} + /> +
+ +
+ + updateGlobalProperty('className', e.target.value)} + /> +

+ Used as: .slide.{themeConfig.className || 'class-name'} +

+
+
+ + {/* Global Styles */} +
+

Global Slide Styles

+ +
+
+ +
+ updateGlobalProperty('globalBackground', e.target.value)} + /> + updateGlobalProperty('globalBackground', e.target.value)} + /> +
+
+ +
+ +
+ updateGlobalProperty('globalColor', e.target.value)} + /> + updateGlobalProperty('globalColor', e.target.value)} + /> +
+
+
+ +
+ + updateGlobalProperty('globalBackgroundImage', e.target.value)} + /> +

+ Relative to workspace root or full URL +

+
+ +
+
+ + updateGlobalProperty('globalFontSize', e.target.value)} + /> +
+ +
+ + updateGlobalProperty('globalFontFamily', e.target.value)} + /> +
+
+
+ + {/* Slide Templates */} +
+
+

Slide Templates

+ +
+ + {/* Template Tabs */} + {Object.keys(themeConfig.slideTemplates).length > 0 && ( +
+
+ {Object.keys(themeConfig.slideTemplates).map((templateName) => ( + + ))} +
+ + {/* Active Template Editor */} + {activeTemplate && themeConfig.slideTemplates[activeTemplate] && ( +
+
+

Configure: {activeTemplate}

+ +
+ +
+
+ + +
+ +
+ + + updateTemplateProperty(activeTemplate, 'padding', e.target.value) + } + /> +
+
+ +
+
+ + + updateTemplateProperty(activeTemplate, 'h1FontSize', e.target.value) + } + /> +
+ +
+ +
+ + updateTemplateProperty(activeTemplate, 'h1Color', e.target.value) + } + /> + + updateTemplateProperty(activeTemplate, 'h1Color', e.target.value) + } + /> +
+
+
+ +
+ + + updateTemplateProperty(activeTemplate, 'backgroundImage', e.target.value) + } + /> +
+
+ )} +
+ )} + + {Object.keys(themeConfig.slideTemplates).length === 0 && ( +

+ No templates configured. Add a template from the dropdown above. +

+ )} +
+ + {/* Actions */} +
+ + {saveStatus.text && ( + + {saveStatus.text} + + )} +
+ + {/* Preview */} +
+

+ + CSS Preview +

+
+              {generateCSS()}
+            
+
+ + {/* Help */} +
+

💡 How to Use

+
    +
  • Configure global styles that apply to all slides
  • +
  • Add slide templates (intro, section, quote, etc.) and customize each one
  • +
  • Set background colors/images globally or per template
  • +
  • Themes are saved to .demo/theme/ folder
  • +
  • Use the theme in your slide markdown: theme: {themeConfig.className || 'your-theme'}
  • +
+
+
+
+
+ ); +}; + +export default ThemeBuilderView; diff --git a/apps/webviews/src/main.tsx b/apps/webviews/src/main.tsx index 59584083..f6c79227 100644 --- a/apps/webviews/src/main.tsx +++ b/apps/webviews/src/main.tsx @@ -12,6 +12,7 @@ const WEBVIEW_MAP: Record>> = 'preview': lazy(() => import('./components/webviews/PreviewView')), 'presenter': lazy(() => import('./components/webviews/PresenterView')), 'overview': lazy(() => import('./components/webviews/DemoScriptView')), + 'themeBuilder': lazy(() => import('./components/webviews/ThemeBuilderView')), }; const root = document.getElementById('root'); diff --git a/docs/src/content/docs/features/theme-builder.mdx b/docs/src/content/docs/features/theme-builder.mdx new file mode 100644 index 00000000..761b19c4 --- /dev/null +++ b/docs/src/content/docs/features/theme-builder.mdx @@ -0,0 +1,139 @@ +--- +title: Theme Builder (Pro) +description: Visual theme builder to create and customize presentation slide themes +--- + +The **Visual Theme Builder** is a Pro feature that provides an easy-to-use interface for creating custom themes for your presentation slides. No CSS knowledge required! + +## Accessing the Theme Builder + +The Theme Builder is available to GitHub Sponsors. To unlock this feature: + +1. Run the command **"Demo Time: Authenticate with GitHub (Unlock Pro Features)"** from the command palette +2. Sign in with your GitHub account +3. If you're a sponsor, Pro features will be unlocked automatically + +Once authenticated, you can access the Theme Builder: + +- From the command palette: **"Demo Time: Open Theme Builder (Pro)"** +- From the Demo Time Scenes view menu + +## Creating a Theme + +### 1. Theme Information + +Start by defining your theme: + +- **Theme Name**: A descriptive name for your theme (e.g., "My Conference Theme") +- **CSS Class Name**: The CSS class identifier (e.g., "my-theme", "espc25", "mc2mc") + +The class name will be used in your slide files as: `theme: my-theme` + +### 2. Global Slide Styles + +Configure styles that apply to all slides: + +- **Background Color**: Choose a color picker or enter a hex value +- **Text Color**: Set the default text color for your slides +- **Background Image**: Add a background image (URL or path to `.demo/assets/`) +- **Font Size**: Default font size (e.g., "24px") +- **Font Family**: Font stack (e.g., "Arial, sans-serif") + +### 3. Slide Templates + +Customize individual slide layouts: + +#### Available Templates +- **default**: Standard content slide +- **intro**: Title/intro slide +- **section**: Section divider slide +- **quote**: Quote slide +- **image**: Image-focused slide +- **image-left**: Content with left image +- **image-right**: Content with right image +- **two-columns**: Two-column layout +- **video**: Video background slide + +#### Template Configuration + +For each template, you can configure: + +- **Layout**: Default, Center, or Flex Start +- **Padding**: Custom padding values +- **H1 Font Size**: Heading size for this template +- **H1 Color**: Heading color for this template +- **Background Image**: Template-specific background + +### 4. Save and Export + +Click **"Save Theme"** to save your theme to `.demo/theme/[classname].css` + +## Using Your Theme + +Once saved, reference your theme in slide markdown files: + +```markdown +--- +theme: my-theme +layout: intro +--- + +# Welcome to My Presentation +``` + +## Example Theme Structure + +Here's what a generated theme looks like: + +```css +.slide.my-theme { + background: #ffffff; + color: #000000; + font-size: 24px; + font-family: "Arial", sans-serif; + + .slide__layout { + background-image: url(".demo/assets/background.png"); + background-repeat: no-repeat; + background-size: cover; + background-position: center center; + } + + .intro { + .slide__content__inner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + } + + h1 { + font-size: 42px; + color: #1b9ae0; + } + } + + /* ... more templates ... */ +} +``` + +## Tips + +- **Start Simple**: Configure global styles first, then add template customizations +- **Use Color Pickers**: Visual color selection makes it easy to match brand colors +- **Preview CSS**: Check the CSS preview section to see the generated code +- **Background Images**: Store images in `.demo/assets/` for portability +- **Consistent Naming**: Use lowercase and hyphens for class names (e.g., "my-conference-2025") + +## File Locations + +- **Themes**: Saved in `.demo/theme/` as `.css` files +- **Assets**: Store background images in `.demo/assets/` + +## Learn More + +- [Slides Documentation](https://demotime.show/slides/) +- The visual builder generates CSS following common slide theme patterns +- Example theme structure provided in the CSS Preview section + diff --git a/packages/common/src/constants/Command.ts b/packages/common/src/constants/Command.ts index f70c454a..5353a80b 100644 --- a/packages/common/src/constants/Command.ts +++ b/packages/common/src/constants/Command.ts @@ -3,6 +3,7 @@ export const EXTENSION_NAME = 'demo-time'; export const COMMAND = { // Pro features authenticate: `${EXTENSION_NAME}.authenticate`, + openThemeBuilder: `${EXTENSION_NAME}.openThemeBuilder`, // Documentation documentation: `${EXTENSION_NAME}.docs`, // Demo file actions diff --git a/packages/common/src/constants/WebViewMessages.ts b/packages/common/src/constants/WebViewMessages.ts index a1080515..c5d22078 100644 --- a/packages/common/src/constants/WebViewMessages.ts +++ b/packages/common/src/constants/WebViewMessages.ts @@ -200,6 +200,15 @@ export const WebViewMessages = { presenter: { checkNextDemo: 'checkPresenterNextDemo', }, + themeBuilder: { + getExistingThemes: 'getThemeBuilderExistingThemes', + getExistingLayouts: 'getThemeBuilderExistingLayouts', + loadTheme: 'loadThemeBuilderTheme', + loadLayout: 'loadThemeBuilderLayout', + saveTheme: 'saveThemeBuilderTheme', + saveLayout: 'saveThemeBuilderLayout', + getPreviewHtml: 'getThemeBuilderPreviewHtml', + }, }, toWebview: { /**