Extract editor code into hooks (#1531)

* Extract code into hooks

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-10-06 21:07:33 +02:00 committed by GitHub
parent 73352a7b08
commit 6adb63967b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 218 additions and 120 deletions

View file

@ -4,23 +4,24 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Editor, EditorChange, EditorConfiguration, ScrollInfo } from 'codemirror' import { Editor, EditorChange } from 'codemirror'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2' import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { useTranslation } from 'react-i18next'
import { MaxLengthWarningModal } from '../editor-modals/max-length-warning-modal' import { MaxLengthWarningModal } from '../editor-modals/max-length-warning-modal'
import { ScrollProps, ScrollState } from '../synced-scroll/scroll-props' import { 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 { defaultKeyMap } from './key-map'
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar' import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar' import { ToolBar } from './tool-bar/tool-bar'
import { handleUpload } from './upload-handler'
import { handleFilePaste, handleTablePaste, PasteEvent } from './tool-bar/utils/pasteHandlers'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import './codemirror-imports' import './codemirror-imports'
import { setNoteContent } from '../../../redux/note-details/methods' import { setNoteContent } from '../../../redux/note-details/methods'
import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content' import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content'
import { useCodeMirrorOptions } from './hooks/use-code-mirror-options'
import { useOnEditorPasteCallback } from './hooks/use-on-editor-paste-callback'
import { useOnEditorFileDrop } from './hooks/use-on-editor-file-drop'
import { useOnEditorScroll } from './hooks/use-on-editor-scroll'
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
const onChange = (editor: Editor) => { const onChange = (editor: Editor) => {
for (const hinter of allHinters) { for (const hinter of allHinters) {
@ -37,79 +38,18 @@ const onChange = (editor: Editor) => {
} }
} }
interface DropEvent {
pageX: number
pageY: number
dataTransfer: {
files: FileList
effectAllowed: string
} | null
preventDefault: () => void
}
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 { t } = useTranslation()
const maxLength = useApplicationState((state) => state.config.maxDocumentLength) const maxLength = useApplicationState((state) => state.config.maxDocumentLength)
const smartPasteEnabled = useApplicationState((state) => state.editorConfig.smartPaste)
const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false) const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false)
const maxLengthWarningAlreadyShown = useRef(false) const maxLengthWarningAlreadyShown = useRef(false)
const [editor, setEditor] = useState<Editor>() const [editor, setEditor] = useState<Editor>()
const [statusBarInfo, setStatusBarInfo] = useState<StatusBarInfo>(defaultState) const [statusBarInfo, setStatusBarInfo] = useState<StatusBarInfo>(defaultState)
const editorPreferences = useApplicationState((state) => state.editorConfig.preferences)
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const lastScrollPosition = useRef<number>() const onPaste = useOnEditorPasteCallback()
const [editorScroll, setEditorScroll] = useState<ScrollInfo>() const onEditorScroll = useOnEditorScroll(onScroll)
const onEditorScroll = useCallback((editor: Editor, data: ScrollInfo) => setEditorScroll(data), []) useApplyScrollState(editor, scrollState)
const onPaste = useCallback(
(pasteEditor: Editor, event: PasteEvent) => {
if (!event || !event.clipboardData) {
return
}
if (smartPasteEnabled) {
const tableInserted = handleTablePaste(event, pasteEditor)
if (tableInserted) {
return
}
}
handleFilePaste(event, pasteEditor)
},
[smartPasteEnabled]
)
useEffect(() => {
if (!editor || !onScroll || !editorScroll) {
return
}
const line = editor.lineAtHeight(editorScroll.top, 'local')
const startYOfLine = editor.heightAtLine(line, 'local')
const lineInfo = editor.lineInfo(line)
if (lineInfo === null) {
return
}
const heightOfLine = (lineInfo.handle as { height: number }).height
const percentageRaw = Math.max(editorScroll.top - startYOfLine, 0) / heightOfLine
const percentage = Math.floor(percentageRaw * 100)
const newScrollState: ScrollState = { firstLineInView: line + 1, scrolledPercentage: percentage }
onScroll(newScrollState)
}, [editor, editorScroll, onScroll])
useEffect(() => {
if (!editor || !scrollState) {
return
}
const startYOfLine = editor.heightAtLine(scrollState.firstLineInView - 1, 'local')
const heightOfLine = (editor.lineInfo(scrollState.firstLineInView - 1).handle as { height: number }).height
const newPositionRaw = startYOfLine + (heightOfLine * scrollState.scrolledPercentage) / 100
const newPosition = Math.floor(newPositionRaw)
if (newPosition !== lastScrollPosition.current) {
lastScrollPosition.current = newPosition
editor.scrollTo(0, newPosition)
}
}, [editor, scrollState])
const onBeforeChange = useCallback( const onBeforeChange = useCallback(
(editor: Editor, data: EditorChange, value: string) => { (editor: Editor, data: EditorChange, value: string) => {
@ -140,56 +80,9 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
[maxLength] [maxLength]
) )
const onDrop = useCallback((dropEditor: Editor, event: DropEvent) => { const onDrop = useOnEditorFileDrop()
if (
event &&
dropEditor &&
event.pageX &&
event.pageY &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length >= 1
) {
event.preventDefault()
const top: number = event.pageY
const left: number = event.pageX
const newCursor = dropEditor.coordsChar({ top, left }, 'page')
dropEditor.setCursor(newCursor)
const files: FileList = event.dataTransfer.files
handleUpload(files[0], dropEditor)
}
}, [])
const onMaxLengthHide = useCallback(() => setShowMaxLengthWarning(false), []) const onMaxLengthHide = useCallback(() => setShowMaxLengthWarning(false), [])
const codeMirrorOptions = useCodeMirrorOptions()
const codeMirrorOptions: EditorConfiguration = useMemo<EditorConfiguration>(
() => ({
...editorPreferences,
mode: 'gfm',
viewportMargin: 20,
styleActiveLine: true,
lineNumbers: true,
lineWrapping: true,
showCursorWhenSelecting: true,
highlightSelectionMatches: true,
inputStyle: 'textarea',
matchBrackets: true,
autoCloseBrackets: true,
matchTags: {
bothTags: true
},
autoCloseTags: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'authorship-gutters', 'CodeMirror-foldgutter'],
extraKeys: defaultKeyMap,
flattenSpans: true,
addModeClass: true,
autoRefresh: true,
// otherCursors: true,
placeholder: t('editor.placeholder')
}),
[t, editorPreferences]
)
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}>

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useRef } from 'react'
import { Editor } from 'codemirror'
import { ScrollState } from '../../synced-scroll/scroll-props'
/**
* Monitors the given scroll state and scrolls the editor to the state if changed.
*
* @param editor The editor that should be manipulated
* @param scrollState The scroll state that should be monitored
*/
export const useApplyScrollState = (editor?: Editor, scrollState?: ScrollState): void => {
const lastScrollPosition = useRef<number>()
useEffect(() => {
if (!editor || !scrollState) {
return
}
const startYOfLine = editor.heightAtLine(scrollState.firstLineInView - 1, 'local')
const heightOfLine = (editor.lineInfo(scrollState.firstLineInView - 1).handle as { height: number }).height
const newPositionRaw = startYOfLine + (heightOfLine * scrollState.scrolledPercentage) / 100
const newPosition = Math.floor(newPositionRaw)
if (newPosition !== lastScrollPosition.current) {
lastScrollPosition.current = newPosition
editor.scrollTo(0, newPosition)
}
}, [editor, scrollState])
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EditorConfiguration } from 'codemirror'
import { useMemo } from 'react'
import { defaultKeyMap } from '../key-map'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useTranslation } from 'react-i18next'
/**
* Generates the configuration for a CodeMirror instance.
*/
export const useCodeMirrorOptions = (): EditorConfiguration => {
const editorPreferences = useApplicationState((state) => state.editorConfig.preferences)
const { t } = useTranslation()
return useMemo<EditorConfiguration>(
() => ({
...editorPreferences,
mode: 'gfm',
viewportMargin: 20,
styleActiveLine: true,
lineNumbers: true,
lineWrapping: true,
showCursorWhenSelecting: true,
highlightSelectionMatches: true,
inputStyle: 'textarea',
matchBrackets: true,
autoCloseBrackets: true,
matchTags: {
bothTags: true
},
autoCloseTags: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'authorship-gutters', 'CodeMirror-foldgutter'],
extraKeys: defaultKeyMap,
flattenSpans: true,
addModeClass: true,
autoRefresh: true,
// otherCursors: true,
placeholder: t('editor.placeholder')
}),
[t, editorPreferences]
)
}

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback } from 'react'
import { Editor } from 'codemirror'
import { handleUpload } from '../upload-handler'
import { DomEvent } from 'react-codemirror2'
interface DropEvent {
pageX: number
pageY: number
dataTransfer: {
files: FileList
effectAllowed: string
} | null
preventDefault: () => void
}
/**
* Creates a callback that is used to process file drops on the code mirror editor
*
* @return the code mirror callback
*/
export const useOnEditorFileDrop = (): DomEvent => {
return useCallback((dropEditor: Editor, event: DropEvent) => {
if (
event &&
dropEditor &&
event.pageX &&
event.pageY &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length >= 1
) {
event.preventDefault()
const newCursor = dropEditor.coordsChar({ top: event.pageY, left: event.pageX }, 'page')
dropEditor.setCursor(newCursor)
const files: FileList = event.dataTransfer.files
handleUpload(files[0], dropEditor)
}
}, [])
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback } from 'react'
import { Editor } from 'codemirror'
import { handleFilePaste, handleTablePaste, PasteEvent } from '../tool-bar/utils/pasteHandlers'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { DomEvent } from 'react-codemirror2'
/**
* Creates a callback that handles the table or file paste action in code mirror.
*
* @return the created callback
*/
export const useOnEditorPasteCallback = (): DomEvent => {
const smartPasteEnabled = useApplicationState((state) => state.editorConfig.smartPaste)
return useCallback(
(pasteEditor: Editor, event: PasteEvent) => {
if (!event || !event.clipboardData) {
return
}
if (smartPasteEnabled && handleTablePaste(event, pasteEditor)) {
return
}
handleFilePaste(event, pasteEditor)
},
[smartPasteEnabled]
)
}

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DomEvent } from 'react-codemirror2'
import { useCallback, useEffect, useState } from 'react'
import { Editor, ScrollInfo } from 'codemirror'
import { ScrollState } from '../../synced-scroll/scroll-props'
/**
* Creates a callback for the scroll binding of the code mirror editor.
* It calculates a {@link ScrollState} and posts it on change.
*
* @param onScroll The callback that is used to post the {@link ScrolLState}.
* @return The callback for the code mirror scroll binding.
*/
export const useOnEditorScroll = (onScroll?: (scrollState: ScrollState) => void): DomEvent => {
const [editorScrollState, setEditorScrollState] = useState<ScrollState>()
useEffect(() => {
if (onScroll && editorScrollState) {
onScroll(editorScrollState)
}
}, [editorScrollState, onScroll])
return useCallback(
(editor: Editor, scrollInfo: ScrollInfo) => {
if (!editor || !onScroll || !scrollInfo) {
return
}
const line = editor.lineAtHeight(scrollInfo.top, 'local')
const startYOfLine = editor.heightAtLine(line, 'local')
const lineInfo = editor.lineInfo(line)
if (lineInfo === null) {
return
}
const heightOfLine = (lineInfo.handle as { height: number }).height
const percentageRaw = Math.max(scrollInfo.top - startYOfLine, 0) / heightOfLine
const percentage = Math.floor(percentageRaw * 100)
setEditorScrollState({ firstLineInView: line + 1, scrolledPercentage: percentage })
},
[onScroll]
)
}