1133 lines · 41.9 KB · TypeScript
RawDownload
import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react";import {  useCallback,  useEffect,  useRef,  useState,  type PointerEvent as ReactPointerEvent,} from "react";import {  applyThemeColorPreview,  THEME_COLOR_ROLES,  THEME_FILE_VERSION,  createVividThemeColors,  getCustomThemes,  getStandardThemeColors,  getThemeColorsForMode,  getThemeModes,  installCustomTheme,  isThemeColor,  parseThemeFile,  removeCustomTheme,  themeIdFromName,  updateCustomTheme,  type ThemeAppearance,  type ThemeColorRole,  type ThemeDefinition,} from "../../themePalette";import { cn } from "../../lib/utils";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker";import {  clearThemeInspectorHover,  clearThemeInspectorHighlights,  highlightThemeRoleUsage,  inspectThemeRoleAtElement,  inspectThemeRoleFromUtilitiesAtElement,  refreshThemeInspectorSpotlight,  showThemeInspectorHover,  type ThemeElementInspection,} from "./themeInspector"; const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray<ThemeColorRole> = [  "canvas",  "chrome",  "sidebar",  "surface",  "text",  "textMuted",  "placeholder",  "secondaryLabel",  "iconMuted",  "accent",  "messageSurface",  "messageAction",]; const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; const THEME_EDITOR_STATUS_ROLES: ReadonlyArray<ThemeColorRole> = [  "error",  "errorForeground",  "errorSurface",  "warning",  "warningForeground",  "warningSurface",  "update",  "updateForeground",  "updateSurface",]; const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter(  (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role),); const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{  id: string;  title: string;  roles: ReadonlyArray<ThemeColorRole>;}> = [  {    id: "main",    title: "Main colors",    roles: THEME_EDITOR_PRIMARY_ROLES,  },  {    id: "status",    title: "Status colors",    roles: THEME_EDITOR_STATUS_ROLES,  },  {    id: "additional",    title: "Other colors",    roles: THEME_EDITOR_ADVANCED_ROLES,  },]; type ThemeEditorColors = Record<ThemeColorRole, string>;type ThemeEditorColorsByAppearance = Record<ThemeAppearance, ThemeEditorColors>; // A draft with no source theme starts as the standard T3 Code look — the// palette on screen when no theme is installed — so creating from the default// theme changes nothing until the user edits a color.function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors {  return { ...getStandardThemeColors(appearance) };} function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance {  return {    light: getThemeEditorDefaults("light"),    dark: getThemeEditorDefaults("dark"),  };} function isThemeEditorColor(value: string): boolean {  return isThemeColor(value.trim());} function getManagedEditorColors(  appearance: ThemeAppearance,  colors: ThemeEditorColors,): ThemeEditorColors {  const defaults = getStandardThemeColors(appearance);  // The editor keeps the user's exact picks and derives the rest through the  // perceptual vivid engine, so a two-color theme carries its own identity.  return createVividThemeColors(    appearance,    isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas,    isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent,  );} export function ThemeEditorPanel({  open,  onOpenChange,  onSaved,  editingTheme,  initialAppearance,  seedTheme,  seedName,  restoreTheme,}: {  open: boolean;  onOpenChange: (open: boolean) => void;  onSaved: (    theme: ThemeDefinition,    context: {      created: boolean;      /** Set when a create merged its palette into an existing theme. */      mergedAppearance?: ThemeAppearance;    },  ) => boolean;  editingTheme: ThemeDefinition | null;  initialAppearance: ThemeAppearance;  /** The theme a new theme starts from, so tuning what you already use is a   *  matter of editing rather than rebuilding. Null starts from the defaults. */  seedTheme?: ThemeDefinition | null;  /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */  seedName?: string | undefined;  /** Reapplies the stored theme once the draft stops being previewed. */  restoreTheme: () => void;}) {  const isEditing = editingTheme !== null;  const [name, setName] = useState("");  const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);  const [isAdvanced, setIsAdvanced] = useState(false);  const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>    getThemeEditorColorsByAppearance(),  );  const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState<    Record<ThemeAppearance, boolean>  >({ light: false, dark: false });  const [error, setError] = useState<string | null>(null);  const [isMinimized, setIsMinimized] = useState(false);  const [roleQuery, setRoleQuery] = useState("");  const [isInspecting, setIsInspecting] = useState(false);  const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);  const [usageCount, setUsageCount] = useState<number | null>(null);  // Null parks the panel at its default corner; a value is a dragged spot,  // kept clamped so the header can always be grabbed again.  const [position, setPosition] = useState<{ x: number; y: number } | null>(null);  // Null keeps the responsive default size; a value is a corner-grip resize.  const [size, setSize] = useState<{ width: number; height: number } | null>(null);  const panelRef = useRef<HTMLDivElement>(null);  const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null);  const resizeStartRef = useRef<{    pointerX: number;    pointerY: number;    // Where the panel's top-left sits: the grip only moves the opposite    // corner, so the room to grow is measured from here.    left: number;    top: number;    width: number;    height: number;  } | null>(null);  useEffect(() => {    if (!open) return;    // A panel sized wider than the window can no longer be clamped back into    // view by position alone -- its right edge (close, minimize, the grip)    // stays off screen. So the size shrinks to fit first, then the position    // is re-clamped against the new size.    const clamp = () => {      const margin = 8;      let clampedWidth: number | undefined;      let clampedHeight: number | undefined;      setSize((current) => {        if (!current) return current;        clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2));        clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2));        return { width: clampedWidth, height: clampedHeight };      });      setPosition((current) => {        if (!current) return current;        const clamped = clampPosition(current.x, current.y, clampedWidth);        // Dragging may park the panel with only its header showing, but a        // window resize should pull the whole thing back into view when it        // fits -- otherwise the grip ends up below the fold. Minimized, the        // stored height is not applied (the panel hugs its header), so the        // rendered height is what has to fit.        const height = isMinimized          ? (panelRef.current?.offsetHeight ?? 0)          : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0);        const maxY = Math.max(margin, window.innerHeight - height - margin);        return { x: clamped.x, y: Math.min(clamped.y, maxY) };      });    };    window.addEventListener("resize", clamp);    return () => window.removeEventListener("resize", clamp);  }, [isMinimized, open]);   // The draft only reaches the live app once this open has been seeded;  // previewing in the seeding commit would paint the previous session's  // colors for a frame.  const [isDraftSeeded, setIsDraftSeeded] = useState(false);  const previousOpenRef = useRef(false);   useEffect(() => {    if (open && !previousOpenRef.current) {      // Editing works on the theme itself; creating starts from the theme      // that is currently in use, so tuning what you already run is an edit      // away instead of a rebuild from the defaults.      const sourceTheme = editingTheme ?? seedTheme ?? null;      const nextColors = getThemeEditorColorsByAppearance();      const nextAppearance = sourceTheme        ? getThemeColorsForMode(sourceTheme, initialAppearance)          ? initialAppearance          : sourceTheme.appearance        : initialAppearance;      if (sourceTheme) {        nextColors[sourceTheme.appearance] = { ...sourceTheme.colors };        for (const appearance of ["light", "dark"] as const) {          const variantColors = sourceTheme.variants?.[appearance];          if (variantColors) nextColors[appearance] = { ...variantColors };        }      }       setName(editingTheme?.label ?? seedName ?? "");      setActiveAppearance(nextAppearance);      // Themes saved by the guided editor carry the managed flag; anything      // else (imports, hand-edited files, older saves) opens in advanced mode      // so guided regeneration cannot silently discard hand-tuned colors. A      // seeded new theme follows the same rule: its palette is only safe to      // regenerate when the guided editor produced it.      setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true);      setSimpleColorsDirtyByAppearance({ light: false, dark: false });      setColorsByAppearance(nextColors);      setSelectedRole(null);      setUsageCount(null);      setIsInspecting(false);      setError(null);      setIsDraftSeeded(true);    }    if (!open && isDraftSeeded) setIsDraftSeeded(false);    previousOpenRef.current = open;  }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]);   // A name an installed theme already uses combines instead of failing:  // creating adds the new palette to that theme, and renaming an existing  // theme onto it folds the edited palette in and retires the old entry —  // light "My Theme" plus a dark "My Theme" become one theme with both modes.  // Labels are matched as well as derived ids: a rename keeps a theme's  // original id, so its label is the only name a user can see and retype.  const nameTargetId = themeIdFromName(name);  const normalizedName = name.trim().toLowerCase();  const mergeTarget =    normalizedName === ""      ? null      : (getCustomThemes().find(          (theme) =>            theme.id !== editingTheme?.id &&            (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName),        ) ?? null);  const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : [];  const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null;   // The appearance a mode button would produce can be blocked two ways: the  // merge target already has that palette, or the theme being edited never  // had it (adding one is a create-with-same-name away).  const appearanceLockReason = (appearance: ThemeAppearance): string | null => {    if (editableAppearances && !editableAppearances.includes(appearance)) {      return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`;    }    if (!isEditing && takenAppearances.includes(appearance)) {      return `“${mergeTarget?.label}” already has a ${appearance} palette.`;    }    return null;  };   // Typing a name whose theme already owns the selected appearance flips the  // draft to the free side, so the merge affordance works without a manual  // toggle. Both sides taken leaves the selection alone; save is blocked with  // an explanation instead.  const mergeTargetId = mergeTarget?.id ?? null;  const takenAppearancesKey = takenAppearances.join(",");  useEffect(() => {    if (isEditing || mergeTargetId === null) return;    const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];    if (taken.length !== 1) return;    setActiveAppearance((current) => {      if (!taken.includes(current)) return current;      return taken[0] === "light" ? "dark" : "light";    });  }, [isEditing, mergeTargetId, takenAppearancesKey]);   // The whole app wears the draft while the editor is open, so a role change  // is judged on the real interface rather than a miniature. The stored theme  // comes back when the editor closes, including on cancel.  useEffect(() => {    if (!open || !isDraftSeeded) return;    applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance);  }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]);   useEffect(() => {    if (!open) return;    return () => {      restoreTheme();    };  }, [open, restoreTheme]);   const updateColor = useCallback(    (role: ThemeColorRole, value: string) => {      setColorsByAppearance((current) => {        const nextColors = { ...current[activeAppearance], [role]: value };        const shouldManageColors =          !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value);         return {          ...current,          [activeAppearance]: shouldManageColors            ? getManagedEditorColors(activeAppearance, nextColors)            : nextColors,        };      });      if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) {        setSimpleColorsDirtyByAppearance((current) => ({          ...current,          [activeAppearance]: true,        }));      }    },    [activeAppearance, isAdvanced],  );   const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => {    setSelectedRole(role);    if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) {      setIsAdvanced(true);      setRoleQuery("");    }    if (!reveal) return;     requestAnimationFrame(() => {      panelRef.current        ?.querySelector(`[data-theme-color-role="${role}"]`)        ?.scrollIntoView({ behavior: "smooth", block: "nearest" });    });  }, []);   const toggleThemeRole = useCallback((role: ThemeColorRole) => {    setSelectedRole((current) => (current === role ? null : role));  }, []);   const clearInspectorSelection = useCallback(() => {    setSelectedRole(null);    setUsageCount(null);    setIsInspecting(false);  }, []);   const selectedHighlightRoles = selectedRole    ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)      ? THEME_COLOR_ROLES.filter(          (role) =>            colorsByAppearance[activeAppearance][role].trim().toLowerCase() ===            colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(),        )      : [selectedRole]    : [];  const selectedHighlightRolesKey = selectedHighlightRoles.join(",");   useEffect(() => {    clearThemeInspectorHighlights();    if (!open || selectedRole === null) {      setUsageCount(null);      return;    }    // Picking a new element needs the unobscured app, so suspend the existing    // spotlight while the picker is armed.    if (isInspecting) return;     const highlightedRoles = selectedHighlightRolesKey.split(",") as Array<ThemeColorRole>;    const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles));    refreshHighlights();    // A refresh snapshots computed styles for the whole tree twice, so it is    // throttled rather than run per frame: a streaming reply or a virtualized    // list mutates the DOM continuously and would otherwise stall the main    // thread for as long as the inspector is open.    const MIN_REFRESH_INTERVAL_MS = 500;    let refreshFrame: number | null = null;    let refreshTimer: ReturnType<typeof setTimeout> | null = null;    let lastRefreshAt = performance.now();    const scheduleRefresh = () => {      if (refreshFrame !== null || refreshTimer !== null) return;      const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt));      const run = () => {        refreshFrame = null;        refreshTimer = null;        lastRefreshAt = performance.now();        refreshHighlights();      };      if (wait === 0) refreshFrame = requestAnimationFrame(run);      else refreshTimer = setTimeout(run, wait);    };    const observer = new MutationObserver((mutations) => {      if (        mutations.every(          (mutation) =>            mutation.target instanceof Element &&            (mutation.target.closest("#theme-inspector-spotlight") ||              mutation.target.closest("[data-theme-editor-panel]")),        )      ) {        return;      }      scheduleRefresh();    });    observer.observe(document.body, { childList: true, subtree: true });    let spotlightFrame: number | null = null;    const scheduleSpotlightRefresh = () => {      spotlightFrame ??= requestAnimationFrame(() => {        spotlightFrame = null;        refreshThemeInspectorSpotlight();      });    };    window.addEventListener("resize", scheduleSpotlightRefresh);    window.addEventListener("scroll", scheduleSpotlightRefresh, true);    return () => {      observer.disconnect();      if (refreshFrame !== null) cancelAnimationFrame(refreshFrame);      if (refreshTimer !== null) clearTimeout(refreshTimer);      if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame);      window.removeEventListener("resize", scheduleSpotlightRefresh);      window.removeEventListener("scroll", scheduleSpotlightRefresh, true);      clearThemeInspectorHighlights();    };  }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]);   useEffect(() => {    if (!open || !isInspecting) {      clearThemeInspectorHover();      return;    }     let shouldDisarmAfterClick = false;    let hoverTarget: Element | null = null;    let hoverInspection: ThemeElementInspection | null = null;    let hoverTimer: number | null = null;    let hoverFrame: number | null = null;    const clearHoverTimer = () => {      if (hoverTimer === null) return;      window.clearTimeout(hoverTimer);      hoverTimer = null;    };    const clearHover = () => {      clearHoverTimer();      hoverTarget = null;      hoverInspection = null;      clearThemeInspectorHover();    };    const showInspection = (inspection: ThemeElementInspection) => {      hoverInspection = inspection;      showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role));    };    const handlePointerOver = (event: PointerEvent) => {      const target = event.target;      if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) {        clearHover();        return;      }

Lines 1–500 of 1133.