mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-21 18:55:19 -04:00
Move toolbar functionality from redux to codemirror dispatch (#2083)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
a8bd22aef3
commit
e93607c96e
99 changed files with 1730 additions and 1721 deletions
|
@ -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]
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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('')
|
||||
})
|
||||
})
|
|
@ -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}`
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -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]
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 = ``
|
||||
const noteId = getGlobalState().noteDetails.id
|
||||
changeContent(({ currentSelection }) => {
|
||||
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
|
||||
})
|
||||
uploadFile(noteId, file)
|
||||
.then(({ url }) => {
|
||||
const replacement = ``
|
||||
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]
|
||||
)
|
||||
}
|
|
@ -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]
|
||||
}, [])
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue