diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index 191133a04..a3306d7ec 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -28,6 +28,7 @@ describe('File upload', () => { ) }) it('via button', () => { + cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true') cy.getByCypressId('editor-toolbar-upload-image-button').should('be.visible') cy.getByCypressId('editor-toolbar-upload-image-input').selectFile( { @@ -37,15 +38,16 @@ describe('File upload', () => { }, { force: true } ) - cy.get('.cm-line').contains(`![](${imageUrl})`) + cy.get('.cm-line').contains(`![demo.png](${imageUrl})`) }) it('via paste', () => { + cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true') cy.fixture('demo.png').then((image: string) => { const pasteEvent = { clipboardData: { files: [Cypress.Blob.base64StringToBlob(image, 'image/png')], - getData: (_: string) => '' + getData: () => '' } } cy.get('.cm-content').trigger('paste', pasteEvent) @@ -54,6 +56,7 @@ describe('File upload', () => { }) it('via drag and drop', () => { + cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true') cy.get('.cm-content').selectFile( { contents: '@demoImage', @@ -62,11 +65,12 @@ describe('File upload', () => { }, { action: 'drag-drop', force: true } ) - cy.get('.cm-line').contains(`![](${imageUrl})`) + cy.get('.cm-line').contains(`![demo.png](${imageUrl})`) }) }) it('fails', () => { + cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true') cy.intercept( { method: 'POST', @@ -89,12 +93,16 @@ describe('File upload', () => { }) it('lets text paste still work', () => { + cy.getByCypressId('editor-pane').should('have.attr', 'data-cypress-editor-ready', 'true') const testText = 'a long test text' - const pasteEvent = { + + const pasteEvent: Event = Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { - getData: (type = 'text') => testText + files: [], + getData: () => testText } - } + }) + cy.get('.cm-content').trigger('paste', pasteEvent) cy.get('.cm-line').contains(`${testText}`) }) diff --git a/src/components/editor-page/change-content-context/change-content-context.tsx b/src/components/editor-page/change-content-context/change-content-context.tsx new file mode 100644 index 000000000..4c1271504 --- /dev/null +++ b/src/components/editor-page/change-content-context/change-content-context.tsx @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { PropsWithChildren } from 'react' +import React, { createContext, useContext, useState } from 'react' +import Optional from 'optional-js' +import type { EditorView } from '@codemirror/view' +import type { ContentEdits } from '../editor-pane/tool-bar/formatters/types/changes' +import type { CursorSelection } from '../editor-pane/tool-bar/formatters/types/cursor-selection' + +export type CodeMirrorReference = EditorView | undefined +type SetCodeMirrorReference = (value: CodeMirrorReference) => void + +export type ContentFormatter = (parameters: { + currentSelection: CursorSelection + markdownContent: string +}) => [ContentEdits, CursorSelection | undefined] + +type ChangeEditorContentContext = [CodeMirrorReference, SetCodeMirrorReference] + +const changeEditorContentContext = createContext(undefined) + +/** + * Extracts the code mirror reference from the parent context + */ +export const useCodeMirrorReference = (): CodeMirrorReference => { + const contextContent = Optional.ofNullable(useContext(changeEditorContentContext)).orElseThrow( + () => new Error('No change content received. Did you forget to use the provider component') + ) + return contextContent[0] +} + +/** + * Extracts the code mirror reference from the parent context + */ +export const useSetCodeMirrorReference = (): SetCodeMirrorReference => { + const contextContent = Optional.ofNullable(useContext(changeEditorContentContext)).orElseThrow( + () => new Error('No change content received. Did you forget to use the provider component') + ) + return contextContent[1] +} + +/** + * Provides a context for the child components that contains a ref to the current code mirror instance and a callback that posts changes to this codemirror. + */ +export const ChangeEditorContentContextProvider: React.FC> = ({ children }) => { + const [codeMirrorRef, setCodeMirrorRef] = useState(undefined) + + return ( + + {children} + + ) +} diff --git a/src/components/editor-page/change-content-context/code-mirror-selection.d.ts b/src/components/editor-page/change-content-context/code-mirror-selection.d.ts new file mode 100644 index 000000000..e6493ee43 --- /dev/null +++ b/src/components/editor-page/change-content-context/code-mirror-selection.d.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface CodeMirrorSelection { + anchor: number + head?: number +} diff --git a/src/components/editor-page/change-content-context/use-change-editor-content-callback.tsx b/src/components/editor-page/change-content-context/use-change-editor-content-callback.tsx new file mode 100644 index 000000000..c5dca8d7a --- /dev/null +++ b/src/components/editor-page/change-content-context/use-change-editor-content-callback.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo } from 'react' +import Optional from 'optional-js' +import type { CodeMirrorSelection } from './code-mirror-selection' +import type { ContentFormatter } from './change-content-context' +import { useCodeMirrorReference } from './change-content-context' +import type { CursorSelection } from '../editor-pane/tool-bar/formatters/types/cursor-selection' +import type { EditorView } from '@codemirror/view' + +/** + * Changes the content of the given CodeMirror view using the given formatter function. + * + * @param view The CodeMirror view whose content should be changed + * @param formatter A function that generates changes that get dispatched to CodeMirror + */ +export const changeEditorContent = (view: EditorView, formatter: ContentFormatter): void => { + const [changes, selection] = formatter({ + currentSelection: { + from: view.state.selection.main.from, + to: view.state.selection.main.to + }, + markdownContent: view.state.doc.toString() + }) + + view.dispatch({ changes: changes, selection: convertSelectionToCodeMirrorSelection(selection) }) +} + +/** + * Provides a {@link ContentFormatter formatter function} that is linked to the current CodeMirror-View + * @see changeEditorContent + */ +export const useChangeEditorContentCallback = () => { + const codeMirrorRef = useCodeMirrorReference() + return useMemo(() => { + if (codeMirrorRef) { + return (callback: ContentFormatter) => changeEditorContent(codeMirrorRef, callback) + } + }, [codeMirrorRef]) +} + +const convertSelectionToCodeMirrorSelection = (selection: CursorSelection | undefined) => { + return Optional.ofNullable(selection) + .map((selection) => ({ anchor: selection.from, head: selection.to })) + .orElse(undefined) +} diff --git a/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx b/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx index 8a7875d69..918ffcff8 100644 --- a/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx +++ b/src/components/editor-page/editor-document-renderer/editor-document-renderer.tsx @@ -9,8 +9,15 @@ import type { RenderIframeProps } from '../renderer-pane/render-iframe' import { RenderIframe } from '../renderer-pane/render-iframe' import { useSendFrontmatterInfoFromReduxToRenderer } from '../renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer' import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter' +import { NoteType } from '../../../redux/note-details/types/note-details' +import { useApplicationState } from '../../../hooks/common/use-application-state' +import { RendererType } from '../../render-page/window-post-message-communicator/rendering-message' +import { useSetCheckboxInEditor } from './hooks/use-set-checkbox-in-editor' -export type EditorDocumentRendererProps = Omit +export type EditorDocumentRendererProps = Omit< + RenderIframeProps, + 'markdownContentLines' | 'rendererType' | 'onTaskCheckedChange' +> /** * Renders the markdown content from the global application state with the iframe renderer. @@ -20,6 +27,15 @@ export type EditorDocumentRendererProps = Omit = (props) => { useSendFrontmatterInfoFromReduxToRenderer() const trimmedContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter() + const noteType: NoteType = useApplicationState((state) => state.noteDetails.frontmatter.type) + const setCheckboxInEditor = useSetCheckboxInEditor() - return + return ( + + ) } diff --git a/src/components/editor-page/editor-document-renderer/hooks/use-set-checkbox-in-editor.tsx b/src/components/editor-page/editor-document-renderer/hooks/use-set-checkbox-in-editor.tsx new file mode 100644 index 000000000..125cc2f89 --- /dev/null +++ b/src/components/editor-page/editor-document-renderer/hooks/use-set-checkbox-in-editor.tsx @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useChangeEditorContentCallback } from '../../change-content-context/use-change-editor-content-callback' +import { useCallback } from 'react' +import type { ContentEdits } from '../../editor-pane/tool-bar/formatters/types/changes' +import Optional from 'optional-js' + +const TASK_REGEX = /(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]?])/ + +/** + * Provides a callback that changes the state of a checkbox in a given line in the current codemirror instance. + */ +export const useSetCheckboxInEditor = () => { + const changeEditorContent = useChangeEditorContentCallback() + + return useCallback( + (changedLineIndex: number, checkboxChecked: boolean): void => { + changeEditorContent?.(({ markdownContent }) => { + const lines = markdownContent.split('\n') + const lineStartIndex = findStartIndexOfLine(lines, changedLineIndex) + const edits = Optional.ofNullable(TASK_REGEX.exec(lines[changedLineIndex])) + .map(([, beforeCheckbox, oldCheckbox]) => { + const checkboxStartIndex = lineStartIndex + beforeCheckbox.length + return createCheckboxContentEdit(checkboxStartIndex, oldCheckbox, checkboxChecked) + }) + .orElse([]) + return [edits, undefined] + }) + }, + [changeEditorContent] + ) +} + +/** + * Finds the start position of the wanted line index if the given lines would be concat with new-line-characters. + * + * @param lines The lines to search through + * @param wantedLineIndex The index of the line whose start position should be found + * @return the found start position + */ +const findStartIndexOfLine = (lines: string[], wantedLineIndex: number): number => { + return lines + .map((value) => value.length) + .filter((value, index) => index < wantedLineIndex) + .reduce((state, lineLength) => state + lineLength + 1, 0) +} + +/** + * Creates a {@link ContentEdits content edit} for the change of a checkbox at a given position. + * + * @param checkboxStartIndex The start index of the checkbox + * @param oldCheckbox The old checkbox that should be replaced + * @param newCheckboxState The new status of the checkbox + * @return the created {@link ContentEdits edit} + */ +const createCheckboxContentEdit = ( + checkboxStartIndex: number, + oldCheckbox: string, + newCheckboxState: boolean +): ContentEdits => { + return [ + { + from: checkboxStartIndex, + to: checkboxStartIndex + oldCheckbox.length, + insert: `[${newCheckboxState ? 'x' : ' '}]` + } + ] +} diff --git a/src/components/editor-page/editor-page-content.tsx b/src/components/editor-page/editor-page-content.tsx index 8ba30a2bc..8c2fe30b9 100644 --- a/src/components/editor-page/editor-page-content.tsx +++ b/src/components/editor-page/editor-page-content.tsx @@ -4,10 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode' -import { setCheckboxInMarkdownContent, updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' +import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' import { MotdModal } from '../common/motd-modal/motd-modal' import { AppBar, AppBarMode } from './app-bar/app-bar' import { EditorMode } from './app-bar/editor-view-mode' @@ -15,17 +15,16 @@ import { useViewModeShortcuts } from './hooks/useViewModeShortcuts' import { Sidebar } from './sidebar/sidebar' import { Splitter } from './splitter/splitter' import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props' -import { RendererType } from '../render-page/window-post-message-communicator/rendering-message' import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl' import { UiNotifications } from '../notifications/ui-notifications' import { useUpdateLocalHistoryEntry } from './hooks/useUpdateLocalHistoryEntry' import { useApplicationState } from '../../hooks/common/use-application-state' import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer' import { Logger } from '../../utils/logger' -import { NoteType } from '../../redux/note-details/types/note-details' import { NoteAndAppTitleHead } from '../layout/note-and-app-title-head' import equal from 'fast-deep-equal' import { EditorPane } from './editor-pane/editor-pane' +import { ChangeEditorContentContextProvider } from './change-content-context/change-content-context' export enum ScrollSource { EDITOR = 'editor', @@ -112,7 +111,6 @@ export const EditorPageContent: React.FC = () => { ), [onEditorScroll, scrollState.editorScrollState, setEditorToScrollSource] ) - const noteType: NoteType = useApplicationState((state) => state.noteDetails.frontmatter.type) const rightPane = useMemo( () => ( @@ -120,17 +118,15 @@ export const EditorPageContent: React.FC = () => { frameClasses={'h-100 w-100'} onMakeScrollSource={setRendererToScrollSource} onFirstHeadingChange={updateNoteTitleByFirstHeading} - onTaskCheckedChange={setCheckboxInMarkdownContent} onScroll={onMarkdownRendererScroll} scrollState={scrollState.rendererScrollState} - rendererType={noteType === NoteType.SLIDE ? RendererType.SLIDESHOW : RendererType.DOCUMENT} /> ), - [noteType, onMarkdownRendererScroll, scrollState.rendererScrollState, setRendererToScrollSource] + [onMarkdownRendererScroll, scrollState.rendererScrollState, setRendererToScrollSource] ) return ( - + @@ -147,6 +143,6 @@ export const EditorPageContent: React.FC = () => { - + ) } diff --git a/src/components/editor-page/editor-pane/editor-pane.tsx b/src/components/editor-page/editor-pane/editor-pane.tsx index f5bb0a520..186fedf49 100644 --- a/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/src/components/editor-page/editor-pane/editor-pane.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useCallback, useMemo, useRef } from 'react' +import React, { useCallback, useMemo } from 'react' import type { ScrollProps } from '../synced-scroll/scroll-props' import { StatusBar } from './status-bar/status-bar' import { ToolBar } from './tool-bar/tool-bar' @@ -12,54 +12,50 @@ import { useApplicationState } from '../../../hooks/common/use-application-state import { setNoteContent } from '../../../redux/note-details/methods' import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content' import { MaxLengthWarning } from './max-length-warning/max-length-warning' -import { useOnImageUploadFromRenderer } from './hooks/use-on-image-upload-from-renderer' -import type { ReactCodeMirrorRef } from '@uiw/react-codemirror' import ReactCodeMirror from '@uiw/react-codemirror' -import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback' import { useApplyScrollState } from './hooks/use-apply-scroll-state' import styles from './extended-codemirror/codemirror.module.scss' import { oneDark } from '@codemirror/theme-one-dark' import { useTranslation } from 'react-i18next' -import { Logger } from '../../../utils/logger' import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension' -import { useCodeMirrorPasteExtension } from './hooks/code-mirror-extensions/use-code-mirror-paste-extension' -import { useCodeMirrorFileDropExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-drop-extension' +import { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView } from '@codemirror/view' import { autocompletion } from '@codemirror/autocomplete' -import { useCodeMirrorFocusReference } from './hooks/use-code-mirror-focus-reference' -import { useOffScreenScrollProtection } from './hooks/use-off-screen-scroll-protection' -import { cypressId } from '../../../utils/cypress-attribute' +import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { findLanguageByCodeBlockName } from '../../markdown-renderer/markdown-extension/code-block-markdown-extension/find-language-by-code-block-name' import { languages } from '@codemirror/language-data' - -const logger = new Logger('EditorPane') +import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback' +import { useCodeMirrorReference, useSetCodeMirrorReference } from '../change-content-context/change-content-context' +import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension' +import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer' export const EditorPane: React.FC = ({ scrollState, onScroll, onMakeScrollSource }) => { const markdownContent = useNoteMarkdownContent() const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) - const codeMirrorRef = useRef(null) - useApplyScrollState(codeMirrorRef, scrollState) + useApplyScrollState(scrollState) const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll) - const editorPasteExtension = useCodeMirrorPasteExtension() - const dropExtension = useCodeMirrorFileDropExtension() - const [focusExtension, editorFocused] = useCodeMirrorFocusReference() - const saveOffFocusScrollStateExtensions = useOffScreenScrollProtection() - const cursorActivityExtension = useCursorActivityCallback(editorFocused) + const tablePasteExtensions = useCodeMirrorTablePasteExtension() + const fileInsertExtension = useCodeMirrorFileInsertExtension() + const cursorActivityExtension = useCursorActivityCallback() - const onBeforeChange = useCallback( - (value: string): void => { - if (!editorFocused.current) { - logger.debug("Don't post content change because editor isn't focused") - } else { - setNoteContent(value) + const onBeforeChange = useCallback((value: string): void => { + setNoteContent(value) + }, []) + + const codeMirrorRef = useCodeMirrorReference() + const setCodeMirrorReference = useSetCodeMirrorReference() + + const updateViewContext = useMemo(() => { + return EditorView.updateListener.of((update) => { + if (codeMirrorRef !== update.view) { + setCodeMirrorReference(update.view) } - }, - [editorFocused] - ) + }) + }, [codeMirrorRef, setCodeMirrorReference]) const extensions = useMemo( () => [ @@ -67,23 +63,15 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, onMak base: markdownLanguage, codeLanguages: (input) => findLanguageByCodeBlockName(languages, input) }), - ...saveOffFocusScrollStateExtensions, - focusExtension, EditorView.lineWrapping, editorScrollExtension, - editorPasteExtension, - dropExtension, + tablePasteExtensions, + fileInsertExtension, autocompletion(), - cursorActivityExtension - ], - [ cursorActivityExtension, - dropExtension, - editorPasteExtension, - editorScrollExtension, - focusExtension, - saveOffFocusScrollStateExtensions - ] + updateViewContext + ], + [cursorActivityExtension, fileInsertExtension, tablePasteExtensions, editorScrollExtension, updateViewContext] ) useOnImageUploadFromRenderer() @@ -100,7 +88,8 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, onMak className={`d-flex flex-column h-100 position-relative`} onTouchStart={onMakeScrollSource} onMouseEnter={onMakeScrollSource} - {...cypressId('editor-pane')}> + {...cypressId('editor-pane')} + {...cypressAttribute('editor-ready', String(codeMirrorRef !== undefined))}> = ({ scrollState, onScroll, onMak theme={oneDark} value={markdownContent} onChange={onBeforeChange} - ref={codeMirrorRef} /> diff --git a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-drop-extension.ts b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-drop-extension.ts deleted file mode 100644 index a774bfec5..000000000 --- a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-drop-extension.ts +++ /dev/null @@ -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] - ) -} diff --git a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-insert-extension.ts b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-insert-extension.ts new file mode 100644 index 000000000..e7d92c721 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-file-insert-extension.ts @@ -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() + } + }) + }, []) +} diff --git a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-paste-extension.ts b/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-paste-extension.ts deleted file mode 100644 index c0026d81e..000000000 --- a/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-paste-extension.ts +++ /dev/null @@ -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 - } - } - }), - [] - ) -} diff --git a/src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts b/src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/find-regex-match-in-text.test.ts similarity index 94% rename from src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts rename to src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/find-regex-match-in-text.test.ts index 862c6b054..f0ce20956 100644 --- a/src/components/editor-page/editor-pane/find-regex-match-in-text.test.ts +++ b/src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/find-regex-match-in-text.test.ts @@ -1,5 +1,5 @@ /* - * 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 */ diff --git a/src/components/editor-page/editor-pane/find-regex-match-in-text.ts b/src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/find-regex-match-in-text.ts similarity index 92% rename from src/components/editor-page/editor-pane/find-regex-match-in-text.ts rename to src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/find-regex-match-in-text.ts index c1d82a625..30629b595 100644 --- a/src/components/editor-page/editor-pane/find-regex-match-in-text.ts +++ b/src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/find-regex-match-in-text.ts @@ -1,5 +1,5 @@ /* - * 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 */ diff --git a/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts b/src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/use-on-image-upload-from-renderer.ts similarity index 61% rename from src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts rename to src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/use-on-image-upload-from-renderer.ts index 5044c5597..f219ab062 100644 --- a/src/components/editor-page/editor-pane/hooks/use-on-image-upload-from-renderer.ts +++ b/src/components/editor-page/editor-pane/hooks/image-upload-from-renderer/use-on-image-upload-from-renderer.ts @@ -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] + ) ) } diff --git a/src/components/editor-page/editor-pane/hooks/table-paste/codefenceDetection.test.ts b/src/components/editor-page/editor-pane/hooks/table-paste/codefenceDetection.test.ts new file mode 100644 index 000000000..61e503707 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/table-paste/codefenceDetection.test.ts @@ -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) + }) +}) diff --git a/src/components/editor-page/editor-pane/hooks/table-paste/codefenceDetection.ts b/src/components/editor-page/editor-pane/hooks/table-paste/codefenceDetection.ts new file mode 100644 index 000000000..9f66d67cc --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/table-paste/codefenceDetection.ts @@ -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 +} diff --git a/src/components/editor-page/editor-pane/table-extractor.test.ts b/src/components/editor-page/editor-pane/hooks/table-paste/table-extractor.test.ts similarity index 97% rename from src/components/editor-page/editor-pane/table-extractor.test.ts rename to src/components/editor-page/editor-pane/hooks/table-paste/table-extractor.test.ts index 2c758c47f..4682907e3 100644 --- a/src/components/editor-page/editor-pane/table-extractor.test.ts +++ b/src/components/editor-page/editor-pane/hooks/table-paste/table-extractor.test.ts @@ -1,5 +1,5 @@ /* - * 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 */ diff --git a/src/components/editor-page/editor-pane/table-extractor.ts b/src/components/editor-page/editor-pane/hooks/table-paste/table-extractor.ts similarity index 80% rename from src/components/editor-page/editor-pane/table-extractor.ts rename to src/components/editor-page/editor-pane/hooks/table-paste/table-extractor.ts index f118f9b00..84a45267c 100644 --- a/src/components/editor-page/editor-pane/table-extractor.ts +++ b/src/components/editor-page/editor-pane/hooks/table-paste/table-extractor.ts @@ -1,11 +1,15 @@ /* - * 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 { createNumberRangeArray } from '../../common/number-range/number-range' +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')) { @@ -27,6 +31,11 @@ export const isTable = (text: string): boolean => { 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 '' diff --git a/src/components/editor-page/editor-pane/hooks/table-paste/use-code-mirror-table-paste-extension.ts b/src/components/editor-page/editor-pane/hooks/table-paste/use-code-mirror-table-paste-extension.ts new file mode 100644 index 000000000..e6803c852 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/table-paste/use-code-mirror-table-paste-extension.ts @@ -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]) +} diff --git a/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts b/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts index a7d951597..71f5fd03f 100644 --- a/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts +++ b/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts @@ -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, - scrollState?: ScrollState -): void => { +export const useApplyScrollState = (scrollState?: ScrollState): void => { const lastScrollPosition = useRef() + 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]) } diff --git a/src/components/editor-page/editor-pane/hooks/use-code-mirror-focus-reference.ts b/src/components/editor-page/editor-pane/hooks/use-code-mirror-focus-reference.ts deleted file mode 100644 index 4645d3dae..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-code-mirror-focus-reference.ts +++ /dev/null @@ -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 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] => { - const focusReference = useRef(false) - const codeMirrorExtension = useMemo( - () => - EditorView.domEventHandlers({ - blur: () => { - focusReference.current = false - }, - focus: () => { - focusReference.current = true - } - }), - [] - ) - - return [codeMirrorExtension, focusReference] -} diff --git a/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts b/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts index a1ce7c06a..d33af42b4 100644 --- a/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts +++ b/src/components/editor-page/editor-pane/hooks/use-cursor-activity-callback.ts @@ -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): Extension => { +export const useCursorActivityCallback = (): Extension => { const lastMainSelection = useRef() return useMemo( @@ -30,16 +26,11 @@ export const useCursorActivityCallback = (editorFocused: RefObject): 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] + [] ) } diff --git a/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx b/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx new file mode 100644 index 000000000..3cb6e871b --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { uploadFile } from '../../../../api/media' +import { getGlobalState } from '../../../../redux' +import { supportedMimeTypes } from '../../../common/upload-image-mimetypes' +import { t } from 'i18next' +import { showErrorNotification } from '../../../../redux/ui-notifications/methods' +import { useCallback } from 'react' +import { changeEditorContent } from '../../change-content-context/use-change-editor-content-callback' +import { replaceSelection } from '../tool-bar/formatters/replace-selection' +import { replaceInContent } from '../tool-bar/formatters/replace-in-content' +import type { CursorSelection } from '../tool-bar/formatters/types/cursor-selection' +import type { EditorView } from '@codemirror/view' +import type { ContentFormatter } from '../../change-content-context/change-content-context' +import { useCodeMirrorReference } from '../../change-content-context/change-content-context' + +/** + * Processes the upload of the given file and inserts the correct Markdown code + * + * @param view the codemirror instance that is used to insert the Markdown code + * @param file The file to upload + * @param cursorSelection The position where the progress message should be placed + * @param description The text that should be used in the description part of the resulting image tag + * @param additionalUrlText Additional text that should be inserted behind the link but within the tag + */ +export const handleUpload = ( + view: EditorView, + file: File, + cursorSelection?: CursorSelection, + description?: string, + additionalUrlText?: string +): void => { + const changeContent = (callback: ContentFormatter) => changeEditorContent(view, callback) + if (!file || !supportedMimeTypes.includes(file.type) || !changeContent) { + return + } + const randomId = Math.random().toString(36).slice(7) + const uploadFileInfo = description + ? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description }) + : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name }) + + const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})` + const noteId = getGlobalState().noteDetails.id + changeContent(({ currentSelection }) => { + return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false) + }) + uploadFile(noteId, file) + .then(({ url }) => { + const replacement = `![${description ?? file.name ?? ''}](${url}${additionalUrlText ?? ''})` + changeContent(({ markdownContent }) => [ + replaceInContent(markdownContent, uploadPlaceholder, replacement), + undefined + ]) + }) + .catch((error: Error) => { + showErrorNotification('editor.upload.failed', { fileName: file.name })(error) + const replacement = `![upload of ${file.name} failed]()` + changeContent(({ markdownContent }) => [ + replaceInContent(markdownContent, uploadPlaceholder, replacement), + undefined + ]) + }) +} + +/** + * Provides a callback that uploads the given file and writes the progress into the given editor at the given cursor positions. + * + * @return The generated callback + */ +export const useHandleUpload = (): (( + file: File, + cursorSelection?: CursorSelection, + description?: string, + additionalUrlText?: string +) => void) => { + const codeMirrorReference = useCodeMirrorReference() + return useCallback( + (file: File, cursorSelection?: CursorSelection, description?: string, additionalUrlText?: string): void => { + if (codeMirrorReference) { + handleUpload(codeMirrorReference, file, cursorSelection, description, additionalUrlText) + } + }, + [codeMirrorReference] + ) +} diff --git a/src/components/editor-page/editor-pane/hooks/use-off-screen-scroll-protection.ts b/src/components/editor-page/editor-pane/hooks/use-off-screen-scroll-protection.ts deleted file mode 100644 index eb48125eb..000000000 --- a/src/components/editor-page/editor-pane/hooks/use-off-screen-scroll-protection.ts +++ /dev/null @@ -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() - - 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] - }, []) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/bold-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/bold-button.tsx new file mode 100644 index 000000000..05d1000cf --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/bold-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const BoldButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '**', '**') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/check-list-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/check-list-button.tsx new file mode 100644 index 000000000..5624291f4 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/check-list-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection' + +export const CheckListButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return prependLinesOfSelection(markdownContent, currentSelection, () => `- [ ] `) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/code-fence-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/code-fence-button.tsx new file mode 100644 index 000000000..b33299a37 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/code-fence-button.tsx @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { changeCursorsToWholeLineIfNoToCursor } from '../formatters/utils/change-cursors-to-whole-line-if-no-to-cursor' + +export const CodeFenceButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return wrapSelection(changeCursorsToWholeLineIfNoToCursor(markdownContent, currentSelection), '```\n', '\n```') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/collapsible-block-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/collapsible-block-button.tsx new file mode 100644 index 000000000..fe5c7b730 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/collapsible-block-button.tsx @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import { changeCursorsToWholeLineIfNoToCursor } from '../formatters/utils/change-cursors-to-whole-line-if-no-to-cursor' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const CollapsibleBlockButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return wrapSelection( + changeCursorsToWholeLineIfNoToCursor(markdownContent, currentSelection), + '\n:::spoiler Toggle label\n', + '\n:::\n' + ) + }, []) + return ( + + ) +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/comment-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/comment-button.tsx new file mode 100644 index 000000000..a3399e9ac --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/comment-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { replaceSelection } from '../formatters/replace-selection' + +export const CommentButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return replaceSelection({ from: currentSelection.to ?? currentSelection.from }, '> []', true) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/header-level-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/header-level-button.tsx new file mode 100644 index 000000000..671bf1f35 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/header-level-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection' + +export const HeaderLevelButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return prependLinesOfSelection(markdownContent, currentSelection, (line) => (line.startsWith('#') ? `#` : `# `)) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/highlight-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/highlight-button.tsx new file mode 100644 index 000000000..902cbe147 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/highlight-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const HighlightButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '==', '==') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/horizontal-line-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/horizontal-line-button.tsx new file mode 100644 index 000000000..17f0d700f --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/horizontal-line-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { replaceSelection } from '../formatters/replace-selection' + +export const HorizontalLineButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return replaceSelection({ from: currentSelection.to ?? currentSelection.from }, '----\n', true) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/image-link-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/image-link-button.tsx new file mode 100644 index 000000000..2d29c7312 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/image-link-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { addLink } from '../formatters/add-link' + +export const ImageLinkButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return addLink(markdownContent, currentSelection, '!') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/italic-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/italic-button.tsx new file mode 100644 index 000000000..a4aab70cb --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/italic-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const ItalicButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '*', '*') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/link-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/link-button.tsx new file mode 100644 index 000000000..cc3024dd6 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/link-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { addLink } from '../formatters/add-link' + +export const LinkButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return addLink(markdownContent, currentSelection) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/ordered-list-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/ordered-list-button.tsx new file mode 100644 index 000000000..daf045afd --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/ordered-list-button.tsx @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection' + +export const OrderedListButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return prependLinesOfSelection( + markdownContent, + currentSelection, + (line, lineIndexInBlock) => `${lineIndexInBlock + 1}. ` + ) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/quotes-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/quotes-button.tsx new file mode 100644 index 000000000..b2a186401 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/quotes-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection' + +export const QuotesButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return prependLinesOfSelection(markdownContent, currentSelection, () => `> `) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/strikethrough-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/strikethrough-button.tsx new file mode 100644 index 000000000..e702c0846 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/strikethrough-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const StrikethroughButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '~~', '~~') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/subscript-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/subscript-button.tsx new file mode 100644 index 000000000..b191da6c1 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/subscript-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const SubscriptButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '~', '~') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/superscript-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/superscript-button.tsx new file mode 100644 index 000000000..bf32d1861 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/superscript-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const SuperscriptButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '^', '^') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/underline-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/underline-button.tsx new file mode 100644 index 000000000..14dbcce27 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/underline-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import { wrapSelection } from '../formatters/wrap-selection' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' + +export const UnderlineButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection }) => { + return wrapSelection(currentSelection, '++', '++') + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/buttons/unordered-list-button.tsx b/src/components/editor-page/editor-pane/tool-bar/buttons/unordered-list-button.tsx new file mode 100644 index 000000000..2261fe70b --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/buttons/unordered-list-button.tsx @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { ToolbarButton } from '../toolbar-button' +import type { ContentFormatter } from '../../../change-content-context/change-content-context' +import { prependLinesOfSelection } from '../formatters/prepend-lines-of-selection' + +export const UnorderedListButton: React.FC = () => { + const formatter: ContentFormatter = useCallback(({ currentSelection, markdownContent }) => { + return prependLinesOfSelection(markdownContent, currentSelection, () => `- `) + }, []) + return +} diff --git a/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx b/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx index 4aed09abd..5877719df 100644 --- a/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/emoji-picker/emoji-picker-button.tsx @@ -1,5 +1,5 @@ /* - * 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 */ @@ -10,18 +10,26 @@ import { useTranslation } from 'react-i18next' import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon' 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' +import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback' +import { replaceSelection } from '../formatters/replace-selection' +import { extractEmojiShortCode } from './extract-emoji-short-code' 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 changeEditorContent = useChangeEditorContentCallback() + + const onEmojiSelected = useCallback( + (emojiClickEvent: EmojiClickEventDetail) => { + setShowEmojiPicker(false) + Optional.ofNullable(extractEmojiShortCode(emojiClickEvent)).ifPresent((shortCode) => { + changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, shortCode, false)) + }) + }, + [changeEditorContent] + ) const hidePicker = useCallback(() => setShowEmojiPicker(false), []) const showPicker = useCallback(() => setShowEmojiPicker(true), []) @@ -32,7 +40,8 @@ export const EmojiPickerButton: React.FC = () => { {...cypressId('show-emoji-picker')} variant='light' onClick={showPicker} - title={t('editor.editorToolbar.emoji')}> + title={t('editor.editorToolbar.emoji')} + disabled={!changeEditorContent}> diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/emojiUtils.ts b/src/components/editor-page/editor-pane/tool-bar/emoji-picker/extract-emoji-short-code.ts similarity index 52% rename from src/components/editor-page/editor-pane/tool-bar/utils/emojiUtils.ts rename to src/components/editor-page/editor-pane/tool-bar/emoji-picker/extract-emoji-short-code.ts index 712ddcb55..a3c71a2a3 100644 --- a/src/components/editor-page/editor-pane/tool-bar/utils/emojiUtils.ts +++ b/src/components/editor-page/editor-pane/tool-bar/emoji-picker/extract-emoji-short-code.ts @@ -1,23 +1,18 @@ /* - * 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 { EmojiClickEventDetail, NativeEmoji } from 'emoji-picker-element/shared' -export const getEmojiIcon = (emoji: EmojiClickEventDetail): string => { - if (emoji.unicode) { - return emoji.unicode - } - if (emoji.name) { - // noinspection CheckTagEmptyBody - return `` - } - return '' -} - -export const getEmojiShortCode = (emoji: EmojiClickEventDetail): string | undefined => { +/** + * Extracts the first shortcode that is associated with a clicked emoji. + * + * @param emoji The click event data from the emoji picker + * @return The found emoji short code + */ +export const extractEmojiShortCode = (emoji: EmojiClickEventDetail): string | undefined => { if (!emoji.emoji.shortcodes) { return undefined } diff --git a/src/components/editor-page/editor-pane/tool-bar/formatters/add-link.test.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/add-link.test.ts new file mode 100644 index 000000000..bf47e25ea --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/add-link.test.ts @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { addLink } from './add-link' +import type { ContentEdits } from './changes' + +describe('add link', () => { + describe('without to-cursor', () => { + it('inserts a link', () => { + const actual = addLink('', { from: 0 }, '') + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 0, + insert: '[](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 12 }]) + }) + + it('inserts a link into a line', () => { + const actual = addLink('aa', { from: 1 }, '') + const expectedChanges: ContentEdits = [ + { + from: 1, + to: 1, + insert: '[](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 1, to: 13 }]) + }) + + it('inserts a link with a prefix', () => { + const actual = addLink('', { from: 0 }, 'prefix') + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 0, + insert: 'prefix[](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 18 }]) + }) + }) + + describe('with a normal text selected', () => { + it('wraps the selection', () => { + const actual = addLink( + 'a', + { + from: 0, + to: 1 + }, + '' + ) + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 1, + insert: '[a](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 13 }]) + }) + + it('wraps the selection inside of a line', () => { + const actual = addLink('aba', { from: 1, to: 2 }, '') + const expectedChanges: ContentEdits = [ + { + from: 1, + to: 2, + insert: '[b](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 1, to: 14 }]) + }) + + it('wraps the selection with a prefix', () => { + const actual = addLink('a', { from: 0, to: 1 }, 'prefix') + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 1, + insert: 'prefix[a](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 19 }]) + }) + + it('wraps a multi line selection', () => { + const actual = addLink('a\nb\nc', { from: 0, to: 5 }, '') + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 5, + insert: '[a\nb\nc](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 17 }]) + }) + }) + + describe('with a url selected', () => { + it('wraps the selection', () => { + const actual = addLink( + 'https://google.com', + { + from: 0, + to: 18 + }, + '' + ) + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 18, + insert: '[](https://google.com)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 22 }]) + }) + + it('wraps the selection with a prefix', () => { + const actual = addLink( + 'https://google.com', + { + from: 0, + to: 18 + }, + 'prefix' + ) + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 18, + insert: 'prefix[](https://google.com)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 28 }]) + }) + + it(`wraps a multi line selection not as link`, () => { + const actual = addLink('a\nhttps://google.com\nc', { from: 0, to: 22 }, '') + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 22, + insert: '[a\nhttps://google.com\nc](https://)' + } + ] + expect(actual).toEqual([expectedChanges, { from: 0, to: 34 }]) + }) + }) +}) diff --git a/src/redux/note-details/format-selection/formatters/add-link.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/add-link.ts similarity index 81% rename from src/redux/note-details/format-selection/formatters/add-link.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/add-link.ts index 6c5a0bde7..7e30f6b32 100644 --- a/src/redux/note-details/format-selection/formatters/add-link.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/add-link.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { stringSplice } from './utils/string-splice' -import type { CursorSelection } from '../../../editor/types' +import type { CursorSelection } from './types/cursor-selection' +import type { ContentEdits } from './types/changes' const beforeDescription = '[' const afterDescriptionBeforeLink = '](' @@ -25,13 +25,19 @@ export const addLink = ( markdownContent: string, selection: CursorSelection, prefix = '' -): [string, CursorSelection] => { +): [ContentEdits, CursorSelection] => { const from = selection.from const to = selection.to ?? from const selectedText = markdownContent.slice(from, to) const link = buildLink(selectedText, prefix) - const newContent = stringSplice(markdownContent, selection.from, link, selectedText.length) - return [newContent, { from, to: from + link.length }] + const changes: ContentEdits = [ + { + from: from, + to: to, + insert: link + } + ] + return [changes, { from, to: from + link.length }] } const buildLink = (selectedText: string, prefix: string): string => { diff --git a/src/components/editor-page/editor-pane/tool-bar/formatters/prepend-lines-of-selection.test.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/prepend-lines-of-selection.test.ts new file mode 100644 index 000000000..196395f1d --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/prepend-lines-of-selection.test.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { prependLinesOfSelection } from './prepend-lines-of-selection' +import type { ContentEdits } from './types/changes' + +describe('replace lines of selection', () => { + it('replaces only the from-cursor line if no to-cursor is present', () => { + const actual = prependLinesOfSelection( + 'a\nb\nc', + { + from: 2 + }, + (line, lineIndexInBlock) => `text_${lineIndexInBlock}_` + ) + const expectedChanges: ContentEdits = [ + { + from: 2, + to: 2, + insert: 'text_0_' + } + ] + expect(actual).toStrictEqual([expectedChanges, { from: 2, to: 10 }]) + }) + + it('inserts a line prepend if no content is there', () => { + const actual = prependLinesOfSelection( + '', + { + from: 0 + }, + (line, lineIndexInBlock) => `text_${lineIndexInBlock}_` + ) + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 0, + insert: 'text_0_' + } + ] + expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 7 }]) + }) + + it('replaces only one line if from-cursor and to-cursor are in the same line', () => { + const actual = prependLinesOfSelection( + 'a\nb\nc', + { + from: 2, + to: 2 + }, + (line, lineIndexInBlock) => `text_${lineIndexInBlock}_` + ) + const expectedChanges: ContentEdits = [ + { + from: 2, + to: 2, + insert: 'text_0_' + } + ] + expect(actual).toStrictEqual([expectedChanges, { from: 2, to: 10 }]) + }) + + it('replaces multiple lines', () => { + const actual = prependLinesOfSelection( + 'a\nb\nc\nd\ne', + { + from: 2, + to: 6 + }, + (line, lineIndexInBlock) => `${lineIndexInBlock} ` + ) + const expectedChanges: ContentEdits = [ + { + from: 2, + to: 2, + insert: '0 ' + }, + { + from: 4, + to: 4, + insert: '1 ' + }, + { + from: 6, + to: 6, + insert: '2 ' + } + ] + expect(actual).toEqual([expectedChanges, { from: 2, to: 13 }]) + }) +}) diff --git a/src/components/editor-page/editor-pane/tool-bar/formatters/prepend-lines-of-selection.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/prepend-lines-of-selection.ts new file mode 100644 index 000000000..ab98cf4b6 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/prepend-lines-of-selection.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CursorSelection } from './types/cursor-selection' +import { searchForEndOfLine, searchForStartOfLine } from './utils/change-cursors-to-whole-line-if-no-to-cursor' +import type { ContentEdits } from './types/changes' + +/** + * Creates a copy of the given markdown content lines but modifies the whole selected lines. + * + * @param markdownContent The lines of the document to modify + * @param selection If the selection has no to cursor then only the from line will be modified. + * If the selection has a to cursor then all lines in the selection will be modified. + * @param modifyLine A function that modifies the selected lines + * @return the modified copy of lines + */ +export const prependLinesOfSelection = ( + markdownContent: string, + selection: CursorSelection, + modifyLine: (line: string, lineIndexInBlock: number) => string +): [ContentEdits, CursorSelection] => { + const toIndex = selection.to ?? selection.from + let currentIndex = selection.from + let indexInBlock = 0 + let newStartOfSelection = selection.from + let newEndOfSelection = toIndex + let lengthOfAddedPrefixes = 0 + const changes: ContentEdits = [] + while (currentIndex <= toIndex && currentIndex <= markdownContent.length) { + const startOfLine = searchForStartOfLine(markdownContent, currentIndex) + if (startOfLine < newStartOfSelection) { + newStartOfSelection = startOfLine + } + const endOfLine = searchForEndOfLine(markdownContent, currentIndex) + const line = markdownContent.slice(startOfLine, endOfLine) + const linePrefix = modifyLine(line, indexInBlock) + lengthOfAddedPrefixes += linePrefix.length + indexInBlock += 1 + changes.push({ + from: startOfLine, + to: startOfLine, + insert: linePrefix + }) + currentIndex = endOfLine + 1 + if (endOfLine + lengthOfAddedPrefixes > newEndOfSelection) { + newEndOfSelection = endOfLine + lengthOfAddedPrefixes + } + } + return [changes, { from: newStartOfSelection, to: newEndOfSelection }] +} diff --git a/src/components/editor-page/editor-pane/tool-bar/formatters/replace-in-content.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/replace-in-content.ts new file mode 100644 index 000000000..18717e3e5 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/replace-in-content.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ContentEdits } from './types/changes' +import Optional from 'optional-js' + +export const replaceInContent = (currentContent: string, replaceable: string, replacement: string): ContentEdits => { + return Optional.ofNullable(currentContent.indexOf(replaceable)) + .filter((index) => index > -1) + .map((index) => [{ from: index, to: index + replaceable.length, insert: replacement }]) + .orElse([]) +} diff --git a/src/redux/note-details/format-selection/formatters/replace-selection.test.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/replace-selection.test.ts similarity index 52% rename from src/redux/note-details/format-selection/formatters/replace-selection.test.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/replace-selection.test.ts index f28028ff2..f8e44292a 100644 --- a/src/redux/note-details/format-selection/formatters/replace-selection.test.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/replace-selection.test.ts @@ -5,52 +5,77 @@ */ import { replaceSelection } from './replace-selection' +import type { ContentEdits } from './changes' describe('replace selection', () => { it('inserts a text after the from-cursor if no to-cursor is present', () => { const actual = replaceSelection( - 'text1', { from: 2 }, 'text2' ) - expect(actual).toEqual(['tetext2xt1', { from: 2, to: 7 }]) + const expectedChanges: ContentEdits = [ + { + from: 2, + to: 2, + insert: 'text2' + } + ] + expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }]) }) it('inserts a text if from-cursor and to-cursor are the same', () => { const actual = replaceSelection( - 'text1', { from: 2, to: 2 }, 'text2' ) - expect(actual).toEqual(['tetext2xt1', { from: 2, to: 7 }]) + const expectedChanges: ContentEdits = [ + { + from: 2, + to: 2, + insert: 'text2' + } + ] + expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }]) }) it('replaces a single line text', () => { const actual = replaceSelection( - 'text1\ntext2\ntext3', { from: 7, to: 8 }, 'text4' ) - expect(actual).toEqual(['text1\nttext4xt2\ntext3', { from: 7, to: 12 }]) + const expectedChanges: ContentEdits = [ + { + from: 7, + to: 8, + insert: 'text4' + } + ] + expect(actual).toEqual([expectedChanges, { from: 7, to: 12 }]) }) it('replaces a multi line text', () => { const actual = replaceSelection( - 'text1\ntext2\ntext3', { from: 2, to: 15 }, 'text4' ) - expect(actual).toEqual(['tetext4t3', { from: 2, to: 7 }]) + const expectedChanges: ContentEdits = [ + { + from: 2, + to: 15, + insert: 'text4' + } + ] + expect(actual).toEqual([expectedChanges, { from: 2, to: 7 }]) }) }) diff --git a/src/redux/note-details/format-selection/formatters/replace-selection.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/replace-selection.ts similarity index 58% rename from src/redux/note-details/format-selection/formatters/replace-selection.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/replace-selection.ts index f683115d2..ac5e8b8c1 100644 --- a/src/redux/note-details/format-selection/formatters/replace-selection.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/replace-selection.ts @@ -4,26 +4,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { stringSplice } from './utils/string-splice' -import type { CursorSelection } from '../../../editor/types' +import type { ContentEdits } from './types/changes' +import type { CursorSelection } from './types/cursor-selection' /** * Creates a new {@link NoteDetails note state} but replaces the selected text. * - * @param markdownContent The content of the document to modify * @param selection If the selection has no to cursor then text will only be inserted. * If the selection has a to cursor then the selection will be replaced. * @param insertText The text that should be inserted * @return The modified state */ export const replaceSelection = ( - markdownContent: string, selection: CursorSelection, - insertText: string -): [string, CursorSelection] => { + insertText: string, + insertNewLine?: boolean +): [ContentEdits, CursorSelection] => { const fromCursor = selection.from const toCursor = selection.to ?? selection.from - const newContent = stringSplice(markdownContent, fromCursor, insertText, toCursor - fromCursor) - return [newContent, { from: fromCursor, to: insertText.length + fromCursor }] + const changes: ContentEdits = [ + { + from: fromCursor, + to: toCursor, + insert: (insertNewLine ? '\n' : '') + insertText + } + ] + return [changes, { from: fromCursor, to: insertText.length + fromCursor + (insertNewLine ? 1 : 0) }] } diff --git a/src/components/editor-page/editor-pane/tool-bar/formatters/types/changes.d.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/types/changes.d.ts new file mode 100644 index 000000000..64bbbdfa9 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/types/changes.d.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface ContentEdit { + from: number + to: number + insert: string +} + +export type ContentEdits = ContentEdit[] diff --git a/src/components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection.d.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection.d.ts new file mode 100644 index 000000000..becbcdc91 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection.d.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface CursorSelection { + from: number + to?: number +} diff --git a/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts similarity index 97% rename from src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts index caf24a3e8..d9061aa63 100644 --- a/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.test.ts @@ -9,7 +9,7 @@ import { searchForEndOfLine, searchForStartOfLine } from './change-cursors-to-whole-line-if-no-to-cursor' -import type { CursorSelection } from '../../../../editor/types' +import type { CursorSelection } from '../types/cursor-selection' describe('changeCursorsToWholeLineIfNoToCursor', () => { it(`returns the given selection if to cursor is present`, () => { diff --git a/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts similarity index 97% rename from src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts index fc6ff1621..7d82754bf 100644 --- a/src/redux/note-details/format-selection/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/utils/change-cursors-to-whole-line-if-no-to-cursor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { CursorSelection } from '../../../../editor/types' +import type { CursorSelection } from '../types/cursor-selection' /** * If the given cursor selection has no to position then the selection will be changed to cover the whole line of the from cursor. diff --git a/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/wrap-selection.test.ts similarity index 54% rename from src/redux/note-details/format-selection/formatters/wrap-selection.test.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/wrap-selection.test.ts index c2a0e7893..2fa281523 100644 --- a/src/redux/note-details/format-selection/formatters/wrap-selection.test.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/wrap-selection.test.ts @@ -5,11 +5,11 @@ */ import { wrapSelection } from './wrap-selection' +import type { ContentEdits } from './types/changes' describe('wrap selection', () => { it(`doesn't modify any line if no to-cursor is present`, () => { const actual = wrapSelection( - 'a\nb\nc', { from: 0 }, @@ -17,12 +17,11 @@ describe('wrap selection', () => { 'after' ) - expect(actual).toStrictEqual(['a\nb\nc', { from: 0 }]) + expect(actual).toStrictEqual([[], { from: 0 }]) }) it(`wraps the selected text in the same line`, () => { const actual = wrapSelection( - 'a\nb\nc', { from: 0, to: 1 @@ -30,13 +29,24 @@ describe('wrap selection', () => { 'before', 'after' ) + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 0, + insert: 'before' + }, + { + from: 1, + to: 1, + insert: 'after' + } + ] - expect(actual).toStrictEqual(['beforeaafter\nb\nc', { from: 0, to: 12 }]) + expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 12 }]) }) it(`wraps the selected text in different lines`, () => { const actual = wrapSelection( - 'a\nb\nc', { from: 0, to: 5 @@ -45,6 +55,19 @@ describe('wrap selection', () => { 'after' ) - expect(actual).toStrictEqual(['beforea\nb\ncafter', { from: 0, to: 16 }]) + const expectedChanges: ContentEdits = [ + { + from: 0, + to: 0, + insert: 'before' + }, + { + from: 5, + to: 5, + insert: 'after' + } + ] + + expect(actual).toStrictEqual([expectedChanges, { from: 0, to: 16 }]) }) }) diff --git a/src/redux/note-details/format-selection/formatters/wrap-selection.ts b/src/components/editor-page/editor-pane/tool-bar/formatters/wrap-selection.ts similarity index 57% rename from src/redux/note-details/format-selection/formatters/wrap-selection.ts rename to src/components/editor-page/editor-pane/tool-bar/formatters/wrap-selection.ts index 53997d675..71ef0444d 100644 --- a/src/redux/note-details/format-selection/formatters/wrap-selection.ts +++ b/src/components/editor-page/editor-pane/tool-bar/formatters/wrap-selection.ts @@ -4,13 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { stringSplice } from './utils/string-splice' -import type { CursorSelection } from '../../../editor/types' +import type { ContentEdits } from './types/changes' +import type { CursorSelection } from './types/cursor-selection' /** * Creates a copy of the given markdown content lines but wraps the selection. * - * @param markdownContent The lines of the document to modify * @param selection If the selection has no to cursor then nothing will happen. * If the selection has a to cursor then the selected text will be wrapped. * @param symbolStart A text that will be inserted before the from cursor @@ -18,19 +17,28 @@ import type { CursorSelection } from '../../../editor/types' * @return the modified copy of lines */ export const wrapSelection = ( - markdownContent: string, selection: CursorSelection, symbolStart: string, symbolEnd: string -): [string, CursorSelection] => { +): [ContentEdits, CursorSelection] => { if (selection.to === undefined) { - return [markdownContent, selection] + return [[], selection] } - const to = selection.to ?? selection.from + const to = selection.to const from = selection.from + const changes: ContentEdits = [ + { + from: from, + to: from, + insert: symbolStart + }, + { + from: to, + to: to, + insert: symbolEnd + } + ] - const afterToModify = stringSplice(markdownContent, to, symbolEnd) - const afterFromModify = stringSplice(afterToModify, from, symbolStart) - return [afterFromModify, { from, to: to + symbolEnd.length + symbolStart.length }] + return [changes, { from, to: to + symbolEnd.length + symbolStart.length }] } diff --git a/src/components/editor-page/editor-pane/tool-bar/table-picker/create-markdown-table.test.ts b/src/components/editor-page/editor-pane/tool-bar/table-picker/create-markdown-table.test.ts new file mode 100644 index 000000000..8eca4cd94 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/table-picker/create-markdown-table.test.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createMarkdownTable } from './create-markdown-table' + +describe('create markdown table', () => { + it('generates a valid table', () => { + expect(createMarkdownTable(5, 2)).toBe(`| # 1 | # 2 | +| ---- | ---- | +| | | +| | | +| | | +| | | +| | |`) + }) + it('crashes if called with zero rows', () => { + expect(() => createMarkdownTable(0, 1)).toThrow() + }) + it('crashes if called with zero columns', () => { + expect(() => createMarkdownTable(1, 0)).toThrow() + }) + it('crashes if called with negative rows', () => { + expect(() => createMarkdownTable(-1, 1)).toThrow() + }) + it('crashes if called with negative columns', () => { + expect(() => createMarkdownTable(1, -1)).toThrow() + }) +}) diff --git a/src/components/editor-page/editor-pane/tool-bar/table-picker/create-markdown-table.tsx b/src/components/editor-page/editor-pane/tool-bar/table-picker/create-markdown-table.tsx new file mode 100644 index 000000000..29ee8c2f6 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/table-picker/create-markdown-table.tsx @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createNumberRangeArray } from '../../../../common/number-range/number-range' + +/** + * Creates a Markdown table with the given size. + * + * @param rows The number of table rows + * @param columns The number of table columns + * @throws Error if an invalid table size was given + * @return The created Markdown table + */ +export const createMarkdownTable = (rows: number, columns: number): string => { + if (rows <= 0) { + throw new Error(`Can't generate a table with ${rows} rows.`) + } else if (columns <= 0) { + throw new Error(`Can't generate a table with ${columns} columns.`) + } + 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(() => ' ').join(' | ') + ' |').join('\n') + return `${head}\n${divider}\n${body}` +} diff --git a/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx b/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx index 1bcd25adf..d7a281f10 100644 --- a/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/table-picker/table-picker-button.tsx @@ -1,5 +1,5 @@ /* - * 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 */ @@ -12,8 +12,9 @@ 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' -import { addTableAtCursor } from '../../../../../redux/note-details/methods' +import { replaceSelection } from '../formatters/replace-selection' +import { useChangeEditorContentCallback } from '../../../change-content-context/use-change-editor-content-callback' +import { createMarkdownTable } from './create-markdown-table' enum PickerMode { INVISIBLE, @@ -29,23 +30,22 @@ export const TablePickerButton: React.FC = () => { const [pickerMode, setPickerMode] = useState(PickerMode.INVISIBLE) const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), []) const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), []) + const changeEditorContent = useChangeEditorContentCallback() - const onSizeSelect = useCallback((rows: number, columns: number) => { - addTableAtCursor(rows, columns) - setPickerMode(PickerMode.INVISIBLE) - }, []) + const onSizeSelect = useCallback( + (rows: number, columns: number) => { + const table = createMarkdownTable(rows, columns) + changeEditorContent?.(({ currentSelection }) => replaceSelection(currentSelection, table, true)) + setPickerMode(PickerMode.INVISIBLE) + }, + [changeEditorContent] + ) const tableTitle = useMemo(() => t('editor.editorToolbar.table.titleWithoutSize'), [t]) - const button = useRef(null) - - const toggleOverlayVisibility = useCallback( - () => - setPickerMode((oldPickerMode) => - oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE - ), - [] - ) + const toggleOverlayVisibility = useCallback(() => { + setPickerMode((oldPickerMode) => (oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE)) + }, []) const onOverlayHide = useCallback(() => { setPickerMode((oldMode) => { @@ -76,7 +76,8 @@ export const TablePickerButton: React.FC = () => { variant='light' onClick={toggleOverlayVisibility} title={tableTitle} - ref={button}> + ref={button} + disabled={!changeEditorContent}> { onHide={onOverlayHide} show={pickerMode === PickerMode.GRID} placement={'bottom'} - rootClose={true}> + rootClose={pickerMode === PickerMode.GRID}> {createPopoverElement} - - - + ) } diff --git a/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx b/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx index f4e6c77ae..81443e718 100644 --- a/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/tool-bar.tsx @@ -1,5 +1,5 @@ /* - * 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 */ @@ -8,9 +8,25 @@ import React, { Fragment, Suspense } from 'react' import { ButtonGroup, ButtonToolbar } from 'react-bootstrap' import { TablePickerButton } from './table-picker/table-picker-button' import styles from './tool-bar.module.scss' -import { UploadImageButton } from './upload-image-button' -import { ToolbarButton } from './toolbar-button' -import { FormatType } from '../../../../redux/note-details/types' +import { UploadImageButton } from './upload-image-button/upload-image-button' +import { BoldButton } from './buttons/bold-button' +import { ItalicButton } from './buttons/italic-button' +import { UnderlineButton } from './buttons/underline-button' +import { StrikethroughButton } from './buttons/strikethrough-button' +import { SubscriptButton } from './buttons/subscript-button' +import { SuperscriptButton } from './buttons/superscript-button' +import { HighlightButton } from './buttons/highlight-button' +import { HeaderLevelButton } from './buttons/header-level-button' +import { CodeFenceButton } from './buttons/code-fence-button' +import { QuotesButton } from './buttons/quotes-button' +import { UnorderedListButton } from './buttons/unordered-list-button' +import { OrderedListButton } from './buttons/ordered-list-button' +import { CheckListButton } from './buttons/check-list-button' +import { LinkButton } from './buttons/link-button' +import { ImageLinkButton } from './buttons/image-link-button' +import { HorizontalLineButton } from './buttons/horizontal-line-button' +import { CollapsibleBlockButton } from './buttons/collapsible-block-button' +import { CommentButton } from './buttons/comment-button' const EmojiPickerButton = React.lazy(() => import('./emoji-picker/emoji-picker-button')) @@ -18,32 +34,32 @@ export const ToolBar: React.FC = () => { return ( - - - - - - - + + + + + + + - - - - - - + + + + + + - - + + - - - + + + }> diff --git a/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx b/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx index c6810a897..90bc80ca1 100644 --- a/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/toolbar-button.tsx @@ -8,28 +8,41 @@ 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' +import { useChangeEditorContentCallback } from '../../change-content-context/use-change-editor-content-callback' +import type { ContentFormatter } from '../../change-content-context/change-content-context' export interface ToolbarButtonProps { - icon: IconName - formatType: FormatType + i18nKey: string + iconName: IconName + formatter: ContentFormatter } -export const ToolbarButton: React.FC = ({ formatType, icon }) => { +/** + * Renders a button for the editor toolbar that formats the content using a given formatter function. + * + * @param i18nKey Used to generate a title for the button by interpreting it as translation key in the i18n-namespace `editor.editorToolbar`- + * @param iconName A fork awesome icon name that is shown in the button + * @param formatter The formatter function changes the editor content on click + */ +export const ToolbarButton: React.FC = ({ i18nKey, iconName, formatter }) => { const { t } = useTranslation('', { keyPrefix: 'editor.editorToolbar' }) + const changeEditorContent = useChangeEditorContentCallback() const onClick = useCallback(() => { - formatSelection(formatType) - }, [formatType]) - - const title = useMemo(() => t(formatType), [formatType, t]) + changeEditorContent?.(formatter) + }, [formatter, changeEditorContent]) + const title = useMemo(() => t(i18nKey), [i18nKey, t]) return ( - ) } diff --git a/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx b/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx deleted file mode 100644 index 871d918a1..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/upload-image-button.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import React, { Fragment, useCallback, useRef } from 'react' -import { Button } from 'react-bootstrap' -import { useTranslation } from 'react-i18next' -import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon' -import { UploadInput } from '../../sidebar/upload-input' -import { handleUpload } from '../upload-handler' -import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes' -import { cypressId } from '../../../../utils/cypress-attribute' - -export const UploadImageButton: React.FC = () => { - const { t } = useTranslation() - const clickRef = useRef<() => void>() - const buttonClick = useCallback(() => { - clickRef.current?.() - }, []) - - const onUploadImage = useCallback((file: File) => { - handleUpload(file) - return Promise.resolve() - }, []) - - return ( - - - - - ) -} diff --git a/src/components/editor-page/editor-pane/tool-bar/upload-image-button/extract-selected-text.test.ts b/src/components/editor-page/editor-pane/tool-bar/upload-image-button/extract-selected-text.test.ts new file mode 100644 index 000000000..0bb0c3293 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/upload-image-button/extract-selected-text.test.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { EditorState, SelectionRange } from '@codemirror/state' +import { Mock } from 'ts-mockery' +import { extractSelectedText } from './extract-selected-text' + +describe('extract selected text', () => { + const mockContent = "I'm a mock content!" + + const mockState = (selection: SelectionRange | undefined): EditorState => { + return Mock.of({ + selection: { + main: selection + }, + sliceDoc: (from, to) => mockContent.slice(from, to) + }) + } + + it('extracts the correct text', () => { + const selection = Mock.of({ + from: 2, + to: 5 + }) + const state = mockState(selection) + expect(extractSelectedText(state)).toBe('m a') + }) + + it("doesn't extract if from and to are the same", () => { + const selection = Mock.of({ + from: 2, + to: 2 + }) + const state = mockState(selection) + expect(extractSelectedText(state)).toBeUndefined() + }) + + it("doesn't extract if there is no selection", () => { + const state = mockState(undefined) + expect(extractSelectedText(state)).toBeUndefined() + }) +}) diff --git a/src/components/editor-page/editor-pane/tool-bar/upload-image-button/extract-selected-text.ts b/src/components/editor-page/editor-pane/tool-bar/upload-image-button/extract-selected-text.ts new file mode 100644 index 000000000..8c651d12e --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/upload-image-button/extract-selected-text.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { EditorState } from '@codemirror/state' +import Optional from 'optional-js' + +/** + * Extracts the currently selected text from the given CodeMirror state. + * + * @param state The CodeMirror state that provides the content and the selection + * @return The selected text or {@code undefined} if no text was selected + */ +export const extractSelectedText = (state: EditorState): string | undefined => { + return Optional.ofNullable(state.selection.main) + .map((selection) => [selection.from, selection.to]) + .filter(([from, to]) => from !== to) + .map(([from, to]) => state.sliceDoc(from, to)) + .orElse(undefined) +} diff --git a/src/components/editor-page/editor-pane/tool-bar/upload-image-button/upload-image-button.tsx b/src/components/editor-page/editor-pane/tool-bar/upload-image-button/upload-image-button.tsx new file mode 100644 index 000000000..2a29a8819 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/upload-image-button/upload-image-button.tsx @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment, useCallback, useRef } from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon' +import { UploadInput } from '../../../sidebar/upload-input' +import { acceptedMimeTypes } from '../../../../common/upload-image-mimetypes' +import { cypressId } from '../../../../../utils/cypress-attribute' +import { useHandleUpload } from '../../hooks/use-handle-upload' +import { ShowIf } from '../../../../common/show-if/show-if' +import { useCodeMirrorReference } from '../../../change-content-context/change-content-context' +import { extractSelectedText } from './extract-selected-text' +import Optional from 'optional-js' + +/** + * Shows a button that uploads a chosen file to the backend and adds the link to the note. + */ +export const UploadImageButton: React.FC = () => { + const { t } = useTranslation() + const clickRef = useRef<() => void>() + const buttonClick = useCallback(() => { + clickRef.current?.() + }, []) + + const handleUpload = useHandleUpload() + const codeMirror = useCodeMirrorReference() + + const onUploadImage = useCallback( + (file: File) => { + const description = Optional.ofNullable(codeMirror?.state) + .map((state) => extractSelectedText(state)) + .orElse(undefined) + handleUpload(file, undefined, description) + return Promise.resolve() + }, + [codeMirror, handleUpload] + ) + + return ( + + + + + + + ) +} diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts deleted file mode 100644 index 5540fdbea..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * 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' - -describe('Check whether cursor is in codefence', () => { - const getGlobalStateMocked = jest.spyOn(storeModule, 'getGlobalState') - - const mockRedux = (content: string, from: number): void => { - const contentLines = content.split('\n') - getGlobalStateMocked.mockImplementation(() => - Mock.from({ - noteDetails: { - ...initialState, - selection: { - from - }, - markdownContent: { - plain: content, - lines: contentLines - } - } - }) - ) - } - - beforeEach(() => { - jest.resetModules() - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('returns false for empty document', () => { - mockRedux('', 0) - expect(isCursorInCodeFence()).toBe(false) - }) - - it('returns true with one open codefence directly above', () => { - mockRedux('```\n', 4) - expect(isCursorInCodeFence()).toBe(true) - }) - - it('returns true with one open codefence and empty lines above', () => { - mockRedux('```\n\n\n', 5) - expect(isCursorInCodeFence()).toBe(true) - }) - - it('returns false with one completed codefence above', () => { - mockRedux('```\n\n```\n', 8) - expect(isCursorInCodeFence()).toBe(false) - }) - - it('returns true with one completed and one open codefence above', () => { - mockRedux('```\n\n```\n\n```\n\n', 13) - expect(isCursorInCodeFence()).toBe(true) - }) -}) diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts deleted file mode 100644 index 30e2d65e2..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { getGlobalState } from '../../../../../redux' - -/** - * Checks if the start of the current {@link CursorSelection cursor selection} is in a code fence. - */ -export const isCursorInCodeFence = (): boolean => { - const noteDetails = getGlobalState().noteDetails - const lines = noteDetails.markdownContent.plain.slice(0, noteDetails.selection.from).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 -} diff --git a/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts b/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts deleted file mode 100644 index 58da7afd3..000000000 --- a/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { convertClipboardTableToMarkdown, isTable } from '../../table-extractor' -import { handleUpload } from '../../upload-handler' -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' - -export interface PasteEvent { - clipboardData: { - files: FileList - getData: (format: ClipboardDataFormats) => string - } - preventDefault: () => void -} - -/** - * Checks if the given {@link DataTransfer clipboard data} contains a text formatted table - * and inserts it into the markdown content. This happens only if smart paste is activated. - * - * @param clipboardData The {@link DataTransfer} from the paste event - * @return {@code true} if the event was processed. {@code false} otherwise - */ -export const handleTablePaste = (clipboardData: DataTransfer): boolean => { - if (!getGlobalState().editorConfig.smartPaste || isCursorInCodeFence()) { - return false - } - - return Optional.ofNullable(clipboardData.getData('text')) - .filter(isTable) - .map(convertClipboardTableToMarkdown) - .map((markdownTable) => { - replaceSelection(markdownTable) - return true - }) - .orElse(false) -} - -/** - * Checks if the given {@link PasteEvent paste event} contains files and uploads them. - * - * @param clipboardData The {@link DataTransfer} from the paste event - * @return {@code true} if the event was processed. {@code false} otherwise - */ -export const handleFilePaste = (clipboardData: DataTransfer): boolean => { - return Optional.of(clipboardData.files) - .filter((files) => files.length > 0) - .map((files) => { - handleUpload(files[0]) - return true - }) - .orElse(false) -} diff --git a/src/components/editor-page/editor-pane/upload-handler.ts b/src/components/editor-page/editor-pane/upload-handler.ts deleted file mode 100644 index a06a6b9e0..000000000 --- a/src/components/editor-page/editor-pane/upload-handler.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 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 { replaceInMarkdownContent, replaceSelection } 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 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, - cursorSelection?: CursorSelection, - description?: string, - additionalUrlText?: string -): void => { - if (!file) { - return - } - if (!supportedMimeTypes.includes(file.type)) { - return - } - const randomId = Math.random().toString(36).slice(7) - const uploadFileInfo = description - ? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description }) - : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name }) - - const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})` - const noteId = getGlobalState().noteDetails.id - - replaceSelection(uploadPlaceholder, cursorSelection) - uploadFile(noteId, file) - .then(({ url }) => { - replaceInMarkdownContent(uploadPlaceholder, `![${description ?? ''}](${url}${additionalUrlText ?? ''})`) - }) - .catch((error: Error) => { - showErrorNotification('editor.upload.failed', { fileName: file.name })(error) - replaceInMarkdownContent(uploadPlaceholder, `![upload of ${file.name} failed]()`) - }) -} diff --git a/src/components/editor-page/sidebar/sidebar-button/sidebar-button.tsx b/src/components/editor-page/sidebar/sidebar-button/sidebar-button.tsx index 41a992d01..bf07f7f70 100644 --- a/src/components/editor-page/sidebar/sidebar-button/sidebar-button.tsx +++ b/src/components/editor-page/sidebar/sidebar-button/sidebar-button.tsx @@ -1,5 +1,5 @@ /* - * 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 */ @@ -34,6 +34,7 @@ export const SidebarButton: React.FC> = ({ buttonRef, hide, variant, + disabled, ...props }) => { const variantClass = useMemo(() => { @@ -44,6 +45,7 @@ export const SidebarButton: React.FC> = ({