Move toolbar functions into redux reducer (#1763)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-01-26 17:14:28 +01:00 committed by GitHub
parent a6a2251c88
commit b30cc5b390
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2481 additions and 2303 deletions

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createNumberRangeArray } from './number-range'
describe('number range', () => {
it('creates an empty number range', () => {
expect(createNumberRangeArray(0)).toEqual([])
})
it('creates a non-empty number range', () => {
expect(createNumberRangeArray(10)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
})
it('fails with a negative range', () => {
expect(() => createNumberRangeArray(-1)).toThrow()
})
})

View file

@ -11,7 +11,7 @@ import { findWordAtCursor } from './index'
const wordRegExp = /^(<d(?:e|et|eta|etai|etail|etails)?)$/
const collapsableBlockHint = (editor: Editor): Promise<Hints | null> => {
const collapsibleBlockHint = (editor: Editor): Promise<Hints | null> => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor)
const searchResult = wordRegExp.exec(searchTerm.text)
@ -37,7 +37,7 @@ const collapsableBlockHint = (editor: Editor): Promise<Hints | null> => {
})
}
export const CollapsableBlockHinter: Hinter = {
export const CollapsibleBlockHinter: Hinter = {
wordRegExp,
hint: collapsableBlockHint
hint: collapsibleBlockHint
}

View file

@ -6,7 +6,7 @@
import type { Editor, Hints } from 'codemirror'
import { CodeBlockHinter } from './code-block'
import { CollapsableBlockHinter } from './collapsable-block'
import { CollapsibleBlockHinter } from './collapsible-block'
import { ContainerHinter } from './container'
import { EmojiHinter } from './emoji'
import { HeaderHinter } from './header'
@ -65,5 +65,5 @@ export const allHinters: Hinter[] = [
ImageHinter,
LinkAndExtraTagHinter,
PDFHinter,
CollapsableBlockHinter
CollapsibleBlockHinter
]

View file

@ -9,7 +9,7 @@ import { Pos } from 'codemirror'
import { DateTime } from 'luxon'
import type { Hinter } from './index'
import { findWordAtCursor } from './index'
import { store } from '../../../../redux'
import { getGlobalState } from '../../../../redux'
const wordRegExp = /^(\[(.*])?)$/
const allSupportedLinks = [
@ -27,7 +27,7 @@ const allSupportedLinks = [
]
const getUserName = (): string => {
const user = store.getState().user
const user = getGlobalState().user
return user ? user.displayName : 'Anonymous'
}

View file

@ -5,7 +5,7 @@
*/
import type { Editor, EditorChange } from 'codemirror'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useRef } from 'react'
import type { ScrollProps } from '../synced-scroll/scroll-props'
import { StatusBar } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar'
@ -18,14 +18,14 @@ 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'
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
import { useCreateStatusBarInfo } from './hooks/use-create-status-bar-info'
import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer'
import { ExtendedCodemirror } from './extended-codemirror/extended-codemirror'
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const markdownContent = useNoteMarkdownContent()
const [editor, setEditor] = useState<Editor>()
const editor = useRef<Editor>()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const onPaste = useOnEditorPasteCallback()
@ -36,38 +36,56 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
setNoteContent(value)
}, [])
const [statusBarInfo, updateStatusBarInfo] = useCreateStatusBarInfo()
useOnImageUploadFromRenderer()
useOnImageUploadFromRenderer(editor)
const onEditorDidMount = useCallback(
(mountedEditor: Editor) => {
updateStatusBarInfo(mountedEditor)
setEditor(mountedEditor)
},
[updateStatusBarInfo]
)
const onEditorDidMount = useCallback((mountedEditor: Editor) => {
editor.current = mountedEditor
}, [])
const onCursorActivity = useCursorActivityCallback()
const onDrop = useOnEditorFileDrop()
const codeMirrorOptions = useCodeMirrorOptions()
const editorFocus = useRef<boolean>(false)
const onFocus = useCallback(() => {
editorFocus.current = true
if (editor.current) {
onCursorActivity(editor.current)
}
}, [editor, onCursorActivity])
const onBlur = useCallback(() => {
editorFocus.current = false
}, [])
const cursorActivity = useCallback(
(editor: Editor) => {
if (editorFocus.current) {
onCursorActivity(editor)
}
},
[onCursorActivity]
)
return (
<div className={`d-flex flex-column h-100 position-relative`} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarning />
<ToolBar editor={editor} />
<ToolBar />
<ExtendedCodemirror
className={`overflow-hidden w-100 flex-fill`}
value={markdownContent}
options={codeMirrorOptions}
onPaste={onPaste}
onDrop={onDrop}
onCursorActivity={updateStatusBarInfo}
onCursorActivity={cursorActivity}
editorDidMount={onEditorDidMount}
onBeforeChange={onBeforeChange}
onScroll={onEditorScroll}
onFocus={onFocus}
onBlur={onBlur}
ligatures={ligaturesEnabled}
/>
<StatusBar statusBarInfo={statusBarInfo} />
<StatusBar />
</div>
)
}

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import type { Editor } from 'codemirror'
import type { ScrollState } from '../../synced-scroll/scroll-props'
@ -11,12 +12,16 @@ import type { 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 editorRef The editor that should be manipulated
* @param scrollState The scroll state that should be monitored
*/
export const useApplyScrollState = (editor?: Editor, scrollState?: ScrollState): void => {
export const useApplyScrollState = (
editorRef: MutableRefObject<Editor | undefined>,
scrollState?: ScrollState
): void => {
const lastScrollPosition = useRef<number>()
useEffect(() => {
const editor = editorRef.current
if (!editor || !scrollState) {
return
}
@ -28,5 +33,5 @@ export const useApplyScrollState = (editor?: Editor, scrollState?: ScrollState):
lastScrollPosition.current = newPosition
editor.scrollTo(0, newPosition)
}
}, [editor, scrollState])
}, [editorRef, scrollState])
}

View file

@ -5,34 +5,31 @@
*/
import type { StatusBarInfo } from '../status-bar/status-bar'
import { defaultState } from '../status-bar/status-bar'
import type { Editor } from 'codemirror'
import { useCallback, useState } from 'react'
import { 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: StatusBarInfo,
updateStatusBarInfo: (editor: Editor) => void
] => {
export const useCreateStatusBarInfo = (): StatusBarInfo => {
const maxDocumentLength = useApplicationState((state) => state.config.maxDocumentLength)
const [statusBarInfo, setStatusBarInfo] = useState(defaultState)
const selection = useApplicationState((state) => state.noteDetails.selection)
const markdownContent = useApplicationState((state) => state.noteDetails.markdownContent)
const markdownContentLines = useApplicationState((state) => state.noteDetails.markdownContentLines)
const updateStatusBarInfo = useCallback(
(editor: Editor): void => {
setStatusBarInfo({
position: editor.getCursor(),
charactersInDocument: editor.getValue().length,
remainingCharacters: maxDocumentLength - editor.getValue().length,
linesInDocument: editor.lineCount(),
selectedColumns: editor.getSelection().length,
selectedLines: editor.getSelection().split('\n').length
})
},
[maxDocumentLength]
)
return useMemo(() => {
const startCharacter = selection.from.character
const endCharacter = selection.to?.character ?? 0
const startLine = selection.from.line
const endLine = selection.to?.line ?? 0
return [statusBarInfo, updateStatusBarInfo]
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

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import { useCallback } from 'react'
import type { CursorPosition } from '../../../../redux/editor/types'
import { updateCursorPositions } from '../../../../redux/note-details/methods'
/**
* 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
})
}, [])
}

View file

@ -39,7 +39,7 @@ export const useOnEditorFileDrop = (): DomEvent => {
const newCursor = dropEditor.coordsChar({ top: event.pageY, left: event.pageX }, 'page')
dropEditor.setCursor(newCursor)
const files: FileList = event.dataTransfer.files
handleUpload(files[0], dropEditor)
handleUpload(files[0])
}
}, [])
}

View file

@ -8,7 +8,6 @@ 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 { useApplicationState } from '../../../../hooks/common/use-application-state'
import type { DomEvent } from 'react-codemirror2'
/**
@ -17,18 +16,13 @@ import type { DomEvent } from 'react-codemirror2'
* @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]
)
return useCallback((pasteEditor: Editor, event: PasteEvent) => {
if (!event || !event.clipboardData) {
return
}
if (handleTablePaste(event) || handleFilePaste(event)) {
event.preventDefault()
return
}
}, [])
}

View file

@ -8,56 +8,47 @@ import { useEditorReceiveHandler } from '../../../render-page/window-post-messag
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 { store } from '../../../../redux'
import { getGlobalState } from '../../../../redux'
import { handleUpload } from '../upload-handler'
import type { Editor, Position } from 'codemirror'
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'
const log = new Logger('useOnImageUpload')
const imageWithPlaceholderLinkRegex = /!\[([^\]]*)]\(https:\/\/([^)]*)\)/g
/**
* Receives {@link CommunicationMessageType.IMAGE_UPLOAD image upload events} via iframe communication and processes the attached uploads.
*
* @param editor The {@link Editor codemirror editor} that should be used to change the markdown code
*/
export const useOnImageUploadFromRenderer = (editor: Editor | undefined): void => {
export const useOnImageUploadFromRenderer = (): void => {
useEditorReceiveHandler(
CommunicationMessageType.IMAGE_UPLOAD,
useCallback(
(values: ImageUploadMessage) => {
const { dataUri, fileName, lineIndex, placeholderIndexInLine } = values
if (!editor) {
return
}
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 { cursorFrom, cursorTo, description, additionalText } = Optional.ofNullable(lineIndex)
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.orElseGet(() => calculateInsertAtCurrentCursorPosition(editor))
handleUpload(file, editor, cursorFrom, cursorTo, description, additionalText)
})
.catch((error) => log.error(error))
},
[editor]
)
fetch(dataUri)
.then((result) => result.blob())
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
.map((actualLineIndex) => findPlaceholderInMarkdownContent(actualLineIndex, placeholderIndexInLine))
.orElseGet(() => ({}))
handleUpload(file, cursorSelection, alt, title)
})
.catch((error) => log.error(error))
}, [])
)
}
export interface ExtractResult {
cursorFrom: Position
cursorTo: Position
description?: string
additionalText?: string
cursorSelection?: CursorSelection
alt?: string
title?: string
}
/**
@ -68,7 +59,7 @@ export interface ExtractResult {
* @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 = store.getState().noteDetails.markdownContent.split('\n')
const currentMarkdownContentLines = getGlobalState().noteDetails.markdownContent.split('\n')
const lineAtIndex = currentMarkdownContentLines[lineIndex]
if (lineAtIndex === undefined) {
return
@ -95,26 +86,17 @@ const findImagePlaceholderInLine = (
}
return {
cursorFrom: {
ch: startOfImageTag.index,
line: lineIndex
cursorSelection: {
from: {
character: startOfImageTag.index,
line: lineIndex
},
to: {
character: startOfImageTag.index + startOfImageTag[0].length,
line: lineIndex
}
},
cursorTo: {
ch: startOfImageTag.index + startOfImageTag[0].length,
line: lineIndex
},
description: startOfImageTag[1],
additionalText: startOfImageTag[2]
alt: startOfImageTag[1],
title: startOfImageTag[2]
}
}
/**
* Calculates a fallback position that is the current editor cursor position.
* This wouldn't replace anything and only insert.
*
* @param editor The editor whose cursor should be used
*/
const calculateInsertAtCurrentCursorPosition = (editor: Editor): ExtractResult => {
const editorCursor = editor.getCursor()
return { cursorFrom: editorCursor, cursorTo: editorCursor }
}

View file

@ -7,14 +7,8 @@
import type { Editor, KeyMap, Pass } from 'codemirror'
import CodeMirror from 'codemirror'
import { isMac } from '../utils'
import {
addLink,
makeSelectionBold,
makeSelectionItalic,
markSelection,
strikeThroughSelection,
underlineSelection
} from './tool-bar/utils/toolbarButtonUtils'
import { formatSelection } from '../../../redux/note-details/methods'
import { FormatType } from '../../../redux/note-details/types'
const isVim = (keyMapName?: string) => keyMapName?.substr(0, 3) === 'vim'
@ -83,11 +77,11 @@ export const createDefaultKeyMap: () => KeyMap = () => {
'Cmd-Right': 'goLineRight',
Home: 'goLineLeftSmart',
End: 'goLineRight',
'Cmd-I': makeSelectionItalic,
'Cmd-B': makeSelectionBold,
'Cmd-U': underlineSelection,
'Cmd-D': strikeThroughSelection,
'Cmd-M': markSelection
'Cmd-I': () => formatSelection(FormatType.ITALIC),
'Cmd-B': () => formatSelection(FormatType.BOLD),
'Cmd-U': () => formatSelection(FormatType.UNDERLINE),
'Cmd-D': () => formatSelection(FormatType.STRIKETHROUGH),
'Cmd-M': () => formatSelection(FormatType.HIGHLIGHT)
} as KeyMap
} else {
return {
@ -99,12 +93,12 @@ export const createDefaultKeyMap: () => KeyMap = () => {
Tab: tab,
Home: 'goLineLeftSmart',
End: 'goLineRight',
'Ctrl-I': makeSelectionItalic,
'Ctrl-B': makeSelectionBold,
'Ctrl-U': underlineSelection,
'Ctrl-D': strikeThroughSelection,
'Ctrl-M': markSelection,
'Ctrl-K': addLink
'Ctrl-I': () => formatSelection(FormatType.ITALIC),
'Ctrl-B': () => formatSelection(FormatType.BOLD),
'Ctrl-U': () => formatSelection(FormatType.UNDERLINE),
'Ctrl-D': () => formatSelection(FormatType.STRIKETHROUGH),
'Ctrl-M': () => formatSelection(FormatType.HIGHLIGHT),
'Ctrl-K': () => formatSelection(FormatType.LINK)
} as KeyMap
}
}

View file

@ -6,10 +6,10 @@
import React, { useMemo } from 'react'
import { Trans } from 'react-i18next'
import type { Position } from 'codemirror'
import type { CursorPosition } from '../../../../redux/editor/types'
export interface CursorPositionInfoProps {
cursorPosition: Position
cursorPosition: CursorPosition
}
/**
@ -21,9 +21,9 @@ export const CursorPositionInfo: React.FC<CursorPositionInfoProps> = ({ cursorPo
const translationOptions = useMemo(
() => ({
line: cursorPosition.line + 1,
columns: cursorPosition.ch + 1
columns: cursorPosition.character + 1
}),
[cursorPosition.ch, cursorPosition.line]
[cursorPosition.character, cursorPosition.line]
)
return (

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Position } from 'codemirror'
import React from 'react'
import styles from './status-bar.module.scss'
import { RemainingCharactersInfo } from './remaining-characters-info'
@ -13,9 +12,11 @@ import { CursorPositionInfo } from './cursor-position-info'
import { SelectionInfo } from './selection-info'
import { ShowIf } from '../../../common/show-if/show-if'
import { SeparatorDash } from './separator-dash'
import type { CursorPosition } from '../../../../redux/editor/types'
import { useCreateStatusBarInfo } from '../hooks/use-create-status-bar-info'
export interface StatusBarInfo {
position: Position
position: CursorPosition
selectedColumns: number
selectedLines: number
linesInDocument: number
@ -24,7 +25,7 @@ export interface StatusBarInfo {
}
export const defaultState: StatusBarInfo = {
position: { line: 0, ch: 0 },
position: { line: 0, character: 0 },
selectedColumns: 0,
selectedLines: 0,
linesInDocument: 0,
@ -32,16 +33,12 @@ export const defaultState: StatusBarInfo = {
remainingCharacters: 0
}
export interface StatusBarProps {
statusBarInfo: StatusBarInfo
}
/**
* Shows additional information about the document length and the current selection.
*
* @param statusBarInfo The information to show
*/
export const StatusBar: React.FC<StatusBarProps> = ({ statusBarInfo }) => {
export const StatusBar: React.FC = () => {
const statusBarInfo = useCreateStatusBarInfo()
return (
<div className={`d-flex flex-row ${styles['status-bar']} px-2`}>
<div>

View file

@ -4,37 +4,34 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type CodeMirror from 'codemirror'
import React, { Fragment, useState } from 'react'
import React, { Fragment, useCallback, useState } from 'react'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { addEmoji } from '../utils/toolbarButtonUtils'
import { EmojiPicker } from './emoji-picker'
import { cypressId } from '../../../../../utils/cypress-attribute'
import { getEmojiShortCode } from '../utils/emojiUtils'
import { replaceSelection } from '../../../../../redux/note-details/methods'
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
import Optional from 'optional-js'
export interface EmojiPickerButtonProps {
editor: CodeMirror.Editor
}
export const EmojiPickerButton: React.FC<EmojiPickerButtonProps> = ({ editor }) => {
export const EmojiPickerButton: React.FC = () => {
const { t } = useTranslation()
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const onEmojiSelected = useCallback((emoji: EmojiClickEventDetail) => {
setShowEmojiPicker(false)
Optional.ofNullable(getEmojiShortCode(emoji)).ifPresent((shortCode) => replaceSelection(shortCode))
}, [])
const hidePicker = useCallback(() => setShowEmojiPicker(false), [])
const showPicker = useCallback(() => setShowEmojiPicker(true), [])
return (
<Fragment>
<EmojiPicker
show={showEmojiPicker}
onEmojiSelected={(emoji) => {
setShowEmojiPicker(false)
addEmoji(emoji, editor)
}}
onDismiss={() => setShowEmojiPicker(false)}
/>
<EmojiPicker show={showEmojiPicker} onEmojiSelected={onEmojiSelected} onDismiss={hidePicker} />
<Button
{...cypressId('show-emoji-picker')}
variant='light'
onClick={() => setShowEmojiPicker((old) => !old)}
onClick={showPicker}
title={t('editor.editorToolbar.emoji')}>
<ForkAwesomeIcon icon='smile-o' />
</Button>

View file

@ -4,21 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type CodeMirror from 'codemirror'
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'
import { Button, Overlay } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { addTable } from '../utils/toolbarButtonUtils'
import { cypressId } from '../../../../../utils/cypress-attribute'
import { TableSizePickerPopover } from './table-size-picker-popover'
import { CustomTableSizeModal } from './custom-table-size-modal'
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
import { ShowIf } from '../../../../common/show-if/show-if'
export interface TablePickerButtonProps {
editor: CodeMirror.Editor
}
import { addTableAtCursor } from '../../../../../redux/note-details/methods'
enum PickerMode {
INVISIBLE,
@ -28,24 +23,19 @@ enum PickerMode {
/**
* Toggles the visibility of a table size picker overlay and inserts the result into the editor.
*
* @param editor The editor in which the result should get inserted
*/
export const TablePickerButton: React.FC<TablePickerButtonProps> = ({ editor }) => {
export const TablePickerButton: React.FC = () => {
const { t } = useTranslation()
const [pickerMode, setPickerMode] = useState<PickerMode>(PickerMode.INVISIBLE)
const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), [])
const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), [])
const onSizeSelect = useCallback(
(rows: number, columns: number) => {
addTable(editor, rows, columns)
setPickerMode(PickerMode.INVISIBLE)
},
[editor]
)
const onSizeSelect = useCallback((rows: number, columns: number) => {
addTableAtCursor(rows, columns)
setPickerMode(PickerMode.INVISIBLE)
}, [])
const tableTitle = useMemo(() => t('editor.editorToolbar.table.title'), [t])
const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t])
const button = useRef(null)

View file

@ -58,7 +58,7 @@ export const TableSizePickerPopover: React.FC<TableSizePickerPopoverProps> = ({
{...cypressAttribute('col', `${col + 1}`)}
{...cypressAttribute('row', `${row + 1}`)}
onMouseEnter={onSizeHover(row + 1, col + 1)}
title={t('editor.editorToolbar.table.size', { cols: col + 1, rows: row + 1 })}
title={t('editor.editorToolbar.table.titleWithSize', { cols: col + 1, rows: row + 1 })}
onClick={() => onTableSizeSelected(row + 1, col + 1)}
/>
)

View file

@ -4,179 +4,47 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import React from 'react'
import { Button, ButtonGroup, ButtonToolbar } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
import { EditorPreferences } from './editor-preferences/editor-preferences'
import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
import { TablePickerButton } from './table-picker/table-picker-button'
import styles from './tool-bar.module.scss'
import { UploadImageButton } from './upload-image-button'
import {
addCodeFences,
addCollapsableBlock,
addComment,
addHeaderLevel,
addImage,
addLine,
addLink,
addList,
addOrderedList,
addQuotes,
addTaskList,
makeSelectionBold,
makeSelectionItalic,
strikeThroughSelection,
subscriptSelection,
superscriptSelection,
underlineSelection
} from './utils/toolbarButtonUtils'
import { cypressId } from '../../../../utils/cypress-attribute'
export interface ToolBarProps {
editor?: Editor
}
export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
const { t } = useTranslation()
if (!editor) {
return null
}
import { ToolbarButton } from './toolbar-button'
import { FormatType } from '../../../../redux/note-details/types'
export const ToolBar: React.FC = () => {
return (
<ButtonToolbar className={`bg-light ${styles.toolbar}`}>
<ButtonGroup className={'mx-1 flex-wrap'}>
<Button
{...cypressId('format-bold')}
variant='light'
onClick={() => makeSelectionBold(editor)}
title={t('editor.editorToolbar.bold')}>
<ForkAwesomeIcon icon='bold' />
</Button>
<Button
{...cypressId('format-italic')}
variant='light'
onClick={() => makeSelectionItalic(editor)}
title={t('editor.editorToolbar.italic')}>
<ForkAwesomeIcon icon='italic' />
</Button>
<Button
{...cypressId('format-underline')}
variant='light'
onClick={() => underlineSelection(editor)}
title={t('editor.editorToolbar.underline')}>
<ForkAwesomeIcon icon='underline' />
</Button>
<Button
{...cypressId('format-strikethrough')}
variant='light'
onClick={() => strikeThroughSelection(editor)}
title={t('editor.editorToolbar.strikethrough')}>
<ForkAwesomeIcon icon='strikethrough' />
</Button>
<Button
{...cypressId('format-subscript')}
variant='light'
onClick={() => subscriptSelection(editor)}
title={t('editor.editorToolbar.subscript')}>
<ForkAwesomeIcon icon='subscript' />
</Button>
<Button
{...cypressId('format-superscript')}
variant='light'
onClick={() => superscriptSelection(editor)}
title={t('editor.editorToolbar.superscript')}>
<ForkAwesomeIcon icon='superscript' />
</Button>
<ToolbarButton icon={'bold'} formatType={FormatType.BOLD} />
<ToolbarButton icon={'italic'} formatType={FormatType.ITALIC} />
<ToolbarButton icon={'underline'} formatType={FormatType.UNDERLINE} />
<ToolbarButton icon={'strikethrough'} formatType={FormatType.STRIKETHROUGH} />
<ToolbarButton icon={'subscript'} formatType={FormatType.SUBSCRIPT} />
<ToolbarButton icon={'superscript'} formatType={FormatType.SUPERSCRIPT} />
<ToolbarButton icon={'eraser'} formatType={FormatType.HIGHLIGHT} />
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<Button
{...cypressId('format-heading')}
variant='light'
onClick={() => addHeaderLevel(editor)}
title={t('editor.editorToolbar.header')}>
<ForkAwesomeIcon icon='header' />
</Button>
<Button
{...cypressId('format-code-block')}
variant='light'
onClick={() => addCodeFences(editor)}
title={t('editor.editorToolbar.code')}>
<ForkAwesomeIcon icon='code' />
</Button>
<Button
{...cypressId('format-block-quote')}
variant='light'
onClick={() => addQuotes(editor)}
title={t('editor.editorToolbar.blockquote')}>
<ForkAwesomeIcon icon='quote-right' />
</Button>
<Button
{...cypressId('format-unordered-list')}
variant='light'
onClick={() => addList(editor)}
title={t('editor.editorToolbar.unorderedList')}>
<ForkAwesomeIcon icon='list' />
</Button>
<Button
{...cypressId('format-ordered-list')}
variant='light'
onClick={() => addOrderedList(editor)}
title={t('editor.editorToolbar.orderedList')}>
<ForkAwesomeIcon icon='list-ol' />
</Button>
<Button
{...cypressId('format-check-list')}
variant='light'
onClick={() => addTaskList(editor)}
title={t('editor.editorToolbar.checkList')}>
<ForkAwesomeIcon icon='check-square' />
</Button>
<ToolbarButton icon={'header'} formatType={FormatType.HEADER_LEVEL} />
<ToolbarButton icon={'code'} formatType={FormatType.CODE_FENCE} />
<ToolbarButton icon={'quote-right'} formatType={FormatType.QUOTES} />
<ToolbarButton icon={'list'} formatType={FormatType.UNORDERED_LIST} />
<ToolbarButton icon={'list-ol'} formatType={FormatType.ORDERED_LIST} />
<ToolbarButton icon={'check-square'} formatType={FormatType.CHECK_LIST} />
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<Button
{...cypressId('format-link')}
variant='light'
onClick={() => addLink(editor)}
title={t('editor.editorToolbar.link')}>
<ForkAwesomeIcon icon='link' />
</Button>
<Button
{...cypressId('format-image')}
variant='light'
onClick={() => addImage(editor)}
title={t('editor.editorToolbar.image')}>
<ForkAwesomeIcon icon='picture-o' />
</Button>
<UploadImageButton editor={editor} />
<ToolbarButton icon={'link'} formatType={FormatType.LINK} />
<ToolbarButton icon={'picture-o'} formatType={FormatType.IMAGE_LINK} />
<UploadImageButton />
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<TablePickerButton editor={editor} />
<Button
{...cypressId('format-add-line')}
variant='light'
onClick={() => addLine(editor)}
title={t('editor.editorToolbar.line')}>
<ForkAwesomeIcon icon='minus' />
</Button>
<Button
{...cypressId('format-collapsable-block')}
variant='light'
onClick={() => addCollapsableBlock(editor)}
title={t('editor.editorToolbar.collapsableBlock')}>
<ForkAwesomeIcon icon='caret-square-o-down' />
</Button>
<Button
{...cypressId('format-add-comment')}
variant='light'
onClick={() => addComment(editor)}
title={t('editor.editorToolbar.comment')}>
<ForkAwesomeIcon icon='comment' />
</Button>
<EmojiPickerButton editor={editor} />
<TablePickerButton />
<ToolbarButton icon={'minus'} formatType={FormatType.HORIZONTAL_LINE} />
<ToolbarButton icon={'caret-square-o-down'} formatType={FormatType.COLLAPSIBLE_BLOCK} />
<ToolbarButton icon={'comment'} formatType={FormatType.COMMENT} />
<EmojiPickerButton />
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<EditorPreferences />

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo } from 'react'
import { Button } from 'react-bootstrap'
import { cypressId } from '../../../../utils/cypress-attribute'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import type { FormatType } from '../../../../redux/note-details/types'
import type { IconName } from '../../../common/fork-awesome/types'
import { useTranslation } from 'react-i18next'
import { formatSelection } from '../../../../redux/note-details/methods'
export interface ToolbarButtonProps {
icon: IconName
formatType: FormatType
}
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({ formatType, icon }) => {
const { t } = useTranslation('', { keyPrefix: 'editor.editorToolbar' })
const onClick = useCallback(() => {
formatSelection(formatType)
}, [formatType])
const title = useMemo(() => t(formatType), [formatType, t])
return (
<Button variant='light' onClick={onClick} title={title} {...cypressId('toolbar.' + formatType)}>
<ForkAwesomeIcon icon={icon} />
</Button>
)
}

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import React, { Fragment, useCallback, useRef } from 'react'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
@ -14,30 +13,17 @@ import { handleUpload } from '../upload-handler'
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
import { cypressId } from '../../../../utils/cypress-attribute'
export interface UploadImageButtonProps {
editor?: Editor
}
export const UploadImageButton: React.FC<UploadImageButtonProps> = ({ editor }) => {
export const UploadImageButton: React.FC = () => {
const { t } = useTranslation()
const clickRef = useRef<() => void>()
const buttonClick = useCallback(() => {
clickRef.current?.()
}, [])
const onUploadImage = useCallback(
(file: File) => {
if (editor) {
handleUpload(file, editor)
}
return Promise.resolve()
},
[editor]
)
if (!editor) {
return null
}
const onUploadImage = useCallback((file: File) => {
handleUpload(file)
return Promise.resolve()
}, [])
return (
<Fragment>

View file

@ -4,54 +4,64 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ApplicationState } from '../../../../../redux/application-state'
import { initialState } from '../../../../../redux/note-details/initial-state'
import { isCursorInCodeFence } from './codefenceDetection'
import * as storeModule from '../../../../../redux'
import { Mock } from 'ts-mockery'
import type { Editor } from 'codemirror'
import { isCursorInCodefence } from './codefenceDetection'
Mock.configure('jest')
const mockEditor = (content: string, line: number) => {
const contentLines = content.split('\n')
return Mock.of<Editor>({
getCursor() {
return {
line: line,
ch: 0
}
},
getDoc() {
return {
getLine(ln: number) {
return contentLines[ln] ?? ''
}
}
}
})
}
describe('Check whether cursor is in codefence', () => {
const getGlobalStateMocked = jest.spyOn(storeModule, 'getGlobalState')
const mockRedux = (content: string, line: number): void => {
const contentLines = content.split('\n')
getGlobalStateMocked.mockImplementation(() =>
Mock.from<ApplicationState>({
noteDetails: {
...initialState,
selection: {
from: {
line: line,
character: 0
}
},
markdownContentLines: contentLines,
markdownContent: content
}
})
)
}
beforeEach(() => {
jest.resetModules()
})
afterEach(() => {
jest.clearAllMocks()
})
it('returns false for empty document', () => {
const editor = mockEditor('', 0)
expect(isCursorInCodefence(editor)).toBe(false)
mockRedux('', 0)
expect(isCursorInCodeFence()).toBe(false)
})
it('returns true with one open codefence directly above', () => {
const editor = mockEditor('```\n', 1)
expect(isCursorInCodefence(editor)).toBe(true)
mockRedux('```\n', 1)
expect(isCursorInCodeFence()).toBe(true)
})
it('returns true with one open codefence and empty lines above', () => {
const editor = mockEditor('```\n\n\n', 3)
expect(isCursorInCodefence(editor)).toBe(true)
mockRedux('```\n\n\n', 3)
expect(isCursorInCodeFence()).toBe(true)
})
it('returns false with one completed codefence above', () => {
const editor = mockEditor('```\n\n```\n', 3)
expect(isCursorInCodefence(editor)).toBe(false)
mockRedux('```\n\n```\n', 3)
expect(isCursorInCodeFence()).toBe(false)
})
it('returns true with one completed and one open codefence above', () => {
const editor = mockEditor('```\n\n```\n\n```\n\n', 6)
expect(isCursorInCodefence(editor)).toBe(true)
mockRedux('```\n\n```\n\n```\n\n', 6)
expect(isCursorInCodeFence()).toBe(true)
})
})

View file

@ -4,16 +4,25 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import { getGlobalState } from '../../../../../redux'
export const isCursorInCodefence = (editor: Editor): boolean => {
const currentLine = editor.getCursor().line
let codefenceCount = 0
for (let line = currentLine; line >= 0; --line) {
const markdownContentLine = editor.getDoc().getLine(line)
if (markdownContentLine.startsWith('```')) {
codefenceCount++
}
}
return codefenceCount % 2 === 1
/**
* Checks if the start of the current {@link CursorSelection cursor selection} is in a code fence.
*/
export const isCursorInCodeFence = (): boolean => {
const lines = getGlobalState().noteDetails.markdownContentLines.slice(
0,
getGlobalState().noteDetails.selection.from.line
)
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

@ -4,11 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import { convertClipboardTableToMarkdown, isTable } from '../../table-extractor'
import { handleUpload } from '../../upload-handler'
import { insertAtCursor } from './toolbarButtonUtils'
import { isCursorInCodefence } from './codefenceDetection'
import { replaceSelection } from '../../../../../redux/note-details/methods'
import { isCursorInCodeFence } from './codefenceDetection'
import { getGlobalState } from '../../../../../redux'
import Optional from 'optional-js'
type ClipboardDataFormats = 'text' | 'url' | 'text/plain' | 'text/uri-list' | 'text/html'
@ -20,26 +21,41 @@ export interface PasteEvent {
preventDefault: () => void
}
export const handleTablePaste = (event: PasteEvent, editor: Editor): boolean => {
const pasteText = event.clipboardData.getData('text')
if (!pasteText || isCursorInCodefence(editor) || !isTable(pasteText)) {
/**
* Checks if the given {@link PasteEvent paste event} contains a text formatted table
* and inserts it into the markdown content.
* This happens only if smart paste was activated.
*
* @param event The {@link PasteEvent} from the browser
* @return {@code true} if the event was processed. {@code false} otherwise
*/
export const handleTablePaste = (event: PasteEvent): boolean => {
if (!getGlobalState().editorConfig.smartPaste || isCursorInCodeFence()) {
return false
}
event.preventDefault()
const markdownTable = convertClipboardTableToMarkdown(pasteText)
insertAtCursor(editor, markdownTable)
return true
return Optional.ofNullable(event.clipboardData.getData('text'))
.filter((pasteText) => !!pasteText && isTable(pasteText))
.map((pasteText) => convertClipboardTableToMarkdown(pasteText))
.map((markdownTable) => {
replaceSelection(markdownTable)
return true
})
.orElse(false)
}
export const handleFilePaste = (event: PasteEvent, editor: Editor): boolean => {
if (!event.clipboardData.files || event.clipboardData.files.length < 1) {
return false
}
event.preventDefault()
const files: FileList = event.clipboardData.files
if (files && files.length >= 1) {
handleUpload(files[0], editor)
return true
}
return false
/**
* Checks if the given {@link PasteEvent paste event} contains files and uploads them.
*
* @param event The {@link PasteEvent} from the browser
* @return {@code true} if the event was processed. {@code false} otherwise
*/
export const handleFilePaste = (event: PasteEvent): boolean => {
return Optional.ofNullable(event.clipboardData.files)
.filter((files) => !!files && files.length > 0)
.map((files) => {
handleUpload(files[0])
return true
})
.orElse(false)
}

View file

@ -1,153 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor } from 'codemirror'
import type { EmojiClickEventDetail } from 'emoji-picker-element/shared'
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
import { getEmojiShortCode } from './emojiUtils'
export const makeSelectionBold = (editor: Editor): void => wrapTextWith(editor, '**')
export const makeSelectionItalic = (editor: Editor): void => wrapTextWith(editor, '*')
export const strikeThroughSelection = (editor: Editor): void => wrapTextWith(editor, '~~')
export const underlineSelection = (editor: Editor): void => wrapTextWith(editor, '++')
export const subscriptSelection = (editor: Editor): void => wrapTextWith(editor, '~')
export const superscriptSelection = (editor: Editor): void => wrapTextWith(editor, '^')
export const markSelection = (editor: Editor): void => wrapTextWith(editor, '==')
export const addHeaderLevel = (editor: Editor): void =>
changeLines(editor, (line) => (line.startsWith('#') ? `#${line}` : `# ${line}`))
export const addCodeFences = (editor: Editor): void => wrapTextWithOrJustPut(editor, '```\n', '\n```')
export const addQuotes = (editor: Editor): void => insertOnStartOfLines(editor, '> ')
export const addList = (editor: Editor): void => createList(editor, () => '- ')
export const addOrderedList = (editor: Editor): void => createList(editor, (j) => `${j}. `)
export const addTaskList = (editor: Editor): void => createList(editor, () => '- [ ] ')
export const addImage = (editor: Editor): void => addLink(editor, '!')
export const addLine = (editor: Editor): void => changeLines(editor, (line) => `${line}\n----`)
export const addCollapsableBlock = (editor: Editor): void =>
changeLines(editor, (line) => `${line}\n:::spoiler Toggle label\n Toggled content\n:::`)
export const addComment = (editor: Editor): void => changeLines(editor, (line) => `${line}\n> []`)
export const addTable = (editor: Editor, rows: number, columns: number): void => {
const rowArray = createNumberRangeArray(rows)
const colArray = createNumberRangeArray(columns).map((col) => col + 1)
const head = '| # ' + colArray.join(' | # ') + ' |'
const divider = '| ' + colArray.map(() => '----').join(' | ') + ' |'
const body = rowArray.map(() => '| ' + colArray.map(() => 'Text').join(' | ') + ' |').join('\n')
const table = `${head}\n${divider}\n${body}`
changeLines(editor, (line) => `${line}\n${table}`)
}
export const addEmoji = (emoji: EmojiClickEventDetail, editor: Editor): void => {
const shortCode = getEmojiShortCode(emoji)
if (shortCode) {
insertAtCursor(editor, shortCode)
}
}
export const wrapTextWith = (editor: Editor, symbol: string, endSymbol?: string): void => {
if (!editor.getSelection()) {
return
}
const ranges = editor.listSelections()
for (const range of ranges) {
if (range.empty()) {
continue
}
const from = range.from()
const to = range.to()
const selection = editor.getRange(from, to)
editor.replaceRange(symbol + selection + (endSymbol || symbol), from, to, '+input')
range.head.ch += symbol.length
range.anchor.ch += endSymbol ? endSymbol.length : symbol.length
}
editor.setSelections(ranges)
}
const wrapTextWithOrJustPut = (editor: Editor, symbol: string, endSymbol?: string): void => {
if (!editor.getSelection()) {
const cursor = editor.getCursor()
const lineNumber = cursor.line
const line = editor.getLine(lineNumber)
const replacement = /\s*\\n/.exec(line) ? `${symbol}${endSymbol ?? ''}` : `${symbol}${line}${endSymbol ?? ''}`
editor.replaceRange(replacement, { line: cursor.line, ch: 0 }, { line: cursor.line, ch: line.length }, '+input')
}
wrapTextWith(editor, symbol, endSymbol ?? symbol)
}
export const insertOnStartOfLines = (editor: Editor, symbol: string): void => {
const cursor = editor.getCursor()
const ranges = editor.listSelections()
for (const range of ranges) {
const from = range.empty() ? { line: cursor.line, ch: 0 } : range.from()
const to = range.empty() ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : range.to()
const selection = editor.getRange(from, to)
const lines = selection.split('\n')
editor.replaceRange(lines.map((line) => `${symbol}${line}`).join('\n'), from, to, '+input')
}
editor.setSelections(ranges)
}
export const changeLines = (editor: Editor, replaceFunction: (line: string) => string): void => {
const cursor = editor.getCursor()
const ranges = editor.listSelections()
for (const range of ranges) {
const lineNumber = range.empty() ? cursor.line : range.from().line
const line = editor.getLine(lineNumber)
editor.replaceRange(
replaceFunction(line),
{ line: lineNumber, ch: 0 },
{
line: lineNumber,
ch: line.length
},
'+input'
)
}
editor.setSelections(ranges)
}
export const createList = (editor: Editor, listMark: (i: number) => string): void => {
const cursor = editor.getCursor()
const ranges = editor.listSelections()
for (const range of ranges) {
const from = range.empty() ? { line: cursor.line, ch: 0 } : range.from()
const to = range.empty() ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : range.to()
const selection = editor.getRange(from, to)
const lines = selection.split('\n')
editor.replaceRange(lines.map((line, i) => `${listMark(i + 1)}${line}`).join('\n'), from, to, '+input')
}
editor.setSelections(ranges)
}
export const addLink = (editor: Editor, prefix?: string): void => {
const cursor = editor.getCursor()
const ranges = editor.listSelections()
for (const range of ranges) {
const from = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.from()
const to = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.to()
const selection = editor.getRange(from, to)
const linkRegex = /^(?:https?|ftp|mailto):/
if (linkRegex.exec(selection)) {
editor.replaceRange(`${prefix || ''}[](${selection})`, from, to, '+input')
} else {
editor.replaceRange(`${prefix || ''}[${selection}](https://)`, from, to, '+input')
}
}
}
export const insertAtCursor = (editor: Editor, text: string): void => {
const cursor = editor.getCursor()
const ranges = editor.listSelections()
for (const range of ranges) {
const from = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.from()
const to = range.empty() ? { line: cursor.line, ch: cursor.ch } : range.to()
editor.replaceRange(`${text}`, from, to, '+input')
}
}

View file

@ -4,30 +4,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Editor, Position } from 'codemirror'
import { uploadFile } from '../../../api/media'
import { store } from '../../../redux'
import { getGlobalState } from '../../../redux'
import { supportedMimeTypes } from '../../common/upload-image-mimetypes'
import { replaceInMarkdownContent } from '../../../redux/note-details/methods'
import { replaceSelection, replaceInMarkdownContent } from '../../../redux/note-details/methods'
import { t } from 'i18next'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import type { CursorSelection } from '../../../redux/editor/types'
/**
* Uploads the given file and writes the progress into the given editor at the given cursor positions.
*
* @param file The file to upload
* @param editor The editor that should be used to show the progress
* @param cursorFrom The position where the progress message should be placed
* @param cursorTo An optional position that should be used to replace content in the editor
* @param imageDescription The text that should be used in the description part of the resulting image tag
* @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 = (
file: File,
editor: Editor,
cursorFrom?: Position,
cursorTo?: Position,
imageDescription?: string,
cursorSelection?: CursorSelection,
description?: string,
additionalUrlText?: string
): void => {
if (!file) {
@ -37,24 +33,20 @@ export const handleUpload = (
return
}
const randomId = Math.random().toString(36).slice(7)
const uploadFileInfo =
imageDescription !== undefined
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: imageDescription })
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
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 = store.getState().noteDetails.id
const insertCode = (replacement: string) => {
replaceInMarkdownContent(uploadPlaceholder, replacement)
}
const noteId = getGlobalState().noteDetails.id
editor.replaceRange(uploadPlaceholder, cursorFrom ?? editor.getCursor(), cursorTo, '+input')
replaceSelection(uploadPlaceholder, cursorSelection)
uploadFile(noteId, file)
.then(({ link }) => {
insertCode(`![${imageDescription ?? ''}](${link}${additionalUrlText ?? ''})`)
replaceInMarkdownContent(uploadPlaceholder, `![${description ?? ''}](${link}${additionalUrlText ?? ''})`)
})
.catch((error: Error) => {
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
insertCode(`![upload of ${file.name} failed]()`)
replaceInMarkdownContent(uploadPlaceholder, `![upload of ${file.name} failed]()`)
})
}

View file

@ -6,7 +6,7 @@
import equal from 'fast-deep-equal'
import { useEffect, useRef } from 'react'
import { store } from '../../../redux'
import { getGlobalState } from '../../../redux'
import type { HistoryEntry } from '../../../redux/history/types'
import { HistoryEntryOrigin } from '../../../redux/history/types'
import { updateLocalHistoryEntry } from '../../../redux/history/methods'
@ -28,7 +28,7 @@ export const useUpdateLocalHistoryEntry = (updateReady: boolean): void => {
if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) {
return
}
const history = store.getState().history
const history = getGlobalState().history
const entry: HistoryEntry = history.find((entry) => entry.identifier === id) ?? {
identifier: id,
title: '',

View file

@ -6,7 +6,7 @@
import React, { useCallback } from 'react'
import sanitize from 'sanitize-filename'
import { store } from '../../../../redux'
import { getGlobalState } from '../../../../redux'
import { Trans, useTranslation } from 'react-i18next'
import { download } from '../../../common/download/download'
import { SidebarButton } from '../sidebar-button/sidebar-button'
@ -17,7 +17,7 @@ export const ExportMarkdownSidebarEntry: React.FC = () => {
const { t } = useTranslation()
const markdownContent = useNoteMarkdownContent()
const onClick = useCallback(() => {
const sanitized = sanitize(store.getState().noteDetails.noteTitle)
const sanitized = sanitize(getGlobalState().noteDetails.noteTitle)
download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown')
}, [markdownContent, t])

View file

@ -18,7 +18,7 @@ import { SanitizerMarkdownExtension } from '../markdown-extension/sanitizer/sani
/**
* Renders markdown code into react elements
*
* @param markdownCode The markdown code that should be rendered
* @param markdownContentLines The markdown code lines that should be rendered
* @param additionalMarkdownExtensions A list of {@link MarkdownExtension markdown extensions} that should be used
* @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
* @return The React DOM that represents the rendered markdown code
@ -77,7 +77,6 @@ export const useConvertMarkdownToReactDom = (
return useMemo(() => {
const html = markdownIt.render(markdownContentLines.join('\n'))
htmlToReactTransformer.resetReplacers()
return convertHtmlToReact(html, {

View file

@ -7,12 +7,13 @@
import { MarkdownExtension } from './markdown-extension'
import type MarkdownIt from 'markdown-it'
import { Logger } from '../../../utils/logger'
import { isDevMode } from '../../../utils/test-modes'
const log = new Logger('DebuggerMarkdownExtension')
export class DebuggerMarkdownExtension extends MarkdownExtension {
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
if (process.env.NODE_ENV !== 'production') {
if (isDevMode()) {
markdownIt.core.ruler.push('printStateToConsole', (state) => {
log.debug('Current state', state)
return false

View file

@ -13,22 +13,25 @@ import { escapeHtml } from 'markdown-it/lib/common/utils'
export class SpoilerMarkdownExtension extends MarkdownExtension {
private static readonly spoilerRegEx = /^spoiler\s+(.*)$/
private static createSpoilerContainer(tokens: Token[], index: number): string {
/**
* Renders the opening and closing token of the container.
*
* @param tokens The tokens of the document
* @param index The currently viewed token
* @return The html rendering of the tokens
*/
private static renderSpoilerContainer(tokens: Token[], index: number): string {
const matches = SpoilerMarkdownExtension.spoilerRegEx.exec(tokens[index].info.trim())
if (tokens[index].nesting === 1 && matches && matches[1]) {
// opening tag
return `<details><summary>${escapeHtml(matches[1])}</summary>`
} else {
// closing tag
return '</details>\n'
}
return tokens[index].nesting === 1 && matches && matches[1]
? `<details><summary>${escapeHtml(matches[1])}</summary>`
: '</details>\n'
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItContainer(markdownIt, 'spoiler', {
validate: (params: string) => SpoilerMarkdownExtension.spoilerRegEx.test(params),
render: SpoilerMarkdownExtension.createSpoilerContainer.bind(this)
render: SpoilerMarkdownExtension.renderSpoilerContainer.bind(this)
})
}
}