diff --git a/app/layout.tsx b/app/layout.tsx index ef1ad163..ed0add16 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,4 @@ import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper"; -import { DynamicFontLoader } from "@/components/dynamic-font-loader"; import { GetProDialogWrapper } from "@/components/get-pro-dialog-wrapper"; import { PostHogInit } from "@/components/posthog-init"; import { ThemeProvider } from "@/components/theme-provider"; @@ -10,6 +9,8 @@ import { QueryProvider } from "@/lib/query-client"; import type { Metadata, Viewport } from "next"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { Suspense } from "react"; +import { ThemeHotKeyHandler } from "@/components/home/theme-hotkey-handler"; +import KeyboardShortcutsOverlay from "@/components/keyboard-shortcut-overlay"; import "./globals.css"; export const metadata: Metadata = { @@ -55,7 +56,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - @@ -67,8 +67,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) sizes="180x180" /> - - {/* PRELOAD FONTS USED BY BUILT-IN THEMES */} + + @@ -93,6 +93,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + + ); diff --git a/components/editor/action-bar/action-bar.tsx b/components/editor/action-bar/action-bar.tsx index aa4168fc..1126c873 100644 --- a/components/editor/action-bar/action-bar.tsx +++ b/components/editor/action-bar/action-bar.tsx @@ -3,6 +3,8 @@ import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons"; import { HorizontalScrollArea } from "@/components/horizontal-scroll-area"; import { DialogActionsProvider, useDialogActions } from "@/hooks/use-dialog-actions"; +import { useActionsStore } from "@/store/action-store"; +import { useEffect} from "react"; export function ActionBar() { return ( @@ -16,6 +18,19 @@ function ActionBarContent() { const { isCreatingTheme, handleSaveClick, handleShareClick, setCssImportOpen, setCodePanelOpen } = useDialogActions(); + const setHandleSaveClick = useActionsStore((store) => store.setHandleSaveClick) + const setSetCodePanelOpen = useActionsStore((store) => store.setSetCodePanelOpen) + + useEffect(()=> { + setHandleSaveClick(handleSaveClick) + return () => setHandleSaveClick(() => {}) + },[setHandleSaveClick , handleSaveClick]) + + useEffect(() => { + setSetCodePanelOpen(setCodePanelOpen) + return () => setSetCodePanelOpen(() => {}) + },[setCodePanelOpen, setSetCodePanelOpen]) + return (
@@ -29,4 +44,4 @@ function ActionBarContent() {
); -} +} \ No newline at end of file diff --git a/components/home/theme-hotkey-handler.tsx b/components/home/theme-hotkey-handler.tsx new file mode 100644 index 00000000..fef60c6a --- /dev/null +++ b/components/home/theme-hotkey-handler.tsx @@ -0,0 +1,276 @@ +"use client" +import { useClient } from "@/hooks/use-client" +import { useActionsStore } from "@/store/action-store" +import { useEditorStore } from "@/store/editor-store" +import { useThemePresetStore } from "@/store/theme-preset-store" +import { usePreferencesStore } from "@/store/preferences-store" +import { defaultPresets } from "@/utils/theme-presets" +import { generateThemeCode } from "@/utils/theme-style-generator" +import { useRouter } from "next/navigation" +import { useCallback, useEffect, useMemo } from "react" +import { usePostHog } from 'posthog-js/react' +import { useAIChatStore } from "@/store/ai-chat-store" +import { ThemePreset } from "@/types/theme" + +export const ThemeHotKeyHandler = ({children}:{children:React.ReactNode}) => { + const { themeState, applyThemePreset, undo, redo , resetToCurrentPreset } = useEditorStore() + const {clearMessages} = useAIChatStore() + const { colorFormat, tailwindVersion, packageManager } = usePreferencesStore() + const router = useRouter() + const availableThemes = useMemo(() => Object.keys(defaultPresets),[]) + const isClient = useClient() + const presets = useThemePresetStore((store) => store.getAllPresets()); + const posthog = usePostHog() + + const { + triggerSaveTheme, + triggerCodePanelOpen, + } = useActionsStore() + + const copyThemeCSS = useCallback(async () => { + if (!isClient) return + + try { + const cssCode = generateThemeCode(themeState, colorFormat, tailwindVersion) + + if (!cssCode) { + return + } + + await navigator.clipboard.writeText(cssCode) + + if (posthog) { + posthog.capture("COPY_CODE_HOTKEY", { + editorType: "theme", + preset: themeState.preset, + colorFormat, + tailwindVersion, + }) + } + } catch (error) { + console.error("Error copying CSS:", error) + } + }, [isClient, themeState, colorFormat, tailwindVersion, triggerCodePanelOpen, posthog]) + + const copyRegistryCommand = useCallback(async () => { + if (!isClient) return + + try { + const preset = themeState.preset ?? "default" + + const presetData = presets[preset] + const isSavedPreset = presetData?.source === "SAVED" + + const url = isSavedPreset + ? `https://tweakcn.com/r/themes/${preset}` + : `https://tweakcn.com/r/themes/${preset}.json` + + let command = "" + switch (packageManager) { + case "pnpm": + command = `pnpm dlx shadcn@latest add ${url}` + break + case "npm": + command = `npx shadcn@latest add ${url}` + break + case "yarn": + command = `yarn dlx shadcn@latest add ${url}` + break + case "bun": + command = `bunx shadcn@latest add ${url}` + break + default: + command = `npx shadcn@latest add ${url}` + } + + if (!command) { + return + } + + await navigator.clipboard.writeText(command) + + if (posthog) { + posthog.capture("COPY_REGISTRY_COMMAND_HOTKEY", { + editorType: "theme", + preset: themeState.preset, + colorFormat, + tailwindVersion, + }) + } + + } catch (error) { + console.error("Error copying registry command:", error) + } + }, [isClient, themeState.preset, presets, packageManager, triggerCodePanelOpen, posthog, colorFormat, tailwindVersion]) + + const allPresetNames = useMemo(() => ["default", ...Object.keys(presets)], [presets]); + + const sortAndFilterPresets = useCallback((allNames: string[], currentPresets: Record) => { + const filteredList = allNames; + const isSavedTheme = (presetId: string) => currentPresets[presetId]?.source === "SAVED"; + const savedThemesList = filteredList.filter((name) => name !== "default" && isSavedTheme(name)); + const defaultThemesList = filteredList.filter((name) => !savedThemesList.includes(name)); + + const sortThemesInternal = (list: string[]) => { + const defaultTheme = list.filter((name) => name === "default"); + const otherThemes = list + .filter((name) => name !== "default") + .sort((a, b) => { + const labelA = currentPresets[a]?.label || a; + const labelB = currentPresets[b]?.label || b; + return labelA.localeCompare(labelB); + }); + return [...defaultTheme, ...otherThemes]; + }; + + return [...sortThemesInternal(savedThemesList), ...sortThemesInternal(defaultThemesList)]; + }, []); + + const availableThemesForHotkeyCycling = useMemo(() => { + return sortAndFilterPresets(allPresetNames, presets); + }, [allPresetNames, presets, sortAndFilterPresets]); + + const currentThemeIndex = useMemo( + () => availableThemesForHotkeyCycling.indexOf(themeState.preset || "default"), + [availableThemesForHotkeyCycling, themeState.preset] + ); + + const cycleTheme = useCallback( + (direction: "prev" | "next") => { + if (availableThemesForHotkeyCycling.length === 0) { + return; + } + let newIndex; + if (direction === "next") { + newIndex = (currentThemeIndex + 1) % availableThemesForHotkeyCycling.length; + } else { + newIndex = (currentThemeIndex - 1 + availableThemesForHotkeyCycling.length) % availableThemesForHotkeyCycling.length; + if (newIndex < 0) { + newIndex += availableThemesForHotkeyCycling.length; + } + } + applyThemePreset(availableThemesForHotkeyCycling[newIndex]); + }, + [currentThemeIndex, availableThemesForHotkeyCycling, applyThemePreset] + ); + + const applyRandomTheme = useCallback(() => { + if(!isClient) return; + + const currentTheme = themeState.preset + const otherThemes = availableThemes.filter(theme => theme != currentTheme) + + if(otherThemes.length > 0){ + const randomIndex = Math.floor(Math.random()* otherThemes.length) + const randomTheme = otherThemes[randomIndex] + applyThemePreset(randomTheme) + } + }, [isClient, themeState.preset, availableThemes, applyThemePreset]) + + const handleReset = () => { + resetToCurrentPreset() + clearMessages() + } + + useEffect(() => { + if(!isClient) return; + + const handleGlobalKeyStroke = async (event: KeyboardEvent) => { + + if(!event.target || !(event.target instanceof HTMLElement)) return; + + if(event.target instanceof HTMLElement && + (event.target.tagName === "INPUT" || + event.target.tagName === "TEXTAREA" || + event.target.isContentEditable || + event.target.closest('[contenteditable="true"]') + )) { + return; + } + + if(event.code === "Space"){ + event.preventDefault(); + applyRandomTheme(); + } + + if(event.ctrlKey && event.shiftKey && event.code === "KeyO"){ + event.preventDefault(); + router.push("https://tweakcn.com/editor/theme?tab=ai"); + } + + if(event.ctrlKey && event.code === "KeyZ" && !event.shiftKey){ + event.preventDefault() + undo() + } + + if(event.ctrlKey && event.code === "KeyY"){ + event.preventDefault() + redo() + } + + if(event.ctrlKey && event.code ==="ArrowRight"){ + event.preventDefault() + cycleTheme("next") + } + + if(event.ctrlKey && event.code === "ArrowLeft"){ + event.preventDefault() + cycleTheme("prev") + } + + if(event.ctrlKey && event.code === "KeyS"){ + event.preventDefault() + triggerSaveTheme(); + } + + if(event.ctrlKey && event.code === "KeyB"){ + event.preventDefault() + triggerCodePanelOpen() + } + + if(event.ctrlKey && event.shiftKey && event.code === "KeyC"){ + event.preventDefault() + try { + await copyThemeCSS() + } catch (error) { + console.error("Error copying CSS:", error); + } + } + + if(event.ctrlKey && event.altKey && event.code === "KeyC"){ + event.preventDefault() + try { + await copyRegistryCommand() + } catch (error) { + console.error("Error copying theme command:", error); + } + } + + if(event.ctrlKey && event.code === "KeyR"){ + event.preventDefault() + handleReset() + } + }; + window.addEventListener('keydown', handleGlobalKeyStroke); + + return () => { + window.removeEventListener('keydown', handleGlobalKeyStroke); + }; + }, [ + isClient, + applyRandomTheme, + undo, + redo, + cycleTheme, + triggerSaveTheme, + triggerCodePanelOpen, + copyThemeCSS, + copyRegistryCommand, + ]); + + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/components/keyboard-shortcut-overlay.tsx b/components/keyboard-shortcut-overlay.tsx new file mode 100644 index 00000000..fc764f34 --- /dev/null +++ b/components/keyboard-shortcut-overlay.tsx @@ -0,0 +1,180 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface KeyboardShortcutsOverlayProps { + children: React.ReactNode; +} + +const KeyboardShortcutsOverlay: React.FC = ({ children }) => { + const [isVisible, setIsVisible] = useState(false); + const overlayRef = useRef(null); + + const shortcuts = [ + { + category: "EDITING", + items: [ + { action: "Apply random theme", keys: ["Space"] }, + { action: "Undo", keys: ["Ctrl", "Z"] }, + { action: "Redo", keys: ["Ctrl", "Y"] }, + { action: "Reset to current preset", keys: ["Ctrl", "R"] }, + { action: "Save theme", keys: ["Ctrl", "S"] }, + ] + }, + { + category: "NAVIGATION", + items: [ + { action: "Next theme", keys: ["Ctrl", "→"] }, + { action: "Previous theme", keys: ["Ctrl", "←"] }, + { action: "Open AI tab", fun: () => console.log("Open AI Tab (Ctrl+Shift+O)"), keys: ["Ctrl", "Shift", "O"] }, + { action: "Toggle code panel", keys: ["Ctrl", "B"] }, + ] + }, + { + category: "COPY", + items: [ + { action: "Copy theme CSS", keys: ["Ctrl", "Shift", "C"] }, + { action: "Copy registry command", keys: ["Ctrl", "Alt", "C"] }, + ] + }, + { + category: "HELP", + items: [ + { action: "Show/hide shortcuts", keys: ["Ctrl", "/"] }, + ] + } + ]; + + const handleClose = useCallback(() => { + setIsVisible(false); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey && event.code === 'Slash') { + event.preventDefault(); + setIsVisible(prev => !prev); + return; + } + + + if (event.code === 'Escape' && isVisible) { + event.preventDefault(); + handleClose(); + return; + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if (isVisible && overlayRef.current && !overlayRef.current.contains(event.target as Node)) { + handleClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isVisible, handleClose]); + + useEffect(() => { + if (isVisible) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isVisible]); + + const KeyBadge: React.FC<{ keyName: string; className?: string }> = ({ keyName, className }) => ( + + {keyName} + + ); + + return ( + <> + {children} + {isVisible && ( +
+
+
+
+
+ ⌘ +
+

Keyboard Shortcuts

+
+ +
+ + +

+ Speed up your theme editing workflow with these keyboard shortcuts. +

+ +
+ {shortcuts.map((category, categoryIndex) => ( +
+

+ {category.category} +

+
+ {category.items.map((shortcut, index) => ( +
+ + {shortcut.action} + +
+ {shortcut.keys.map((key, keyIndex) => ( + + + {keyIndex < shortcut.keys.length - 1 && ( + + + )} + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+
+ )} + + ); +}; + +export default KeyboardShortcutsOverlay \ No newline at end of file diff --git a/hooks/use-client.ts b/hooks/use-client.ts new file mode 100644 index 00000000..c93f7aeb --- /dev/null +++ b/hooks/use-client.ts @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect, useState } from 'react'; + +export function useClient(): boolean { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return isClient; +} \ No newline at end of file diff --git a/store/action-store.ts b/store/action-store.ts new file mode 100644 index 00000000..0bc682e6 --- /dev/null +++ b/store/action-store.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; + +interface ActionsStateProps { + _handleSaveClickRef: (() => void) | null; + setHandleSaveClick: (handler: () => void) => void; + triggerSaveTheme: () => void; + + _setCodePanelOpenRef: ((open: boolean) => void) | null; + setSetCodePanelOpen: (handler: (open: boolean) => void) => void; + triggerCodePanelOpen: () => void; + + _handleResetClickRef: (() => void) | null; + setHandleRestClick: (handler: () => void) => void; + triggerResetTheme: () => void; +} + +export const useActionsStore = create((set, get) => ({ + _handleSaveClickRef: null, + setHandleSaveClick: (handler) => { + set({ _handleSaveClickRef: handler }); + }, + triggerSaveTheme: () => { + const handler = get()._handleSaveClickRef; + if (handler) { + handler(); + } else { + console.warn("Save handler not set."); + } + }, + + _setCodePanelOpenRef: null, + setSetCodePanelOpen: (handler) => { + set({ _setCodePanelOpenRef: handler }); + }, + triggerCodePanelOpen: () => { + const handler = get()._setCodePanelOpenRef; + if (handler) { + handler(true); + } else { + console.warn("Code Panel handler not set."); + } + }, + + _handleResetClickRef: null, + setHandleRestClick: (handler) => { + set({_handleResetClickRef: handler}); + }, + triggerResetTheme: () => { + const handler = get()._handleResetClickRef; + if(handler){ + handler(); + }else{ + console.warn("Reset handler not set") + } + }, +})); \ No newline at end of file