feat: add settings dialog

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-07-21 19:36:29 +02:00
parent 39823275a0
commit 4e18ce38f3
45 changed files with 656 additions and 376 deletions

View file

@ -9,6 +9,7 @@ import { languages } from '../fixtures/languages'
describe('Languages', () => { describe('Languages', () => {
beforeEach(() => { beforeEach(() => {
cy.visitHome() cy.visitHome()
cy.getByCypressId('settingsButton').click()
}) })
it('all languages are available', () => { it('all languages are available', () => {

View file

@ -307,16 +307,8 @@
"view": "View", "view": "View",
"both": "Both" "both": "Both"
}, },
"darkMode": {
"switchToDark": "Switch to Dark Mode",
"switchToLight": "Switch to Light Mode"
},
"appBar": { "appBar": {
"new": "New", "new": "New"
"syncScroll": {
"disable": "Disable sync scroll",
"enable": "Enable sync scroll"
}
}, },
"editorToolbar": { "editorToolbar": {
"bold": "Bold", "bold": "Bold",
@ -516,6 +508,8 @@
"common": { "common": {
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"on": "On",
"off": "Off",
"import": "Import", "import": "Import",
"export": "Export", "export": "Export",
"refresh": "Refresh", "refresh": "Refresh",
@ -591,5 +585,46 @@
"title": "Note '{{noteTitle}}' deleted", "title": "Note '{{noteTitle}}' deleted",
"text": "You were redirected to the history page, because the note you just edited was deleted." "text": "You were redirected to the history page, because the note you just edited was deleted."
} }
},
"settings": {
"title": "Settings",
"editor": {
"label": "Editor",
"ligatures": {
"label": "Ligatures",
"help": "(De)Activates support for ligatures in the editor."
},
"smartPaste": {
"label": "Smart Paste",
"help": "Smart paste detects common formats (like tables) when pasting content into the editor and formats them as markdown."
},
"syncScroll": {
"label": "Sync Scrolling",
"help": "Synchronizes the scroll state of editor and rendering view."
}
},
"global": {
"label": "Global",
"darkMode": {
"label": "Dark mode",
"help": "Enforces the (de)activation of the dark mode for the app or lets the browser decide.",
"dark": {
"tooltip": "Switch to dark mode",
"label": "Dark"
},
"light": {
"tooltip": "Switch to light mode",
"label": "Light"
},
"browser": {
"tooltip": "Let browser decide",
"label": "Auto"
}
},
"language": {
"label": "Language",
"help": "The primary user interface language"
}
}
} }
} }

View file

@ -3,10 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { setDarkMode } from '../../../redux/dark-mode/methods' import { setDarkModePreference } from '../../../redux/dark-mode/methods'
import { Logger } from '../../../utils/logger' import { Logger } from '../../../utils/logger'
import type { DarkModeConfig } from '../../../redux/dark-mode/types'
import { isClientSideRendering } from '../../../utils/is-client-side-rendering' import { isClientSideRendering } from '../../../utils/is-client-side-rendering'
import { DarkModePreference } from '../../../redux/dark-mode/types'
import { DARK_MODE_LOCAL_STORAGE_KEY } from '../../../hooks/common/use-apply-dark-mode'
const logger = new Logger('Dark mode initializer') const logger = new Logger('Dark mode initializer')
@ -16,16 +17,9 @@ const logger = new Logger('Dark mode initializer')
* *
* @return A promise that resolves as soon as the dark mode has been loaded. * @return A promise that resolves as soon as the dark mode has been loaded.
*/ */
export const loadDarkMode = async (): Promise<void> => { export const loadDarkMode = (): Promise<void> => {
return new Promise<void>((resolve) => { setDarkModePreference(fetchDarkModeFromLocalStorage())
setDarkMode( return Promise.resolve()
fetchDarkModeFromLocalStorage() ??
determineDarkModeBrowserSettings() ?? {
darkMode: false
}
)
resolve()
})
} }
/** /**
@ -34,38 +28,21 @@ export const loadDarkMode = async (): Promise<void> => {
* @return {@link true} if the local storage has saved that the user prefers dark mode. * @return {@link true} if the local storage has saved that the user prefers dark mode.
* {@link false} if the user doesn't prefer dark mode or if the value couldn't be read from local storage. * {@link false} if the user doesn't prefer dark mode or if the value couldn't be read from local storage.
*/ */
const fetchDarkModeFromLocalStorage = (): boolean => { const fetchDarkModeFromLocalStorage = (): DarkModePreference => {
if (!isClientSideRendering()) { if (!isClientSideRendering()) {
return false return DarkModePreference.AUTO
} }
try { try {
return window.localStorage.getItem('nightMode') === 'true' const colorScheme = window.localStorage.getItem(DARK_MODE_LOCAL_STORAGE_KEY)
if (colorScheme === 'dark') {
return DarkModePreference.DARK
} else if (colorScheme === 'light') {
return DarkModePreference.LIGHT
} else {
return DarkModePreference.AUTO
}
} catch (error) { } catch (error) {
logger.error('Loading from local storage failed', error) logger.error('Loading from local storage failed', error)
return false return DarkModePreference.AUTO
}
}
/**
* Tries to read the preferred dark mode setting from the browser settings.
*
* @return {@link true} if the browser has reported that the user prefers dark mode.
* {@link false} if the browser doesn't prefer dark mode.
* {@link undefined} if the browser doesn't support the `prefers-color-scheme` media query.
*/
const determineDarkModeBrowserSettings = (): DarkModeConfig | undefined => {
if (!isClientSideRendering()) {
return {
darkMode: false
}
}
try {
const mediaQueryResult = window.matchMedia('(prefers-color-scheme: dark)').matches
return {
darkMode: mediaQueryResult
}
} catch (error) {
logger.error('Can not determine setting from browser', error)
return undefined
} }
} }

View file

@ -11,9 +11,10 @@ import { ForkAwesomeIcon } from '../fork-awesome/fork-awesome-icon'
import type { IconName } from '../fork-awesome/types' import type { IconName } from '../fork-awesome/types'
import { ShowIf } from '../show-if/show-if' import { ShowIf } from '../show-if/show-if'
import styles from './icon-button.module.scss' import styles from './icon-button.module.scss'
import type { PropsWithDataTestId } from '../../../utils/test-id'
import { testId } from '../../../utils/test-id' import { testId } from '../../../utils/test-id'
export interface IconButtonProps extends ButtonProps { export interface IconButtonProps extends ButtonProps, PropsWithDataTestId {
icon: IconName icon: IconName
onClick?: () => void onClick?: () => void
border?: boolean border?: boolean

View file

@ -9,15 +9,14 @@ import { Nav, Navbar } from 'react-bootstrap'
import { ShowIf } from '../../common/show-if/show-if' import { ShowIf } from '../../common/show-if/show-if'
import { SignInButton } from '../../landing-layout/navigation/sign-in-button' import { SignInButton } from '../../landing-layout/navigation/sign-in-button'
import { UserDropdown } from '../../landing-layout/navigation/user-dropdown' import { UserDropdown } from '../../landing-layout/navigation/user-dropdown'
import { DarkModeButton } from './dark-mode-button'
import { HelpButton } from './help-button/help-button' import { HelpButton } from './help-button/help-button'
import { NavbarBranding } from './navbar-branding' import { NavbarBranding } from './navbar-branding'
import { SyncScrollButtons } from './sync-scroll-buttons/sync-scroll-buttons'
import { SlideModeButton } from './slide-mode-button' import { SlideModeButton } from './slide-mode-button'
import { ReadOnlyModeButton } from './read-only-mode-button' import { ReadOnlyModeButton } from './read-only-mode-button'
import { NewNoteButton } from './new-note-button' import { NewNoteButton } from './new-note-button'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { NoteType } from '../../../redux/note-details/types/note-details' import { NoteType } from '../../../redux/note-details/types/note-details'
import { SettingsButton } from '../../layout/settings-dialog/settings-button'
export enum AppBarMode { export enum AppBarMode {
BASIC, BASIC,
@ -41,10 +40,6 @@ export const AppBar: React.FC<AppBarProps> = ({ mode }) => {
<Navbar expand={true} className={'bg-light px-3'}> <Navbar expand={true} className={'bg-light px-3'}>
<Nav className='me-auto d-flex align-items-center'> <Nav className='me-auto d-flex align-items-center'>
<NavbarBranding /> <NavbarBranding />
<ShowIf condition={mode === AppBarMode.EDITOR}>
<SyncScrollButtons />
</ShowIf>
<DarkModeButton />
<ShowIf condition={mode === AppBarMode.EDITOR}> <ShowIf condition={mode === AppBarMode.EDITOR}>
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}> <ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
<SlideModeButton /> <SlideModeButton />
@ -56,6 +51,7 @@ export const AppBar: React.FC<AppBarProps> = ({ mode }) => {
</ShowIf> </ShowIf>
</Nav> </Nav>
<Nav className='d-flex align-items-center text-secondary justify-content-end'> <Nav className='d-flex align-items-center text-secondary justify-content-end'>
<SettingsButton className={'p-1 mx-2'} variant={'outline-dark'} />
<NewNoteButton /> <NewNoteButton />
<ShowIf condition={!userExists}> <ShowIf condition={!userExists}>
<SignInButton size={'sm'} /> <SignInButton size={'sm'} />

View file

@ -1,42 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { Button, ButtonGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated'
import { setDarkMode } from '../../../redux/dark-mode/methods'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
/**
* Renders a button group to activate / deactivate the dark mode.
*/
const DarkModeButton: React.FC = () => {
const { t } = useTranslation()
const darkModeEnabled = useIsDarkModeActivated()
const enable = useCallback(() => setDarkMode(true), [])
const disable = useCallback(() => setDarkMode(false), [])
return (
<ButtonGroup className='ms-2'>
<Button
onClick={enable}
variant={darkModeEnabled ? 'secondary' : 'outline-secondary'}
title={t('editor.darkMode.switchToDark') ?? undefined}>
<ForkAwesomeIcon icon='moon' />
</Button>
<Button
onClick={disable}
variant={darkModeEnabled ? 'outline-secondary' : 'secondary'}
title={t('editor.darkMode.switchToLight') ?? undefined}>
<ForkAwesomeIcon icon='sun-o' />
</Button>
</ButtonGroup>
)
}
export { DarkModeButton }

View file

@ -7,7 +7,7 @@
import React from 'react' import React from 'react'
import { Navbar } from 'react-bootstrap' import { Navbar } from 'react-bootstrap'
import Link from 'next/link' import Link from 'next/link'
import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated' import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
import { Branding } from '../../common/branding/branding' import { Branding } from '../../common/branding/branding'
import { import {
HedgeDocLogoSize, HedgeDocLogoSize,
@ -19,7 +19,7 @@ import {
* Renders the branding for the {@link AppBar} * Renders the branding for the {@link AppBar}
*/ */
export const NavbarBranding: React.FC = () => { export const NavbarBranding: React.FC = () => {
const darkModeActivated = useIsDarkModeActivated() const darkModeActivated = useDarkModeState()
return ( return (
<Navbar.Brand> <Navbar.Brand>

View file

@ -1,84 +0,0 @@
<!--
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0
-->
<svg xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns="http://www.w3.org/2000/svg"
width="512"
height="512"
viewBox="0 0 135.46666 135.46666"
version="1.1"
id="svg8"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="buttonIcon.svg">
<defs id="defs2"/>
<sodipodi:namedview
fit-margin-bottom="0"
fit-margin-right="0"
fit-margin-left="0"
fit-margin-top="0"
id="base"
pagecolor="#545b62"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="1"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="151.94971"
inkscape:cy="220.06486"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
units="px"
inkscape:window-width="3434"
inkscape:window-height="1321"
inkscape:window-x="0"
inkscape:window-y="84"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="false"/>
<metadata id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g transform="translate(253.17277,890.86874)"
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path id="path864"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000"
d="m -185.5862,-882.45881 c -1.05348,0.0354 -2.05943,0.44876 -2.83393,1.16582 l -17.37672,16.08016 c -1.77986,1.64837 -1.89553,4.45148 -0.25787,6.24199 1.63744,1.79079 4.43758,1.90901 6.21875,0.262 l 14.39653,-13.32218 14.39654,13.32218 c 1.78118,1.64702 4.5813,1.5288 6.21874,-0.262 1.63691,-1.79129 1.52077,-4.59444 -0.25993,-6.24199 l -17.37465,-16.08016 c -0.84861,-0.78564 -1.97318,-1.20418 -3.12746,-1.16582 z"/>
<path id="path851"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000"
d="m -202.99806,-788.72004 c -1.12724,0.0475 -2.23794,0.52467 -3.05666,1.42007 -1.63766,1.79051 -1.52199,4.59363 0.25787,6.24199 l 17.37672,16.08016 c 0.7745,0.71706 1.78045,1.13042 2.83393,1.16582 1.15428,0.0384 2.27885,-0.38018 3.12746,-1.16582 l 17.37465,-16.08016 c 1.7807,-1.64754 1.89684,-4.4507 0.25993,-6.24199 -1.63744,-1.79081 -4.43756,-1.90902 -6.21874,-0.262 l -14.39654,13.32218 -14.39653,-13.32218 c -0.89058,-0.8235 -2.03485,-1.20561 -3.16209,-1.15807 z"/>
<circle
r="16" cy="-823.13544"
cx="-185.43944"
id="path845"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:5.565;stroke-linecap:round;stroke-linejoin:round"/>
<g
style="display:inline;opacity:1;stroke:none;stroke-opacity:1"
id="g855">
<path
id="path858"
d="m -128.54012,-883.40784 -121.89532,111.71083 8.0967,8.83405 121.89531,-111.71083 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:5.565;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"/>
<path
id="path860"
d="m -128.61914,-886.18945 a 2.7827783,2.7827783 0 0 0 -1.80078,0.73047 l -121.89453,111.71093 a 2.7827783,2.7827783 0 0 0 -0.17188,3.93164 l 8.09571,8.83399 a 2.7827783,2.7827783 0 0 0 3.93164,0.16992 l 121.89648,-111.70898 a 2.7827783,2.7827783 0 0 0 0.16992,-3.93164 l -8.0957,-8.83399 a 2.7827783,2.7827783 0 0 0 -2.13086,-0.90234 z m -0.0937,6.71289 4.33789,4.73047 -117.79297,107.95117 -4.33594,-4.73047 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#545b62;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:5.565;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -1,14 +0,0 @@
<!--
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0
-->
<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<g transform="matrix(.11811 0 0 .11811 29.639 105.22)" fill="currentColor">
<path d="m-185.61-890.87c-1.2028 0.0405-2.3513 0.51236-3.2355 1.331l-19.839 18.359c-2.0321 1.882-2.1642 5.0823-0.29441 7.1266 1.8695 2.0446 5.0664 2.1796 7.1 0.29914l16.437-15.21 16.437 15.21c2.0336 1.8804 5.2306 1.7454 7.1-0.29914 1.8689-2.0451 1.7363-5.2456-0.29677-7.1266l-19.837-18.359c-0.96887-0.89698-2.2528-1.3748-3.5707-1.331z"/>
<path d="m-205.49-783.84c-1.287 0.0542-2.5551 0.59901-3.4898 1.6213-1.8698 2.0443-1.7377 5.2446 0.29441 7.1266l19.839 18.359c0.88425 0.81868 2.0328 1.2906 3.2355 1.331 1.3179 0.0439 2.6018-0.43406 3.5707-1.331l19.837-18.359c2.0331-1.881 2.1657-5.0814 0.29677-7.1266-1.8695-2.0446-5.0664-2.1796-7.1-0.29913l-16.437 15.21-16.437-15.21c-1.0168-0.94021-2.3232-1.3765-3.6102-1.3222z"/>
<path d="m-185.49-841.4a18.267 18.267 0 0 0-18.216 18.268 18.267 18.267 0 0 0 0.23777 2.9358l22.524-20.642a18.267 18.267 0 0 0-4.494-0.56168 18.267 18.267 0 0 0-0.0519 0zm18.082 15.333-22.523 20.641a18.267 18.267 0 0 0 4.4934 0.56168 18.267 18.267 0 0 0 18.268-18.268 18.267 18.267 0 0 0-0.23777-2.9347z"
fill-rule="evenodd"/>
<path d="m-124.79-883.92-126.15 116.29 4.9507 5.4008 126.15-116.29z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,12 +0,0 @@
<!--
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0
-->
<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<g transform="scale(.11811)" fill="currentColor">
<path d="m67.566 0.0027667c-1.2028 0.040417-2.3513 0.51236-3.2356 1.331l-19.839 18.359c-2.0321 1.882-2.1642 5.0823-0.29442 7.1266 1.8695 2.0446 5.0665 2.1796 7.1 0.29913l16.437-15.21 16.437 15.21c2.0336 1.8804 5.2306 1.7455 7.1-0.29913 1.8689-2.0451 1.7363-5.2456-0.29677-7.1266l-19.837-18.359c-0.96887-0.89698-2.2528-1.3748-3.5707-1.331z"/>
<path d="m47.687 107.03c-1.287 0.0542-2.5551 0.59903-3.4898 1.6213-1.8698 2.0443-1.7377 5.2446 0.29442 7.1266l19.839 18.359c0.88426 0.81868 2.0328 1.2906 3.2356 1.331 1.3179 0.0438 2.6018-0.43406 3.5707-1.331l19.837-18.359c2.0331-1.881 2.1657-5.0814 0.29677-7.1266-1.8695-2.0446-5.0664-2.1796-7.1-0.29913l-16.437 15.21-16.437-15.21c-1.0168-0.94021-2.3232-1.3765-3.6102-1.3222z"/>
<circle cx="67.734" cy="67.733" r="18.267" fill-rule="evenodd"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.sync-scroll-buttons {
svg {
width: 20px;
height: 20px;
}
:global(.btn) {
svg g {
fill: var(--bs-secondary);
}
:global(&.active), &:hover {
svg g {
fill: var(--bs-light);
}
}
}
}

View file

@ -1,42 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { Button, ButtonGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { setEditorSyncScroll } from '../../../../redux/editor/methods'
import DisabledScroll from './disabledScroll.svg'
import EnabledScroll from './enabledScroll.svg'
import './sync-scroll-buttons.module.scss'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Renders a button group with two states for the sync scroll buttons.
* This makes it possible to activate or deactivate sync scrolling.
*/
export const SyncScrollButtons: React.FC = () => {
const syncScrollEnabled = useApplicationState((state) => state.editorConfig.syncScroll)
const { t } = useTranslation()
const enable = useCallback(() => setEditorSyncScroll(true), [])
const disable = useCallback(() => setEditorSyncScroll(false), [])
return (
<ButtonGroup className='ms-2 ms-2 sync-scroll-buttons'>
<Button
onClick={enable}
variant={syncScrollEnabled ? 'secondary' : 'outline-secondary'}
title={t('editor.appBar.syncScroll.enable') ?? undefined}>
<EnabledScroll />
</Button>
<Button
onClick={disable}
variant={syncScrollEnabled ? 'outline-secondary' : 'secondary'}
title={t('editor.appBar.syncScroll.disable') ?? undefined}>
<DisabledScroll />
</Button>
</ButtonGroup>
)
}

View file

@ -8,7 +8,7 @@ import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'
import { getRevision } from '../../../../api/revisions' import { getRevision } from '../../../../api/revisions'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated' import { useDarkModeState } from '../../../../hooks/common/use-dark-mode-state'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary' import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { applyPatch, parsePatch } from 'diff' import { applyPatch, parsePatch } from 'diff'
import { invertUnifiedPatch } from './invert-unified-patch' import { invertUnifiedPatch } from './invert-unified-patch'
@ -26,7 +26,7 @@ export interface RevisionViewerProps {
*/ */
export const RevisionViewer: React.FC<RevisionViewerProps> = ({ selectedRevisionId }) => { export const RevisionViewer: React.FC<RevisionViewerProps> = ({ selectedRevisionId }) => {
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const darkModeEnabled = useIsDarkModeActivated() const darkModeEnabled = useDarkModeState()
const { value, error, loading } = useAsync(async () => { const { value, error, loading } = useAsync(async () => {
if (selectedRevisionId === undefined) { if (selectedRevisionId === undefined) {

View file

@ -40,7 +40,7 @@ import { lintGutter } from '@codemirror/lint'
import { useLinter } from './linter/linter' import { useLinter } from './linter/linter'
import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted' import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted'
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name' import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated' import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
/** /**
* Renders the text editor pane of the editor. * Renders the text editor pane of the editor.
@ -128,7 +128,7 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
const { t } = useTranslation() const { t } = useTranslation()
const darkModeActivated = useIsDarkModeActivated() const darkModeActivated = useDarkModeState()
return ( return (
<div <div

View file

@ -11,7 +11,7 @@ import type { Extension } from '@codemirror/state'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import { optionalAppExtensions } from '../../../../extensions/extra-integrations/optional-app-extensions' import { optionalAppExtensions } from '../../../../extensions/extra-integrations/optional-app-extensions'
import { FrontmatterLinter } from './frontmatter-linter' import { FrontmatterLinter } from './frontmatter-linter'
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated' import { useDarkModeState } from '../../../../hooks/common/use-dark-mode-state'
/** /**
* The Linter interface. * The Linter interface.
@ -37,7 +37,7 @@ const createLinterExtension = () =>
* @return The build codemirror linter extension * @return The build codemirror linter extension
*/ */
export const useLinter = (): Extension => { export const useLinter = (): Extension => {
const darkModeActivated = useIsDarkModeActivated() const darkModeActivated = useDarkModeState()
return useMemo(() => (darkModeActivated ? createLinterExtension() : createLinterExtension()), [darkModeActivated]) return useMemo(() => (darkModeActivated ? createLinterExtension() : createLinterExtension()), [darkModeActivated])
} }

View file

@ -7,7 +7,7 @@
import { Picker } from 'emoji-picker-element' import { Picker } from 'emoji-picker-element'
import type { CustomEmoji, EmojiClickEvent, EmojiClickEventDetail } from 'emoji-picker-element/shared' import type { CustomEmoji, EmojiClickEvent, EmojiClickEventDetail } from 'emoji-picker-element/shared'
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import { useIsDarkModeActivated } from '../../../../../hooks/common/use-is-dark-mode-activated' import { useDarkModeState } from '../../../../../hooks/common/use-dark-mode-state'
import styles from './emoji-picker.module.scss' import styles from './emoji-picker.module.scss'
import forkawesomeIcon from './forkawesome.png' import forkawesomeIcon from './forkawesome.png'
import { ForkAwesomeIcons } from '../../../../common/fork-awesome/fork-awesome-icons' import { ForkAwesomeIcons } from '../../../../common/fork-awesome/fork-awesome-icons'
@ -49,7 +49,7 @@ export interface EmojiPickerProps extends PopoverProps {
*/ */
export const EmojiPickerPopover = React.forwardRef<HTMLDivElement, EmojiPickerProps>( export const EmojiPickerPopover = React.forwardRef<HTMLDivElement, EmojiPickerProps>(
({ onEmojiSelected, ...props }, ref) => { ({ onEmojiSelected, ...props }, ref) => {
const darkModeEnabled = useIsDarkModeActivated() const darkModeEnabled = useDarkModeState()
const pickerContainerRef = useRef<HTMLDivElement>(null) const pickerContainerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<Picker>() const pickerRef = useRef<Picker>()

View file

@ -4,10 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated'
import { useMemo } from 'react' import { useMemo } from 'react'
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message' import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
import { useSendToRenderer } from '../../../render-page/window-post-message-communicator/hooks/use-send-to-renderer' import { useSendToRenderer } from '../../../render-page/window-post-message-communicator/hooks/use-send-to-renderer'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/** /**
* Sends the current dark mode setting to the renderer. * Sends the current dark mode setting to the renderer.
@ -16,15 +16,15 @@ import { useSendToRenderer } from '../../../render-page/window-post-message-comm
* @param rendererReady Defines if the target renderer is ready * @param rendererReady Defines if the target renderer is ready
*/ */
export const useSendDarkModeStatusToRenderer = (forcedDarkMode: boolean | undefined, rendererReady: boolean): void => { export const useSendDarkModeStatusToRenderer = (forcedDarkMode: boolean | undefined, rendererReady: boolean): void => {
const savedDarkMode = useIsDarkModeActivated() const darkModePreference = useApplicationState((state) => state.darkMode.darkModePreference)
useSendToRenderer( useSendToRenderer(
useMemo( useMemo(
() => ({ () => ({
type: CommunicationMessageType.SET_DARKMODE, type: CommunicationMessageType.SET_DARKMODE,
activated: forcedDarkMode ?? savedDarkMode preference: darkModePreference
}), }),
[forcedDarkMode, savedDarkMode] [darkModePreference]
), ),
rendererReady rendererReady
) )

View file

@ -5,7 +5,6 @@
*/ */
import React from 'react' import React from 'react'
import { LanguagePicker } from './language-picker'
import { PoweredByLinks } from './powered-by-links' import { PoweredByLinks } from './powered-by-links'
import { SocialLink } from './social-links' import { SocialLink } from './social-links'
@ -15,7 +14,6 @@ import { SocialLink } from './social-links'
export const Footer: React.FC = () => { export const Footer: React.FC = () => {
return ( return (
<footer className='text-light small'> <footer className='text-light small'>
<LanguagePicker />
<PoweredByLinks /> <PoweredByLinks />
<SocialLink /> <SocialLink />
</footer> </footer>

View file

@ -14,6 +14,7 @@ import { NewUserNoteButton } from '../new-user-note-button'
import { SignInButton } from '../sign-in-button' import { SignInButton } from '../sign-in-button'
import { UserDropdown } from '../user-dropdown' import { UserDropdown } from '../user-dropdown'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import { SettingsButton } from '../../../layout/settings-dialog/settings-button'
/** /**
* Renders a header bar for the intro and history page. * Renders a header bar for the intro and history page.
@ -33,6 +34,7 @@ const HeaderBar: React.FC = () => {
</HeaderNavLink> </HeaderNavLink>
</div> </div>
<div className='d-inline-flex'> <div className='d-inline-flex'>
<SettingsButton className={'p-1 mx-2'} variant={'outline-light'} />
{!userExists ? ( {!userExists ? (
<Fragment> <Fragment>
<span className={'mx-1 d-flex'}> <span className={'mx-1 d-flex'}>

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { ListGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { SettingLine } from '../utils/setting-line'
import { LigatureSettingButtonGroup } from './ligature-setting-button-group'
import { SmartPasteSettingButtonGroup } from './smart-paste-setting-button-group'
import { SyncScrollSettingButtonGroup } from './sync-scroll-setting-button-group'
/**
* Shows the editor specific settings.
*/
export const EditorSettingsTabContent: React.FC = () => {
useTranslation()
return (
<ListGroup>
<SettingLine i18nKey={'editor.ligatures'}>
<LigatureSettingButtonGroup />
</SettingLine>
<SettingLine i18nKey={'editor.smartPaste'}>
<SmartPasteSettingButtonGroup />
</SettingLine>
<SettingLine i18nKey={'editor.syncScroll'}>
<SyncScrollSettingButtonGroup />
</SettingLine>
</ListGroup>
)
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { OnOffButtonGroup } from '../utils/on-off-button-group'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setEditorLigatures } from '../../../../redux/editor/methods'
/**
* Allows to change if ligatures should be used or not in the editor.
*/
export const LigatureSettingButtonGroup: React.FC = () => {
const enabled = useApplicationState((state) => state.editorConfig.ligatures)
return <OnOffButtonGroup value={enabled} onSelect={setEditorLigatures} />
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { OnOffButtonGroup } from '../utils/on-off-button-group'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setEditorSmartPaste } from '../../../../redux/editor/methods'
/**
* Allows to change if smart paste should be used in the editor.
*/
export const SmartPasteSettingButtonGroup: React.FC = () => {
const enabled = useApplicationState((state) => state.editorConfig.smartPaste)
return <OnOffButtonGroup value={enabled} onSelect={setEditorSmartPaste} />
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { OnOffButtonGroup } from '../utils/on-off-button-group'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setEditorSyncScroll } from '../../../../redux/editor/methods'
/**
* Allows to change if editor and rendering should scroll in sync.
*/
export const SyncScrollSettingButtonGroup: React.FC = () => {
const enabled = useApplicationState((state) => state.editorConfig.syncScroll)
return <OnOffButtonGroup value={enabled} onSelect={setEditorSyncScroll} />
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { ToggleButtonGroup } from 'react-bootstrap'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { SettingsToggleButton } from '../utils/settings-toggle-button'
import { setDarkModePreference } from '../../../../redux/dark-mode/methods'
import { DarkModePreference } from '../../../../redux/dark-mode/types'
/**
* Allows to change if the app should enforce dark mode, light mode or let the browser decide.
*/
const DarkModeSettingButtonGroup: React.FC = () => {
const darkModePreference = useApplicationState((state) => state.darkMode.darkModePreference)
const onSelect = useCallback((value: DarkModePreference) => setDarkModePreference(value), [])
return (
<ToggleButtonGroup type='radio' name='dark-mode'>
<SettingsToggleButton
onSelect={onSelect}
value={DarkModePreference.DARK}
selected={darkModePreference === DarkModePreference.DARK}
i18nKeyLabel={'settings.global.darkMode.dark.label'}
i18nKeyTooltip={'settings.global.darkMode.dark.tooltip'}
/>
<SettingsToggleButton
onSelect={onSelect}
value={DarkModePreference.LIGHT}
selected={darkModePreference === DarkModePreference.LIGHT}
i18nKeyLabel={'settings.global.darkMode.light.label'}
i18nKeyTooltip={'settings.global.darkMode.light.tooltip'}
/>
<SettingsToggleButton
onSelect={onSelect}
value={DarkModePreference.AUTO}
selected={darkModePreference === DarkModePreference.AUTO}
i18nKeyLabel={'settings.global.darkMode.browser.label'}
i18nKeyTooltip={'settings.global.darkMode.browser.tooltip'}
/>
</ToggleButtonGroup>
)
}
export { DarkModeSettingButtonGroup }

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { DarkModeSettingButtonGroup } from './dark-mode-setting-button-group'
import { ListGroup } from 'react-bootstrap'
import { LanguagePicker } from './language-picker'
import { SettingLine } from '../utils/setting-line'
/**
* Contains global settings that influence every page of the app.
*/
export const GlobalSettingsTabContent: React.FC = () => {
return (
<ListGroup>
<SettingLine i18nKey={'global.darkMode'}>
<DarkModeSettingButtonGroup />
</SettingLine>
<SettingLine i18nKey={'global.language'}>
<LanguagePicker />
</SettingLine>
</ListGroup>
)
}

View file

@ -8,8 +8,8 @@ import { Settings } from 'luxon'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { Form } from 'react-bootstrap' import { Form } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Logger } from '../../../utils/logger' import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
const log = new Logger('LanguagePicker') const log = new Logger('LanguagePicker')
const languages = { const languages = {
@ -88,8 +88,7 @@ export const LanguagePicker: React.FC = () => {
return ( return (
<Form.Select <Form.Select
as='select' as='select'
size='sm' className='w-auto'
className='mb-2 mx-auto w-auto'
value={languageCode} value={languageCode}
onChange={onChangeLang} onChange={onChangeLang}
{...cypressId('language-picker')}> {...cypressId('language-picker')}>

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
import { IconButton } from '../../common/icon-button/icon-button'
import { SettingsModal } from './settings-modal'
import type { ButtonProps } from 'react-bootstrap'
import { cypressId } from '../../../utils/cypress-attribute'
export type SettingsButtonProps = Omit<ButtonProps, 'onClick'>
/**
* Renders a button that opens a settings modal.
*/
export const SettingsButton: React.FC<SettingsButtonProps> = (props) => {
const [show, showModal, hideModal] = useBooleanState(false)
return (
<Fragment>
<IconButton {...props} {...cypressId('settingsButton')} onClick={showModal} icon={'cog'} />
<SettingsModal show={show} onHide={hideModal} />
</Fragment>
)
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import type { CommonModalProps } from '../../common/modals/common-modal'
import { CommonModal } from '../../common/modals/common-modal'
import { Modal, Tab, Tabs } from 'react-bootstrap'
import { t } from 'i18next'
import { GlobalSettingsTabContent } from './global/global-settings-tab-content'
import { EditorSettingsTabContent } from './editor/editor-settings-tab-content'
/**
* Shows global and scope specific settings
*
* @param show if the modal should be visible
* @param onHide callback that is executed if the modal should be closed
*/
export const SettingsModal: React.FC<CommonModalProps> = ({ show, onHide }) => {
return (
<CommonModal
show={show}
modalSize={'lg'}
onHide={onHide}
titleIcon={'cog'}
title={'settings.title'}
showCloseButton={true}>
<Modal.Body>
<Tabs navbar={false} variant={'pills'} defaultActiveKey={'global'}>
<Tab title={t('settings.global.label')} eventKey={'global'}>
<GlobalSettingsTabContent />
</Tab>
<Tab title={t('settings.editor.label')} eventKey={'editor'}>
<EditorSettingsTabContent />
</Tab>
</Tabs>
</Modal.Body>
</CommonModal>
)
}

View file

@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Settings On-Off Button Group can switch value 1`] = `
<div>
<div
class="btn-group"
role="group"
>
<button
class="btn btn-outline-secondary"
data-testid="onOffButtonGroupOn"
name="dark-mode"
title="common.on"
type="radio"
>
common.on
</button>
<button
class="btn btn-secondary"
data-testid="onOffButtonGroupOff"
name="dark-mode"
title="common.off"
type="radio"
>
common.off
</button>
</div>
</div>
`;
exports[`Settings On-Off Button Group can switch value 2`] = `
<div>
<div
class="btn-group"
role="group"
>
<button
class="btn btn-secondary"
data-testid="onOffButtonGroupOn"
name="dark-mode"
title="common.on"
type="radio"
>
common.on
</button>
<button
class="btn btn-outline-secondary"
data-testid="onOffButtonGroupOff"
name="dark-mode"
title="common.off"
type="radio"
>
common.off
</button>
</div>
</div>
`;

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { act, render, screen } from '@testing-library/react'
import { OnOffButtonGroup } from './on-off-button-group'
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
describe('Settings On-Off Button Group', () => {
beforeAll(mockI18n)
it('can switch value', async () => {
let value = false
const onSelect = (newValue: boolean) => (value = newValue)
const view = render(<OnOffButtonGroup value={value} onSelect={onSelect} />)
expect(view.container).toMatchSnapshot()
const onButton = await screen.findByTestId('onOffButtonGroupOn')
act(() => {
onButton.click()
})
expect(value).toBeTruthy()
view.rerender(<OnOffButtonGroup value={value} onSelect={onSelect} />)
expect(view.container).toMatchSnapshot()
const offButton = await screen.findByTestId('onOffButtonGroupOff')
act(() => {
offButton.click()
})
expect(value).toBeFalsy()
})
})

View file

@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo } from 'react'
import { ToggleButtonGroup } from 'react-bootstrap'
import { SettingsToggleButton } from './settings-toggle-button'
import { testId } from '../../../../utils/test-id'
enum OnOffState {
ON,
OFF
}
export interface OnOffButtonGroupProps {
value: boolean
onSelect: (value: boolean) => void
}
/**
* Shows a button group that is used to toggle a setting on or off.
*
* @param onSelect callback that is executed if the on/off value has changed
* @param value the current on/off value that should be visible
*/
export const OnOffButtonGroup: React.FC<OnOffButtonGroupProps> = ({ onSelect, value }) => {
const buttonGroupValue = useMemo(() => (value ? OnOffState.ON : OnOffState.OFF), [value])
const onButtonSelect = useCallback(
(value: number) => {
onSelect(value === OnOffState.ON)
},
[onSelect]
)
return (
<ToggleButtonGroup type='radio' name='dark-mode' value={buttonGroupValue}>
<SettingsToggleButton
onSelect={onButtonSelect}
selected={buttonGroupValue === OnOffState.ON}
value={OnOffState.ON}
i18nKeyTooltip={'common.on'}
i18nKeyLabel={'common.on'}
{...testId('onOffButtonGroupOn')}
/>
<SettingsToggleButton
onSelect={onButtonSelect}
selected={buttonGroupValue === OnOffState.OFF}
value={OnOffState.OFF}
i18nKeyTooltip={'common.off'}
i18nKeyLabel={'common.off'}
{...testId('onOffButtonGroupOff')}
/>
</ToggleButtonGroup>
)
}

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React from 'react'
import { Col, ListGroup, Row } from 'react-bootstrap'
import { Trans } from 'react-i18next'
export interface SettingLineProps {
i18nKey: string
}
/**
* Renders one setting with label and help text
*
* @param i18nKey The i18n key that is used as namespace for label and help
* @param children The setting control that should be placed in this line
*/
export const SettingLine: React.FC<PropsWithChildren<SettingLineProps>> = ({ i18nKey, children }) => {
return (
<ListGroup.Item>
<Row>
<Col md={3}>
<Trans i18nKey={`settings.${i18nKey}.label`} />
</Col>
<Col md={4}>{children}</Col>
<Col md={5}>
<Trans i18nKey={`settings.${i18nKey}.help`} />
</Col>
</Row>
</ListGroup.Item>
)
}

View file

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo } from 'react'
import type { ButtonProps } from 'react-bootstrap'
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import type { PropsWithDataTestId } from '../../../../utils/test-id'
type DarkModeToggleButtonProps = Omit<ButtonProps, 'onSelect'> &
PropsWithDataTestId & {
onSelect: (value: number) => void
selected: boolean
value: number
i18nKeyLabel: string
i18nKeyTooltip: string
}
/**
* A button that is used in a toggle group to change settings.
*
* @param settingI18nKey The partial i18n key in the "settings" namespace for the setting
* @param selected Defines if the button should be rendered as selected
* @param onSelect Callback that is executed when the button is selected
* @param value The value of the button that is sent back through the onSelect callback
* @param props Other button props
* @constructor
*/
export const SettingsToggleButton = ({
i18nKeyLabel,
i18nKeyTooltip,
selected,
onSelect,
value,
...props
}: DarkModeToggleButtonProps) => {
const { t } = useTranslation()
const title = useMemo(() => t(i18nKeyTooltip), [i18nKeyTooltip, t])
const onChange = useCallback(() => {
if (!selected) {
onSelect(value)
}
}, [onSelect, selected, value])
return (
<Button {...props} variant={selected ? 'secondary' : 'outline-secondary'} title={title} onClick={onChange}>
<Trans i18nKey={i18nKeyLabel} />
</Button>
)
}

View file

@ -8,7 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ScrollState } from '../editor-page/synced-scroll/scroll-props' import type { ScrollState } from '../editor-page/synced-scroll/scroll-props'
import type { BaseConfiguration } from './window-post-message-communicator/rendering-message' import type { BaseConfiguration } from './window-post-message-communicator/rendering-message'
import { CommunicationMessageType, RendererType } from './window-post-message-communicator/rendering-message' import { CommunicationMessageType, RendererType } from './window-post-message-communicator/rendering-message'
import { setDarkMode } from '../../redux/dark-mode/methods' import { setDarkModePreference } from '../../redux/dark-mode/methods'
import { MarkdownDocument } from './markdown-document' import { MarkdownDocument } from './markdown-document'
import { countWords } from './word-counter' import { countWords } from './word-counter'
import { useRendererToEditorCommunicator } from '../editor-page/render-context/renderer-to-editor-communicator-context-provider' import { useRendererToEditorCommunicator } from '../editor-page/render-context/renderer-to-editor-communicator-context-provider'
@ -55,7 +55,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
useRendererReceiveHandler( useRendererReceiveHandler(
CommunicationMessageType.SET_DARKMODE, CommunicationMessageType.SET_DARKMODE,
useCallback((values) => setDarkMode(values.activated), []) useCallback((values) => setDarkModePreference(values.preference), [])
) )
useRendererReceiveHandler( useRendererReceiveHandler(

View file

@ -5,6 +5,7 @@
*/ */
import type { ScrollState } from '../../editor-page/synced-scroll/scroll-props' import type { ScrollState } from '../../editor-page/synced-scroll/scroll-props'
import type { SlideOptions } from '../../../redux/note-details/types/slide-show-options' import type { SlideOptions } from '../../../redux/note-details/types/slide-show-options'
import type { DarkModePreference } from '../../../redux/dark-mode/types'
export enum CommunicationMessageType { export enum CommunicationMessageType {
SET_MARKDOWN_CONTENT = 'SET_MARKDOWN_CONTENT', SET_MARKDOWN_CONTENT = 'SET_MARKDOWN_CONTENT',
@ -29,7 +30,7 @@ export interface NoPayloadMessage<TYPE extends CommunicationMessageType> {
export interface SetDarkModeMessage { export interface SetDarkModeMessage {
type: CommunicationMessageType.SET_DARKMODE type: CommunicationMessageType.SET_DARKMODE
activated: boolean preference: DarkModePreference
} }
export interface ExtensionEvent { export interface ExtensionEvent {

View file

@ -9,12 +9,18 @@ import { FlowChart } from './flowchart'
import type * as flowchartJsModule from 'flowchart.js' import type * as flowchartJsModule from 'flowchart.js'
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n' import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
import { StoreProvider } from '../../../redux/store-provider' import { StoreProvider } from '../../../redux/store-provider'
import * as useMediaQuery from '@restart/hooks/useMediaQuery'
jest.mock('@restart/hooks/useMediaQuery')
describe('Flowchart', () => { describe('Flowchart', () => {
const successText = 'Flowchart rendering succeeded!' const successText = 'Flowchart rendering succeeded!'
const expectedValidFlowchartCode = 'test code' const expectedValidFlowchartCode = 'test code'
beforeAll(() => mockI18n()) beforeAll(async () => {
jest.spyOn(useMediaQuery, 'default').mockImplementation(() => false)
await mockI18n()
})
afterEach(() => { afterEach(() => {
jest.resetModules() jest.resetModules()

View file

@ -13,7 +13,7 @@ import type { CodeProps } from '../../../components/markdown-renderer/replace-co
import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary' import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary'
import { ShowIf } from '../../../components/common/show-if/show-if' import { ShowIf } from '../../../components/common/show-if/show-if'
import { testId } from '../../../utils/test-id' import { testId } from '../../../utils/test-id'
import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated' import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
import { Logger } from '../../../utils/logger' import { Logger } from '../../../utils/logger'
const log = new Logger('FlowChart') const log = new Logger('FlowChart')
@ -27,7 +27,7 @@ const log = new Logger('FlowChart')
export const FlowChart: React.FC<CodeProps> = ({ code }) => { export const FlowChart: React.FC<CodeProps> = ({ code }) => {
const diagramRef = useRef<HTMLDivElement>(null) const diagramRef = useRef<HTMLDivElement>(null)
const [syntaxError, setSyntaxError] = useState(false) const [syntaxError, setSyntaxError] = useState(false)
const darkModeActivated = useIsDarkModeActivated() const darkModeActivated = useDarkModeState()
useTranslation() useTranslation()

View file

@ -5,22 +5,48 @@
*/ */
import { useEffect } from 'react' import { useEffect } from 'react'
import { useIsDarkModeActivated } from './use-is-dark-mode-activated' import { useApplicationState } from './use-application-state'
import { isClientSideRendering } from '../../utils/is-client-side-rendering'
import { Logger } from '../../utils/logger'
import useMediaQuery from '@restart/hooks/useMediaQuery'
import { DarkModePreference } from '../../redux/dark-mode/types'
const logger = new Logger('useApplyDarkMode')
export const DARK_MODE_LOCAL_STORAGE_KEY = 'forcedDarkMode'
/** /**
* Applies the `dark` css class to the body tag according to the dark mode state. * Applies the `dark` css class to the body tag according to the dark mode state.
*/ */
export const useApplyDarkMode = (): void => { export const useApplyDarkMode = (): void => {
const darkModeActivated = useIsDarkModeActivated() const preference = useApplicationState((state) => state.darkMode.darkModePreference)
const isBrowserPreferringDark = useMediaQuery('(prefers-color-scheme: dark)')
useEffect(() => saveToLocalStorage(preference), [preference])
useEffect(() => { useEffect(() => {
if (darkModeActivated) { if (preference === DarkModePreference.DARK || (preference === DarkModePreference.AUTO && isBrowserPreferringDark)) {
window.document.body.classList.add('dark') window.document.body.classList.add('dark')
} else { } else {
window.document.body.classList.remove('dark') window.document.body.classList.remove('dark')
} }
return () => { }, [isBrowserPreferringDark, preference])
window.document.body.classList.remove('dark')
} useEffect(() => () => window.document.body.classList.remove('dark'), [])
}, [darkModeActivated]) }
export const saveToLocalStorage = (preference: DarkModePreference): void => {
if (!isClientSideRendering()) {
return
}
try {
if (preference === DarkModePreference.DARK) {
window.localStorage.setItem(DARK_MODE_LOCAL_STORAGE_KEY, 'dark')
} else if (preference === DarkModePreference.LIGHT) {
window.localStorage.setItem(DARK_MODE_LOCAL_STORAGE_KEY, 'light')
} else if (preference === DarkModePreference.AUTO) {
window.localStorage.removeItem(DARK_MODE_LOCAL_STORAGE_KEY)
}
} catch (error) {
logger.error('Saving to local storage failed', error)
}
} }

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from './use-application-state'
import useMediaQuery from '@restart/hooks/useMediaQuery'
import { DarkModePreference } from '../../redux/dark-mode/types'
/**
* Uses the user settings and the browser preference to determine if dark mode should be used.
*
* @return The current state of the dark mode.
*/
export const useDarkModeState = (): boolean => {
const preference = useApplicationState((state) => state.darkMode.darkModePreference)
const isBrowserPreferringDark = useMediaQuery('(prefers-color-scheme: dark)')
return preference === DarkModePreference.DARK || (preference === DarkModePreference.AUTO && isBrowserPreferringDark)
}

View file

@ -1,16 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from './use-application-state'
/**
* Extracts the current state of the dark mode from the global application state.
*
* @return The current state of the dark mode.
*/
export const useIsDarkModeActivated = (): boolean => {
return useApplicationState((state) => state.darkMode.darkMode)
}

View file

@ -1,30 +1,16 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { store } from '..' import { store } from '..'
import type { DarkModeConfig, SetDarkModeConfigAction } from './types' import type { DarkModeConfigAction, DarkModePreference } from './types'
import { DarkModeConfigActionType } from './types' import { DarkModeConfigActionType } from './types'
import { Logger } from '../../utils/logger'
const log = new Logger('Redux > DarkMode') export const setDarkModePreference = (darkModePreference: DarkModePreference): void => {
export const setDarkMode = (darkMode: boolean): void => {
store.dispatch({ store.dispatch({
type: DarkModeConfigActionType.SET_DARK_MODE, type: DarkModeConfigActionType.SET_DARK_MODE,
darkMode: darkMode darkModePreference
} as SetDarkModeConfigAction) } as DarkModeConfigAction)
}
export const saveToLocalStorage = (darkModeConfig: DarkModeConfig): void => {
if (!window) {
return
}
try {
window.localStorage.setItem('nightMode', String(darkModeConfig.darkMode))
} catch (error) {
log.error('Saving to local storage failed', error)
}
} }

View file

@ -1,31 +1,26 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Reducer } from 'redux' import type { Reducer } from 'redux'
import { saveToLocalStorage } from './methods' import type { DarkModeConfig, DarkModeConfigAction } from './types'
import type { DarkModeConfig, DarkModeConfigActions } from './types' import { DarkModeConfigActionType, DarkModePreference } from './types'
import { DarkModeConfigActionType } from './types'
const initalState: DarkModeConfig = { const initialState: DarkModeConfig = {
darkMode: false darkModePreference: DarkModePreference.AUTO
} }
export const DarkModeConfigReducer: Reducer<DarkModeConfig, DarkModeConfigActions> = ( export const DarkModeConfigReducer: Reducer<DarkModeConfig, DarkModeConfigAction> = (
state: DarkModeConfig = initalState, state: DarkModeConfig = initialState,
action: DarkModeConfigActions action: DarkModeConfigAction
) => { ) => {
let darkModeConfigState: DarkModeConfig
switch (action.type) { switch (action.type) {
case DarkModeConfigActionType.SET_DARK_MODE: case DarkModeConfigActionType.SET_DARK_MODE:
darkModeConfigState = { return {
...state, darkModePreference: action.darkModePreference
darkMode: action.darkMode
} }
saveToLocalStorage(darkModeConfigState)
return darkModeConfigState
default: default:
return state return state
} }

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -10,13 +10,14 @@ export enum DarkModeConfigActionType {
SET_DARK_MODE = 'dark-mode/set' SET_DARK_MODE = 'dark-mode/set'
} }
export enum DarkModePreference {
DARK,
LIGHT,
AUTO
}
export interface DarkModeConfig { export interface DarkModeConfig {
darkMode: boolean darkModePreference: DarkModePreference
} }
export type DarkModeConfigActions = SetDarkModeConfigAction export type DarkModeConfigAction = Action<DarkModeConfigActionType.SET_DARK_MODE> & DarkModeConfig
export interface SetDarkModeConfigAction extends Action<DarkModeConfigActionType> {
type: DarkModeConfigActionType.SET_DARK_MODE
darkMode: boolean
}

View file

@ -18,18 +18,14 @@ export interface PropsWithDataCypressId {
* @param identifier The identifier that is used to find the element * @param identifier The identifier that is used to find the element
* @return An object if in test mode, {@link undefined} otherwise. * @return An object if in test mode, {@link undefined} otherwise.
*/ */
export const cypressId = ( export const cypressId = (identifier: string | undefined | PropsWithDataCypressId): PropsWithDataCypressId => {
identifier: string | undefined | PropsWithDataCypressId
): Record<'data-cypress-id', string> | undefined => {
if (!isTestMode || !identifier) { if (!isTestMode || !identifier) {
return return {}
} }
const attributeContent = typeof identifier === 'string' ? identifier : identifier['data-cypress-id'] const attributeContent = typeof identifier === 'string' ? identifier : identifier['data-cypress-id']
if (attributeContent !== undefined) { return attributeContent !== undefined ? { 'data-cypress-id': attributeContent } : {}
return { 'data-cypress-id': attributeContent }
}
} }
/** /**

View file

@ -4,6 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export interface PropsWithDataTestId {
'data-testid'?: string | undefined
}
/** /**
* Returns an object with the "data-testid" attribute that is used to find * Returns an object with the "data-testid" attribute that is used to find
* elements in unit tests. * elements in unit tests.
@ -12,8 +16,6 @@
* @param identifier The identifier that is used to find the element * @param identifier The identifier that is used to find the element
* @return An object if in test mode, undefined otherwise. * @return An object if in test mode, undefined otherwise.
*/ */
export const testId = (identifier: string): Record<'data-testid', string> | undefined => { export const testId = (identifier: string): PropsWithDataTestId => {
if (process.env.NODE_ENV === 'test') { return process.env.NODE_ENV === 'test' ? { 'data-testid': identifier } : {}
return { 'data-testid': identifier }
}
} }