Restructure the max-length-warning and the status bar (#1654)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-11-28 22:21:02 +01:00 committed by GitHub
parent 7182f22e52
commit 4023acc9d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 291 additions and 92 deletions

View file

@ -5,14 +5,12 @@
*/ */
import type { Editor, EditorChange } from 'codemirror' import type { Editor, EditorChange } from 'codemirror'
import React, { useCallback, useRef, useState } from 'react' import React, { useCallback, useState } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2' import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { MaxLengthWarningModal } from '../editor-modals/max-length-warning-modal'
import type { ScrollProps } from '../synced-scroll/scroll-props' import type { ScrollProps } from '../synced-scroll/scroll-props'
import { allHinters, findWordAtCursor } from './autocompletion' import { allHinters, findWordAtCursor } from './autocompletion'
import './editor-pane.scss' import './editor-pane.scss'
import type { StatusBarInfo } from './status-bar/status-bar' import { StatusBar } from './status-bar/status-bar'
import { createStatusInfo, defaultState, StatusBar } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar' import { ToolBar } from './tool-bar/tool-bar'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import './codemirror-imports' import './codemirror-imports'
@ -23,6 +21,8 @@ import { useOnEditorPasteCallback } from './hooks/use-on-editor-paste-callback'
import { useOnEditorFileDrop } from './hooks/use-on-editor-file-drop' import { useOnEditorFileDrop } from './hooks/use-on-editor-file-drop'
import { useOnEditorScroll } from './hooks/use-on-editor-scroll' import { useOnEditorScroll } from './hooks/use-on-editor-scroll'
import { useApplyScrollState } from './hooks/use-apply-scroll-state' import { useApplyScrollState } from './hooks/use-apply-scroll-state'
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
const onChange = (editor: Editor) => { const onChange = (editor: Editor) => {
for (const hinter of allHinters) { for (const hinter of allHinters) {
@ -41,53 +41,34 @@ const onChange = (editor: Editor) => {
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => { export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const markdownContent = useNoteMarkdownContent() const markdownContent = useNoteMarkdownContent()
const maxLength = useApplicationState((state) => state.config.maxDocumentLength)
const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false)
const maxLengthWarningAlreadyShown = useRef(false)
const [editor, setEditor] = useState<Editor>() const [editor, setEditor] = useState<Editor>()
const [statusBarInfo, setStatusBarInfo] = useState<StatusBarInfo>(defaultState)
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const onPaste = useOnEditorPasteCallback() const onPaste = useOnEditorPasteCallback()
const onEditorScroll = useOnEditorScroll(onScroll) const onEditorScroll = useOnEditorScroll(onScroll)
useApplyScrollState(editor, scrollState) useApplyScrollState(editor, scrollState)
const onBeforeChange = useCallback( const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => {
(editor: Editor, data: EditorChange, value: string) => {
if (value.length > maxLength && !maxLengthWarningAlreadyShown.current) {
setShowMaxLengthWarning(true)
maxLengthWarningAlreadyShown.current = true
}
if (value.length <= maxLength) {
maxLengthWarningAlreadyShown.current = false
}
setNoteContent(value) setNoteContent(value)
}, }, [])
[maxLength]
) const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
const onEditorDidMount = useCallback( const onEditorDidMount = useCallback(
(mountedEditor: Editor) => { (mountedEditor: Editor) => {
setStatusBarInfo(createStatusInfo(mountedEditor, maxLength)) updateStatusBarInfo(mountedEditor)
setEditor(mountedEditor) setEditor(mountedEditor)
}, },
[maxLength] [updateStatusBarInfo]
)
const onCursorActivity = useCallback(
(editorWithActivity: Editor) => {
setStatusBarInfo(createStatusInfo(editorWithActivity, maxLength))
},
[maxLength]
) )
const onDrop = useOnEditorFileDrop() const onDrop = useOnEditorFileDrop()
const onMaxLengthHide = useCallback(() => setShowMaxLengthWarning(false), [])
const codeMirrorOptions = useCodeMirrorOptions() const codeMirrorOptions = useCodeMirrorOptions()
return ( return (
<div className={'d-flex flex-column h-100 position-relative'} onMouseEnter={onMakeScrollSource}> <div className={'d-flex flex-column h-100 position-relative'} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarningModal show={showMaxLengthWarning} onHide={onMaxLengthHide} maxLength={maxLength} /> <MaxLengthWarning />
<ToolBar editor={editor} /> <ToolBar editor={editor} />
<ControlledCodeMirror <ControlledCodeMirror
className={`overflow-hidden w-100 flex-fill ${ligaturesEnabled ? '' : 'no-ligatures'}`} className={`overflow-hidden w-100 flex-fill ${ligaturesEnabled ? '' : 'no-ligatures'}`}
@ -96,12 +77,12 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
onChange={onChange} onChange={onChange}
onPaste={onPaste} onPaste={onPaste}
onDrop={onDrop} onDrop={onDrop}
onCursorActivity={onCursorActivity} onCursorActivity={updateStatusBarInfo}
editorDidMount={onEditorDidMount} editorDidMount={onEditorDidMount}
onBeforeChange={onBeforeChange} onBeforeChange={onBeforeChange}
onScroll={onEditorScroll} onScroll={onEditorScroll}
/> />
<StatusBar {...statusBarInfo} /> <StatusBar statusBarInfo={statusBarInfo} />
</div> </div>
) )
} }

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { StatusBarInfo } from '../status-bar/status-bar'
import { defaultState } from '../status-bar/status-bar'
import type { Editor } from 'codemirror'
import { useCallback, useState } from 'react'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Provides a {@link StatusBarInfo} object and a function that can update this object using a {@link CodeMirror code mirror instance}.
*/
export const useCreateStatusBarInfo = (): [
statusBarInfo: StatusBarInfo,
updateStatusBarInfo: (editor: Editor) => void
] => {
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const [statusBarInfo, setStatusBarInfo] = useState(defaultState)
const updateStatusBarInfo = useCallback(
(editor: Editor): void => {
setStatusBarInfo({
position: editor.getCursor(),
charactersInDocument: editor.getValue().length,
remainingCharacters: maxDocumentLength - editor.getValue().length,
linesInDocument: editor.lineCount(),
selectedColumns: editor.getSelection().length,
selectedLines: editor.getSelection().split('\n').length
})
},
[maxDocumentLength]
)
return [statusBarInfo, updateStatusBarInfo]
}

View file

@ -7,16 +7,20 @@
import React from 'react' import React from 'react'
import { Button, Modal } from 'react-bootstrap' import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import type { ModalVisibilityProps } from '../../common/modals/common-modal' import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../common/modals/common-modal' import { CommonModal } from '../../../common/modals/common-modal'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
export interface MaxLengthWarningModalProps extends ModalVisibilityProps { /**
maxLength: number * Shows a modal that informs the user that the document is too long.
} *
* @param show is {@code true} if the modal should be shown
export const MaxLengthWarningModal: React.FC<MaxLengthWarningModalProps> = ({ show, onHide, maxLength }) => { * @param onHide gets called if the modal was closed
*/
export const MaxLengthWarningModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation() useTranslation()
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
return ( return (
<CommonModal <CommonModal
@ -26,7 +30,7 @@ export const MaxLengthWarningModal: React.FC<MaxLengthWarningModalProps> = ({ sh
title={'editor.error.limitReached.title'} title={'editor.error.limitReached.title'}
showCloseButton={true}> showCloseButton={true}>
<Modal.Body> <Modal.Body>
<Trans i18nKey={'editor.error.limitReached.description'} values={{ maxLength }} /> <Trans i18nKey={'editor.error.limitReached.description'} values={{ maxDocumentLength }} />
<strong className='mt-2 d-block'> <strong className='mt-2 d-block'>
<Trans i18nKey={'editor.error.limitReached.advice'} /> <Trans i18nKey={'editor.error.limitReached.advice'} />
</strong> </strong>

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { MaxLengthWarningModal } from './max-length-warning-modal'
import { useNoteMarkdownContent } from '../../../../hooks/common/use-note-markdown-content'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Watches the length of the document and shows a warning modal to the user if the document length exceeds the configured value.
*/
export const MaxLengthWarning: React.FC = () => {
const [showMaxLengthWarningModal, setShowMaxLengthWarningModal] = useState(false)
const maxLengthWarningAlreadyShown = useRef(false)
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const hideWarning = useCallback(() => setShowMaxLengthWarningModal(false), [])
const markdownContent = useNoteMarkdownContent()
useEffect(() => {
if (markdownContent.length > maxDocumentLength && !maxLengthWarningAlreadyShown.current) {
setShowMaxLengthWarningModal(true)
maxLengthWarningAlreadyShown.current = true
}
if (markdownContent.length <= maxDocumentLength) {
maxLengthWarningAlreadyShown.current = false
}
}, [markdownContent, maxDocumentLength])
return <MaxLengthWarningModal show={showMaxLengthWarningModal} onHide={hideWarning} />
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Trans } from 'react-i18next'
import type { Position } from 'codemirror'
export interface CursorPositionInfoProps {
cursorPosition: Position
}
/**
* Renders a translated text that shows the given cursor position.
*
* @param cursorPosition The cursor position that should be included
*/
export const CursorPositionInfo: React.FC<CursorPositionInfoProps> = ({ cursorPosition }) => {
const translationOptions = useMemo(
() => ({
line: cursorPosition.line + 1,
columns: cursorPosition.ch + 1
}),
[cursorPosition.ch, cursorPosition.line]
)
return (
<span>
<Trans i18nKey={'editor.statusBar.cursor'} values={translationOptions} />
</span>
)
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
export interface LinesInDocumentInfoProps {
numberOfLinesInDocument: number
}
/**
* Renders a translated text that shows the number of lines in the document.
*
* @param linesInDocument The number of lines in the document
*/
export const NumberOfLinesInDocumentInfo: React.FC<LinesInDocumentInfoProps> = ({ numberOfLinesInDocument }) => {
useTranslation()
const translationOptions = useMemo(() => ({ lines: numberOfLinesInDocument }), [numberOfLinesInDocument])
return (
<span>
<Trans i18nKey={'editor.statusBar.lines'} values={translationOptions} />
</span>
)
}

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { cypressId } from '../../../../utils/cypress-attribute'
import { Trans, useTranslation } from 'react-i18next'
export interface LengthInfoProps {
remainingCharacters: number
charactersInDocument: number
}
/**
* Renders a translated text that shows the number of remaining characters.
*
* @param remainingCharacters The number of characters that are still available in this document
* @param charactersInDocument The total number of characters in the document
*/
export const RemainingCharactersInfo: React.FC<LengthInfoProps> = ({ remainingCharacters, charactersInDocument }) => {
const { t } = useTranslation()
const remainingCharactersClass = useMemo(() => {
if (remainingCharacters <= 0) {
return 'text-danger'
} else if (remainingCharacters <= 100) {
return 'text-warning'
} else {
return ''
}
}, [remainingCharacters])
const lengthTooltip = useMemo(() => {
if (remainingCharacters === 0) {
return t('editor.statusBar.lengthTooltip.maximumReached')
} else if (remainingCharacters < 0) {
return t('editor.statusBar.lengthTooltip.exceeded', { exceeded: -remainingCharacters })
} else {
return t('editor.statusBar.lengthTooltip.remaining', { remaining: remainingCharacters })
}
}, [remainingCharacters, t])
const translationOptions = useMemo(() => ({ length: charactersInDocument }), [charactersInDocument])
return (
<span {...cypressId('remainingCharacters')} title={lengthTooltip} className={remainingCharactersClass}>
<Trans i18nKey={'editor.statusBar.length'} values={translationOptions} />
</span>
)
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
export interface SelectionInfoProps {
count: number
translationKey: 'column' | 'line'
}
/**
* Renders a translated text that shows the number of selected columns or lines.
*
* @param count The number that should be included in the text
* @param translationKey Defines if the text for selected columns or lines should be used
*/
export const SelectionInfo: React.FC<SelectionInfoProps> = ({ count, translationKey }) => {
useTranslation()
const countTranslationOptions = useMemo(() => ({ count: count }), [count])
console.log(count, translationKey)
return (
<span>
<Trans i18nKey={`editor.statusBar.selection.${translationKey}`} values={countTranslationOptions} />
</span>
)
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
/**
* Renders a dash without breaking spaces around.
*/
export const SeparatorDash: React.FC = () => {
return <Fragment>&nbsp;&nbsp;</Fragment>
}

View file

@ -4,12 +4,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Editor, Position } from 'codemirror' import type { Position } from 'codemirror'
import React, { useMemo } from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { ShowIf } from '../../../common/show-if/show-if'
import './status-bar.scss' import './status-bar.scss'
import { cypressId } from '../../../../utils/cypress-attribute' import { RemainingCharactersInfo } from './remaining-characters-info'
import { NumberOfLinesInDocumentInfo } from './number-of-lines-in-document-info'
import { CursorPositionInfo } from './cursor-position-info'
import { SelectionInfo } from './selection-info'
import { ShowIf } from '../../../common/show-if/show-if'
import { SeparatorDash } from './separator-dash'
export interface StatusBarInfo { export interface StatusBarInfo {
position: Position position: Position
@ -29,57 +32,36 @@ export const defaultState: StatusBarInfo = {
remainingCharacters: 0 remainingCharacters: 0
} }
export const createStatusInfo = (editor: Editor, maxDocumentLength: number): StatusBarInfo => ({ export interface StatusBarProps {
position: editor.getCursor(), statusBarInfo: StatusBarInfo
charactersInDocument: editor.getValue().length,
remainingCharacters: maxDocumentLength - editor.getValue().length,
linesInDocument: editor.lineCount(),
selectedColumns: editor.getSelection().length,
selectedLines: editor.getSelection().split('\n').length
})
export const StatusBar: React.FC<StatusBarInfo> = ({
position,
selectedColumns,
selectedLines,
charactersInDocument,
linesInDocument,
remainingCharacters
}) => {
const { t } = useTranslation()
const getLengthTooltip = useMemo(() => {
if (remainingCharacters === 0) {
return t('editor.statusBar.lengthTooltip.maximumReached')
} }
if (remainingCharacters < 0) {
return t('editor.statusBar.lengthTooltip.exceeded', { exceeded: -remainingCharacters })
}
return t('editor.statusBar.lengthTooltip.remaining', { remaining: remainingCharacters })
}, [remainingCharacters, t])
/**
* Shows additional information about the document length and the current selection.
*
* @param statusBarInfo The information to show
*/
export const StatusBar: React.FC<StatusBarProps> = ({ statusBarInfo }) => {
return ( return (
<div className='d-flex flex-row status-bar px-2'> <div className='d-flex flex-row status-bar px-2'>
<div> <div>
<span>{t('editor.statusBar.cursor', { line: position.line + 1, columns: position.ch + 1 })}</span> <CursorPositionInfo cursorPosition={statusBarInfo.position} />
<ShowIf condition={selectedColumns !== 0 && selectedLines !== 0}> <ShowIf condition={statusBarInfo.selectedLines === 1 && statusBarInfo.selectedColumns > 0}>
<ShowIf condition={selectedLines === 1}> <SeparatorDash />
<span>&nbsp;&nbsp;{t('editor.statusBar.selection.column', { count: selectedColumns })}</span> <SelectionInfo count={statusBarInfo.selectedColumns} translationKey={'column'} />
</ShowIf>
<ShowIf condition={selectedLines > 1}>
<span>&nbsp;&nbsp;{t('editor.statusBar.selection.line', { count: selectedLines })}</span>
</ShowIf> </ShowIf>
<ShowIf condition={statusBarInfo.selectedLines > 1}>
<SeparatorDash />
<SelectionInfo count={statusBarInfo.selectedLines} translationKey={'line'} />
</ShowIf> </ShowIf>
</div> </div>
<div className='ml-auto'> <div className='ml-auto'>
<span>{t('editor.statusBar.lines', { lines: linesInDocument })}</span> <NumberOfLinesInDocumentInfo numberOfLinesInDocument={statusBarInfo.linesInDocument} />
&nbsp;&nbsp; <SeparatorDash />
<span <RemainingCharactersInfo
{...cypressId('remainingCharacters')} remainingCharacters={statusBarInfo.remainingCharacters}
title={getLengthTooltip} charactersInDocument={statusBarInfo.charactersInDocument}
className={remainingCharacters <= 0 ? 'text-danger' : remainingCharacters <= 100 ? 'text-warning' : ''}> />
{t('editor.statusBar.length', { length: charactersInDocument })}
</span>
</div> </div>
</div> </div>
) )