diff --git a/web/src/components/core/Header.tsx b/web/src/components/core/Header.tsx index af486777..424fdb54 100644 --- a/web/src/components/core/Header.tsx +++ b/web/src/components/core/Header.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { getTheme } from '@fluentui/react'; import { CommandBar, ICommandBarItemProps } from '@fluentui/react/lib/CommandBar'; import SettingsModal, { SettingsChanges } from '~/components/settings/SettingsModal'; +import ThemeableComponent from '@components/utils/ThemeableComponent'; import AboutModal from '~/components/modals/AboutModal'; import config from '~/services/config'; import { getSnippetsMenuItems, SnippetMenuItem } from '~/utils/headerutils'; @@ -13,9 +13,12 @@ import { Dispatcher, dispatchToggleTheme, formatFileDispatcher, - newBuildParamsChangeDispatcher, newCodeImportDispatcher, + newBuildParamsChangeDispatcher, + newCodeImportDispatcher, newImportFileDispatcher, - newMonacoParamsChangeDispatcher, newSnippetLoadDispatcher, + newMonacoParamsChangeDispatcher, + newSnippetLoadDispatcher, + newSettingsChangeDispatcher, runFileDispatcher, saveFileDispatcher, shareSnippetDispatcher @@ -39,15 +42,17 @@ interface Props { darkMode: boolean loading: boolean snippetName?: string + hideThemeToggle?: boolean, dispatch: (d: Dispatcher) => void } @Connect(({ settings, status, ui }) => ({ darkMode: settings.darkMode, loading: status?.loading, + hideThemeToggle: settings.useSystemTheme, snippetName: ui?.shareCreated && ui?.snippetId })) -export class Header extends React.Component { +export class Header extends ThemeableComponent { private fileInput?: HTMLInputElement; private snippetMenuItems = getSnippetsMenuItems(i => this.onSnippetMenuItemClick(i)); @@ -80,10 +85,9 @@ export class Header extends React.Component { } onSnippetMenuItemClick(item: SnippetMenuItem) { - // if (item.snippet) { - // this.setState({ showShareMessage: true }); - // } - const dispatcher = item.snippet ? newSnippetLoadDispatcher(item.snippet) : newCodeImportDispatcher(item.label, item.text as string); + const dispatcher = item.snippet ? + newSnippetLoadDispatcher(item.snippet) : + newCodeImportDispatcher(item.label, item.text as string); this.props.dispatch(dispatcher); } @@ -173,6 +177,7 @@ export class Header extends React.Component { text: 'Toggle Dark Mode', ariaLabel: 'Toggle Dark Mode', iconOnly: true, + hidden: this.props.hideThemeToggle, iconProps: { iconName: this.props.darkMode ? 'Brightness' : 'ClearNight' }, onClick: () => { this.props.dispatch(dispatchToggleTheme) @@ -209,14 +214,6 @@ export class Header extends React.Component { ] } - get styles() { - // Apply the same colors as rest of Fabric components - const theme = getTheme(); - return { - backgroundColor: theme.palette.white - } - } - private onSettingsClose(changes: SettingsChanges) { if (changes.monaco) { // Update monaco state if some of it's settings were changed @@ -229,36 +226,53 @@ export class Header extends React.Component { this.props.dispatch(newBuildParamsChangeDispatcher(runtime, autoFormat)); } + if (changes.settings) { + this.props.dispatch(newSettingsChangeDispatcher(changes.settings)); + } + this.setState({ showSettings: false }); } render() { const { showShareMessage } = this.state; const { snippetName } = this.props; - - return
- Golang Logo - - this.setState({ showShareMessage: false })} - /> - this.onSettingsClose(args)} isOpen={this.state.showSettings} /> - this.setState({ showAbout: false })} isOpen={this.state.showAbout} /> - this.setState({ showChangelog: false })} isOpen={this.state.showChangelog} /> -
; + return ( +
+ Golang Logo + !hidden)} + overflowItems={this.overflowItems} + ariaLabel='CodeEditor menu' + /> + this.setState({ showShareMessage: false })} + /> + this.onSettingsClose(args)} + isOpen={this.state.showSettings} + /> + this.setState({ showAbout: false })} + isOpen={this.state.showAbout} + /> + this.setState({ showChangelog: false })} + isOpen={this.state.showChangelog} + /> +
+ ); } } diff --git a/web/src/components/modals/AboutModal.tsx b/web/src/components/modals/AboutModal.tsx index 7d16f028..3d70973d 100644 --- a/web/src/components/modals/AboutModal.tsx +++ b/web/src/components/modals/AboutModal.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { getTheme, IconButton, FontWeights, FontSizes, mergeStyleSets } from '@fluentui/react'; +import { + IconButton, + FontWeights, + FontSizes, + mergeStyleSets, + useTheme +} from '@fluentui/react'; import { Modal } from '@fluentui/react/lib/Modal'; import { Link } from '@fluentui/react/lib/Link'; @@ -29,7 +35,7 @@ const modalStyles = mergeStyleSets({ }); export default function AboutModal(props: AboutModalProps) { - const theme = getTheme(); + const theme = useTheme(); const contentStyles = getContentStyles(theme); const iconButtonStyles = getIconButtonStyles(theme); diff --git a/web/src/components/modals/ChangeLogModal.tsx b/web/src/components/modals/ChangeLogModal.tsx index 1b53ce04..8645aaa9 100644 --- a/web/src/components/modals/ChangeLogModal.tsx +++ b/web/src/components/modals/ChangeLogModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { getTheme, IconButton } from '@fluentui/react'; +import { useTheme, IconButton } from '@fluentui/react'; import { Modal } from '@fluentui/react/lib/Modal'; import { Link } from '@fluentui/react/lib/Link'; @@ -18,7 +18,7 @@ interface ChangeLogModalProps { } export default function ChangeLogModal(props: ChangeLogModalProps) { - const theme = getTheme(); + const theme = useTheme(); const contentStyles = getContentStyles(theme); const iconButtonStyles = getIconButtonStyles(theme); diff --git a/web/src/components/preview/Preview.tsx b/web/src/components/preview/Preview.tsx index 3dc50de6..3fbb9911 100644 --- a/web/src/components/preview/Preview.tsx +++ b/web/src/components/preview/Preview.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { MessageBar, MessageBarType, getTheme } from '@fluentui/react'; +import { MessageBar, MessageBarType } from '@fluentui/react'; +import ThemeableComponent from '@components/utils/ThemeableComponent'; import { getDefaultFontFamily } from '~/services/fonts'; import { Connect } from '~/store'; import { RuntimeType } from '~/services/config'; @@ -16,9 +17,9 @@ export interface PreviewProps { } @Connect(s => ({ darkMode: s.settings.darkMode, runtime: s.settings.runtime, ...s.status })) -export default class Preview extends React.Component { +export default class Preview extends ThemeableComponent { get styles() { - const { palette } = getTheme(); + const { palette } = this.theme; return { backgroundColor: palette.neutralLight, color: palette.neutralDark, @@ -35,12 +36,14 @@ export default class Preview extends React.Component { const isWasm = this.props.runtime === RuntimeType.WebAssembly; let content; if (this.props.lastError) { - content = - Error -
-          {this.props.lastError}
-        
-
+ content = ( + + Error +
+            {this.props.lastError}
+          
+
+ ) } else if (this.props.events) { content = this.props.events.map(({Message, Delay, Kind}, k) => ( = ({ collapsed, onViewChange }) => { - const {palette: { accent }, semanticColors: { buttonBorder }} = getTheme(); + const {palette: { accent }, semanticColors: { buttonBorder }} = useTheme(); const onResize = useCallback((e, direction, ref, size) => { switch (layout) { case LayoutType.Vertical: @@ -83,6 +84,7 @@ const ResizablePreview: React.FC = ({ enable={enabledCorners} onResizeStop={onResize} minHeight={MIN_HEIGHT} + minWidth={MIN_WIDTH} style={{ '--pg-handle-active-color': accent, '--pg-handle-default-color': buttonBorder, diff --git a/web/src/components/settings/SettingsModal.tsx b/web/src/components/settings/SettingsModal.tsx index cee3825e..7fd9d4c9 100644 --- a/web/src/components/settings/SettingsModal.tsx +++ b/web/src/components/settings/SettingsModal.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Checkbox, Dropdown, - getTheme, IconButton, IDropdownOption, Modal @@ -11,6 +10,7 @@ import {Pivot, PivotItem} from '@fluentui/react/lib/Pivot'; import {MessageBar, MessageBarType} from '@fluentui/react/lib/MessageBar'; import {Link} from '@fluentui/react/lib/Link'; +import ThemeableComponent from '@components/utils/ThemeableComponent'; import {getContentStyles, getIconButtonStyles} from '~/styles/modal'; import SettingsProperty from './SettingsProperty'; import {MonacoSettings, RuntimeType} from '~/services/config'; @@ -63,6 +63,7 @@ const FONT_OPTS: IDropdownOption[] = [ export interface SettingsChanges { monaco?: MonacoParamsChanges args?: BuildParamsArgs, + settings?: Partial } export interface SettingsProps { @@ -83,7 +84,7 @@ interface SettingsModalState { settings: state.settings, monaco: state.monaco, })) -export default class SettingsModal extends React.Component { +export default class SettingsModal extends ThemeableComponent { private titleID = 'Settings'; private subtitleID = 'SettingsSubText'; private changes: SettingsChanges = {}; @@ -110,10 +111,21 @@ export default class SettingsModal extends React.Component) { + if (!this.changes.settings) { + this.changes.settings = changes; + return; + } + + this.changes.settings = { + ...this.changes.settings, + ...changes, + }; + } + render() { - const theme = getTheme(); - const contentStyles = getContentStyles(theme); - const iconButtonStyles = getIconButtonStyles(theme); + const contentStyles = getContentStyles(this.theme); + const iconButtonStyles = getIconButtonStyles(this.theme); const { showGoTipMessage, showWarning } = this.state; return ( } /> + { + this.touchSettingsProperty({ + useSystemTheme: val + }); + }} + />} + /> = ({darkMode, children, ...props}) => ( - - {children} - -); +const getInitialTheme = ({darkMode, useSystemTheme}: SettingsState) => { + if (useSystemTheme && supportsPreferColorScheme()) { + return { currentTheme: isDarkModeEnabled() ? ThemeVariant.dark : ThemeVariant.light, matchMedia: true}; + } -export default connect(({settings: {darkMode}}: any) => ({darkMode}))(ConnectedThemeProvider); + return { currentTheme: darkMode ? ThemeVariant.dark : ThemeVariant.light, matchMedia: false}; +}; + +const ConnectedThemeProvider: React.FunctionComponent = ({settings, children, dispatch, ...props}) => { + const { currentTheme, matchMedia } = getInitialTheme(settings as SettingsState); + const systemTheme = usePrefersColorScheme(currentTheme, matchMedia); + useEffect(() => { + dispatch?.(newSettingsChangeAction({ darkMode: systemTheme === ThemeVariant.dark})); + }, [systemTheme, dispatch]); + + return ( + + {children} + + ); +}; + +export default connect(({settings, dispatch}: any) => + ({settings, dispatch}))(ConnectedThemeProvider); diff --git a/web/src/components/utils/SharePopup.tsx b/web/src/components/utils/SharePopup.tsx index 0888dc96..87682997 100644 --- a/web/src/components/utils/SharePopup.tsx +++ b/web/src/components/utils/SharePopup.tsx @@ -1,7 +1,13 @@ -import React, { FC, useMemo } from 'react'; +import React, {FC, useContext, useMemo} from 'react'; import copy from 'copy-to-clipboard'; import { Target } from '@fluentui/react-hooks'; -import { TeachingBubble, Link, getTheme, DirectionalHint, IButtonProps } from '@fluentui/react'; +import { + TeachingBubble, + Link, + DirectionalHint, + IButtonProps, + useTheme +} from '@fluentui/react'; interface Props { visible?: boolean @@ -12,6 +18,7 @@ interface Props { } const SharePopup: FC = ({ visible, snippetId, originUrl, onDismiss, target }) => { + const { semanticColors: { bodyBackground } } = useTheme(); const primaryButtonProps: IButtonProps = useMemo( () => ({ children: 'Copy link', @@ -28,13 +35,12 @@ const SharePopup: FC = ({ visible, snippetId, originUrl, onDismiss, targe return <>; } - const { semanticColors: { bodyBackground } } = getTheme(); return ( extends React.Component { + static contextType = ThemeContext; + + get theme() { + return this.context as ITheme; + } +} diff --git a/web/src/services/config.ts b/web/src/services/config.ts index 90a83986..2d1f8fb7 100644 --- a/web/src/services/config.ts +++ b/web/src/services/config.ts @@ -3,8 +3,10 @@ import { DEFAULT_FONT } from './fonts'; import { DarkTheme, LightTheme } from './colors'; import {PanelState} from '~/store'; import { defaultPanelProps } from '~/styles/layout'; +import {supportsPreferColorScheme} from "~/utils/theme"; const DARK_THEME_KEY = 'ui.darkTheme.enabled'; +const USE_SYSTEM_THEME_KEY = 'ui.darkTheme.useSystem'; const RUNTIME_TYPE_KEY = 'go.build.runtime'; const AUTOFORMAT_KEY = 'go.build.autoFormat'; const MONACO_SETTINGS = 'ms.monaco.settings'; @@ -88,6 +90,19 @@ const Config = { localStorage.setItem(DARK_THEME_KEY, enable.toString()); }, + get useSystemTheme() { + if (this._cache[DARK_THEME_KEY]) { + return this._cache[DARK_THEME_KEY]; + } + + return this.getBoolean(USE_SYSTEM_THEME_KEY, supportsPreferColorScheme()); + }, + + set useSystemTheme(val: boolean) { + this._cache[USE_SYSTEM_THEME_KEY] = val; + localStorage.setItem(USE_SYSTEM_THEME_KEY, val.toString()); + }, + get runtimeType(): RuntimeType { if (this._cache[RUNTIME_TYPE_KEY]) { return this._cache[RUNTIME_TYPE_KEY]; diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index 283238a5..3b9ca91a 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -1,5 +1,5 @@ import {editor} from "monaco-editor"; -import {PanelState, UIState} from './state'; +import {PanelState, SettingsState, UIState} from './state'; import { RunResponse, EvalEvent } from '~/services/api'; import { MonacoSettings, RuntimeType } from '~/services/config'; @@ -16,6 +16,7 @@ export enum ActionType { MARKER_CHANGE = 'MARKER_CHANGE', ENVIRONMENT_CHANGE = 'ENVIRONMENT_CHANGE', PANEL_STATE_CHANGE = 'PANEL_STATE_CHANGE', + SETTINGS_CHANGE = 'SETTINGS_CHANGE', // Special actions used by Go WASM bridge EVAL_START = 'EVAL_START', @@ -104,6 +105,13 @@ export const newMonacoParamsChangeAction = (changes: MonacoParamsChanges) payload: changes }); +export const newSettingsChangeAction = (changes: Partial) => ( + { + type: ActionType.SETTINGS_CHANGE, + payload: changes + } +); + export const newProgramWriteAction = (event: EvalEvent) => ({ type: ActionType.EVAL_EVENT, diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 37fa8f25..7963cc0f 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -11,6 +11,7 @@ import { newLoadingAction, newMonacoParamsChangeAction, newPanelStateChangeAction, + newSettingsChangeAction, newProgramWriteAction, newToggleThemeAction, newUIStateChangeAction @@ -23,7 +24,8 @@ import client, { import config, {RuntimeType} from '~/services/config'; import {DEMO_CODE} from '~/components/editor/props'; import {getImportObject, goRun} from '~/services/go'; -import {PanelState, State} from './state'; +import {PanelState, SettingsState, State} from './state'; +import {isDarkModeEnabled} from "~/utils/theme"; export type StateProvider = () => State export type DispatchFn = (a: Action | any) => any @@ -64,6 +66,20 @@ export function newMonacoParamsChangeDispatcher(changes: MonacoParamsChanges): D }; } +export const newSettingsChangeDispatcher = (changes: Partial): Dispatcher => ( + (dispatch: DispatchFn, _: StateProvider) => { + if ('useSystemTheme' in changes) { + config.useSystemTheme = !!changes.useSystemTheme; + changes.darkMode = isDarkModeEnabled(); + } + + if ('darkMode' in changes) { + config.darkThemeEnabled = !!changes.darkMode; + } + + dispatch(newSettingsChangeAction(changes)); + } +); export function newBuildParamsChangeDispatcher(runtime: RuntimeType, autoFormat: boolean): Dispatcher { return (dispatch: DispatchFn, _: StateProvider) => { diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index e5105a49..8bc46bbe 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -16,6 +16,7 @@ import { PanelState, UIState, } from './state'; +import {supportsPreferColorScheme} from "~/utils/theme"; const reducers = { editor: mapByAction({ @@ -91,8 +92,16 @@ const reducers = { }, [ActionType.ENVIRONMENT_CHANGE]: (s: SettingsState, { payload }: Action) => ({ ...s, runtime: payload, + }), + [ActionType.SETTINGS_CHANGE]: (s: SettingsState, {payload}: Action>) => ({ + ...s, ...payload }) - }, { darkMode: localConfig.darkThemeEnabled, autoFormat: true, runtime: RuntimeType.GoPlayground }), + }, { + darkMode: localConfig.darkThemeEnabled, + autoFormat: true, + runtime: RuntimeType.GoPlayground, + useSystemTheme: localConfig.useSystemTheme, + }), monaco: mapByAction({ [ActionType.MONACO_SETTINGS_CHANGE]: (s: MonacoSettings, a: Action) => { return Object.assign({}, s, a.payload); @@ -136,6 +145,7 @@ export const getInitialState = (): State => ({ darkMode: localConfig.darkThemeEnabled, autoFormat: localConfig.autoFormat, runtime: localConfig.runtimeType, + useSystemTheme: localConfig.useSystemTheme, }, monaco: config.monacoSettings, panel: defaultPanelProps diff --git a/web/src/store/state.ts b/web/src/store/state.ts index e29b3033..3881f515 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -23,6 +23,7 @@ export interface StatusState { export interface SettingsState { darkMode: boolean + useSystemTheme: boolean autoFormat: boolean, runtime: RuntimeType, } diff --git a/web/src/styles/modal.ts b/web/src/styles/modal.ts index 79aab3c3..b8f3358f 100644 --- a/web/src/styles/modal.ts +++ b/web/src/styles/modal.ts @@ -5,19 +5,19 @@ import { ITheme } from '@fluentui/react'; -export const getIconButtonStyles = (theme: ITheme) => mergeStyleSets({ +export const getIconButtonStyles = (theme?: ITheme) => mergeStyleSets({ root: { - color: theme.palette.neutralPrimary, + color: theme?.palette.neutralPrimary, marginLeft: 'auto', marginTop: '4px', marginRight: '2px' }, rootHovered: { - color: theme.palette.neutralDark + color: theme?.palette.neutralDark } }); -export const getContentStyles = (theme: ITheme) => mergeStyleSets({ +export const getContentStyles = (theme?: ITheme) => mergeStyleSets({ container: { display: 'flex', flexFlow: 'column nowrap', @@ -26,11 +26,11 @@ export const getContentStyles = (theme: ITheme) => mergeStyleSets({ maxWidth: '480px' }, header: [ - theme.fonts.xLargePlus, + theme?.fonts.xLargePlus, { flex: '1 1 auto', - borderTop: `4px solid ${theme.palette.themePrimary}`, - color: theme.palette.neutralPrimary, + borderTop: `4px solid ${theme?.palette.themePrimary}`, + color: theme?.palette.neutralPrimary, display: 'flex', fontSize: FontSizes.xLarge, alignItems: 'center', diff --git a/web/src/utils/theme.ts b/web/src/utils/theme.ts new file mode 100644 index 00000000..1deda9f1 --- /dev/null +++ b/web/src/utils/theme.ts @@ -0,0 +1,57 @@ +import {useState, useEffect} from "react"; +import {DarkTheme, LightTheme} from "@services/colors"; + +const query = '(prefers-color-scheme)'; + +export enum ThemeVariant { + dark = 'dark', + light = 'light' +} + +export const supportsPreferColorScheme = () => { + if (!('matchMedia' in window)) { + return false; + } + + // See: https://kilianvalkhof.com/2021/web/detecting-media-query-support-in-css-and-javascript/ + const { media } = window.matchMedia(query); + return media === query; +}; + +export const isDarkModeEnabled = () => { + if (!supportsPreferColorScheme()) { + return false; + } + + const { matches } = window.matchMedia('(prefers-color-scheme: dark)'); + return matches; +} + +export const usePrefersColorScheme = (defaultValue: ThemeVariant, enabled = true) : ThemeVariant => { + const [theme, setTheme] = useState(defaultValue); + useEffect(() => { + if (!enabled) { + return; + } + + const handler = ({matches}) => { + if (!enabled) { + return; + } + setTheme(matches ? ThemeVariant.dark : ThemeVariant.light); + }; + + const query = window.matchMedia('(prefers-color-scheme: dark)'); + setTheme(query.matches ? ThemeVariant.dark : ThemeVariant.light); + + query.addEventListener('change', handler); + return () => { + query.removeEventListener('change', handler); + } + }, [enabled]); + return theme; +} + +export const getThemeFromVariant = (variant: ThemeVariant) => ( + variant === ThemeVariant.dark ? DarkTheme : LightTheme +);