feat(splitter): add snapping, icon and buttons to splitter

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-10-16 23:15:52 +02:00
parent 0984a84a68
commit 5ba1e9e565
19 changed files with 822 additions and 460 deletions

View file

@ -10,7 +10,6 @@ const title = 'This is a test title'
describe('Document Title', () => { describe('Document Title', () => {
beforeEach(() => { beforeEach(() => {
cy.visitTestNote() cy.visitTestNote()
cy.getByCypressId('view-mode-both').should('exist')
}) })
describe('title should be yaml metadata title', () => { describe('title should be yaml metadata title', () => {

View file

@ -1,25 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PAGE_MODE } from '../support/visit'
describe('Editor mode from URL parameter is used', () => {
it('mode view', () => {
cy.visitTestNote(PAGE_MODE.EDITOR, 'view')
cy.getByCypressId('editor-pane').should('not.be.visible')
cy.getByCypressId('documentIframe').should('be.visible')
})
it('mode both', () => {
cy.visitTestNote(PAGE_MODE.EDITOR, 'both')
cy.getByCypressId('editor-pane').should('be.visible')
cy.getByCypressId('documentIframe').should('be.visible')
})
it('mode edit', () => {
cy.visitTestNote(PAGE_MODE.EDITOR, 'edit')
cy.getByCypressId('editor-pane').should('be.visible')
cy.getByCypressId('documentIframe').should('not.be.visible')
})
})

View file

@ -10,7 +10,6 @@ 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 { DarkModeButton } from './dark-mode-button'
import { EditorViewMode } from './editor-view-mode'
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 { SyncScrollButtons } from './sync-scroll-buttons/sync-scroll-buttons'
@ -43,7 +42,6 @@ export const AppBar: React.FC<AppBarProps> = ({ mode }) => {
<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}> <ShowIf condition={mode === AppBarMode.EDITOR}>
<EditorViewMode />
<SyncScrollButtons /> <SyncScrollButtons />
</ShowIf> </ShowIf>
<DarkModeButton /> <DarkModeButton />

View file

@ -1,54 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Button, ButtonGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { setEditorMode } from '../../../redux/editor/methods'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute'
export enum EditorMode {
PREVIEW = 'view',
BOTH = 'both',
EDITOR = 'edit'
}
/**
* Renders the button group to set the editor mode.
* @see EditorMode
*/
export const EditorViewMode: React.FC = () => {
const { t } = useTranslation()
const editorMode = useApplicationState((state) => state.editorConfig.editorMode)
return (
<ButtonGroup>
<Button
{...cypressId('view-mode-editor')}
onClick={() => setEditorMode(EditorMode.EDITOR)}
variant={editorMode === EditorMode.EDITOR ? 'secondary' : 'outline-secondary'}
title={t('editor.viewMode.edit')}>
<ForkAwesomeIcon icon='pencil' />
</Button>
<Button
{...cypressId('view-mode-both')}
onClick={() => setEditorMode(EditorMode.BOTH)}
variant={editorMode === EditorMode.BOTH ? 'secondary' : 'outline-secondary'}
title={t('editor.viewMode.both')}>
<ForkAwesomeIcon icon='columns' />
</Button>
<Button
{...cypressId('view-mode-preview')}
onClick={() => setEditorMode(EditorMode.PREVIEW)}
variant={editorMode === EditorMode.PREVIEW ? 'secondary' : 'outline-secondary'}
title={t('editor.viewMode.view')}>
<ForkAwesomeIcon icon='eye' />
</Button>
</ButtonGroup>
)
}

View file

@ -24,7 +24,6 @@ import { useBaseUrl } from '../../../../hooks/common/use-base-url'
export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => { export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation() useTranslation()
const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter) const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter)
const editorMode = useApplicationState((state) => state.editorConfig.editorMode)
const baseUrl = useBaseUrl() const baseUrl = useBaseUrl()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
@ -32,10 +31,7 @@ export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) =>
<CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.shareLink.title'}> <CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.shareLink.title'}>
<Modal.Body> <Modal.Body>
<Trans i18nKey={'editor.modal.shareLink.editorDescription'} /> <Trans i18nKey={'editor.modal.shareLink.editorDescription'} />
<CopyableField <CopyableField content={`${baseUrl}n/${noteIdentifier}`} shareOriginUrl={`${baseUrl}n/${noteIdentifier}`} />
content={`${baseUrl}n/${noteIdentifier}?${editorMode}`}
shareOriginUrl={`${baseUrl}n/${noteIdentifier}?${editorMode}`}
/>
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}> <ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
<Trans i18nKey={'editor.modal.shareLink.slidesDescription'} /> <Trans i18nKey={'editor.modal.shareLink.slidesDescription'} />
<CopyableField content={`${baseUrl}p/${noteIdentifier}`} shareOriginUrl={`${baseUrl}p/${noteIdentifier}`} /> <CopyableField content={`${baseUrl}p/${noteIdentifier}`} shareOriginUrl={`${baseUrl}p/${noteIdentifier}`} />

View file

@ -10,12 +10,9 @@ import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
import { MotdModal } from '../common/motd-modal/motd-modal' import { MotdModal } from '../common/motd-modal/motd-modal'
import { AppBar, AppBarMode } from './app-bar/app-bar' import { AppBar, AppBarMode } from './app-bar/app-bar'
import { EditorMode } from './app-bar/editor-view-mode'
import { useViewModeShortcuts } from './hooks/use-view-mode-shortcuts'
import { Sidebar } from './sidebar/sidebar' import { Sidebar } from './sidebar/sidebar'
import { Splitter } from './splitter/splitter' import { Splitter } from './splitter/splitter'
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props' import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
import { useEditorModeFromUrl } from './hooks/use-editor-mode-from-url'
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry' import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
import { useApplicationState } from '../../hooks/common/use-application-state' import { useApplicationState } from '../../hooks/common/use-application-state'
import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer' import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer'
@ -41,7 +38,6 @@ const log = new Logger('EditorPage')
export const EditorPageContent: React.FC = () => { export const EditorPageContent: React.FC = () => {
useTranslation() useTranslation()
const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR) const scrollSource = useRef<ScrollSource>(ScrollSource.EDITOR)
const editorMode: EditorMode = useApplicationState((state) => state.editorConfig.editorMode)
const editorSyncScroll: boolean = useApplicationState((state) => state.editorConfig.syncScroll) const editorSyncScroll: boolean = useApplicationState((state) => state.editorConfig.syncScroll)
const [scrollState, setScrollState] = useState<DualScrollState>(() => ({ const [scrollState, setScrollState] = useState<DualScrollState>(() => ({
@ -83,9 +79,7 @@ export const EditorPageContent: React.FC = () => {
[editorSyncScroll] [editorSyncScroll]
) )
useViewModeShortcuts()
useApplyDarkMode() useApplyDarkMode()
useEditorModeFromUrl()
useUpdateLocalHistoryEntry() useUpdateLocalHistoryEntry()
@ -140,11 +134,9 @@ export const EditorPageContent: React.FC = () => {
<AppBar mode={AppBarMode.EDITOR} /> <AppBar mode={AppBarMode.EDITOR} />
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}> <div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>
<Splitter <Splitter
showLeft={editorMode === EditorMode.EDITOR || editorMode === EditorMode.BOTH}
left={leftPane} left={leftPane}
showRight={editorMode === EditorMode.PREVIEW || editorMode === EditorMode.BOTH}
right={rightPane} right={rightPane}
additionalContainerClassName={'overflow-hidden'} additionalContainerClassName={'overflow-hidden position-relative'}
/> />
<Sidebar /> <Sidebar />
</div> </div>

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
import { EditorMode } from '../app-bar/editor-view-mode'
import { setEditorMode } from '../../../redux/editor/methods'
import { useRouter } from 'next/router'
/**
* Extracts the specified editor mode from the URL query and sets that into the global application state.
*/
export const useEditorModeFromUrl = (): void => {
const { query } = useRouter()
useEffect(() => {
const mode = Object.values(EditorMode).find((mode) => query[mode] !== undefined)
if (mode) {
setEditorMode(mode)
}
}, [query])
}

View file

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
import { setEditorMode } from '../../../redux/editor/methods'
import { EditorMode } from '../app-bar/editor-view-mode'
const shortcutHandler = (event: KeyboardEvent): void => {
if (event.ctrlKey && event.altKey && event.key === 'b') {
setEditorMode(EditorMode.BOTH)
event.preventDefault()
}
if (event.ctrlKey && event.altKey && event.key === 'v') {
setEditorMode(EditorMode.PREVIEW)
event.preventDefault()
}
if (event.ctrlKey && event.altKey && (event.key === 'e' || event.key === '€')) {
setEditorMode(EditorMode.EDITOR)
event.preventDefault()
}
}
/**
* Adds global view mode keyboard shortcuts and removes them again, if the hook is dismissed.
*
* @see shortcutHandler
*/
export const useViewModeShortcuts = (): void => {
useEffect(() => {
document.addEventListener('keydown', shortcutHandler, false)
return () => {
document.removeEventListener('keydown', shortcutHandler, false)
}
}, [])
}

View file

@ -1,103 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Splitter can render both panes 1`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="splitter left "
style="width: calc(50% - 5px);"
>
left
</div>
<div
class="splitter separator"
>
<div
class="split-divider"
data-testid="splitter-divider"
/>
</div>
<div
class="splitter"
style="width: calc(100% - 50%);"
>
right
</div>
</div>
</div>
`;
exports[`Splitter can render only the left pane 1`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="splitter left "
style="width: calc(100% - 5px);"
>
left
</div>
<div
class="splitter d-none"
style="width: calc(100% - 100%);"
>
right
</div>
</div>
</div>
`;
exports[`Splitter can render only the right pane 1`] = `
<div>
<div
class="flex-fill flex-row d-flex "
>
<div
class="splitter left d-none"
style="width: calc(0% - 5px);"
>
left
</div>
<div
class="splitter"
style="width: calc(100% - 0%);"
>
right
</div>
</div>
</div>
`;
exports[`Splitter resize can change size with mouse 1`] = ` exports[`Splitter resize can change size with mouse 1`] = `
<div> <div>
<div <div
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(50% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 50%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with mouse 2`] = ` exports[`Splitter resize can change size with mouse 2`] = `
@ -106,27 +71,63 @@ exports[`Splitter resize can change size with mouse 2`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(100% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 100%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with mouse 3`] = ` exports[`Splitter resize can change size with mouse 3`] = `
@ -135,27 +136,63 @@ exports[`Splitter resize can change size with mouse 3`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(0% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 0%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with mouse 4`] = ` exports[`Splitter resize can change size with mouse 4`] = `
@ -164,27 +201,63 @@ exports[`Splitter resize can change size with mouse 4`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(0% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 0%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with touch 1`] = ` exports[`Splitter resize can change size with touch 1`] = `
@ -193,27 +266,63 @@ exports[`Splitter resize can change size with touch 1`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(50% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 50%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with touch 2`] = ` exports[`Splitter resize can change size with touch 2`] = `
@ -222,27 +331,63 @@ exports[`Splitter resize can change size with touch 2`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(100% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 100%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with touch 3`] = ` exports[`Splitter resize can change size with touch 3`] = `
@ -251,27 +396,63 @@ exports[`Splitter resize can change size with touch 3`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(0% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 0%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </div>
</div> </div>
</div>
`; `;
exports[`Splitter resize can change size with touch 4`] = ` exports[`Splitter resize can change size with touch 4`] = `
@ -280,25 +461,256 @@ exports[`Splitter resize can change size with touch 4`] = `
class="flex-fill flex-row d-flex " class="flex-fill flex-row d-flex "
> >
<div <div
class="splitter left " class="left"
style="width: calc(0% - 5px);" style="width: calc(50% - 5px);"
>
<div
class="inner"
> >
left left
</div> </div>
<div </div>
class="splitter separator"
>
<div <div
class="split-divider" class="split-divider"
data-testid="splitter-divider" data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/> />
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div> </div>
<div <div
class="splitter" class="right"
style="width: calc(100% - 0%);" style="width: calc(100% - 50%);"
>
<div
class="inner"
> >
right right
</div> </div>
</div> </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="split-divider"
data-testid="splitter-divider"
>
<div
class="bg-light middle shift-right"
>
<div
class="buttons"
>
<button
class="btn btn-secondary"
type="button"
>
<i
class="fa fa-arrow-left "
/>
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</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="split-divider"
data-testid="splitter-divider"
>
<div
class="bg-light middle shift-left"
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/>
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-secondary"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</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="split-divider"
data-testid="splitter-divider"
>
<div
class="bg-light middle "
>
<div
class="buttons"
>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-left "
/>
</button>
<span
class="grabber"
>
<i
class="fa fa-arrows-h "
/>
</span>
<button
class="btn btn-light"
type="button"
>
<i
class="fa fa-arrow-right "
/>
</button>
</div>
</div>
</div>
<div
class="right"
style="width: calc(100% - 50%);"
>
<div
class="inner"
>
right
</div>
</div>
</div>
</div>
`; `;

View file

@ -1,30 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
/**
* Calculates the adjusted relative split value.
*
* @param showLeft Defines if the left split pane should be shown
* @param showRight Defines if the right split pane should be shown
* @param relativeSplitValue The relative size ratio of the split
* @return the limited (0% to 100%) relative split value. If only the left or right pane should be shown then the return value will be always 100 or 0
*/
export const useAdjustedRelativeSplitValue = (
showLeft: boolean,
showRight: boolean,
relativeSplitValue: number
): number =>
useMemo(() => {
if (!showLeft && showRight) {
return 0
} else if (showLeft && !showRight) {
return 100
} else {
return Math.min(100, Math.max(0, relativeSplitValue))
}
}, [relativeSplitValue, showLeft, showRight])

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
/**
* 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) => {
useEffect(() => {
const shortcutHandler = (event: KeyboardEvent): void => {
if (event.ctrlKey && event.altKey && event.key === 'b') {
setRelativeSplitValue(50)
event.preventDefault()
}
if (event.ctrlKey && event.altKey && event.key === 'v') {
setRelativeSplitValue(0)
event.preventDefault()
}
if (event.ctrlKey && event.altKey && (event.key === 'e' || event.key === '€')) {
setRelativeSplitValue(100)
event.preventDefault()
}
}
document.addEventListener('keydown', shortcutHandler, false)
return () => {
document.removeEventListener('keydown', shortcutHandler, false)
}
}, [setRelativeSplitValue])
}

View file

@ -5,14 +5,69 @@
*/ */
.split-divider { .split-divider {
width: 10px; width: 15px;
background: white; background: white;
z-index: 1; z-index: 1;
cursor: col-resize;
box-shadow: 3px 0 6px #e7e7e7; box-shadow: 3px 0 6px #e7e7e7;
display: flex;
align-items: center;
justify-content: center;
:global(body.dark) & { :global(body.dark) & {
box-shadow: 3px 0 6px #7b7b7b; box-shadow: 3px 0 6px #7b7b7b;
} }
} }
.grabber {
cursor: col-resize;
}
.middle {
width: 35px;
height: 35px;
z-index: 100000;
position: absolute;
border-radius: 90px;
border: solid 1px #d5d5d5;
overflow: hidden;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: width 150ms ease-in-out, height 150ms ease-in-out, border-radius 50ms ease-in-out, transform 50ms ease-in-out;
transform: translateX(0);
&.shift-right {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
transform: translateX(8px);
}
&.shift-left {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
transform: translateX(-8px);
}
&:hover, &.open {
width: 135px;
height: 50px;
border-radius: 90px;
}
:global(.btn) {
border-radius: 90px;
}
.buttons {
height: 40px;
width: 3*40px;
position: absolute;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}

View file

@ -7,24 +7,63 @@
import React from 'react' import React from 'react'
import styles from './split-divider.module.scss' import styles from './split-divider.module.scss'
import { testId } from '../../../../utils/test-id' import { testId } from '../../../../utils/test-id'
import { Button } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
export enum DividerButtonsShift {
SHIFT_TO_LEFT = 'shift-left',
SHIFT_TO_RIGHT = 'shift-right',
NO_SHIFT = ''
}
export interface SplitDividerProps { export interface SplitDividerProps {
onGrab: () => void onGrab: () => void
onLeftButtonClick: () => void
onRightButtonClick: () => void
forceOpen: boolean
focusLeft: boolean
focusRight: boolean
dividerButtonsShift: DividerButtonsShift
} }
/** /**
* Renders the divider between the two editor panes. * Renders the divider between the two editor panes.
* This divider supports both mouse and touch interactions. * This divider supports both mouse and touch interactions.
* *
* @param onGrab The callback, that should be called if the splitter is grabbed. * @param onGrab callback that is triggered if the splitter is grabbed.
* @param onLeftButtonClick callback that is triggered when the left arrow button is pressed
* @param onRightButtonClick callback that is triggered when the right arrow button is pressed
* @param dividerShift defines if the buttons should be shifted to the left or right side
* @param focusLeft defines if the left button should be focused
* @param focusRight defines if the right button should be focused
* @param forceOpen defines if the arrow buttons should always be visible
*/ */
export const SplitDivider: React.FC<SplitDividerProps> = ({ onGrab }) => { export const SplitDivider: React.FC<SplitDividerProps> = ({
onGrab,
onLeftButtonClick,
onRightButtonClick,
dividerButtonsShift,
focusLeft,
focusRight,
forceOpen
}) => {
const shiftClass = dividerButtonsShift == '' ? '' : styles[dividerButtonsShift]
return ( return (
<div <div className={`${styles['split-divider']}`} {...testId('splitter-divider')}>
onMouseDown={onGrab} <div className={`bg-light ${styles['middle']} ${forceOpen ? styles['open'] : ''} ${shiftClass}`}>
onTouchStart={onGrab} <div className={styles['buttons']}>
className={styles['split-divider']} <Button variant={focusLeft ? 'secondary' : 'light'} onClick={onLeftButtonClick}>
{...testId('splitter-divider')} <ForkAwesomeIcon icon={'arrow-left'} />
/> </Button>
<span onMouseDown={onGrab} onTouchStart={onGrab} className={styles['grabber']}>
<ForkAwesomeIcon icon={'arrows-h'} />
</span>
<Button variant={focusRight ? 'secondary' : 'light'} onClick={onRightButtonClick}>
<ForkAwesomeIcon icon={'arrow-right'} />
</Button>
</div>
</div>
</div>
) )
} }

View file

@ -3,13 +3,20 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
.left, .right {
.splitter { overflow: hidden;
&.left {
min-width: 200px;
} }
&.separator { .inner {
display: flex; display: flex;
min-width: 20vw;
height: 100%;
flex-direction: column;
} }
.move-overlay {
position: absolute;
height: 100%;
width: 100%;
z-index: 100000;
} }

View file

@ -9,29 +9,24 @@ import { Splitter } from './splitter'
import { Mock } from 'ts-mockery' import { Mock } from 'ts-mockery'
describe('Splitter', () => { describe('Splitter', () => {
it('can render only the left pane', () => {
const view = render(<Splitter showLeft={true} showRight={false} left={<>left</>} right={<>right</>} />)
expect(view.container).toMatchSnapshot()
})
it('can render only the right pane', () => {
const view = render(<Splitter showLeft={false} showRight={true} left={<>left</>} right={<>right</>} />)
expect(view.container).toMatchSnapshot()
})
it('can render both panes', () => {
const view = render(<Splitter showLeft={true} showRight={true} left={<>left</>} right={<>right</>} />)
expect(view.container).toMatchSnapshot()
})
describe('resize', () => { describe('resize', () => {
beforeEach(() => { beforeEach(() => {
Object.defineProperty(window.HTMLDivElement.prototype, 'clientWidth', { value: 1920 }) Object.defineProperty(window.HTMLDivElement.prototype, 'clientWidth', { value: 1920 })
Object.defineProperty(window.HTMLDivElement.prototype, 'offsetLeft', { value: 0 }) Object.defineProperty(window.HTMLDivElement.prototype, 'offsetLeft', { value: 0 })
}) })
it('can react to shortcuts', () => {
const view = render(<Splitter left={<>left</>} right={<>right</>} />)
fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'v' }))
expect(view.container).toMatchSnapshot()
fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'e' }))
expect(view.container).toMatchSnapshot()
fireEvent.keyDown(document, Mock.of<KeyboardEvent>({ ctrlKey: true, altKey: true, key: 'b' }))
expect(view.container).toMatchSnapshot()
})
it('can change size with mouse', async () => { it('can change size with mouse', async () => {
const view = render(<Splitter showLeft={true} showRight={true} 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')
@ -50,7 +45,7 @@ describe('Splitter', () => {
}) })
it('can change size with touch', async () => { it('can change size with touch', async () => {
const view = render(<Splitter showLeft={true} showRight={true} 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')

View file

@ -4,20 +4,17 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ReactElement } from 'react' import type { ReactElement, TouchEvent, MouseEvent } from 'react'
import React, { useCallback, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ShowIf } from '../../common/show-if/show-if' import { DividerButtonsShift, SplitDivider } from './split-divider/split-divider'
import { SplitDivider } from './split-divider/split-divider'
import styles from './splitter.module.scss' import styles from './splitter.module.scss'
import { useAdjustedRelativeSplitValue } from './hooks/use-adjusted-relative-split-value' import { ShowIf } from '../../common/show-if/show-if'
import { useBindPointerMovementEventOnWindow } from '../../../hooks/common/use-bind-pointer-movement-event-on-window' import { useKeyboardShortcuts } from './hooks/use-keyboard-shortcuts'
export interface SplitterProps { export interface SplitterProps {
left?: ReactElement left?: ReactElement
right?: ReactElement right?: ReactElement
additionalContainerClassName?: string additionalContainerClassName?: string
showLeft: boolean
showRight: boolean
} }
/** /**
@ -26,7 +23,7 @@ export interface SplitterProps {
* @param event the event to check * @param event the event to check
* @return {@link true} if the given event is a {@link MouseEvent} * @return {@link true} if the given event is a {@link MouseEvent}
*/ */
const isMouseEvent = (event: Event): event is MouseEvent => { const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => {
return (event as MouseEvent).buttons !== undefined return (event as MouseEvent).buttons !== undefined
} }
@ -48,55 +45,61 @@ const extractHorizontalPosition = (moveEvent: MouseEvent | TouchEvent): number |
} }
} }
const SNAP_PERCENTAGE = 10
/** /**
* Creates a Left/Right splitter react component. * Creates a Left/Right splitter react component.
* *
* @param additionalContainerClassName css classes that are added to the split container. * @param additionalContainerClassName css classes that are added to the split container.
* @param left the react component that should be shown on the left side. * @param left the react component that should be shown on the left side.
* @param right the react component that should be shown on the right side. * @param right the react component that should be shown on the right side.
* @param showLeft defines if the left component should be shown or hidden. Settings this prop will hide the component with css.
* @param showRight defines if the right component should be shown or hidden. Settings this prop will hide the component with css.
* @return the created component * @return the created component
*/ */
export const Splitter: React.FC<SplitterProps> = ({ export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName, left, right }) => {
additionalContainerClassName,
left,
right,
showLeft,
showRight
}) => {
const [relativeSplitValue, setRelativeSplitValue] = useState(50) const [relativeSplitValue, setRelativeSplitValue] = useState(50)
const adjustedRelativeSplitValue = useAdjustedRelativeSplitValue(showLeft, showRight, relativeSplitValue) const [resizingInProgress, setResizingInProgress] = useState(false)
const resizingInProgress = useRef(false) const adjustedRelativeSplitValue = useMemo(() => Math.min(100, Math.max(0, relativeSplitValue)), [relativeSplitValue])
const splitContainer = useRef<HTMLDivElement>(null) const splitContainer = useRef<HTMLDivElement>(null)
/** /**
* Starts the splitter resizing. * Starts the splitter resizing.
*/ */
const onStartResizing = useCallback(() => { const onStartResizing = useCallback(() => {
resizingInProgress.current = true setResizingInProgress(true)
}, []) }, [])
/** /**
* Stops the splitter resizing. * Stops the splitter resizing.
*/ */
const onStopResizing = useCallback(() => { const onStopResizing = useCallback(() => {
if (resizingInProgress.current) { setResizingInProgress(false)
resizingInProgress.current = false
}
}, []) }, [])
useEffect(() => {
if (!resizingInProgress) {
setRelativeSplitValue((value) => {
if (value < SNAP_PERCENTAGE) {
return 0
}
if (value > 100 - SNAP_PERCENTAGE) {
return 100
}
return value
})
}
}, [resizingInProgress])
/** /**
* Recalculates the panel split based on the absolute mouse/touch position. * Recalculates the panel split based on the absolute mouse/touch position.
* *
* @param moveEvent is a {@link MouseEvent} or {@link TouchEvent} that got triggered. * @param moveEvent is a {@link MouseEvent} or {@link TouchEvent} that got triggered.
*/ */
const onMove = useCallback((moveEvent: MouseEvent | TouchEvent) => { const onMove = useCallback((moveEvent: MouseEvent | TouchEvent) => {
if (!resizingInProgress.current || !splitContainer.current) { if (!splitContainer.current) {
return return
} }
if (isMouseEvent(moveEvent) && !isLeftMouseButtonClicked(moveEvent)) { if (isMouseEvent(moveEvent) && !isLeftMouseButtonClicked(moveEvent)) {
resizingInProgress.current = false setResizingInProgress(false)
moveEvent.preventDefault() moveEvent.preventDefault()
return undefined return undefined
} }
@ -107,28 +110,56 @@ export const Splitter: React.FC<SplitterProps> = ({
} }
const horizontalPositionInSplitContainer = horizontalPosition - splitContainer.current.offsetLeft const horizontalPositionInSplitContainer = horizontalPosition - splitContainer.current.offsetLeft
const newRelativeSize = horizontalPositionInSplitContainer / splitContainer.current.clientWidth const newRelativeSize = horizontalPositionInSplitContainer / splitContainer.current.clientWidth
setRelativeSplitValue(newRelativeSize * 100) const number = newRelativeSize * 100
setRelativeSplitValue(number)
moveEvent.preventDefault() moveEvent.preventDefault()
}, []) }, [])
useBindPointerMovementEventOnWindow(onMove, onStopResizing) const onLeftButtonClick = useCallback(() => {
setRelativeSplitValue((value) => (value === 100 ? 50 : 0))
}, [])
const onRightButtonClick = useCallback(() => {
setRelativeSplitValue((value) => (value === 0 ? 50 : 100))
}, [])
const dividerButtonsShift = useMemo(() => {
if (relativeSplitValue === 0) {
return DividerButtonsShift.SHIFT_TO_RIGHT
} else if (relativeSplitValue === 100) {
return DividerButtonsShift.SHIFT_TO_LEFT
} else {
return DividerButtonsShift.NO_SHIFT
}
}, [relativeSplitValue])
useKeyboardShortcuts(setRelativeSplitValue)
return ( return (
<div ref={splitContainer} className={`flex-fill flex-row d-flex ${additionalContainerClassName || ''}`}> <div ref={splitContainer} className={`flex-fill flex-row d-flex ${additionalContainerClassName || ''}`}>
<ShowIf condition={resizingInProgress}>
<div <div
className={`${styles['splitter']} ${styles['left']} ${!showLeft ? 'd-none' : ''}`} className={styles['move-overlay']}
style={{ width: `calc(${adjustedRelativeSplitValue}% - 5px)` }}> onTouchMove={onMove}
{left} onMouseMove={onMove}
</div> onTouchCancel={onStopResizing}
<ShowIf condition={showLeft && showRight}> onTouchEnd={onStopResizing}
<div className={`${styles['splitter']} ${styles['separator']}`}> onMouseUp={onStopResizing}></div>
<SplitDivider onGrab={onStartResizing} />
</div>
</ShowIf> </ShowIf>
<div <div className={styles['left']} style={{ width: `calc(${adjustedRelativeSplitValue}% - 5px)` }}>
className={`${styles['splitter']}${!showRight ? ' d-none' : ''}`} <div className={styles['inner']}>{left}</div>
style={{ width: `calc(100% - ${adjustedRelativeSplitValue}%)` }}> </div>
{right} <SplitDivider
onGrab={onStartResizing}
onLeftButtonClick={onLeftButtonClick}
onRightButtonClick={onRightButtonClick}
forceOpen={resizingInProgress}
focusLeft={relativeSplitValue < SNAP_PERCENTAGE}
focusRight={relativeSplitValue > 100 - SNAP_PERCENTAGE}
dividerButtonsShift={dividerButtonsShift}
/>
<div className={styles['right']} style={{ width: `calc(100% - ${adjustedRelativeSplitValue}%)` }}>
<div className={styles['inner']}>{right}</div>
</div> </div>
</div> </div>
) )

View file

@ -5,13 +5,11 @@
*/ */
import { store } from '..' import { store } from '..'
import type { EditorMode } from '../../components/editor-page/app-bar/editor-view-mode'
import type { import type {
EditorConfig, EditorConfig,
SetEditorLigaturesAction, SetEditorLigaturesAction,
SetEditorSmartPasteAction, SetEditorSmartPasteAction,
SetEditorSyncScrollAction, SetEditorSyncScrollAction
SetEditorViewModeAction
} from './types' } from './types'
import { EditorConfigActionType } from './types' import { EditorConfigActionType } from './types'
import { Logger } from '../../utils/logger' import { Logger } from '../../utils/logger'
@ -39,14 +37,6 @@ export const saveToLocalStorage = (editorConfig: EditorConfig): void => {
} }
} }
export const setEditorMode = (editorMode: EditorMode): void => {
const action: SetEditorViewModeAction = {
type: EditorConfigActionType.SET_EDITOR_VIEW_MODE,
mode: editorMode
}
store.dispatch(action)
}
export const setEditorSyncScroll = (syncScroll: boolean): void => { export const setEditorSyncScroll = (syncScroll: boolean): void => {
const action: SetEditorSyncScrollAction = { const action: SetEditorSyncScrollAction = {
type: EditorConfigActionType.SET_SYNC_SCROLL, type: EditorConfigActionType.SET_SYNC_SCROLL,

View file

@ -5,13 +5,11 @@
*/ */
import type { Reducer } from 'redux' import type { Reducer } from 'redux'
import { EditorMode } from '../../components/editor-page/app-bar/editor-view-mode'
import { loadFromLocalStorage, saveToLocalStorage } from './methods' import { loadFromLocalStorage, saveToLocalStorage } from './methods'
import type { EditorConfig, EditorConfigActions } from './types' import type { EditorConfig, EditorConfigActions } from './types'
import { EditorConfigActionType } from './types' import { EditorConfigActionType } from './types'
const initialState: EditorConfig = { const initialState: EditorConfig = {
editorMode: EditorMode.BOTH,
ligatures: true, ligatures: true,
syncScroll: true, syncScroll: true,
smartPaste: true, smartPaste: true,
@ -28,13 +26,6 @@ export const EditorConfigReducer: Reducer<EditorConfig, EditorConfigActions> = (
) => { ) => {
let newState: EditorConfig let newState: EditorConfig
switch (action.type) { switch (action.type) {
case EditorConfigActionType.SET_EDITOR_VIEW_MODE:
newState = {
...state,
editorMode: action.mode
}
saveToLocalStorage(newState)
return newState
case EditorConfigActionType.SET_SYNC_SCROLL: case EditorConfigActionType.SET_SYNC_SCROLL:
newState = { newState = {
...state, ...state,

View file

@ -5,7 +5,6 @@
*/ */
import type { Action } from 'redux' import type { Action } from 'redux'
import type { EditorMode } from '../../components/editor-page/app-bar/editor-view-mode'
export enum EditorConfigActionType { export enum EditorConfigActionType {
SET_EDITOR_VIEW_MODE = 'editor/view-mode/set', SET_EDITOR_VIEW_MODE = 'editor/view-mode/set',
@ -16,7 +15,6 @@ export enum EditorConfigActionType {
} }
export interface EditorConfig { export interface EditorConfig {
editorMode: EditorMode
syncScroll: boolean syncScroll: boolean
ligatures: boolean ligatures: boolean
smartPaste: boolean smartPaste: boolean
@ -27,7 +25,6 @@ export type EditorConfigActions =
| SetEditorSyncScrollAction | SetEditorSyncScrollAction
| SetEditorLigaturesAction | SetEditorLigaturesAction
| SetEditorSmartPasteAction | SetEditorSmartPasteAction
| SetEditorViewModeAction
| SetSpellCheckAction | SetSpellCheckAction
export interface SetEditorSyncScrollAction extends Action<EditorConfigActionType> { export interface SetEditorSyncScrollAction extends Action<EditorConfigActionType> {
@ -45,11 +42,6 @@ export interface SetEditorSmartPasteAction extends Action<EditorConfigActionType
smartPaste: boolean smartPaste: boolean
} }
export interface SetEditorViewModeAction extends Action<EditorConfigActionType> {
type: EditorConfigActionType.SET_EDITOR_VIEW_MODE
mode: EditorMode
}
export interface SetSpellCheckAction extends Action<EditorConfigActionType> { export interface SetSpellCheckAction extends Action<EditorConfigActionType> {
type: EditorConfigActionType.SET_SPELL_CHECK type: EditorConfigActionType.SET_SPELL_CHECK
spellCheck: boolean spellCheck: boolean