context.tsx 11 KB

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