context.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import { createEffect, onCleanup, onMount } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { createSimpleContext } from "../context/helper"
  4. import oc2ThemeJson from "./themes/oc-2.json"
  5. import { resolveThemeVariant, themeToCss } from "./resolve"
  6. import type { DesktopTheme } from "./types"
  7. export type ColorScheme = "light" | "dark" | "system"
  8. const STORAGE_KEYS = {
  9. THEME_ID: "opencode-theme-id",
  10. COLOR_SCHEME: "opencode-color-scheme",
  11. THEME_CSS_LIGHT: "opencode-theme-css-light",
  12. THEME_CSS_DARK: "opencode-theme-css-dark",
  13. } as const
  14. const THEME_STYLE_ID = "oc-theme"
  15. let files: Record<string, () => Promise<{ default: DesktopTheme }>> | undefined
  16. let ids: string[] | undefined
  17. let known: Set<string> | undefined
  18. function getFiles() {
  19. if (files) return files
  20. files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json")
  21. return files
  22. }
  23. function themeIDs() {
  24. if (ids) return ids
  25. ids = Object.keys(getFiles())
  26. .map((path) => path.slice("./themes/".length, -".json".length))
  27. .sort()
  28. return ids
  29. }
  30. function knownThemes() {
  31. if (known) return known
  32. known = new Set(themeIDs())
  33. return known
  34. }
  35. const names: Record<string, string> = {
  36. "oc-2": "OC-2",
  37. amoled: "AMOLED",
  38. aura: "Aura",
  39. ayu: "Ayu",
  40. carbonfox: "Carbonfox",
  41. catppuccin: "Catppuccin",
  42. "catppuccin-frappe": "Catppuccin Frappe",
  43. "catppuccin-macchiato": "Catppuccin Macchiato",
  44. cobalt2: "Cobalt2",
  45. cursor: "Cursor",
  46. dracula: "Dracula",
  47. everforest: "Everforest",
  48. flexoki: "Flexoki",
  49. github: "GitHub",
  50. gruvbox: "Gruvbox",
  51. kanagawa: "Kanagawa",
  52. "lucent-orng": "Lucent Orng",
  53. material: "Material",
  54. matrix: "Matrix",
  55. mercury: "Mercury",
  56. monokai: "Monokai",
  57. nightowl: "Night Owl",
  58. nord: "Nord",
  59. "one-dark": "One Dark",
  60. onedarkpro: "One Dark Pro",
  61. opencode: "OpenCode",
  62. orng: "Orng",
  63. "osaka-jade": "Osaka Jade",
  64. palenight: "Palenight",
  65. rosepine: "Rose Pine",
  66. shadesofpurple: "Shades of Purple",
  67. solarized: "Solarized",
  68. synthwave84: "Synthwave '84",
  69. tokyonight: "Tokyonight",
  70. vercel: "Vercel",
  71. vesper: "Vesper",
  72. zenburn: "Zenburn",
  73. }
  74. const oc2Theme = oc2ThemeJson as DesktopTheme
  75. function normalize(id: string | null | undefined) {
  76. return id === "oc-1" ? "oc-2" : id
  77. }
  78. function read(key: string) {
  79. if (typeof localStorage !== "object") return null
  80. try {
  81. return localStorage.getItem(key)
  82. } catch {
  83. return null
  84. }
  85. }
  86. function write(key: string, value: string) {
  87. if (typeof localStorage !== "object") return
  88. try {
  89. localStorage.setItem(key, value)
  90. } catch {}
  91. }
  92. function drop(key: string) {
  93. if (typeof localStorage !== "object") return
  94. try {
  95. localStorage.removeItem(key)
  96. } catch {}
  97. }
  98. function clear() {
  99. drop(STORAGE_KEYS.THEME_CSS_LIGHT)
  100. drop(STORAGE_KEYS.THEME_CSS_DARK)
  101. }
  102. function ensureThemeStyleElement(): HTMLStyleElement {
  103. const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
  104. if (existing) return existing
  105. const element = document.createElement("style")
  106. element.id = THEME_STYLE_ID
  107. document.head.appendChild(element)
  108. return element
  109. }
  110. function getSystemMode(): "light" | "dark" {
  111. if (typeof window !== "object") return "light"
  112. return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
  113. }
  114. function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark") {
  115. const isDark = mode === "dark"
  116. const variant = isDark ? theme.dark : theme.light
  117. const tokens = resolveThemeVariant(variant, isDark)
  118. const css = themeToCss(tokens)
  119. if (themeId !== "oc-2") {
  120. write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
  121. }
  122. const fullCss = `:root {
  123. color-scheme: ${mode};
  124. --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"};
  125. ${css}
  126. }`
  127. document.getElementById("oc-theme-preload")?.remove()
  128. ensureThemeStyleElement().textContent = fullCss
  129. document.documentElement.dataset.theme = themeId
  130. document.documentElement.dataset.colorScheme = mode
  131. }
  132. function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
  133. if (themeId === "oc-2") return
  134. for (const mode of ["light", "dark"] as const) {
  135. const isDark = mode === "dark"
  136. const variant = isDark ? theme.dark : theme.light
  137. const tokens = resolveThemeVariant(variant, isDark)
  138. const css = themeToCss(tokens)
  139. write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
  140. }
  141. }
  142. export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
  143. name: "Theme",
  144. init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
  145. const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2"
  146. const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
  147. const mode = colorScheme === "system" ? getSystemMode() : colorScheme
  148. const [store, setStore] = createStore({
  149. themes: {
  150. "oc-2": oc2Theme,
  151. } as Record<string, DesktopTheme>,
  152. themeId,
  153. colorScheme,
  154. mode,
  155. previewThemeId: null as string | null,
  156. previewScheme: null as ColorScheme | null,
  157. })
  158. const loads = new Map<string, Promise<DesktopTheme | undefined>>()
  159. const load = (id: string) => {
  160. const next = normalize(id)
  161. if (!next) return Promise.resolve(undefined)
  162. const hit = store.themes[next]
  163. if (hit) return Promise.resolve(hit)
  164. const pending = loads.get(next)
  165. if (pending) return pending
  166. const file = getFiles()[`./themes/${next}.json`]
  167. if (!file) return Promise.resolve(undefined)
  168. const task = file()
  169. .then((mod) => {
  170. const theme = mod.default
  171. setStore("themes", next, theme)
  172. return theme
  173. })
  174. .finally(() => {
  175. loads.delete(next)
  176. })
  177. loads.set(next, task)
  178. return task
  179. }
  180. const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
  181. applyThemeCss(theme, themeId, mode)
  182. props.onThemeApplied?.(theme, mode)
  183. }
  184. const ids = () => {
  185. const extra = Object.keys(store.themes)
  186. .filter((id) => !knownThemes().has(id))
  187. .sort()
  188. const all = themeIDs()
  189. if (extra.length === 0) return all
  190. return [...all, ...extra]
  191. }
  192. const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes)
  193. const onStorage = (e: StorageEvent) => {
  194. if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) {
  195. const next = normalize(e.newValue)
  196. if (!next) return
  197. if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
  198. setStore("themeId", next)
  199. if (next === "oc-2") {
  200. clear()
  201. return
  202. }
  203. void load(next).then((theme) => {
  204. if (!theme || store.themeId !== next) return
  205. cacheThemeVariants(theme, next)
  206. })
  207. }
  208. if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
  209. setStore("colorScheme", e.newValue as ColorScheme)
  210. setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark"))
  211. }
  212. }
  213. if (typeof window === "object") {
  214. window.addEventListener("storage", onStorage)
  215. onCleanup(() => window.removeEventListener("storage", onStorage))
  216. }
  217. onMount(() => {
  218. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
  219. const onMedia = () => {
  220. if (store.colorScheme !== "system") return
  221. setStore("mode", getSystemMode())
  222. }
  223. mediaQuery.addEventListener("change", onMedia)
  224. onCleanup(() => mediaQuery.removeEventListener("change", onMedia))
  225. const rawTheme = read(STORAGE_KEYS.THEME_ID)
  226. const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2"
  227. const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
  228. if (rawTheme && rawTheme !== savedTheme) {
  229. write(STORAGE_KEYS.THEME_ID, savedTheme)
  230. clear()
  231. }
  232. if (savedTheme !== store.themeId) setStore("themeId", savedTheme)
  233. if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme)
  234. setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme)
  235. void load(savedTheme).then((theme) => {
  236. if (!theme || store.themeId !== savedTheme) return
  237. cacheThemeVariants(theme, savedTheme)
  238. })
  239. })
  240. createEffect(() => {
  241. const theme = store.themes[store.themeId]
  242. if (!theme) return
  243. applyTheme(theme, store.themeId, store.mode)
  244. })
  245. const setTheme = (id: string) => {
  246. const next = normalize(id)
  247. if (!next) {
  248. console.warn(`Theme "${id}" not found`)
  249. return
  250. }
  251. if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) {
  252. console.warn(`Theme "${id}" not found`)
  253. return
  254. }
  255. setStore("themeId", next)
  256. if (next === "oc-2") {
  257. write(STORAGE_KEYS.THEME_ID, next)
  258. clear()
  259. return
  260. }
  261. void load(next).then((theme) => {
  262. if (!theme || store.themeId !== next) return
  263. cacheThemeVariants(theme, next)
  264. write(STORAGE_KEYS.THEME_ID, next)
  265. })
  266. }
  267. const setColorScheme = (scheme: ColorScheme) => {
  268. setStore("colorScheme", scheme)
  269. write(STORAGE_KEYS.COLOR_SCHEME, scheme)
  270. setStore("mode", scheme === "system" ? getSystemMode() : scheme)
  271. }
  272. return {
  273. themeId: () => store.themeId,
  274. colorScheme: () => store.colorScheme,
  275. mode: () => store.mode,
  276. ids,
  277. name: (id: string) => store.themes[id]?.name ?? names[id] ?? id,
  278. loadThemes,
  279. themes: () => store.themes,
  280. setTheme,
  281. setColorScheme,
  282. registerTheme: (theme: DesktopTheme) => setStore("themes", theme.id, theme),
  283. previewTheme: (id: string) => {
  284. const next = normalize(id)
  285. if (!next) return
  286. if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return
  287. setStore("previewThemeId", next)
  288. void load(next).then((theme) => {
  289. if (!theme || store.previewThemeId !== next) return
  290. const mode = store.previewScheme
  291. ? store.previewScheme === "system"
  292. ? getSystemMode()
  293. : store.previewScheme
  294. : store.mode
  295. applyTheme(theme, next, mode)
  296. })
  297. },
  298. previewColorScheme: (scheme: ColorScheme) => {
  299. setStore("previewScheme", scheme)
  300. const mode = scheme === "system" ? getSystemMode() : scheme
  301. const id = store.previewThemeId ?? store.themeId
  302. void load(id).then((theme) => {
  303. if (!theme) return
  304. if ((store.previewThemeId ?? store.themeId) !== id) return
  305. if (store.previewScheme !== scheme) return
  306. applyTheme(theme, id, mode)
  307. })
  308. },
  309. commitPreview: () => {
  310. if (store.previewThemeId) {
  311. setTheme(store.previewThemeId)
  312. }
  313. if (store.previewScheme) {
  314. setColorScheme(store.previewScheme)
  315. }
  316. setStore("previewThemeId", null)
  317. setStore("previewScheme", null)
  318. },
  319. cancelPreview: () => {
  320. setStore("previewThemeId", null)
  321. setStore("previewScheme", null)
  322. void load(store.themeId).then((theme) => {
  323. if (!theme) return
  324. applyTheme(theme, store.themeId, store.mode)
  325. })
  326. },
  327. }
  328. },
  329. })