返回 DeepSeek-Reasonix
ThemeGallery.tsx
根目录 / desktop / frontend / src / components / ThemeGallery.tsx
1 import { SettingsOptions } from "./SettingsOptions";
2 import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
3 import { createPortal } from "react-dom";
4 import { ArrowLeft, Check, CircleHelp, Copy, Download, ImagePlus, MoreHorizontal, Pencil, Plus, Trash2, Upload, X } from "lucide-react";
5 import { app } from "../lib/bridge";
6 import { useT } from "../lib/i18n";
7 import { THEME_STYLES, type ThemeStyle, isThemeStyle } from "../lib/theme";
8 import {
9 type ThemePackView,
10 type ThemeSaveInput,
11 type ThemePackBackground,
12 type ThemePackSceneBackground,
13 type ThemePackRecipes,
14 type ThemePackTokens,
15 beginThemePreview,
16 cancelThemePreview,
17 defaultBackground,
18 defaultTaskBackground,
19 draftPackView,
20 emptyThemeTokens,
21 isSafeHex,
22 themePackKind,
23 themeTokenKeys,
24 } from "../lib/themePack";
25 import {
26 type GalleryTab,
27 type ThemeExperienceView,
28 type ThemeSelection,
29 activateBaseStyle,
30 activateThemePack,
31 cancelGlobalPreview,
32 commitGlobalPreview,
33 groupThemePacks,
34 isSelectionActive,
35 selectionFromPack,
36 startGlobalPreview,
37 } from "../lib/themeExperience";
38 import { useToast } from "../lib/toast";
39 import { themePreviewPalette } from "../lib/themePreviewPalette";
40 import { useConfirmDialog } from "./ConfirmDialog";
41 import { ThemePreviewSurface } from "./ThemePreviewSurface";
42 import { Tooltip } from "./Tooltip";
43
44 type EditorState = {
45 mode: "create" | "edit";
46 id: string;
47 name: string;
48 author: string;
49 description: string;
50 license: string;
51 baseStyle: ThemeStyle;
52 tokens: ThemePackTokens;
53 recipes: ThemePackRecipes;
54 background: ThemePackBackground | null;
55 backgroundDataUrl: string;
56 existingBackgroundUrl: string;
57 taskBackground: ThemePackSceneBackground | null;
58 taskBackgroundDataUrl: string;
59 existingTaskBackgroundUrl: string;
60 tokenMode: "light" | "dark";
61 originalId: string;
62 };
63
64 const TOKEN_GROUPS: { labelKey: string; keys: string[] }[] = [
65 { labelKey: "settings.themeTokens.surfaces", keys: ["bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles"] },
66 { labelKey: "settings.themeTokens.borderText", keys: ["border", "borderSoft", "fg", "fgDim", "fgFaint"] },
67 { labelKey: "settings.themeTokens.accentStatus", keys: ["accent", "accentFg", "ok", "warn", "err"] },
68 ];
69
70 function slugifyId(name: string): string {
71 const s = name
72 .toLowerCase()
73 .replace(/[^a-z0-9]+/g, "-")
74 .replace(/^-+|-+$/g, "")
75 .slice(0, 48);
76 if (!s) return "my-theme";
77 if (/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(s)) return s;
78 return `t-${s}`.slice(0, 48);
79 }
80
81 function packDisplayName(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string {
82 return pack.nameKey ? t(pack.nameKey as never) : pack.name;
83 }
84
85 function packDescription(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string {
86 if (pack.descriptionKey) return t(pack.descriptionKey as never);
87 return pack.description || "";
88 }
89
90 function packKindBadge(pack: ThemePackView, t: ReturnType<typeof useT>): string {
91 const kind = themePackKind(pack);
92 if (kind === "official") return t("settings.themeGallery.kindOfficial");
93 if (kind === "base") return t("settings.themeGallery.kindBase");
94 if (kind === "plugin") {
95 return pack.pluginName
96 ? t("settings.themeGallery.kindPlugin", { name: pack.pluginName })
97 : t("settings.themeGallery.kindPluginUnknown");
98 }
99 return t("settings.themeGallery.kindUser");
100 }
101
102 function createEditorState(baseStyle: ThemeStyle): EditorState {
103 return {
104 mode: "create",
105 id: "my-theme",
106 name: "My Theme",
107 author: "",
108 description: "",
109 license: "",
110 baseStyle,
111 tokens: emptyThemeTokens(),
112 recipes: { density: "comfortable", corners: "soft" },
113 background: null,
114 backgroundDataUrl: "",
115 existingBackgroundUrl: "",
116 taskBackground: null,
117 taskBackgroundDataUrl: "",
118 existingTaskBackgroundUrl: "",
119 tokenMode: "dark",
120 originalId: "",
121 };
122 }
123
124 function handlePreviewRadioKey<T extends string>(
125 event: ReactKeyboardEvent<HTMLButtonElement>,
126 values: readonly T[],
127 current: T,
128 onChange: (value: T) => void,
129 ) {
130 const direction =
131 event.key === "ArrowRight" || event.key === "ArrowDown"
132 ? 1
133 : event.key === "ArrowLeft" || event.key === "ArrowUp"
134 ? -1
135 : 0;
136 if (!direction) return;
137 event.preventDefault();
138 const currentIndex = values.indexOf(current);
139 const nextIndex = (currentIndex + direction + values.length) % values.length;
140 onChange(values[nextIndex]);
141 const radios = event.currentTarget
142 .closest('[role="radiogroup"]')
143 ?.querySelectorAll<HTMLButtonElement>('[role="radio"]');
144 radios?.[nextIndex]?.focus();
145 }
146
147 function ThemePreviewControls({
148 mode,
149 scene,
150 onModeChange,
151 onSceneChange,
152 }: {
153 mode: "light" | "dark";
154 scene: "home" | "task";
155 onModeChange: (mode: "light" | "dark") => void;
156 onSceneChange: (scene: "home" | "task") => void;
157 }) {
158 const t = useT();
159 return (
160 <div className="theme-gallery__preview-controls">
161 <div className="theme-gallery__preview-control">
162 <span className="theme-gallery__preview-label">{t("settings.themeGallery.appearancePreview")}</span>
163 <SettingsOptions className="set-seg" role="radiogroup" aria-label={t("settings.themeGallery.appearancePreview")}>
164 <button
165 type="button"
166 role="radio"
167 aria-checked={mode === "light"}
168 tabIndex={mode === "light" ? 0 : -1}
169 className={`set-seg__btn${mode === "light" ? " set-seg__btn--on" : ""}`}
170 onClick={() => onModeChange("light")}
171 onKeyDown={(event) => handlePreviewRadioKey(event, ["light", "dark"], mode, onModeChange)}
172 >
173 {t("settings.themeLight")}
174 </button>
175 <button
176 type="button"
177 role="radio"
178 aria-checked={mode === "dark"}
179 tabIndex={mode === "dark" ? 0 : -1}
180 className={`set-seg__btn${mode === "dark" ? " set-seg__btn--on" : ""}`}
181 onClick={() => onModeChange("dark")}
182 onKeyDown={(event) => handlePreviewRadioKey(event, ["light", "dark"], mode, onModeChange)}
183 >
184 {t("settings.themeDark")}
185 </button>
186 </SettingsOptions>
187 </div>
188 <div className="theme-gallery__preview-control">
189 <span className="theme-gallery__preview-label">
190 {t("settings.themeGallery.scenePreview")}
191 <Tooltip label={t("settings.themeGallery.scenePreviewHint")} side="top">
192 <button
193 type="button"
194 className="theme-gallery__preview-help"
195 aria-label={t("settings.themeGallery.scenePreviewHint")}
196 >
197 <CircleHelp size={13} aria-hidden="true" />
198 </button>
199 </Tooltip>
200 </span>
201 <SettingsOptions className="set-seg" role="radiogroup" aria-label={t("settings.themeGallery.scenePreview")}>
202 <button
203 type="button"
204 role="radio"
205 aria-checked={scene === "home"}
206 tabIndex={scene === "home" ? 0 : -1}
207 className={`set-seg__btn${scene === "home" ? " set-seg__btn--on" : ""}`}
208 onClick={() => onSceneChange("home")}
209 onKeyDown={(event) => handlePreviewRadioKey(event, ["home", "task"], scene, onSceneChange)}
210 >
211 {t("settings.themeGallery.sceneHome")}
212 </button>
213 <button
214 type="button"
215 role="radio"
216 aria-checked={scene === "task"}
217 tabIndex={scene === "task" ? 0 : -1}
218 className={`set-seg__btn${scene === "task" ? " set-seg__btn--on" : ""}`}
219 onClick={() => onSceneChange("task")}
220 onKeyDown={(event) => handlePreviewRadioKey(event, ["home", "task"], scene, onSceneChange)}
221 >
222 {t("settings.themeGallery.sceneTask")}
223 </button>
224 </SettingsOptions>
225 </div>
226 </div>
227 );
228 }
229
230 export function ThemeGallery({
231 experience,
232 initialCreateBaseStyle,
233 onExperienceChange,
234 onBack,
235 }: {
236 experience: ThemeExperienceView;
237 initialCreateBaseStyle?: ThemeStyle;
238 onExperienceChange: (view: ThemeExperienceView) => void;
239 onBack: () => void;
240 }) {
241 const t = useT();
242 const { showToast } = useToast();
243 const { confirm, dialog: confirmDialog } = useConfirmDialog();
244 const [packs, setPacks] = useState<ThemePackView[]>([]);
245 const [loading, setLoading] = useState(true);
246 const [busy, setBusy] = useState(false);
247 const [tab, setTab] = useState<GalleryTab>("catalog");
248 const [selected, setSelected] = useState<ThemeSelection | null>(null);
249 const [detailMode, setDetailMode] = useState<"light" | "dark">("dark");
250 const [detailScene, setDetailScene] = useState<"home" | "task">("home");
251 const [immersive, setImmersive] = useState(false);
252 const [previewingId, setPreviewingId] = useState<string | null>(null);
253 const [editor, setEditor] = useState<EditorState | null>(() =>
254 initialCreateBaseStyle ? createEditorState(initialCreateBaseStyle) : null,
255 );
256 const [menuOpen, setMenuOpen] = useState(false);
257 const selectionSeeded = useRef(false);
258 const moreActionsRef = useRef<HTMLButtonElement>(null);
259
260 const reload = useCallback(async () => {
261 setLoading(true);
262 try {
263 const list = await app.ListThemePacks();
264 setPacks(list || []);
265 } catch (err) {
266 showToast(err instanceof Error ? err.message : String(err), "error");
267 } finally {
268 setLoading(false);
269 }
270 }, [showToast]);
271
272 useEffect(() => {
273 void reload();
274 return () => {
275 cancelGlobalPreview();
276 cancelThemePreview();
277 };
278 }, [reload]);
279
280 const groups = useMemo(() => groupThemePacks(packs), [packs]);
281 const catalogPacks = useMemo(() => [...groups.official, ...groups.plugin, ...groups.base], [groups.official, groups.plugin, groups.base]);
282 const visible = tab === "catalog" ? catalogPacks : groups.user;
283 const visibleSections =
284 tab === "catalog"
285 ? [
286 { id: "official", label: t("settings.themeGallery.sectionFlagship"), packs: groups.official },
287 { id: "plugin", label: t("settings.themeLibrary.groupPlugin"), packs: groups.plugin },
288 { id: "base", label: t("settings.themeGallery.tabBase"), packs: groups.base },
289 ].filter((section) => section.packs.length > 0)
290 : [{ id: "user", label: "", packs: groups.user }];
291
292 const changeTab = (nextTab: GalleryTab) => {
293 const nextPacks = nextTab === "catalog" ? catalogPacks : groups.user;
294 if (nextTab !== tab) {
295 cancelGlobalPreview();
296 setPreviewingId(null);
297 }
298 setTab(nextTab);
299 setMenuOpen(false);
300 if (selected && nextPacks.some((pack) => pack.id === selected.id)) return;
301 const nextSelection =
302 nextPacks.find((pack) => isSelectionActive(selectionFromPack(pack), experience)) || nextPacks[0] || null;
303 setSelected(nextSelection ? selectionFromPack(nextSelection) : null);
304 };
305
306 // Seed selection from active experience.
307 useEffect(() => {
308 if (selectionSeeded.current || packs.length === 0) return;
309 selectionSeeded.current = true;
310 if (experience.activePack) {
311 setSelected(selectionFromPack(experience.activePack));
312 setTab(themePackKind(experience.activePack) === "user" ? "user" : "catalog");
313 return;
314 }
315 const base = groups.base.find((p) => p.id === experience.baseStyle) || groups.base[0] || groups.official[0];
316 if (base) {
317 setSelected(selectionFromPack(base));
318 setTab("catalog");
319 }
320 }, [packs, experience, selected, groups.base, groups.official]);
321
322 const selectedPack = selected?.pack || (selected?.kind === "base" ? groups.base.find((p) => p.id === selected.id) : null) || null;
323 const isActive = isSelectionActive(selected, experience);
324
325 const previewPackGlobally = useCallback((pack: ThemePackView) => {
326 if (themePackKind(pack) === "base") {
327 const draft = draftPackView({
328 id: pack.id,
329 name: pack.name,
330 baseStyle: pack.id,
331 tokens: emptyThemeTokens(),
332 recipes: { density: "comfortable", corners: "soft" },
333 });
334 startGlobalPreview(draft);
335 return;
336 }
337 startGlobalPreview(pack);
338 }, []);
339
340 // Entering immersive mode is the explicit opt-in to a live, reversible
341 // preview. Every rail selection replaces that preview until Back restores it
342 // or Apply persists it.
343 useEffect(() => {
344 if (!immersive || !selectedPack) return;
345 previewPackGlobally(selectedPack);
346 }, [immersive, selectedPack, previewPackGlobally]);
347
348 const onSelectPack = (pack: ThemePackView) => {
349 setSelected(selectionFromPack(pack));
350 setMenuOpen(false);
351 if (!immersive) {
352 previewPackGlobally(pack);
353 setPreviewingId(pack.id);
354 }
355 };
356
357 const applySelected = async () => {
358 if (!selected) return;
359 setBusy(true);
360 try {
361 if (selected.kind === "base") {
362 const view = await activateBaseStyle(selected.id);
363 onExperienceChange(view);
364 showToast(t("settings.themeGallery.appliedBase", { name: selected.id }), "info");
365 } else {
366 const view = await activateThemePack(selected.id);
367 onExperienceChange(view);
368 commitGlobalPreview(view.activePack ?? null);
369 showToast(t("settings.themeGallery.applied", { name: packDisplayName(selected.pack, t) }), "info");
370 }
371 setPreviewingId(null);
372 await reload();
373 } catch (err) {
374 showToast(err instanceof Error ? err.message : String(err), "error");
375 } finally {
376 setBusy(false);
377 }
378 };
379
380 const closeImmersivePreview = () => {
381 cancelGlobalPreview();
382 setPreviewingId(null);
383 setImmersive(false);
384 };
385
386 const copySelected = async () => {
387 if (!selectedPack) return;
388 setBusy(true);
389 try {
390 const newId = slugifyId(`${selectedPack.id}-copy`);
391 const created = await app.CopyThemePack(selectedPack.id, newId, `${packDisplayName(selectedPack, t)} Copy`);
392 showToast(t("settings.themeLibrary.copied", { name: created.name }), "info");
393 await reload();
394 cancelGlobalPreview();
395 setPreviewingId(null);
396 setTab("user");
397 setSelected(selectionFromPack(created));
398 } catch (err) {
399 showToast(err instanceof Error ? err.message : String(err), "error");
400 } finally {
401 setBusy(false);
402 }
403 };
404
405 const removeSelected = async () => {
406 if (!selectedPack || themePackKind(selectedPack) !== "user") return;
407 const ok = await confirm({
408 title: t("settings.themeLibrary.confirmDeleteTitle"),
409 message: t("settings.themeLibrary.confirmDelete", { name: packDisplayName(selectedPack, t) }),
410 confirmLabel: t("common.delete"),
411 cancelLabel: t("common.cancel"),
412 tone: "danger",
413 });
414 setMenuOpen(false);
415 if (!ok) {
416 requestAnimationFrame(() => moreActionsRef.current?.focus());
417 return;
418 }
419 setBusy(true);
420 try {
421 await app.DeleteThemePack(selectedPack.id);
422 showToast(t("settings.themeGallery.deleted", { name: packDisplayName(selectedPack, t) }), "info");
423 setSelected(null);
424 await reload();
425 // Experience may have changed if we deleted the active pack.
426 const { loadThemeExperience, applyExperienceToDOM } = await import("../lib/themeExperience");
427 const view = await loadThemeExperience();
428 applyExperienceToDOM(view);
429 onExperienceChange(view);
430 } catch (err) {
431 showToast(err instanceof Error ? err.message : String(err), "error");
432 } finally {
433 setBusy(false);
434 setMenuOpen(false);
435 }
436 };
437
438 const exportSelected = async () => {
439 if (!selectedPack || themePackKind(selectedPack) !== "user") return;
440 try {
441 const ok = await confirm({
442 title: t("settings.themeLibrary.exportRightsTitle"),
443 message: t("settings.themeLibrary.exportRights"),
444 confirmLabel: t("settings.themeLibrary.exportConfirm"),
445 cancelLabel: t("common.cancel"),
446 });
447 if (!ok) return;
448 const path = await app.ExportThemePack(selectedPack.id, "");
449 if (path) showToast(t("settings.themeLibrary.exported"), "info");
450 } catch (err) {
451 showToast(err instanceof Error ? err.message : String(err), "error");
452 }
453 setMenuOpen(false);
454 };
455
456 const openCreate = () => {
457 cancelGlobalPreview();
458 setPreviewingId(null);
459 setEditor(createEditorState(isThemeStyle(experience.baseStyle) ? experience.baseStyle : "graphite"));
460 };
461
462 const openEdit = () => {
463 if (!selectedPack || themePackKind(selectedPack) !== "user") return;
464 cancelGlobalPreview();
465 setPreviewingId(null);
466 setEditor({
467 mode: "edit",
468 id: selectedPack.id,
469 name: selectedPack.name,
470 author: selectedPack.author || "",
471 description: selectedPack.description || "",
472 license: selectedPack.license || "",
473 baseStyle: isThemeStyle(selectedPack.baseStyle) ? selectedPack.baseStyle : "graphite",
474 tokens: { light: { ...(selectedPack.tokens?.light || {}) }, dark: { ...(selectedPack.tokens?.dark || {}) } },
475 recipes: {
476 density: selectedPack.recipes?.density === "compact" ? "compact" : "comfortable",
477 corners:
478 selectedPack.recipes?.corners === "square" || selectedPack.recipes?.corners === "round"
479 ? selectedPack.recipes.corners
480 : "soft",
481 },
482 background: selectedPack.background ? { ...selectedPack.background } : null,
483 backgroundDataUrl: "",
484 existingBackgroundUrl: selectedPack.backgroundUrl || "",
485 taskBackground: selectedPack.taskBackground ? { ...selectedPack.taskBackground } : null,
486 taskBackgroundDataUrl: "",
487 existingTaskBackgroundUrl: selectedPack.taskBackgroundUrl || "",
488 tokenMode: "dark",
489 originalId: selectedPack.id,
490 });
491 setMenuOpen(false);
492 };
493
494 const doImport = async () => {
495 setBusy(true);
496 try {
497 const result = await app.ImportThemePack("", false);
498 if (!result) return;
499 if (result.needsReplace) {
500 const ok = await confirm({
501 title: t("settings.themeLibrary.confirmReplaceImportTitle"),
502 message: t("settings.themeLibrary.confirmReplaceImport"),
503 confirmLabel: t("settings.themeLibrary.replaceConfirm"),
504 cancelLabel: t("common.cancel"),
505 });
506 if (!ok) return;
507 const confirmed = await app.ImportThemePack("", true);
508 if (confirmed?.pack) {
509 showToast(t("settings.themeLibrary.imported", { name: confirmed.pack.name }), "info");
510 }
511 } else if (result.pack) {
512 showToast(t("settings.themeLibrary.imported", { name: result.pack.name }), "info");
513 }
514 await reload();
515 cancelGlobalPreview();
516 setPreviewingId(null);
517 setTab("user");
518 } catch (err) {
519 showToast(err instanceof Error ? err.message : String(err), "error");
520 } finally {
521 setBusy(false);
522 }
523 };
524
525 const saveEditor = async (activate: boolean) => {
526 if (!editor) return;
527 setBusy(true);
528 try {
529 const input: ThemeSaveInput = {
530 id: editor.id,
531 name: editor.name,
532 author: editor.author,
533 description: editor.description,
534 license: editor.license,
535 baseStyle: editor.baseStyle,
536 tokens: editor.tokens,
537 recipes: editor.recipes,
538 background: editor.background || undefined,
539 backgroundDataUrl: editor.backgroundDataUrl || undefined,
540 clearBackground: !editor.background && !editor.backgroundDataUrl && !editor.existingBackgroundUrl,
541 taskBackground: editor.taskBackground || undefined,
542 taskBackgroundDataUrl: editor.taskBackgroundDataUrl || undefined,
543 clearTaskBackground: !editor.taskBackground && !editor.taskBackgroundDataUrl && !editor.existingTaskBackgroundUrl,
544 replace: editor.mode === "edit",
545 // Keep save and activation separate. The activation path below is the
546 // sole owner of the active-theme pointer, so a failed activation leaves
547 // the previous theme selected and the preview snapshot reversible.
548 activate: false,
549 };
550 const saved = await app.SaveThemePack(input);
551 showToast(t("settings.themeLibrary.saved", { name: saved.name }), "info");
552 if (activate) {
553 // Commit activation before unmounting the editor. ThemeEditorInline's
554 // cleanup cancels any remaining preview, so closing it earlier would
555 // briefly restore the old snapshot while reload() is in flight.
556 const view = await activateThemePack(saved.id);
557 onExperienceChange(view);
558 } else {
559 cancelThemePreview();
560 }
561 setEditor(null);
562 await reload();
563 setTab("user");
564 setSelected(selectionFromPack(saved));
565 } catch (err) {
566 showToast(err instanceof Error ? err.message : String(err), "error");
567 } finally {
568 setBusy(false);
569 }
570 };
571
572 if (immersive && selectedPack) {
573 return (
574 <div className="theme-gallery theme-gallery--immersive">
575 <header className="theme-gallery__top">
576 <button type="button" className="btn btn--small" onClick={closeImmersivePreview}>
577 <ArrowLeft size={14} /> {t("settings.themeGallery.back")}
578 </button>
579 <h2 className="theme-gallery__title">{t("settings.themeGallery.previewTitle")}</h2>
580 <div className="theme-gallery__top-actions">
581 <button type="button" className="btn btn--small" onClick={() => void doImport()} disabled={busy}>
582 <Upload size={13} /> {t("settings.themeLibrary.import")}
583 </button>
584 <button type="button" className="btn btn--small" onClick={openCreate} disabled={busy}>
585 <Plus size={13} /> {t("settings.themeLibrary.new")}
586 </button>
587 </div>
588 </header>
589 <div className="theme-gallery__immersive-toolbar">
590 <ThemePreviewControls
591 mode={detailMode}
592 scene={detailScene}
593 onModeChange={setDetailMode}
594 onSceneChange={setDetailScene}
595 />
596 </div>
597 <div className="theme-gallery__immersive-body">
598 <ThemePreviewSurface pack={selectedPack} mode={detailMode} scene={detailScene} />
599 <aside className="theme-gallery__immersive-rail">
600 <div className="theme-gallery__detail-head">
601 <div className="theme-gallery__detail-title-row">
602 <h3>{packDisplayName(selectedPack, t)}</h3>
603 {isActive ? (
604 <span className="theme-gallery__detail-status">
605 <Check size={12} strokeWidth={3} /> {t("settings.themeGallery.current")}
606 </span>
607 ) : null}
608 </div>
609 <span className="theme-gallery__badge">
610 {packKindBadge(selectedPack, t)}
611 </span>
612 </div>
613 <p className="theme-gallery__detail-desc">{packDescription(selectedPack, t)}</p>
614 {!isActive ? (
615 <button type="button" className="btn btn--primary" disabled={busy} onClick={() => void applySelected()}>
616 {t("settings.themeGallery.apply")}
617 </button>
618 ) : null}
619 <div className="theme-gallery__rail-list" role="listbox" aria-label={t("settings.themeGallery.title")}>
620 {[
621 { id: "official", label: t("settings.themeLibrary.groupOfficial"), packs: groups.official },
622 { id: "user", label: t("settings.themeLibrary.groupUser"), packs: groups.user },
623 { id: "plugin", label: t("settings.themeLibrary.groupPlugin"), packs: groups.plugin },
624 { id: "base", label: t("settings.themeGallery.tabBase"), packs: groups.base },
625 ]
626 .filter((section) => section.packs.length > 0)
627 .map((section) => (
628 <div key={section.id} className="theme-gallery__rail-section" role="group" aria-label={section.label}>
629 <div className="theme-gallery__rail-section-head" aria-hidden="true">
630 <span>{section.label}</span>
631 <span className="theme-gallery__rail-section-count">{section.packs.length}</span>
632 </div>
633 <div className="theme-gallery__rail-section-items">
634 {section.packs.map((p) => (
635 <button
636 key={p.id}
637 type="button"
638 role="option"
639 aria-selected={selected?.id === p.id}
640 className={`theme-gallery__rail-card${selected?.id === p.id ? " theme-gallery__rail-card--on" : ""}`}
641 onClick={() => onSelectPack(p)}
642 >
643 {themePackKind(p) === "base" ? (
644 <ThemePreviewSurface pack={p} mode={detailMode} scene={detailScene} variant="thumbnail" />
645 ) : p.previewUrl || p.backgroundUrl ? (
646 <img src={p.previewUrl || p.backgroundUrl} alt="" loading="lazy" />
647 ) : (
648 <span className="theme-gallery__rail-fallback" />
649 )}
650 <span>{packDisplayName(p, t)}</span>
651 </button>
652 ))}
653 </div>
654 </div>
655 ))}
656 </div>
657 </aside>
658 </div>
659 {confirmDialog}
660 </div>
661 );
662 }
663
664 return (
665 <div className="theme-gallery">
666 <header className="theme-gallery__top">
667 <div className="theme-gallery__crumbs">
668 <button type="button" className="theme-gallery__back" onClick={onBack}>
669 <ArrowLeft size={14} />
670 <span>
671 {t("settings.appearance")} / {t("settings.themeGallery.title")}
672 </span>
673 </button>
674 <h2 className="theme-gallery__title">{t("settings.themeGallery.title")}</h2>
675 <p className="theme-gallery__sub">{t("settings.themeGallery.subtitle")}</p>
676 </div>
677 <div className="theme-gallery__top-actions">
678 <button type="button" className="btn btn--small" onClick={openCreate} disabled={busy}>
679 <Plus size={13} /> {t("settings.themeLibrary.new")}
680 </button>
681 <button type="button" className="btn btn--small" onClick={() => void doImport()} disabled={busy}>
682 <Upload size={13} /> {t("settings.themeLibrary.import")}
683 </button>
684 </div>
685 </header>
686
687 <div className="theme-gallery__tabs" role="tablist">
688 {(
689 [
690 ["catalog", t("settings.themeGallery.tabAll"), catalogPacks.length],
691 ["user", t("settings.themeLibrary.groupUser"), groups.user.length],
692 ] as const
693 ).map(([id, label, count]) => (
694 <button
695 key={id}
696 type="button"
697 role="tab"
698 aria-selected={tab === id}
699 className={`theme-gallery__tab${tab === id ? " theme-gallery__tab--on" : ""}`}
700 onClick={() => changeTab(id)}
701 >
702 {label} <span className="theme-gallery__tab-count">{count}</span>
703 </button>
704 ))}
705 </div>
706
707 <div className="theme-gallery__body">
708 <div className="theme-gallery__grid" role="listbox" aria-label={t("settings.themeGallery.title")}>
709 {loading ? (
710 <div className="theme-lib-card__sub">{t("settings.themeLibrary.loading")}</div>
711 ) : visible.length === 0 ? (
712 <div className="theme-lib-card__sub">{tab === "user" ? t("settings.themeLibrary.emptyUser") : t("settings.themeGallery.empty")}</div>
713 ) : (
714 visibleSections.map((section) => (
715 <div key={section.id} className="theme-gallery__grid-section" role="group" aria-label={section.label || undefined}>
716 {section.label ? (
717 <div className="theme-gallery__section-head">
718 <h3>{section.label}</h3>
719 <span>{section.packs.length}</span>
720 </div>
721 ) : null}
722 {section.packs.map((pack) => {
723 const name = packDisplayName(pack, t);
724 const active = isSelectionActive(selectionFromPack(pack), experience);
725 const sel = selected?.id === pack.id;
726 return (
727 <button
728 key={pack.id}
729 type="button"
730 role="option"
731 aria-selected={sel}
732 className={`theme-gallery-card${sel ? " theme-gallery-card--selected" : ""}${active ? " theme-gallery-card--active" : ""}`}
733 onClick={() => onSelectPack(pack)}
734 onKeyDown={(e) => {
735 if (e.key === "Enter" || e.key === " ") {
736 e.preventDefault();
737 onSelectPack(pack);
738 }
739 }}
740 >
741 <div className="theme-gallery-card__thumb">
742 {themePackKind(pack) === "base" ? (
743 <ThemePreviewSurface pack={pack} mode={detailMode} scene={detailScene} variant="thumbnail" />
744 ) : pack.previewUrl || pack.backgroundUrl ? (
745 <img src={pack.previewUrl || pack.backgroundUrl} alt={name} loading="lazy" decoding="async" />
746 ) : (
747 <div
748 className="theme-gallery-card__swatches"
749 style={{ background: `linear-gradient(120deg, ${pack.tokens?.light?.bg || "#f4f3ef"}, ${pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d"})` }}
750 />
751 )}
752 {active ? (
753 <span className="theme-gallery-card__check" aria-hidden="true">
754 <Check size={14} strokeWidth={3} />
755 </span>
756 ) : null}
757 </div>
758 <div className="theme-gallery-card__name">{name}</div>
759 {active ? <div className="theme-gallery-card__status">{t("settings.themeGallery.current")}</div> : null}
760 </button>
761 );
762 })}
763 </div>
764 ))
765 )}
766 </div>
767
768 <aside className="theme-gallery__detail" aria-live="polite">
769 {selectedPack ? (
770 <>
771 <div className="theme-gallery__detail-preview">
772 <ThemePreviewSurface pack={selectedPack} mode={detailMode} scene={detailScene} />
773 </div>
774 <div className="theme-gallery__detail-meta">
775 <div className="theme-gallery__detail-head">
776 <div className="theme-gallery__detail-title-row">
777 <h3 className="theme-gallery__detail-name">{packDisplayName(selectedPack, t)}</h3>
778 {isActive ? (
779 <span className="theme-gallery__detail-status">
780 <Check size={12} strokeWidth={3} /> {t("settings.themeGallery.current")}
781 </span>
782 ) : previewingId === selectedPack.id ? (
783 <span className="theme-gallery__detail-status theme-gallery__detail-status--preview">
784 {t("settings.themeGallery.previewing")}
785 </span>
786 ) : null}
787 </div>
788 </div>
789 <div className="theme-gallery__detail-tags">
790 <span className="theme-gallery__badge">
791 {packKindBadge(selectedPack, t)}
792 </span>
793 {selectedPack.license ? <span className="theme-gallery__badge theme-gallery__badge--muted">{selectedPack.license}</span> : null}
794 </div>
795 <p className="theme-gallery__detail-desc">{packDescription(selectedPack, t)}</p>
796 <div className="theme-gallery__detail-palette">
797 <span className="theme-gallery__preview-label">{t("settings.themeGallery.paletteLabel")}</span>
798 <div className="theme-gallery__detail-swatches" aria-hidden="true">
799 <span style={{ background: themePreviewPalette(selectedPack, detailMode).bg }} />
800 <span style={{ background: themePreviewPalette(selectedPack, detailMode).panel }} />
801 <span style={{ background: themePreviewPalette(selectedPack, detailMode).accent }} />
802 </div>
803 </div>
804 <ThemePreviewControls
805 mode={detailMode}
806 scene={detailScene}
807 onModeChange={setDetailMode}
808 onSceneChange={setDetailScene}
809 />
810 {!isActive ? (
811 <button type="button" className="btn btn--primary theme-gallery__apply" disabled={busy} onClick={() => void applySelected()}>
812 {t("settings.themeGallery.apply")}
813 </button>
814 ) : null}
815 <div className="theme-gallery__detail-actions">
816 <button type="button" className="btn btn--small theme-gallery__open-preview" disabled={busy} onClick={() => setImmersive(true)}>
817 {t("settings.themeGallery.openPreview")}
818 </button>
819 {themePackKind(selectedPack) === "user" ? (
820 <div className="theme-gallery__detail-user-actions">
821 <button type="button" className="btn btn--small" disabled={busy} onClick={openEdit}>
822 <Pencil size={12} /> {t("settings.themeLibrary.edit")}
823 </button>
824 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void exportSelected()}>
825 <Download size={12} /> {t("settings.themeLibrary.export")}
826 </button>
827 </div>
828 ) : null}
829 {themePackKind(selectedPack) !== "plugin" ? (
830 <button type="button" className="btn btn--small theme-gallery__detail-copy" disabled={busy} onClick={() => void copySelected()}>
831 <Copy size={12} /> {t("settings.themeLibrary.copyFrom")}
832 </button>
833 ) : null}
834 {themePackKind(selectedPack) === "user" ? (
835 <div className="theme-gallery__more theme-gallery__detail-more">
836 <button ref={moreActionsRef} type="button" className="btn btn--small" onClick={() => setMenuOpen((v) => !v)} aria-expanded={menuOpen}>
837 <MoreHorizontal size={14} /> {t("settings.themeGallery.moreActions")}
838 </button>
839 {menuOpen ? (
840 <div className="theme-gallery__menu" role="menu">
841 <button type="button" role="menuitem" className="theme-gallery__menu-danger" onClick={() => void removeSelected()}>
842 <Trash2 size={12} /> {t("settings.themeLibrary.delete")}
843 </button>
844 </div>
845 ) : null}
846 </div>
847 ) : null}
848 </div>
849 </div>
850 </>
851 ) : (
852 <div className="theme-lib-card__sub">{t("settings.themeGallery.selectHint")}</div>
853 )}
854 </aside>
855 </div>
856
857 {editor ? (
858 <ThemeEditorInline
859 state={editor}
860 busy={busy}
861 onChange={(patch) => setEditor((s) => (s ? { ...s, ...patch } : s))}
862 onCancel={() => {
863 cancelThemePreview();
864 setEditor(null);
865 }}
866 onSave={(activate) => void saveEditor(activate)}
867 />
868 ) : null}
869 {confirmDialog}
870 </div>
871 );
872 }
873
874 function ThemeEditorInline({
875 state,
876 busy,
877 onChange,
878 onCancel,
879 onSave,
880 }: {
881 state: EditorState;
882 busy: boolean;
883 onChange: (patch: Partial<EditorState>) => void;
884 onCancel: () => void;
885 onSave: (activate: boolean) => void;
886 }) {
887 const t = useT();
888 const { showToast } = useToast();
889 const [previewMode, setPreviewMode] = useState<"light" | "dark">("dark");
890 const [previewScene, setPreviewScene] = useState<"home" | "task">("home");
891 const titleId = useId();
892 const editorRef = useRef<HTMLDivElement>(null);
893 const initialFocusRef = useRef<HTMLInputElement>(null);
894 const restoreFocusRef = useRef<HTMLElement | null>(null);
895 const busyRef = useRef(busy);
896 const onCancelRef = useRef(onCancel);
897 busyRef.current = busy;
898 onCancelRef.current = onCancel;
899
900 const homeUrl = state.backgroundDataUrl || state.existingBackgroundUrl;
901 const taskUrl = state.taskBackgroundDataUrl || state.existingTaskBackgroundUrl;
902 const draft = useMemo(
903 () =>
904 draftPackView({
905 id: state.id || "preview",
906 name: state.name || "Preview",
907 baseStyle: state.baseStyle,
908 tokens: state.tokens,
909 recipes: state.recipes,
910 background: state.background,
911 backgroundUrl: homeUrl,
912 taskBackground: state.taskBackground,
913 taskBackgroundUrl: taskUrl,
914 }),
915 [homeUrl, state.baseStyle, state.background, state.id, state.name, state.recipes, state.taskBackground, state.tokens, taskUrl],
916 );
917
918 useEffect(() => {
919 beginThemePreview(draft);
920 }, [draft]);
921
922 useEffect(() => () => cancelThemePreview(), []);
923
924 useLayoutEffect(() => {
925 restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
926 const initialFocus = initialFocusRef.current && !initialFocusRef.current.disabled
927 ? initialFocusRef.current
928 : editorRef.current?.querySelector<HTMLElement>('input:not([disabled]), textarea:not([disabled]), button:not([disabled])');
929 (initialFocus || editorRef.current)?.focus();
930 return () => {
931 if (restoreFocusRef.current?.isConnected) restoreFocusRef.current.focus();
932 };
933 }, []);
934
935 useEffect(() => {
936 const onKeyDown = (event: KeyboardEvent) => {
937 if (event.key === "Escape") {
938 event.preventDefault();
939 event.stopPropagation();
940 if (!busyRef.current) onCancelRef.current();
941 return;
942 }
943 if (event.key !== "Tab") return;
944 const focusable = Array.from(
945 editorRef.current?.querySelectorAll<HTMLElement>(
946 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
947 ) || [],
948 ).filter((element) => !element.hidden && element.getAttribute("aria-hidden") !== "true");
949 if (focusable.length === 0) return;
950 const first = focusable[0];
951 const last = focusable[focusable.length - 1];
952 if (event.shiftKey && document.activeElement === first) {
953 event.preventDefault();
954 last.focus();
955 } else if (!event.shiftKey && document.activeElement === last) {
956 event.preventDefault();
957 first.focus();
958 }
959 };
960 document.addEventListener("keydown", onKeyDown, { capture: true });
961 return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
962 }, []);
963
964 const setToken = (key: string, value: string) => {
965 const next = {
966 ...state.tokens,
967 [state.tokenMode]: { ...(state.tokens[state.tokenMode] || {}) },
968 };
969 if (value) next[state.tokenMode]![key] = value;
970 else delete next[state.tokenMode]![key];
971 onChange({ tokens: next });
972 };
973
974 const pickBackground = async (scene: "home" | "task") => {
975 try {
976 const dataUrl = await app.PickThemeBackground();
977 if (!dataUrl) return;
978 if (scene === "home") {
979 onChange({
980 background: state.background ? { ...state.background } : defaultBackground(),
981 backgroundDataUrl: dataUrl,
982 existingBackgroundUrl: "",
983 });
984 } else {
985 onChange({
986 taskBackground: state.taskBackground ? { ...state.taskBackground } : defaultTaskBackground(),
987 taskBackgroundDataUrl: dataUrl,
988 existingTaskBackgroundUrl: "",
989 });
990 }
991 } catch (err) {
992 showToast(err instanceof Error ? err.message : String(err), "error");
993 }
994 };
995
996 const warnings = useMemo(() => {
997 const out: string[] = [];
998 for (const mode of ["light", "dark"] as const) {
999 const fg = state.tokens[mode]?.fg;
1000 const bg = state.tokens[mode]?.bg;
1001 if (fg && bg && isSafeHex(fg) && isSafeHex(bg)) {
1002 const ratio = themeContrastRatio(fg, bg);
1003 if (ratio < 4.5) out.push(`${mode} fg/bg ${ratio.toFixed(2)} < 4.5`);
1004 }
1005 }
1006 return out;
1007 }, [state.tokens]);
1008
1009 const appLayoutClass = ["app--workbench", "app--creation"]
1010 .find((className) => document.querySelector(`.${className}`)) || "";
1011
1012 return createPortal(
1013 <div className="theme-gallery__editor-overlay" data-app-overlay="">
1014 <div
1015 ref={editorRef}
1016 className={`theme-editor theme-gallery__editor${appLayoutClass ? ` ${appLayoutClass}` : ""}`}
1017 role="dialog"
1018 aria-modal="true"
1019 aria-labelledby={titleId}
1020 tabIndex={-1}
1021 >
1022 <header className="theme-editor__header">
1023 <div>
1024 <strong id={titleId}>{state.mode === "create" ? t("settings.themeLibrary.editorCreate") : t("settings.themeLibrary.editorEdit")}</strong>
1025 <p>{t("settings.themeEditor.subtitle")}</p>
1026 </div>
1027 <button type="button" className="btn btn--icon" aria-label={t("common.close")} disabled={busy} onClick={onCancel}>
1028 <X size={16} />
1029 </button>
1030 </header>
1031
1032 <div className="theme-editor__body">
1033 <div className="theme-editor__controls">
1034 <section className="theme-editor__section">
1035 <h3>{t("settings.themeEditor.metadata")}</h3>
1036 <div className="theme-editor__fields theme-editor__fields--grid">
1037 <label><span>{t("settings.themeLibrary.fieldId")}</span><input ref={initialFocusRef} value={state.id} disabled={state.mode === "edit" || busy} onChange={(e) => onChange({ id: e.target.value })} /></label>
1038 <label><span>{t("settings.themeLibrary.fieldName")}</span><input value={state.name} disabled={busy} onChange={(e) => onChange({ name: e.target.value })} /></label>
1039 <label><span>{t("settings.themeLibrary.fieldAuthor")}</span><input value={state.author} disabled={busy} onChange={(e) => onChange({ author: e.target.value })} /></label>
1040 <label><span>{t("settings.themeEditor.license")}</span><input value={state.license} disabled={busy} placeholder="MIT" onChange={(e) => onChange({ license: e.target.value })} /></label>
1041 <label className="theme-editor__wide"><span>{t("settings.themeEditor.description")}</span><textarea value={state.description} disabled={busy} rows={2} onChange={(e) => onChange({ description: e.target.value })} /></label>
1042 </div>
1043 </section>
1044
1045 <section className="theme-editor__section">
1046 <h3>{t("settings.themeEditor.layout")}</h3>
1047 <div className="theme-editor__setting-row">
1048 <span>{t("settings.themeLibrary.fieldBase")}</span>
1049 <SettingsOptions className="set-seg theme-editor__base-styles">
1050 {THEME_STYLES.map((s) => (
1051 <button key={s} type="button" className={`set-seg__btn${state.baseStyle === s ? " set-seg__btn--on" : ""}`} disabled={busy} onClick={() => onChange({ baseStyle: s })}>{t(`settings.style.${s}.zh` as never)}</button>
1052 ))}
1053 </SettingsOptions>
1054 </div>
1055 <div className="theme-editor__setting-row">
1056 <span>{t("settings.themeLibrary.fieldRecipes")}</span>
1057 <div className="theme-editor__recipe-groups">
1058 <SettingsOptions className="set-seg">
1059 {(["comfortable", "compact"] as const).map((density) => (
1060 <button key={density} type="button" className={`set-seg__btn${state.recipes.density === density ? " set-seg__btn--on" : ""}`} onClick={() => onChange({ recipes: { ...state.recipes, density } })}>{t(`settings.themeEditor.density.${density}` as never)}</button>
1061 ))}
1062 </SettingsOptions>
1063 <SettingsOptions className="set-seg">
1064 {(["square", "soft", "round"] as const).map((corners) => (
1065 <button key={corners} type="button" className={`set-seg__btn${state.recipes.corners === corners ? " set-seg__btn--on" : ""}`} onClick={() => onChange({ recipes: { ...state.recipes, corners } })}>{t(`settings.themeEditor.corners.${corners}` as never)}</button>
1066 ))}
1067 </SettingsOptions>
1068 </div>
1069 </div>
1070 </section>
1071
1072 <section className="theme-editor__section">
1073 <div className="theme-editor__section-head">
1074 <h3>{t("settings.themeEditor.colors")}</h3>
1075 <SettingsOptions className="set-seg">
1076 {(["light", "dark"] as const).map((mode) => (
1077 <button key={mode} type="button" className={`set-seg__btn${state.tokenMode === mode ? " set-seg__btn--on" : ""}`} onClick={() => onChange({ tokenMode: mode })}>{mode === "light" ? t("settings.themeLight") : t("settings.themeDark")}</button>
1078 ))}
1079 </SettingsOptions>
1080 </div>
1081 {TOKEN_GROUPS.map((group) => (
1082 <div key={group.labelKey} className="theme-editor__token-group">
1083 <h4>{t(group.labelKey as never)}</h4>
1084 <div className="theme-editor__color-grid">
1085 {group.keys.filter((key) => themeTokenKeys().includes(key)).map((key) => {
1086 const value = state.tokens[state.tokenMode]?.[key] || "";
1087 return (
1088 <label key={key} className="theme-editor__color">
1089 <span className="theme-editor__token-label"><span>{t(`settings.themeTokens.key.${key}` as never)}</span><code>{key}</code></span>
1090 <div className="theme-editor__color-control">
1091 <input type="color" value={isSafeHex(value) ? value.slice(0, 7) : "#888888"} disabled={busy} onChange={(e) => setToken(key, e.target.value)} />
1092 <input type="text" value={value} placeholder="#RRGGBB" disabled={busy} onChange={(e) => setToken(key, e.target.value.trim())} />
1093 </div>
1094 </label>
1095 );
1096 })}
1097 </div>
1098 </div>
1099 ))}
1100 </section>
1101
1102 <section className="theme-editor__section">
1103 <h3>{t("settings.themeEditor.scenes")}</h3>
1104 <p className="theme-editor__section-help">{t("settings.themeEditor.scenesHint")}</p>
1105 <div className="theme-editor__scene-grid">
1106 <SceneImageEditor
1107 title={t("settings.themeEditor.homeBackground")}
1108 hint={t("settings.themeEditor.homeBackgroundHint")}
1109 url={homeUrl}
1110 present={Boolean(state.background)}
1111 focusX={state.background?.focusX ?? 0.5}
1112 focusY={state.background?.focusY ?? 0.5}
1113 safeArea={state.background?.safeArea || "center"}
1114 opacity={state.background?.homeOpacity ?? 1}
1115 opacityMax={1}
1116 overlayStrength={state.background?.overlayStrength ?? 0.62}
1117 paneOpacity={state.background?.paneOpacity ?? 0.72}
1118 busy={busy}
1119 onPick={() => void pickBackground("home")}
1120 onClear={() => onChange({ background: null, backgroundDataUrl: "", existingBackgroundUrl: "" })}
1121 onPatch={(patch) => {
1122 const { opacity: nextOpacity, ...rest } = patch;
1123 onChange({ background: { ...(state.background || defaultBackground()), ...rest, homeOpacity: nextOpacity ?? state.background?.homeOpacity ?? 1 } });
1124 }}
1125 />
1126 <SceneImageEditor
1127 title={t("settings.themeEditor.taskBackground")}
1128 hint={state.taskBackground ? t("settings.themeEditor.taskBackgroundHint") : t("settings.themeEditor.taskBackgroundFallback")}
1129 url={taskUrl || homeUrl}
1130 present={Boolean(state.taskBackground)}
1131 focusX={state.taskBackground?.focusX ?? state.background?.focusX ?? 0.5}
1132 focusY={state.taskBackground?.focusY ?? state.background?.focusY ?? 0.5}
1133 safeArea={state.taskBackground?.safeArea || state.background?.safeArea || "center"}
1134 opacity={state.taskBackground?.opacity ?? state.background?.taskOpacity ?? 0.28}
1135 opacityMax={1}
1136 overlayStrength={state.taskBackground?.overlayStrength ?? state.background?.overlayStrength ?? 0.62}
1137 paneOpacity={state.taskBackground?.paneOpacity ?? state.background?.paneOpacity ?? 0.80}
1138 busy={busy}
1139 onPick={() => void pickBackground("task")}
1140 onClear={() => onChange({ taskBackground: null, taskBackgroundDataUrl: "", existingTaskBackgroundUrl: "" })}
1141 onPatch={(patch) => onChange({ taskBackground: { ...(state.taskBackground || defaultTaskBackground()), ...patch, opacity: patch.opacity ?? state.taskBackground?.opacity ?? 0.28 } })}
1142 />
1143 </div>
1144 </section>
1145
1146 {warnings.length > 0 ? (
1147 <div className="theme-editor__warn">
1148 {t("settings.themeLibrary.contrastWarn")}
1149 <ul>{warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul>
1150 </div>
1151 ) : null}
1152 </div>
1153
1154 <aside className="theme-editor__live">
1155 <div className="theme-editor__live-head">
1156 <strong>{t("settings.themeEditor.livePreview")}</strong>
1157 <span>{previewScene === "home" ? t("settings.themeGallery.sceneHome") : t("settings.themeGallery.sceneTask")}</span>
1158 </div>
1159 <ThemePreviewControls mode={previewMode} scene={previewScene} onModeChange={setPreviewMode} onSceneChange={setPreviewScene} />
1160 <ThemePreviewSurface pack={draft} mode={previewMode} scene={previewScene} />
1161 <p>{t("settings.themeEditor.livePreviewHint")}</p>
1162 </aside>
1163 </div>
1164
1165 <div className="theme-editor__actions">
1166 <button type="button" className="btn" disabled={busy} onClick={onCancel}>
1167 {t("common.cancel")}
1168 </button>
1169 <button type="button" className="btn" disabled={busy} onClick={() => onSave(false)}>
1170 {t("common.save")}
1171 </button>
1172 <button type="button" className="btn btn--primary" disabled={busy} onClick={() => onSave(true)}>
1173 {t("settings.themeGallery.saveAndApply")}
1174 </button>
1175 </div>
1176 </div>
1177 </div>,
1178 document.body,
1179 );
1180 }
1181
1182 type ScenePatch = {
1183 focusX?: number;
1184 focusY?: number;
1185 safeArea?: "left" | "center" | "right";
1186 opacity?: number;
1187 overlayStrength?: number;
1188 paneOpacity?: number;
1189 };
1190
1191 function SceneImageEditor({
1192 title,
1193 hint,
1194 url,
1195 present,
1196 focusX,
1197 focusY,
1198 safeArea,
1199 opacity,
1200 opacityMax,
1201 overlayStrength,
1202 paneOpacity,
1203 busy,
1204 onPick,
1205 onClear,
1206 onPatch,
1207 }: {
1208 title: string;
1209 hint: string;
1210 url: string;
1211 present: boolean;
1212 focusX: number;
1213 focusY: number;
1214 safeArea: string;
1215 opacity: number;
1216 opacityMax: number;
1217 overlayStrength: number;
1218 paneOpacity: number;
1219 busy: boolean;
1220 onPick: () => void;
1221 onClear: () => void;
1222 onPatch: (patch: ScenePatch) => void;
1223 }) {
1224 const t = useT();
1225 const previewRef = useRef<HTMLDivElement>(null);
1226 const dragging = useRef(false);
1227 const updateFocus = (clientX: number, clientY: number) => {
1228 const rect = previewRef.current?.getBoundingClientRect();
1229 if (!rect || !present) return;
1230 onPatch({
1231 focusX: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
1232 focusY: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
1233 });
1234 };
1235
1236 return (
1237 <div className={`theme-editor__scene${present ? " theme-editor__scene--ready" : ""}`}>
1238 <div className="theme-editor__scene-head">
1239 <div><strong>{title}</strong><p>{hint}</p></div>
1240 <div className="theme-editor__scene-actions">
1241 <button type="button" className="btn btn--small" disabled={busy} onClick={onPick}><ImagePlus size={13} /> {t("settings.themeLibrary.pickImage")}</button>
1242 {present ? <button type="button" className="btn btn--small" disabled={busy} onClick={onClear}>{t("settings.themeLibrary.clearImage")}</button> : null}
1243 </div>
1244 </div>
1245 <div
1246 ref={previewRef}
1247 className="theme-editor__bg-preview"
1248 style={url ? { backgroundImage: `url("${url}")`, opacity } : undefined}
1249 onPointerDown={(event) => {
1250 dragging.current = true;
1251 event.currentTarget.setPointerCapture?.(event.pointerId);
1252 updateFocus(event.clientX, event.clientY);
1253 }}
1254 onPointerMove={(event) => dragging.current && updateFocus(event.clientX, event.clientY)}
1255 onPointerUp={() => { dragging.current = false; }}
1256 >
1257 {!url ? <div className="theme-editor__scene-empty"><ImagePlus size={22} /><span>{t("settings.themeEditor.uploadPrompt")}</span></div> : null}
1258 {present ? <span className="theme-editor__focus" style={{ left: `${focusX * 100}%`, top: `${focusY * 100}%` }} /> : null}
1259 </div>
1260 {present ? (
1261 <div className="theme-editor__scene-controls">
1262 <div className="theme-editor__setting-block">
1263 <div className="theme-editor__setting-row">
1264 <span>{t("settings.themeEditor.safeArea")}</span>
1265 <SettingsOptions className="set-seg" role="radiogroup" aria-label={t("settings.themeEditor.safeArea")}>
1266 {(["left", "center", "right"] as const).map((area) => <button key={area} type="button" role="radio" aria-checked={safeArea === area} className={`set-seg__btn${safeArea === area ? " set-seg__btn--on" : ""}`} onClick={() => onPatch({ safeArea: area })}>{t(`settings.themeEditor.safeArea.${area}` as never)}</button>)}
1267 </SettingsOptions>
1268 </div>
1269 <p className="theme-editor__setting-hint">{t("settings.themeEditor.safeAreaHint")}</p>
1270 </div>
1271 <label className="theme-editor__range"><span>{t("settings.themeEditor.opacity")} <b>{Math.round(opacity * 100)}%</b></span><input type="range" min={0} max={opacityMax} step={0.01} value={opacity} onChange={(e) => onPatch({ opacity: Number(e.target.value) })} /></label>
1272 <label className="theme-editor__range"><span>{t("settings.themeLibrary.overlayStrength")} <b>{Math.round(overlayStrength * 100)}%</b></span><input type="range" min={0} max={1} step={0.01} value={overlayStrength} onChange={(e) => onPatch({ overlayStrength: Number(e.target.value) })} /></label>
1273 <label className="theme-editor__range"><span>{t("settings.themeEditor.paneOpacity")} <b>{Math.round(paneOpacity * 100)}%</b></span><input type="range" min={0} max={1} step={0.01} value={paneOpacity} onChange={(e) => onPatch({ paneOpacity: Number(e.target.value) })} /></label>
1274 </div>
1275 ) : null}
1276 </div>
1277 );
1278 }
1279
1280 function themeContrastRatio(a: string, b: string): number {
1281 const luminance = (hex: string) => {
1282 const raw = hex.slice(1, 7);
1283 const channels = [0, 2, 4].map((index) => parseInt(raw.slice(index, index + 2), 16) / 255)
1284 .map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4));
1285 return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
1286 };
1287 const la = luminance(a);
1288 const lb = luminance(b);
1289 return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
1290 }
1291
1291 lines Plain Text