feat(editor): re-add editor mode buttons (edit/both/view)

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-08-09 16:59:27 +02:00 committed by Philip Molares
parent f30f0d8e51
commit f9b6f6851b
11 changed files with 139 additions and 393 deletions

View file

@ -10,6 +10,7 @@ import { NoteTitleElement } from '../../../../../components/layout/app-bar/app-b
import { BaseAppBar } from '../../../../../components/layout/app-bar/base-app-bar' import { BaseAppBar } from '../../../../../components/layout/app-bar/base-app-bar'
import { useApplicationState } from '../../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import React from 'react' import React from 'react'
import { EditorModeExtendedAppBar } from './editor-mode-extended-app-bar'
/** /**
* Renders the EditorAppBar that extends the {@link BaseAppBar} with the note title or realtime connection alert. * Renders the EditorAppBar that extends the {@link BaseAppBar} with the note title or realtime connection alert.
@ -22,15 +23,15 @@ export const EditorAppBar: React.FC = () => {
return <BaseAppBar /> return <BaseAppBar />
} else if (isSynced) { } else if (isSynced) {
return ( return (
<BaseAppBar> <EditorModeExtendedAppBar>
<NoteTitleElement /> <NoteTitleElement />
</BaseAppBar> </EditorModeExtendedAppBar>
) )
} else { } else {
return ( return (
<BaseAppBar> <EditorModeExtendedAppBar>
<RealtimeConnectionAlert /> <RealtimeConnectionAlert />
</BaseAppBar> </EditorModeExtendedAppBar>
) )
} }
} }

View file

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import { useCallback } from 'react'
import React, { Fragment } from 'react'
import { BaseAppBar } from '../../../../../components/layout/app-bar/base-app-bar'
import { ButtonGroup } from 'react-bootstrap'
import { Eye as IconEye, FileText as IconFileText, WindowSplit as IconWindowSplit } from 'react-bootstrap-icons'
import { IconButton } from '../../../../../components/common/icon-button/icon-button'
import { setEditorSplitPosition } from '../../../../../redux/editor-config/methods'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { useTranslatedText } from '../../../../../hooks/common/use-translated-text'
/**
* Extended AppBar for the editor mode that includes buttons to switch between the different editor modes
*/
export const EditorModeExtendedAppBar: React.FC<PropsWithChildren> = ({ children }) => {
const splitValue = useApplicationState((state) => state.editorConfig.splitPosition)
const onClickEditorOnly = useCallback(() => {
setEditorSplitPosition(100)
}, [])
const onClickBothViews = useCallback(() => {
setEditorSplitPosition(50)
}, [])
const onClickViewOnly = useCallback(() => {
setEditorSplitPosition(0)
}, [])
const titleEditorOnly = useTranslatedText('editor.viewMode.edit')
const titleBothViews = useTranslatedText('editor.viewMode.both')
const titleViewOnly = useTranslatedText('editor.viewMode.view')
return (
<BaseAppBar
additionalContentLeft={
<Fragment>
<ButtonGroup>
<IconButton
icon={IconFileText}
title={titleEditorOnly}
onClick={onClickEditorOnly}
variant={splitValue === 100 ? 'secondary' : 'outline-secondary'}
/>
<IconButton
icon={IconWindowSplit}
title={titleBothViews}
onClick={onClickBothViews}
variant={splitValue > 0 && splitValue < 100 ? 'secondary' : 'outline-secondary'}
/>
<IconButton
icon={IconEye}
title={titleViewOnly}
onClick={onClickViewOnly}
variant={splitValue === 0 ? 'secondary' : 'outline-secondary'}
/>
</ButtonGroup>
</Fragment>
}>
{children}
</BaseAppBar>
)
}

View file

@ -59,183 +59,6 @@ exports[`Splitter resize can change size with mouse 1`] = `
</div> </div>
`; `;
exports[`Splitter resize can change size with mouse 2`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="left"
style="width: calc(50% - 5px);"
>
<div
class="inner"
>
left
</div>
</div>
<div
class="divider"
data-testid="splitter-divider"
>
<div
class="middle"
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowLeft
</button>
<span
class="grabber"
>
BootstrapIconMock_ArrowLeftRight
</span>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowRight
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 50%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`;
exports[`Splitter resize can change size with mouse 3`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="left"
style="width: calc(50% - 5px);"
>
<div
class="inner"
>
left
</div>
</div>
<div
class="divider"
data-testid="splitter-divider"
>
<div
class="middle"
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowLeft
</button>
<span
class="grabber"
>
BootstrapIconMock_ArrowLeftRight
</span>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowRight
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 50%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`;
exports[`Splitter resize can change size with mouse 4`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="left"
style="width: calc(50% - 5px);"
>
<div
class="inner"
>
left
</div>
</div>
<div
class="divider"
data-testid="splitter-divider"
>
<div
class="middle"
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowLeft
</button>
<span
class="grabber"
>
BootstrapIconMock_ArrowLeftRight
</span>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowRight
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 50%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`;
exports[`Splitter resize can change size with touch 1`] = ` exports[`Splitter resize can change size with touch 1`] = `
<div> <div>
<div <div
@ -471,180 +294,3 @@ exports[`Splitter resize can change size with touch 4`] = `
</div> </div>
</div> </div>
`; `;
exports[`Splitter resize can react to shortcuts 1`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="left"
style="width: calc(0% - 5px);"
>
<div
class="inner"
>
left
</div>
</div>
<div
class="divider"
data-testid="splitter-divider"
>
<div
class="middle shift-right"
>
<div
class="buttons"
>
<button
class="btn btn-secondary"
type="button"
>
BootstrapIconMock_ArrowLeft
</button>
<span
class="grabber"
>
BootstrapIconMock_ArrowLeftRight
</span>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowRight
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 0%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`;
exports[`Splitter resize can react to shortcuts 2`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="left"
style="width: calc(100% - 5px);"
>
<div
class="inner"
>
left
</div>
</div>
<div
class="divider"
data-testid="splitter-divider"
>
<div
class="middle shift-left"
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowLeft
</button>
<span
class="grabber"
>
BootstrapIconMock_ArrowLeftRight
</span>
<button
class="btn btn-secondary"
type="button"
>
BootstrapIconMock_ArrowRight
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 100%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`;
exports[`Splitter resize can react to shortcuts 3`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="left"
style="width: calc(50% - 5px);"
>
<div
class="inner"
>
left
</div>
</div>
<div
class="divider"
data-testid="splitter-divider"
>
<div
class="middle"
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowLeft
</button>
<span
class="grabber"
>
BootstrapIconMock_ArrowLeftRight
</span>
<button
class="btn btn-light"
type="button"
>
BootstrapIconMock_ArrowRight
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 50%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`;

View file

@ -1,30 +1,29 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useEffect } from 'react' import { useEffect } from 'react'
import { setEditorSplitPosition } from '../../../../redux/editor-config/methods'
/** /**
* Binds global keyboard shortcuts for setting the split value. * Binds global keyboard shortcuts for setting the split value.
*
* @param setRelativeSplitValue A function that is used to set the split value
*/ */
export const useKeyboardShortcuts = (setRelativeSplitValue: (value: number) => void) => { export const useKeyboardShortcuts = () => {
useEffect(() => { useEffect(() => {
const shortcutHandler = (event: KeyboardEvent): void => { const shortcutHandler = (event: KeyboardEvent): void => {
if (event.ctrlKey && event.altKey && event.key === 'b') { if (event.ctrlKey && event.altKey && event.key === 'b') {
setRelativeSplitValue(50) setEditorSplitPosition(50)
event.preventDefault() event.preventDefault()
} }
if (event.ctrlKey && event.altKey && event.key === 'v') { if (event.ctrlKey && event.altKey && event.key === 'v') {
setRelativeSplitValue(0) setEditorSplitPosition(0)
event.preventDefault() event.preventDefault()
} }
if (event.ctrlKey && event.altKey && (event.key === 'e' || event.key === '€')) { if (event.ctrlKey && event.altKey && (event.key === 'e' || event.key === '€')) {
setRelativeSplitValue(100) setEditorSplitPosition(100)
event.preventDefault() event.preventDefault()
} }
} }
@ -33,5 +32,5 @@ export const useKeyboardShortcuts = (setRelativeSplitValue: (value: number) => v
return () => { return () => {
document.removeEventListener('keydown', shortcutHandler, false) document.removeEventListener('keydown', shortcutHandler, false)
} }
}, [setRelativeSplitValue]) }, [])
} }

View file

@ -1,11 +1,19 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Splitter } from './splitter' import { Splitter } from './splitter'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { Mock } from 'ts-mockery' import { Mock } from 'ts-mockery'
import * as EditorConfigModule from '../../../redux/editor-config/methods'
import { mockAppState } from '../../../test-utils/mock-app-state'
import type { EditorConfig } from '../../../redux/editor-config/types'
jest.mock('../../../hooks/common/use-application-state')
jest.mock('../../../redux/editor-config/methods')
const setEditorSplitPosition = jest.spyOn(EditorConfigModule, 'setEditorSplitPosition').mockReturnValue()
describe('Splitter', () => { describe('Splitter', () => {
describe('resize', () => { describe('resize', () => {
@ -15,16 +23,22 @@ describe('Splitter', () => {
}) })
it('can react to shortcuts', () => { it('can react to shortcuts', () => {
const view = render(<Splitter left={<>left</>} right={<>right</>} />) render(<Splitter left={<>left</>} right={<>right</>} />)
fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'v' })) fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'v' }))
expect(view.container).toMatchSnapshot() expect(setEditorSplitPosition).toHaveBeenCalledWith(0)
fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'e' })) fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'e' }))
expect(view.container).toMatchSnapshot() expect(setEditorSplitPosition).toHaveBeenCalledWith(100)
fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'b' })) fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'b' }))
expect(view.container).toMatchSnapshot() expect(setEditorSplitPosition).toHaveBeenCalledWith(50)
}) })
it('can change size with mouse', async () => { it('can change size with mouse', async () => {
mockAppState({
editorConfig: { splitPosition: 50 } as EditorConfig
})
const view = render(<Splitter left={<>left</>} right={<>right</>} />) const view = render(<Splitter left={<>left</>} right={<>right</>} />)
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
const divider = await screen.findByTestId('splitter-divider') const divider = await screen.findByTestId('splitter-divider')
@ -32,15 +46,15 @@ describe('Splitter', () => {
fireEvent.mouseDown(divider, {}) fireEvent.mouseDown(divider, {})
fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 })) fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 }))
fireEvent.mouseUp(window) fireEvent.mouseUp(window)
expect(view.container).toMatchSnapshot() expect(setEditorSplitPosition).toHaveBeenCalledWith(100)
fireEvent.mouseDown(divider, {}) fireEvent.mouseDown(divider, {})
fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 0 })) fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 0 }))
fireEvent.mouseUp(window) fireEvent.mouseUp(window)
expect(view.container).toMatchSnapshot() expect(setEditorSplitPosition).toHaveBeenCalledWith(0)
fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 })) fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 }))
expect(view.container).toMatchSnapshot() expect(setEditorSplitPosition).toHaveBeenCalledWith(100)
}) })
it('can change size with touch', async () => { it('can change size with touch', async () => {

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -8,6 +8,8 @@ import { DividerButtonsShift, SplitDivider } from './split-divider/split-divider
import styles from './splitter.module.scss' import styles from './splitter.module.scss'
import type { MouseEvent, ReactElement, TouchEvent } from 'react' import type { MouseEvent, ReactElement, TouchEvent } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { setEditorSplitPosition } from '../../../redux/editor-config/methods'
export interface SplitterProps { export interface SplitterProps {
left?: ReactElement left?: ReactElement
@ -54,7 +56,7 @@ const SNAP_PERCENTAGE = 10
* @return the created component * @return the created component
*/ */
export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName, left, right }) => { export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName, left, right }) => {
const [relativeSplitValue, setRelativeSplitValue] = useState(50) const relativeSplitValue = useApplicationState((state) => state.editorConfig.splitPosition)
const [resizingInProgress, setResizingInProgress] = useState(false) const [resizingInProgress, setResizingInProgress] = useState(false)
const adjustedRelativeSplitValue = useMemo(() => Math.min(100, Math.max(0, relativeSplitValue)), [relativeSplitValue]) const adjustedRelativeSplitValue = useMemo(() => Math.min(100, Math.max(0, relativeSplitValue)), [relativeSplitValue])
const splitContainer = useRef<HTMLDivElement>(null) const splitContainer = useRef<HTMLDivElement>(null)
@ -73,19 +75,19 @@ export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName
setResizingInProgress(false) setResizingInProgress(false)
}, []) }, [])
/**
* Moves the splitter to the left or right side if the relative split value is close to the edges.
*/
useEffect(() => { useEffect(() => {
if (!resizingInProgress) { if (!resizingInProgress) {
setRelativeSplitValue((value) => { if (relativeSplitValue < SNAP_PERCENTAGE && relativeSplitValue > 0) {
if (value < SNAP_PERCENTAGE) { setEditorSplitPosition(0)
return 0 }
} if (relativeSplitValue > 100 - SNAP_PERCENTAGE && relativeSplitValue < 100) {
if (value > 100 - SNAP_PERCENTAGE) { setEditorSplitPosition(100)
return 100 }
}
return value
})
} }
}, [resizingInProgress]) }, [resizingInProgress, relativeSplitValue])
/** /**
* Recalculates the panel split based on the absolute mouse/touch position. * Recalculates the panel split based on the absolute mouse/touch position.
@ -109,17 +111,17 @@ export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName
const horizontalPositionInSplitContainer = horizontalPosition - splitContainer.current.offsetLeft const horizontalPositionInSplitContainer = horizontalPosition - splitContainer.current.offsetLeft
const newRelativeSize = horizontalPositionInSplitContainer / splitContainer.current.clientWidth const newRelativeSize = horizontalPositionInSplitContainer / splitContainer.current.clientWidth
const number = newRelativeSize * 100 const number = newRelativeSize * 100
setRelativeSplitValue(number) setEditorSplitPosition(number)
moveEvent.preventDefault() moveEvent.preventDefault()
}, []) }, [])
const onLeftButtonClick = useCallback(() => { const onLeftButtonClick = useCallback(() => {
setRelativeSplitValue((value) => (value === 100 ? 50 : 0)) setEditorSplitPosition(relativeSplitValue === 100 ? 50 : 0)
}, []) }, [relativeSplitValue])
const onRightButtonClick = useCallback(() => { const onRightButtonClick = useCallback(() => {
setRelativeSplitValue((value) => (value === 0 ? 50 : 100)) setEditorSplitPosition(relativeSplitValue === 0 ? 50 : 100)
}, []) }, [relativeSplitValue])
const dividerButtonsShift = useMemo(() => { const dividerButtonsShift = useMemo(() => {
if (relativeSplitValue === 0) { if (relativeSplitValue === 0) {
@ -131,7 +133,7 @@ export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName
} }
}, [relativeSplitValue]) }, [relativeSplitValue])
useKeyboardShortcuts(setRelativeSplitValue) useKeyboardShortcuts()
return ( return (
<div <div

View file

@ -16,10 +16,14 @@ import React from 'react'
import { Nav, Navbar } from 'react-bootstrap' import { Nav, Navbar } from 'react-bootstrap'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
export interface BaseAppBarProps {
additionalContentLeft?: React.ReactNode
}
/** /**
* Renders the base app bar with branding, help, settings user elements. * Renders the base app bar with branding, help, settings user elements.
*/ */
export const BaseAppBar: React.FC<PropsWithChildren> = ({ children }) => { export const BaseAppBar: React.FC<PropsWithChildren<BaseAppBarProps>> = ({ children, additionalContentLeft }) => {
return ( return (
<Navbar <Navbar
expand={true} expand={true}
@ -27,6 +31,7 @@ export const BaseAppBar: React.FC<PropsWithChildren> = ({ children }) => {
{...cypressId('base-app-bar')}> {...cypressId('base-app-bar')}>
<Nav className={`align-items-center justify-content-start gap-2 flex-grow-1 ${styles.side}`}> <Nav className={`align-items-center justify-content-start gap-2 flex-grow-1 ${styles.side}`}>
<BrandingElement /> <BrandingElement />
{additionalContentLeft}
</Nav> </Nav>
<Nav className={`align-items-center flex-fill overflow-hidden px-4 ${styles.center}`}>{children}</Nav> <Nav className={`align-items-center flex-fill overflow-hidden px-4 ${styles.center}`}>{children}</Nav>
<Nav className={`align-items-stretch justify-content-end flex-grow-1 ${styles.side} h-100 py-1`}> <Nav className={`align-items-stretch justify-content-end flex-grow-1 ${styles.side} h-100 py-1`}>

View file

@ -6,6 +6,7 @@
import type { EditorConfig } from './types' import type { EditorConfig } from './types'
export const initialState: EditorConfig = { export const initialState: EditorConfig = {
splitPosition: 50,
ligatures: true, ligatures: true,
syncScroll: true, syncScroll: true,
smartPaste: true, smartPaste: true,

View file

@ -12,6 +12,12 @@ import { Logger } from '../../utils/logger'
const log = new Logger('Redux > EditorConfig') const log = new Logger('Redux > EditorConfig')
export const setEditorSplitPosition = (splitPosition: number): void => {
const action = editorConfigActionsCreator.setSplitPosition(splitPosition)
store.dispatch(action)
saveToLocalStorage()
}
export const setEditorSyncScroll = (syncScroll: boolean): void => { export const setEditorSyncScroll = (syncScroll: boolean): void => {
const action = editorConfigActionsCreator.setSyncScroll(syncScroll) const action = editorConfigActionsCreator.setSyncScroll(syncScroll)
store.dispatch(action) store.dispatch(action)

View file

@ -12,6 +12,9 @@ const editorConfigSlice = createSlice({
name: 'editorConfig', name: 'editorConfig',
initialState, initialState,
reducers: { reducers: {
setSplitPosition: (state, action: PayloadAction<EditorConfig['splitPosition']>) => {
state.splitPosition = action.payload
},
setSyncScroll: (state, action: PayloadAction<EditorConfig['syncScroll']>) => { setSyncScroll: (state, action: PayloadAction<EditorConfig['syncScroll']>) => {
state.syncScroll = action.payload state.syncScroll = action.payload
}, },

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export interface EditorConfig { export interface EditorConfig {
splitPosition: number
syncScroll: boolean syncScroll: boolean
ligatures: boolean ligatures: boolean
smartPaste: boolean smartPaste: boolean