Move toolbar functionality from redux to codemirror dispatch (#2083)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-06-08 01:10:49 +02:00 committed by GitHub
parent a8bd22aef3
commit e93607c96e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 1730 additions and 1721 deletions

View file

@ -1,42 +0,0 @@
/*
* 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,51 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
import { handleUpload } from '../use-handle-upload'
import Optional from 'optional-js'
import type { CursorSelection } from '../../tool-bar/formatters/types/cursor-selection'
const calculateCursorPositionInEditor = (view: EditorView, event: MouseEvent): number => {
return Optional.ofNullable(event.pageX)
.flatMap((posX) => {
return Optional.ofNullable(event.pageY).map((posY) => {
return view.posAtCoords({ x: posX, y: posY })
})
})
.orElse(view.state.selection.main.head)
}
const processFileList = (view: EditorView, fileList?: FileList, cursorSelection?: CursorSelection): boolean => {
return Optional.ofNullable(fileList)
.filter((files) => files.length > 0)
.map((files) => {
handleUpload(view, files[0], cursorSelection)
return true
})
.orElse(false)
}
/**
* Creates a callback that is used to process file drops and pastes on the code mirror editor
*
* @return the code mirror callback
*/
export const useCodeMirrorFileInsertExtension = (): Extension => {
return useMemo(() => {
return EditorView.domEventHandlers({
drop: (event, view) => {
processFileList(view, event.dataTransfer?.files, { from: calculateCursorPositionInEditor(view, event) }) &&
event.preventDefault()
},
paste: (event, view) => {
processFileList(view, event.clipboardData?.files) && event.preventDefault()
}
})
}, [])
}

View file

@ -1,34 +0,0 @@
/*
* 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,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { findRegexMatchInText } from './find-regex-match-in-text'
describe('find regex index in line', function () {
it('finds the first occurrence', () => {
const result = findRegexMatchInText('aba', /a/g, 0)
expect(result).toBeDefined()
expect(result).toHaveLength(1)
expect((result as RegExpMatchArray).index).toBe(0)
})
it('finds another occurrence', () => {
const result = findRegexMatchInText('aba', /a/g, 1)
expect(result).toBeDefined()
expect(result).toHaveLength(1)
expect((result as RegExpMatchArray).index).toBe(2)
})
it('fails to find with a wrong regex', () => {
const result = findRegexMatchInText('aba', /c/g, 0)
expect(result).not.toBeDefined()
})
it('fails to find with a negative wanted index', () => {
const result = findRegexMatchInText('aba', /a/g, -1)
expect(result).not.toBeDefined()
})
it('fails to find if the index is to high', () => {
const result = findRegexMatchInText('aba', /a/g, 100)
expect(result).not.toBeDefined()
})
})

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Matches a regex against a given text and returns the n-th match of the regex.
*
* @param text The text that should be searched through
* @param regex The regex that should find matches in the text
* @param matchIndex The index of the match to find
* @return The regex match of the found occurrence or undefined if no match could be found
*/
export const findRegexMatchInText = (text: string, regex: RegExp, matchIndex: number): RegExpMatchArray | undefined => {
if (matchIndex < 0) {
return
}
let currentIndex = 0
for (const match of text.matchAll(regex)) {
if (currentIndex === matchIndex) {
return match
}
currentIndex += 1
}
}

View file

@ -1,19 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEditorReceiveHandler } from '../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
import type { ImageUploadMessage } from '../../../render-page/window-post-message-communicator/rendering-message'
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
import { useEditorReceiveHandler } from '../../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
import type { ImageUploadMessage } from '../../../../render-page/window-post-message-communicator/rendering-message'
import { CommunicationMessageType } from '../../../../render-page/window-post-message-communicator/rendering-message'
import { useCallback } from 'react'
import { getGlobalState } from '../../../../redux'
import { handleUpload } from '../upload-handler'
import { Logger } from '../../../../utils/logger'
import { findRegexMatchInText } from '../find-regex-match-in-text'
import { getGlobalState } from '../../../../../redux'
import { Logger } from '../../../../../utils/logger'
import { findRegexMatchInText } from './find-regex-match-in-text'
import Optional from 'optional-js'
import type { CursorSelection } from '../../../../redux/editor/types'
import { useHandleUpload } from '../use-handle-upload'
import type { CursorSelection } from '../../tool-bar/formatters/types/cursor-selection'
const log = new Logger('useOnImageUpload')
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
@ -22,26 +22,31 @@ const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
*/
export const useOnImageUploadFromRenderer = (): void => {
const handleUpload = useHandleUpload()
useEditorReceiveHandler(
CommunicationMessageType.IMAGE_UPLOAD,
useCallback((values: ImageUploadMessage) => {
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
if (!dataUri.startsWith('data:image/')) {
log.error('Received uri is no data uri and image!')
return
}
useCallback(
(values: ImageUploadMessage) => {
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
if (!dataUri.startsWith('data:image/')) {
log.error('Received uri is no data uri and image!')
return
}
fetch(dataUri)
.then((result) => result.blob())
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
.flatMap((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.orElseGet(() => ({}))
handleUpload(file, cursorSelection, alt, title)
})
.catch((error) => log.error(error))
}, [])
fetch(dataUri)
.then((result) => result.blob())
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
.flatMap((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.orElseGet(() => ({}))
handleUpload(file, cursorSelection, alt, title)
})
.catch((error) => log.error(error))
},
[handleUpload]
)
)
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isCursorInCodeFence } from './codefenceDetection'
describe('Check whether cursor is in codefence', () => {
it('returns false for empty document', () => {
expect(isCursorInCodeFence('', 0)).toBe(false)
})
it('returns true with one open codefence directly above', () => {
expect(isCursorInCodeFence('```\n', 4)).toBe(true)
})
it('returns true with one open codefence and empty lines above', () => {
expect(isCursorInCodeFence('```\n\n\n', 5)).toBe(true)
})
it('returns false with one completed codefence above', () => {
expect(isCursorInCodeFence('```\n\n```\n', 8)).toBe(false)
})
it('returns true with one completed and one open codefence above', () => {
expect(isCursorInCodeFence('```\n\n```\n\n```\n\n', 13)).toBe(true)
})
})

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Checks if the given cursor position is in a code fence.
*
* @param markdownContent The markdown content whose content should be checked
* @param cursorPosition The cursor position that may or may not be in a code fence
* @return {@code true} if the given cursor position is in a code fence
*/
export const isCursorInCodeFence = (markdownContent: string, cursorPosition: number): boolean => {
const lines = markdownContent.slice(0, cursorPosition).split('\n')
return countCodeFenceLinesUntilIndex(lines) % 2 === 1
}
/**
* Counts the lines that start or end a code fence.
*
* @param lines The lines that should be inspected
* @return the counted lines
*/
const countCodeFenceLinesUntilIndex = (lines: string[]): number => {
return lines.filter((line) => line.startsWith('```')).length
}

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { convertClipboardTableToMarkdown, isTable } from './table-extractor'
describe('isTable detection: ', () => {
it('empty string is no table', () => {
expect(isTable('')).toBe(false)
})
it('single line is no table', () => {
const input = 'some none table'
expect(isTable(input)).toBe(false)
})
it('multiple lines without tabs are no table', () => {
const input = 'some none table\nanother line'
expect(isTable(input)).toBe(false)
})
it('code blocks are no table', () => {
const input = '```python\ndef a:\n\tprint("a")\n\tprint("b")```'
expect(isTable(input)).toBe(false)
})
it('tab-indented text is no table', () => {
const input = '\tsome tab indented text\n\tabc\n\tdef'
expect(isTable(input)).toBe(false)
})
it('not equal number of tabs is no table', () => {
const input = '1 ...\n2\tabc\n3\td\te\tf\n4\t16'
expect(isTable(input)).toBe(false)
})
it('table without newline at end is valid', () => {
const input = '1\t1\n2\t4\n3\t9\n4\t16\n5\t25'
expect(isTable(input)).toBe(true)
})
it('table with newline at end is valid', () => {
const input = '1\t1\n2\t4\n3\t9\n4\t16\n5\t25\n'
expect(isTable(input)).toBe(true)
})
it('table with some first cells missing is valid', () => {
const input = '1\t1\n\t0\n\t0\n4\t16\n5\t25\n'
expect(isTable(input)).toBe(true)
})
it('table with some last cells missing is valid', () => {
const input = '1\t1\n2\t\n3\t\n4\t16\n'
expect(isTable(input)).toBe(true)
})
})
describe('Conversion from clipboard table to markdown format', () => {
it('normal table without newline at end converts right', () => {
const input = '1\t1\ta\n2\t4\tb\n3\t9\tc\n4\t16\td'
expect(convertClipboardTableToMarkdown(input)).toEqual(
'| #1 | #2 | #3 |\n| -- | -- | -- |\n| 1 | 1 | a |\n| 2 | 4 | b |\n| 3 | 9 | c |\n| 4 | 16 | d |'
)
})
it('normal table with newline at end converts right', () => {
const input = '1\t1\n2\t4\n3\t9\n4\t16\n'
expect(convertClipboardTableToMarkdown(input)).toEqual(
'| #1 | #2 |\n| -- | -- |\n| 1 | 1 |\n| 2 | 4 |\n| 3 | 9 |\n| 4 | 16 |'
)
})
it('table with some first cells missing converts right', () => {
const input = '1\t1\n\t0\n\t0\n4\t16\n'
expect(convertClipboardTableToMarkdown(input)).toEqual(
'| #1 | #2 |\n| -- | -- |\n| 1 | 1 |\n| | 0 |\n| | 0 |\n| 4 | 16 |'
)
})
it('table with some last cells missing converts right', () => {
const input = '1\t1\n2\t\n3\t\n4\t16\n'
expect(convertClipboardTableToMarkdown(input)).toEqual(
'| #1 | #2 |\n| -- | -- |\n| 1 | 1 |\n| 2 | |\n| 3 | |\n| 4 | 16 |'
)
})
it('empty input results in empty output', () => {
expect(convertClipboardTableToMarkdown('')).toEqual('')
})
})

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
/**
* Checks if the given text is a tab-and-new-line-separated table.
* @param text The text to check
*/
export const isTable = (text: string): boolean => {
// Tables must consist of multiple rows and columns
if (!text.includes('\n') || !text.includes('\t')) {
return false
}
// Code within code blocks should not be parsed as a table
if (text.startsWith('```')) {
return false
}
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
// Tab-indented text should not be matched as a table
if (lines.every((line) => line.startsWith('\t'))) {
return false
}
// Every line should have the same amount of tabs (table columns)
const tabsPerLines = lines.map((line) => line.match(/\t/g)?.length ?? 0)
return tabsPerLines.every((line) => line === tabsPerLines[0])
}
/**
* Reformat the given text as Markdown table
* @param pasteData The plain text table separated by tabs and new-lines
* @return the formatted Markdown table
*/
export const convertClipboardTableToMarkdown = (pasteData: string): string => {
if (pasteData.trim() === '') {
return ''
}
const tableRows = pasteData.split(/\r?\n/).filter((row) => row.trim() !== '')
const tableCells = tableRows.reduce((cellsInRow, row, index) => {
cellsInRow[index] = row.split('\t')
return cellsInRow
}, [] as string[][])
const arrayMaxRows = createNumberRangeArray(tableCells.length)
const arrayMaxColumns = createNumberRangeArray(Math.max(...tableCells.map((row) => row.length)))
const headRow1 = arrayMaxColumns.map((col) => `| #${col + 1} `).join('') + '|'
const headRow2 = arrayMaxColumns.map((col) => `| -${'-'.repeat((col + 1).toString().length)} `).join('') + '|'
const body = arrayMaxRows
.map((row) => {
return arrayMaxColumns.map((col) => '| ' + tableCells[row][col] + ' ').join('') + '|'
})
.join('\n')
return `${headRow1}\n${headRow2}\n${body}`
}

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { changeEditorContent } from '../../../change-content-context/use-change-editor-content-callback'
import Optional from 'optional-js'
import { replaceSelection } from '../../tool-bar/formatters/replace-selection'
import { convertClipboardTableToMarkdown, isTable } from './table-extractor'
import { isCursorInCodeFence } from './codefenceDetection'
/**
* Creates a {@link Extension code mirror extension} that handles the smart table detection on paste-from-clipboard events.
*
* @return the created {@link Extension code mirror extension}
*/
export const useCodeMirrorTablePasteExtension = (): Extension[] => {
const smartPaste = useApplicationState((state) => state.editorConfig.smartPaste)
return useMemo(() => {
return smartPaste
? [
EditorView.domEventHandlers({
paste: (event, view) => {
if (isCursorInCodeFence(view.state.doc.toString(), view.state.selection.main.from)) {
return
}
Optional.ofNullable(event.clipboardData)
.map((clipboardData) => clipboardData.getData('text'))
.filter(isTable)
.map(convertClipboardTableToMarkdown)
.ifPresent((markdownTable) => {
changeEditorContent(view, ({ currentSelection }) => replaceSelection(currentSelection, markdownTable))
})
}
})
]
: []
}, [smartPaste])
}

View file

@ -1,15 +1,14 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
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'
import { useCodeMirrorReference } from '../../change-content-context/change-content-context'
/**
* Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}.
@ -31,14 +30,12 @@ export const applyScrollState = (view: EditorView, scrollState: ScrollState): vo
* @param editorRef The editor that should be manipulated
* @param scrollState The scroll state that should be monitored
*/
export const useApplyScrollState = (
editorRef: MutableRefObject<ReactCodeMirrorRef | null>,
scrollState?: ScrollState
): void => {
export const useApplyScrollState = (scrollState?: ScrollState): void => {
const lastScrollPosition = useRef<ScrollState>()
const codeMirrorRef = useCodeMirrorReference()
useEffect(() => {
const view = editorRef.current?.view
const view = codeMirrorRef
if (!view || !scrollState) {
return
}
@ -48,5 +45,5 @@ export const useApplyScrollState = (
}
applyScrollState(view, scrollState)
lastScrollPosition.current = scrollState
}, [editorRef, scrollState])
}, [codeMirrorRef, scrollState])
}

View file

@ -1,33 +0,0 @@
/*
* 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

@ -4,22 +4,18 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RefObject } from 'react'
import { useMemo, useRef } 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, SelectionRange } from '@codemirror/state'
const logger = new Logger('useCursorActivityCallback')
/**
* Provides a callback for codemirror that handles cursor changes
*
* @return the generated callback
*/
export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): Extension => {
export const useCursorActivityCallback = (): Extension => {
const lastMainSelection = useRef<SelectionRange>()
return useMemo(
@ -30,16 +26,11 @@ export const useCursorActivityCallback = (editorFocused: RefObject<boolean>): Ex
return
}
lastMainSelection.current = firstSelection
if (!editorFocused.current) {
logger.debug("Don't post updated cursor because editor isn't focused")
return
}
const newCursorPos = {
updateCursorPositions({
from: firstSelection.from,
to: firstSelection.to === firstSelection.from ? undefined : firstSelection.to
}
updateCursorPositions(newCursorPos)
})
}),
[editorFocused]
[]
)
}

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { uploadFile } from '../../../../api/media'
import { getGlobalState } from '../../../../redux'
import { supportedMimeTypes } from '../../../common/upload-image-mimetypes'
import { t } from 'i18next'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useCallback } from 'react'
import { changeEditorContent } from '../../change-content-context/use-change-editor-content-callback'
import { replaceSelection } from '../tool-bar/formatters/replace-selection'
import { replaceInContent } from '../tool-bar/formatters/replace-in-content'
import type { CursorSelection } from '../tool-bar/formatters/types/cursor-selection'
import type { EditorView } from '@codemirror/view'
import type { ContentFormatter } from '../../change-content-context/change-content-context'
import { useCodeMirrorReference } from '../../change-content-context/change-content-context'
/**
* Processes the upload of the given file and inserts the correct Markdown code
*
* @param view the codemirror instance that is used to insert the Markdown code
* @param file The file to upload
* @param cursorSelection The position where the progress message should be placed
* @param description The text that should be used in the description part of the resulting image tag
* @param additionalUrlText Additional text that should be inserted behind the link but within the tag
*/
export const handleUpload = (
view: EditorView,
file: File,
cursorSelection?: CursorSelection,
description?: string,
additionalUrlText?: string
): void => {
const changeContent = (callback: ContentFormatter) => changeEditorContent(view, callback)
if (!file || !supportedMimeTypes.includes(file.type) || !changeContent) {
return
}
const randomId = Math.random().toString(36).slice(7)
const uploadFileInfo = description
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
const noteId = getGlobalState().noteDetails.id
changeContent(({ currentSelection }) => {
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
})
uploadFile(noteId, file)
.then(({ url }) => {
const replacement = `![${description ?? file.name ?? ''}](${url}${additionalUrlText ?? ''})`
changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined
])
})
.catch((error: Error) => {
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
const replacement = `![upload of ${file.name} failed]()`
changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined
])
})
}
/**
* Provides a callback that uploads the given file and writes the progress into the given editor at the given cursor positions.
*
* @return The generated callback
*/
export const useHandleUpload = (): ((
file: File,
cursorSelection?: CursorSelection,
description?: string,
additionalUrlText?: string
) => void) => {
const codeMirrorReference = useCodeMirrorReference()
return useCallback(
(file: File, cursorSelection?: CursorSelection, description?: string, additionalUrlText?: string): void => {
if (codeMirrorReference) {
handleUpload(codeMirrorReference, file, cursorSelection, description, additionalUrlText)
}
},
[codeMirrorReference]
)
}

View file

@ -1,59 +0,0 @@
/*
* 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]
}, [])
}