mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-29 06:15:29 -04:00
Move toolbar functions into redux reducer (#1763)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
a6a2251c88
commit
b30cc5b390
80 changed files with 2481 additions and 2303 deletions
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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')
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue