diff --git a/frontend/locales/en.json b/frontend/locales/en.json index f1b015e5f..39d19a619 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -609,9 +609,19 @@ "label": "Line Wrapping", "help": "Breaks long lines so they're visible without scrolling." }, - "spellCheck":{ + "spellCheck": { "label": "Spell checking", "help": "Enables browser spell checking." + }, + "indentWithTabs": { + "label": "Indentation character", + "help": "Sets which characters should be used for indentation", + "tabs": "Tabs", + "spaces": "Spaces" + }, + "indentSpaces": { + "label": "Indentation size", + "help": "Sets the amount of spaces for indentation." } }, "global": { diff --git a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx index 90226d11e..a743d3ac9 100644 --- a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx @@ -45,6 +45,7 @@ import ReactCodeMirror from '@uiw/react-codemirror' import React, { useCallback, useEffect, useMemo } from 'react' import { useUiNotifications } from '../../notifications/ui-notification-boundary' import { Lock as IconLock } from 'react-bootstrap-icons' +import { useCodeMirrorIndentationExtension } from './hooks/codemirror-extensions/use-code-mirror-indentation-extension' export type EditorPaneProps = ScrollProps @@ -71,6 +72,7 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, o const fileInsertExtension = useCodeMirrorFileInsertExtension() const spellCheckExtension = useCodeMirrorSpellCheckExtension() const lineWrappingExtension = useCodeMirrorLineWrappingExtension() + const indentationExtension = useCodeMirrorIndentationExtension() const cursorActivityExtension = useCursorActivityCallback() const autoCompletionExtension = useCodeMirrorAutocompletionsExtension() @@ -107,7 +109,8 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, o cursorActivityExtension, updateViewContextExtension, yjsExtension, - spellCheckExtension + spellCheckExtension, + indentationExtension ], [ linterExtension, @@ -120,7 +123,8 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, o updateViewContextExtension, yjsExtension, spellCheckExtension, - lineWrappingExtension + lineWrappingExtension, + indentationExtension ] ) diff --git a/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-indentation-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-indentation-extension.ts new file mode 100644 index 000000000..9e94706f7 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-indentation-extension.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useApplicationState } from '../../../../../hooks/common/use-application-state' +import type { Extension } from '@codemirror/state' +import { useMemo } from 'react' +import { indentUnit } from '@codemirror/language' + +/** + * Creates a {@link Extension codemirror extension} that manages the indentation config. + */ +export const useCodeMirrorIndentationExtension = (): Extension => { + const indentWithTabs = useApplicationState((state) => state.editorConfig.indentWithTabs) + const indentSpaces = useApplicationState((state) => state.editorConfig.indentSpaces) + + return useMemo(() => indentUnit.of(indentWithTabs ? '\t' : ' '.repeat(indentSpaces)), [indentWithTabs, indentSpaces]) +} diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/__snapshots__/editor-settings-tab-content.spec.tsx.snap b/frontend/src/components/global-dialogs/settings-dialog/editor/__snapshots__/editor-settings-tab-content.spec.tsx.snap new file mode 100644 index 000000000..1efd62df1 --- /dev/null +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/__snapshots__/editor-settings-tab-content.spec.tsx.snap @@ -0,0 +1,587 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorSettingsTabContent hides space settings indentWithTabs is true 1`] = ` +
+
+
+
+
+ settings.editor.ligatures.label +
+
+
+ + +
+
+
+ settings.editor.ligatures.help +
+
+
+
+
+
+ settings.editor.smartPaste.label +
+
+
+ + +
+
+
+ settings.editor.smartPaste.help +
+
+
+
+
+
+ settings.editor.syncScroll.label +
+
+
+ + +
+
+
+ settings.editor.syncScroll.help +
+
+
+
+
+
+ settings.editor.lineWrapping.label +
+
+
+ + +
+
+
+ settings.editor.lineWrapping.help +
+
+
+
+
+
+ settings.editor.spellCheck.label +
+
+
+ + +
+
+
+ settings.editor.spellCheck.help +
+
+
+
+
+
+ settings.editor.indentWithTabs.label +
+
+
+ + +
+
+
+ settings.editor.indentWithTabs.help +
+
+
+
+
+`; + +exports[`EditorSettingsTabContent renders space settings when indentWithTabs is false 1`] = ` +
+
+
+
+
+ settings.editor.ligatures.label +
+
+
+ + +
+
+
+ settings.editor.ligatures.help +
+
+
+
+
+
+ settings.editor.smartPaste.label +
+
+
+ + +
+
+
+ settings.editor.smartPaste.help +
+
+
+
+
+
+ settings.editor.syncScroll.label +
+
+
+ + +
+
+
+ settings.editor.syncScroll.help +
+
+
+
+
+
+ settings.editor.lineWrapping.label +
+
+
+ + +
+
+
+ settings.editor.lineWrapping.help +
+
+
+
+
+
+ settings.editor.spellCheck.label +
+
+
+ + +
+
+
+ settings.editor.spellCheck.help +
+
+
+
+
+
+ settings.editor.indentWithTabs.label +
+
+
+ + +
+
+
+ settings.editor.indentWithTabs.help +
+
+
+
+
+
+ settings.editor.indentSpaces.label +
+
+ +
+
+ settings.editor.indentSpaces.help +
+
+
+
+
+`; diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.spec.tsx b/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.spec.tsx new file mode 100644 index 000000000..83f9f5f1c --- /dev/null +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.spec.tsx @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mockAppState } from '../../../../test-utils/mock-app-state' +import { render } from '@testing-library/react' +import { EditorSettingsTabContent } from './editor-settings-tab-content' +import { mockI18n } from '../../../../test-utils/mock-i18n' + +jest.mock('../../../../hooks/common/use-application-state') + +describe('EditorSettingsTabContent', () => { + beforeEach(async () => { + await mockI18n() + }) + + afterEach(() => { + jest.resetAllMocks() + jest.resetModules() + }) + + it('renders space settings when indentWithTabs is false', () => { + mockAppState({ + editorConfig: { + syncScroll: false, + spellCheck: false, + smartPaste: false, + lineWrapping: false, + ligatures: false, + indentWithTabs: false, + indentSpaces: 7 + } + }) + const view = render() + expect(view.container).toMatchSnapshot() + }) + + it('hides space settings indentWithTabs is true', () => { + mockAppState({ + editorConfig: { + syncScroll: false, + spellCheck: false, + smartPaste: false, + lineWrapping: false, + ligatures: false, + indentWithTabs: true, + indentSpaces: 7 + } + }) + const view = render() + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx b/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx index 65441b121..49e42231e 100644 --- a/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/editor-settings-tab-content.tsx @@ -12,12 +12,16 @@ import React from 'react' import { ListGroup } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { SpellcheckSettingButtonGroup } from './spellcheck-setting-button-group' +import { IndentWithTabsSettingButtonGroup } from './indent-with-tabs-setting-button-group' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { IndentSpacesSettingInput } from './indent-spaces-setting-input' /** * Shows the editor specific settings. */ export const EditorSettingsTabContent: React.FC = () => { useTranslation() + const useTabs = useApplicationState((state) => state.editorConfig.indentWithTabs) return ( @@ -36,6 +40,14 @@ export const EditorSettingsTabContent: React.FC = () => { + + + + {!useTabs && ( + + + + )} ) } diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/indent-spaces-setting-input.tsx b/frontend/src/components/global-dialogs/settings-dialog/editor/indent-spaces-setting-input.tsx new file mode 100644 index 000000000..250365f7e --- /dev/null +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/indent-spaces-setting-input.tsx @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React from 'react' +import { Form } from 'react-bootstrap' +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { setEditorIndentSpaces } from '../../../../redux/editor/methods' +import { useCallback } from 'react' + +/** + * Input to change the number of spaces that are used for indentation in the editor. + */ +export const IndentSpacesSettingInput: React.FC = () => { + const spaces = useApplicationState((state) => state.editorConfig.indentSpaces) + + const onChangeHandler = useCallback((event: React.ChangeEvent) => { + const value = parseInt(event.target.value) + if (value > 0) { + setEditorIndentSpaces(value) + } + }, []) + + return +} diff --git a/frontend/src/components/global-dialogs/settings-dialog/editor/indent-with-tabs-setting-button-group.tsx b/frontend/src/components/global-dialogs/settings-dialog/editor/indent-with-tabs-setting-button-group.tsx new file mode 100644 index 000000000..5ecb9901b --- /dev/null +++ b/frontend/src/components/global-dialogs/settings-dialog/editor/indent-with-tabs-setting-button-group.tsx @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useApplicationState } from '../../../../hooks/common/use-application-state' +import { setEditorIndentWithTabs } from '../../../../redux/editor/methods' +import { OnOffButtonGroup } from '../utils/on-off-button-group' +import React from 'react' + +/** + * Allows to change whether spellchecking is enabled or not in the editor. + */ +export const IndentWithTabsSettingButtonGroup: React.FC = () => { + const enabled = useApplicationState((state) => state.editorConfig.indentWithTabs) + return ( + + ) +} diff --git a/frontend/src/components/global-dialogs/settings-dialog/utils/__snapshots__/on-off-button-group.spec.tsx.snap b/frontend/src/components/global-dialogs/settings-dialog/utils/__snapshots__/on-off-button-group.spec.tsx.snap index 3b22db939..17dd70d3c 100644 --- a/frontend/src/components/global-dialogs/settings-dialog/utils/__snapshots__/on-off-button-group.spec.tsx.snap +++ b/frontend/src/components/global-dialogs/settings-dialog/utils/__snapshots__/on-off-button-group.spec.tsx.snap @@ -1,5 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Settings On-Off Button Group accepts custom labels 1`] = ` +
+
+ + +
+
+`; + exports[`Settings On-Off Button Group can switch value 1`] = `
{ }) expect(value).toBeFalsy() }) + + it('accepts custom labels', () => { + const view = render( + {}} + overrideButtonOnI18nKey={'test.custom-on'} + overrideButtonOffI18nKey={'test.custom-off'} + /> + ) + expect(view.container).toMatchSnapshot() + }) }) diff --git a/frontend/src/components/global-dialogs/settings-dialog/utils/on-off-button-group.tsx b/frontend/src/components/global-dialogs/settings-dialog/utils/on-off-button-group.tsx index ae2dd2001..df2be405a 100644 --- a/frontend/src/components/global-dialogs/settings-dialog/utils/on-off-button-group.tsx +++ b/frontend/src/components/global-dialogs/settings-dialog/utils/on-off-button-group.tsx @@ -16,6 +16,8 @@ enum OnOffState { export interface OnOffButtonGroupProps { value: boolean onSelect: (value: boolean) => void + overrideButtonOnI18nKey?: string + overrideButtonOffI18nKey?: string } /** @@ -23,8 +25,15 @@ export interface OnOffButtonGroupProps { * * @param onSelect callback that is executed if the on/off value has changed * @param value the current on/off value that should be visible + * @param overrideButtonOnI18nKey Set to override the i18n key for the on-button + * @param overrideButtonOffI18nKey Set to override the i18n key for the off-button */ -export const OnOffButtonGroup: React.FC = ({ onSelect, value }) => { +export const OnOffButtonGroup: React.FC = ({ + onSelect, + value, + overrideButtonOffI18nKey, + overrideButtonOnI18nKey +}) => { const buttonGroupValue = useMemo(() => (value ? OnOffState.ON : OnOffState.OFF), [value]) const onButtonSelect = useCallback( (value: OnOffState) => { @@ -39,16 +48,16 @@ export const OnOffButtonGroup: React.FC = ({ onSelect, va onSelect={onButtonSelect} selected={buttonGroupValue === OnOffState.ON} value={OnOffState.ON} - i18nKeyTooltip={'common.on'} - i18nKeyLabel={'common.on'} + i18nKeyTooltip={overrideButtonOnI18nKey ?? 'common.on'} + i18nKeyLabel={overrideButtonOnI18nKey ?? 'common.on'} {...testId('onOffButtonGroupOn')} /> diff --git a/frontend/src/redux/editor/methods.ts b/frontend/src/redux/editor/methods.ts index 0fbe04b3a..6ed0c8e8e 100644 --- a/frontend/src/redux/editor/methods.ts +++ b/frontend/src/redux/editor/methods.ts @@ -10,7 +10,9 @@ import type { SetEditorLineWrappingAction, SetEditorSmartPasteAction, SetEditorSyncScrollAction, - SetEditorSpellCheckAction + SetEditorSpellCheckAction, + SetEditorIndentWithTabsAction, + SetEditorIndentSpacesAction } from './types' import { EditorConfigActionType } from './types' @@ -54,6 +56,22 @@ export const setEditorSpellCheck = (spellCheck: boolean): void => { store.dispatch(action) } +export const setEditorIndentWithTabs = (indentWithTabs: boolean): void => { + const action: SetEditorIndentWithTabsAction = { + type: EditorConfigActionType.SET_INDENT_WITH_TABS, + indentWithTabs + } + store.dispatch(action) +} + +export const setEditorIndentSpaces = (indentSpaces: number): void => { + const action: SetEditorIndentSpacesAction = { + type: EditorConfigActionType.SET_INDENT_SPACES, + indentSpaces + } + store.dispatch(action) +} + export const loadFromLocalStorage = (): void => { const action: LoadFromLocalStorageAction = { type: EditorConfigActionType.LOAD_FROM_LOCAL_STORAGE diff --git a/frontend/src/redux/editor/reducers.ts b/frontend/src/redux/editor/reducers.ts index 9e826f645..f24db2bf6 100644 --- a/frontend/src/redux/editor/reducers.ts +++ b/frontend/src/redux/editor/reducers.ts @@ -15,7 +15,9 @@ export const initialState: EditorConfig = { syncScroll: true, smartPaste: true, spellCheck: true, - lineWrapping: true + lineWrapping: true, + indentWithTabs: false, + indentSpaces: 2 } export const EditorConfigReducer: Reducer = ( @@ -61,6 +63,20 @@ export const EditorConfigReducer: Reducer = ( } saveToLocalStorage(newState) return newState + case EditorConfigActionType.SET_INDENT_WITH_TABS: + newState = { + ...state, + indentWithTabs: action.indentWithTabs + } + saveToLocalStorage(newState) + return newState + case EditorConfigActionType.SET_INDENT_SPACES: + newState = { + ...state, + indentSpaces: action.indentSpaces + } + saveToLocalStorage(newState) + return newState default: return state } @@ -72,13 +88,15 @@ export const loadFromLocalStorage = (): EditorConfig | undefined => { if (!stored) { return undefined } - const storedConfiguration = JSON.parse(stored) as Record + const storedConfiguration = JSON.parse(stored) as Partial return { ligatures: storedConfiguration?.ligatures === true ?? true, syncScroll: storedConfiguration?.syncScroll === true ?? true, smartPaste: storedConfiguration?.smartPaste === true ?? true, - spellCheck: storedConfiguration?.spellCheck === true ?? false, - lineWrapping: storedConfiguration?.lineWrapping === true ?? true + spellCheck: storedConfiguration?.spellCheck === true ?? true, + lineWrapping: storedConfiguration?.lineWrapping === true ?? true, + indentWithTabs: storedConfiguration?.indentWithTabs === true ?? false, + indentSpaces: storedConfiguration?.indentSpaces ?? 2 } } catch (_) { return undefined diff --git a/frontend/src/redux/editor/types.ts b/frontend/src/redux/editor/types.ts index cfd95a14c..e88d194ed 100644 --- a/frontend/src/redux/editor/types.ts +++ b/frontend/src/redux/editor/types.ts @@ -6,13 +6,14 @@ import type { Action } from 'redux' export enum EditorConfigActionType { - SET_EDITOR_VIEW_MODE = 'editor/view-mode/set', SET_SYNC_SCROLL = 'editor/syncScroll/set', LOAD_FROM_LOCAL_STORAGE = 'editor/preferences/load', SET_LIGATURES = 'editor/preferences/setLigatures', SET_LINE_WRAPPING = 'editor/preferences/setLineWrapping', SET_SMART_PASTE = 'editor/preferences/setSmartPaste', - SET_SPELL_CHECK = 'editor/preferences/setSpellCheck' + SET_SPELL_CHECK = 'editor/preferences/setSpellCheck', + SET_INDENT_WITH_TABS = 'editor/preferences/setIndentWithTabs', + SET_INDENT_SPACES = 'editor/preferences/setIndentSpaces' } export interface EditorConfig { @@ -21,6 +22,8 @@ export interface EditorConfig { smartPaste: boolean spellCheck: boolean lineWrapping: boolean + indentWithTabs: boolean + indentSpaces: number } export type EditorConfigActions = @@ -29,6 +32,8 @@ export type EditorConfigActions = | SetEditorSmartPasteAction | SetEditorLineWrappingAction | SetEditorSpellCheckAction + | SetEditorIndentWithTabsAction + | SetEditorIndentSpacesAction | LoadFromLocalStorageAction export interface LoadFromLocalStorageAction extends Action { @@ -59,3 +64,13 @@ export interface SetEditorSpellCheckAction extends Action { + type: EditorConfigActionType.SET_INDENT_WITH_TABS + indentWithTabs: boolean +} + +export interface SetEditorIndentSpacesAction extends Action { + type: EditorConfigActionType.SET_INDENT_SPACES + indentSpaces: number +}