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