Spaces:
Running
Running
| import { useRef } from "react"; | |
| import { X } from "lucide-react"; | |
| import { useStore } from "../store"; | |
| import { MG_THEME_LIST, MG_ACCENTS } from "../remotion/mg/templates"; | |
| import { MG_BACKGROUNDS, MG_ANIMATIONS, MG_FONTS, MG_FONTS_AR } from "../remotion/mg/catalog.mjs"; | |
| import { AccentControls } from "./SceneInspector"; | |
| // Bulk styling for a multi-selection of scenes. Mirrors the SceneInspector look controls, | |
| // but every control writes the chosen property onto ALL selected scenes at once (one undo step | |
| // per change) via applyLook({ <prop>: value }, selectedIds). | |
| export function BulkStylePanel() { | |
| const selectedIds = useStore((s) => s.selectedIds); | |
| const clearSelection = useStore((s) => s.clearSelection); | |
| const applyLook = useStore((s) => s.applyLook); | |
| const pushHistory = useStore((s) => s.pushHistory); | |
| const updateMotion = useStore((s) => s.updateMotion); | |
| const motion = useStore((s) => s.motion); // stable ref; filter in render (a selector that .filter()s returns a NEW array each call → infinite re-render loop) | |
| const lang = useStore((s) => s.lang); | |
| // only template scenes are styleable; keep store order | |
| const scenes = motion.filter((m) => m.template && selectedIds.includes(m.id)); | |
| const n = scenes.length; | |
| // live color drag → snapshot once on pointerdown, then live-write to every selected scene (no per-event history) | |
| const armed = useRef(false); | |
| const accentArm = () => { armed.current = true; }; | |
| const accentLive = (c: string) => { if (armed.current) { pushHistory(); armed.current = false; } for (const m of scenes) updateMotion(m.id, { accent: c }); }; | |
| // a property is "active" only when every selected scene shares the same value (else: mixed) | |
| const shared = (get: (m: (typeof scenes)[number]) => string | undefined, dflt: string): string | null => { | |
| if (!n) return dflt; | |
| const first = get(scenes[0]) || dflt; | |
| return scenes.every((m) => (get(m) || dflt) === first) ? first : null; | |
| }; | |
| const theme = shared((m) => m.theme, "dark"); | |
| const accent = shared((m) => m.accent, MG_ACCENTS[0]); | |
| const bg = shared((m) => m.bg, "mesh"); | |
| const anim = shared((m) => m.anim, "rise"); | |
| const font = shared((m) => m.font, "editorial"); | |
| const set = (patch: Record<string, string>) => applyLook(patch, selectedIds); | |
| const eq = (a: string | null, b: string) => a != null && a.toLowerCase() === b.toLowerCase(); | |
| return ( | |
| <div className="flex h-full flex-col"> | |
| <div className="flex items-center gap-2 border-b border-border px-3 py-2"> | |
| <span className="eyebrow !text-fg">Styling {n} scene{n === 1 ? "" : "s"}</span> | |
| <span className="rounded bg-[var(--color-tl-motion)] px-1.5 py-0.5 text-[10px] font-medium text-white">Bulk</span> | |
| <div className="flex-1" /> | |
| <button onClick={clearSelection} title="Clear selection" className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-white/5 hover:text-fg"><X size={12} /> Clear</button> | |
| </div> | |
| <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-3"> | |
| <div className="text-[11px] text-fg-subtle">Changes apply to all {n} selected scenes.</div> | |
| {/* theme */} | |
| <div> | |
| <div className="eyebrow mb-1.5">Theme</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {MG_THEME_LIST.map((t) => ( | |
| <button key={t.id} onClick={() => set({ theme: t.id })} title={t.label} | |
| className={"h-8 w-8 rounded-md border-2 " + (theme === t.id ? "border-primary" : "border-transparent")} | |
| style={{ background: t.swatch }} /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* accent — presets + custom color, identical to the inspector */} | |
| <div> | |
| <div className="eyebrow mb-1.5">Accent {accent == null && <span className="ml-1 text-fg-subtle">· mixed</span>}</div> | |
| <AccentControls accent={accent || ""} onPick={(c) => set({ accent: c })} onArm={accentArm} onLive={accentLive} /> | |
| </div> | |
| {/* background */} | |
| <div> | |
| <div className="eyebrow mb-1.5">Background</div> | |
| <div className="grid grid-cols-6 gap-1.5"> | |
| {MG_BACKGROUNDS.map((bgo) => ( | |
| <button key={bgo.id} onClick={() => set({ bg: bgo.id })} title={`${bgo.label} — ${bgo.blurb}`} | |
| className={"aspect-square overflow-hidden rounded-md border-2 " + (bg === bgo.id ? "border-primary" : "border-border/60 hover:border-primary/50")} | |
| style={{ background: bgo.swatch }} /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* animation */} | |
| <div> | |
| <div className="eyebrow mb-1.5">Animation</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {MG_ANIMATIONS.map((a) => ( | |
| <button key={a.id} onClick={() => set({ anim: a.id })} title={a.blurb} | |
| className={"rounded-md border px-2 py-1 text-[11px] " + (anim === a.id ? "border-primary bg-primary/10 text-fg" : "border-border text-fg-muted hover:border-primary/50")}>{a.label}</button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* font */} | |
| {(() => { | |
| const ar = lang === "ar"; | |
| const fonts = ar ? MG_FONTS_AR : MG_FONTS; | |
| return ( | |
| <div> | |
| <div className="eyebrow mb-1.5 flex items-center gap-1.5">Font{ar && <span className="rounded bg-primary/15 px-1.5 py-px text-[9px] font-semibold text-primary">عربي</span>}</div> | |
| <div className="grid grid-cols-3 gap-1.5"> | |
| {fonts.map((f) => ( | |
| <button key={f.id} onClick={() => set({ font: f.id })} title={f.blurb} | |
| className={"rounded-md border px-2 py-1.5 text-left " + (eq(font, f.id) ? "border-primary bg-primary/10" : "border-border hover:border-primary/50")}> | |
| <div className="truncate text-[18px] leading-none text-fg" style={{ fontFamily: f.preview, direction: ar ? "rtl" : undefined }}>{ar ? "أبجد" : "Ag"}</div> | |
| <div className="mt-1 truncate text-[10px] text-fg-subtle">{f.label}</div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| </div> | |
| ); | |
| } | |