Upgrade to CodeMirror 6 (#1787)

Upgrade to CodeMirror 6

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-02-13 12:14:01 +01:00 committed by GitHub
parent 1a09bfa5f1
commit 6a6f6105b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 1906 additions and 2615 deletions

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useMemo } from 'react'
import { handleUpload } from '../../upload-handler'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
import Optional from 'optional-js'
/**
* Creates a callback that is used to process file drops on the code mirror editor
*
* @return the code mirror callback
*/
export const useCodeMirrorFileDropExtension = (): Extension => {
const onDrop = useCallback((event: DragEvent, view: EditorView): void => {
if (!event.pageX || !event.pageY) {
return
}
Optional.ofNullable(event.dataTransfer?.files)
.filter((files) => files.length > 0)
.ifPresent((files) => {
event.preventDefault()
const newCursor = view.posAtCoords({ y: event.pageY, x: event.pageX })
if (newCursor === null) {
return
}
handleUpload(files[0], { from: newCursor })
})
}, [])
return useMemo(
() =>
EditorView.domEventHandlers({
drop: onDrop
}),
[onDrop]
)
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { handleFilePaste, handleTablePaste } from '../../tool-bar/utils/pasteHandlers'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
/**
* Creates a {@link Extension code mirror extension} that handles the table or file paste action.
*
* @return the created {@link Extension code mirror extension}
*/
export const useCodeMirrorPasteExtension = (): Extension => {
return useMemo(
() =>
EditorView.domEventHandlers({
paste: (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
if (handleTablePaste(clipboardData) || handleFilePaste(clipboardData)) {
event.preventDefault()
return
}
}
}),
[]
)
}

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useMemo } from 'react'
import type { ScrollState } from '../../../synced-scroll/scroll-props'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
export type OnScrollCallback = ((scrollState: ScrollState) => void) | undefined
/**
* Extracts the {@link ScrollState scroll state} from the given {@link EditorView editor view}.
*
* @param view The {@link EditorView editor view} whose scroll state should be extracted.
*/
export const extractScrollState = (view: EditorView): ScrollState => {
const state = view.state
const scrollTop = view.scrollDOM.scrollTop
const lineBlockAtHeight = view.lineBlockAtHeight(scrollTop)
const line = state.doc.lineAt(lineBlockAtHeight.from)
const percentageRaw = (scrollTop - lineBlockAtHeight.top) / lineBlockAtHeight.height
const scrolledPercentage = Math.floor(percentageRaw * 100)
return {
firstLineInView: line.number,
scrolledPercentage
}
}
/**
* Creates a code mirror extension for the scroll binding.
* It calculates a {@link ScrollState} and posts it on change.
*
* @param onScroll The callback that is used to post {@link ScrollState scroll states} when the editor view is scrolling.
* @return The extensions that watches the scrolling in the editor.
*/
export const useCodeMirrorScrollWatchExtension = (onScroll: OnScrollCallback): Extension => {
const onEditorScroll = useCallback(
(view: EditorView) => {
if (!onScroll || !view) {
return undefined
}
onScroll(extractScrollState(view))
},
[onScroll]
)
return useMemo(
() =>
EditorView.domEventHandlers({
scroll: (event, view) => onEditorScroll(view)
}),
[onEditorScroll]
)
}

View file

@ -6,8 +6,24 @@
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import type { Editor } from 'codemirror'
import type { ScrollState } from '../../synced-scroll/scroll-props'
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror'
import { EditorView } from '@codemirror/view'
import equal from 'fast-deep-equal'
/**
* Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}.
*
* @param view The {@link EditorView view} that should be scrolled
* @param scrollState The {@link ScrollState scroll state} that should be applied
*/
export const applyScrollState = (view: EditorView, scrollState: ScrollState): void => {
const line = view.state.doc.line(scrollState.firstLineInView)
const lineBlock = view.lineBlockAt(line.from)
const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100
const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin })
view.dispatch({ effects: [stateEffect] })
}
/**
* Monitors the given scroll state and scrolls the editor to the state if changed.
@ -16,22 +32,21 @@ import type { ScrollState } from '../../synced-scroll/scroll-props'
* @param scrollState The scroll state that should be monitored
*/
export const useApplyScrollState = (
editorRef: MutableRefObject<Editor | undefined>,
editorRef: MutableRefObject<ReactCodeMirrorRef | null>,
scrollState?: ScrollState
): void => {
const lastScrollPosition = useRef<number>()
const lastScrollPosition = useRef<ScrollState>()
useEffect(() => {
const editor = editorRef.current
if (!editor || !scrollState) {
const view = editorRef.current?.view
if (!view || !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)
if (equal(scrollState, lastScrollPosition.current)) {
return
}
applyScrollState(view, scrollState)
lastScrollPosition.current = scrollState
}, [editorRef, scrollState])
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RefObject } from 'react'
import { useMemo, useRef } from 'react'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
/**
* Creates a {@link RefObject<boolean> reference} that contains the information if the editor is currently focused or not.
*
* @returns The reference and the necessary {@link Extension code mirror extension} that receives the focus and blur events
*/
export const useCodeMirrorFocusReference = (): [Extension, RefObject<boolean>] => {
const focusReference = useRef<boolean>(false)
const codeMirrorExtension = useMemo(
() =>
EditorView.domEventHandlers({
blur: () => {
focusReference.current = false
},
focus: () => {
focusReference.current = true
}
}),
[]
)
return [codeMirrorExtension, focusReference]
}

View file

@ -1,48 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { EditorConfiguration } from 'codemirror'
import { useMemo } from 'react'
import { createDefaultKeyMap } 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: createDefaultKeyMap(),
flattenSpans: true,
addModeClass: true,
autoRefresh: true,
// otherCursors: true,
placeholder: t('editor.placeholder')
}),
[t, editorPreferences]
)
}

View file

@ -1,35 +0,0 @@
/*
* 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 { useMemo } 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 => {
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const selection = useApplicationState((state) => state.noteDetails.selection)
const markdownContent = useApplicationState((state) => state.noteDetails.markdownContent)
const markdownContentLines = useApplicationState((state) => state.noteDetails.markdownContentLines)
return useMemo(() => {
const startCharacter = selection.from.character
const endCharacter = selection.to?.character ?? 0
const startLine = selection.from.line
const endLine = selection.to?.line ?? 0
return {
position: { line: startLine, character: startCharacter },
charactersInDocument: markdownContent.length,
remainingCharacters: maxDocumentLength - markdownContent.length,
linesInDocument: markdownContentLines.length,
selectedColumns: endCharacter - startCharacter,
selectedLines: endLine - startLine
}
}, [markdownContent.length, markdownContentLines.length, maxDocumentLength, selection])
}

View file

@ -4,27 +4,36 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import { useCallback } from 'react'
import type { CursorPosition } from '../../../../redux/editor/types'
import type { RefObject } from 'react'
import { useMemo } from 'react'
import { updateCursorPositions } from '../../../../redux/note-details/methods'
import type { ViewUpdate } from '@codemirror/view'
import { EditorView } from '@codemirror/view'
import { Logger } from '../../../../utils/logger'
import type { Extension } from '@codemirror/state'
const logger = new Logger('useCursorActivityCallback')
/**
* Provides a callback for codemirror that handles cursor changes
*
* @return the generated callback
*/
export const useCursorActivityCallback = (): ((editor: Editor) => void) => {
return useCallback((editor) => {
const firstSelection = editor.listSelections()[0]
if (firstSelection === undefined) {
return
}
const start: CursorPosition = { line: firstSelection.from().line, character: firstSelection.from().ch }
const end: CursorPosition = { line: firstSelection.to().line, character: firstSelection.to().ch }
updateCursorPositions({
from: start,
to: start.line === end.line && start.character === end.character ? undefined : end
})
}, [])
export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): Extension => {
return useMemo(
() =>
EditorView.updateListener.of((viewUpdate: ViewUpdate): void => {
if (!editorFocused.current) {
logger.debug("Don't post updated cursor because editor isn't focused")
return
}
const firstSelection = viewUpdate.state.selection.main
const newCursorPos = {
from: firstSelection.from,
to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to
}
updateCursorPositions(newCursorPos)
}),
[editorFocused]
)
}

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
export interface LineBasedPosition {
line: number
character: number
}
const calculateLineBasedPosition = (absolutePosition: number, lineStartIndexes: number[]): LineBasedPosition => {
const foundLineIndex = lineStartIndexes.findIndex((startIndex) => absolutePosition < startIndex)
const line = foundLineIndex === -1 ? lineStartIndexes.length - 1 : foundLineIndex - 1
return {
line: line,
character: absolutePosition - lineStartIndexes[line]
}
}
/**
* Returns the line+character based position of the to cursor, if available.
*/
export const useLineBasedToPosition = (): LineBasedPosition | undefined => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes)
const selection = useApplicationState((state) => state.noteDetails.selection)
return useMemo(() => {
const to = selection.to
if (to === undefined) {
return undefined
}
return calculateLineBasedPosition(to, lineStartIndexes)
}, [selection.to, lineStartIndexes])
}
/**
* Returns the line+character based position of the from cursor.
*/
export const useLineBasedFromPosition = (): LineBasedPosition => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes)
const selection = useApplicationState((state) => state.noteDetails.selection)
return useMemo(() => {
return calculateLineBasedPosition(selection.from, lineStartIndexes)
}, [selection.from, lineStartIndexes])
}

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo, useRef } from 'react'
import type { ScrollState } from '../../synced-scroll/scroll-props'
import { extractScrollState } from './code-mirror-extensions/use-code-mirror-scroll-watch-extension'
import { applyScrollState } from './use-apply-scroll-state'
import { store } from '../../../../redux'
import type { Extension } from '@codemirror/state'
import { Logger } from '../../../../utils/logger'
import { EditorView } from '@codemirror/view'
const logger = new Logger('useOffScreenScrollProtection')
/**
* If the editor content changes while the editor isn't focused then the editor starts jumping around.
* This extension fixes this behaviour by saving the scroll state when the editor looses focus and applies it on content changes.
*
* @returns necessary {@link Extension code mirror extensions} to provide the functionality
*/
export const useOffScreenScrollProtection = (): Extension[] => {
const offFocusScrollState = useRef<ScrollState>()
return useMemo(() => {
const saveOffFocusScrollStateExtension = EditorView.domEventHandlers({
blur: (event, view) => {
offFocusScrollState.current = extractScrollState(view)
logger.debug('Save off-focus scroll state', offFocusScrollState.current)
},
focus: () => {
offFocusScrollState.current = undefined
}
})
const changeExtension = EditorView.updateListener.of((update) => {
const view = update.view
const scrollState = offFocusScrollState.current
if (!scrollState || !update.docChanged) {
return
}
logger.debug('Apply off-focus scroll state', scrollState)
applyScrollState(view, scrollState)
const selection = store.getState().noteDetails.selection
view.dispatch(
view.state.update({
selection: {
anchor: selection.from,
head: selection.to
}
})
)
})
return [saveOffFocusScrollStateExtension, changeExtension]
}, [])
}

View file

@ -1,45 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback } from 'react'
import type { Editor } from 'codemirror'
import { handleUpload } from '../upload-handler'
import type { 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])
}
}, [])
}

View file

@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback } from 'react'
import type { Editor } from 'codemirror'
import type { PasteEvent } from '../tool-bar/utils/pasteHandlers'
import { handleFilePaste, handleTablePaste } from '../tool-bar/utils/pasteHandlers'
import type { 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 => {
return useCallback((pasteEditor: Editor, event: PasteEvent) => {
if (!event || !event.clipboardData) {
return
}
if (handleTablePaste(event) || handleFilePaste(event)) {
event.preventDefault()
return
}
}, [])
}

View file

@ -1,47 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DomEvent } from 'react-codemirror2'
import { useCallback, useEffect, useState } from 'react'
import type { Editor, ScrollInfo } from 'codemirror'
import type { 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]
)
}

View file

@ -36,7 +36,7 @@ export const useOnImageUploadFromRenderer = (): void => {
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.flatMap((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.orElseGet(() => ({}))
handleUpload(file, cursorSelection, alt, title)
})
@ -58,26 +58,25 @@ export interface ExtractResult {
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
* @return the calculated start and end position or undefined if no position could be determined
*/
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): ExtractResult | undefined => {
const currentMarkdownContentLines = getGlobalState().noteDetails.markdownContent.split('\n')
const lineAtIndex = currentMarkdownContentLines[lineIndex]
if (lineAtIndex === undefined) {
return
}
return findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], lineIndex, replacementIndexInLine)
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): Optional<ExtractResult> => {
const noteDetails = getGlobalState().noteDetails
const currentMarkdownContentLines = noteDetails.markdownContent.lines
return Optional.ofNullable(noteDetails.markdownContent.lineStartIndexes[lineIndex]).map((startIndexOfLine) =>
findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], startIndexOfLine, replacementIndexInLine)
)
}
/**
* Tries to find the right image placeholder in the given line.
*
* @param line The line that should be inspected
* @param lineIndex The index of the line in the document
* @param startIndexOfLine The absolute start index of the line in the document
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
* @return the calculated start and end position or undefined if no position could be determined
*/
const findImagePlaceholderInLine = (
line: string,
lineIndex: number,
startIndexOfLine: number,
replacementIndexInLine = 0
): ExtractResult | undefined => {
const startOfImageTag = findRegexMatchInText(line, imageWithPlaceholderLinkRegex, replacementIndexInLine)
@ -85,16 +84,12 @@ const findImagePlaceholderInLine = (
return
}
const from = startIndexOfLine + startOfImageTag.index
const to = from + startOfImageTag[0].length
return {
cursorSelection: {
from: {
character: startOfImageTag.index,
line: lineIndex
},
to: {
character: startOfImageTag.index + startOfImageTag[0].length,
line: lineIndex
}
from,
to
},
alt: startOfImageTag[1],
title: startOfImageTag[2]