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