From 0b4a0afa165e9fd2730daa2034a5d340aaf8f67d Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Wed, 10 Mar 2021 22:52:20 +0100 Subject: [PATCH] Add table formatting on paste of detected table (#957) --- CHANGELOG.md | 5 +- cypress/integration/fileUpload.spec.ts | 3 +- public/locales/en.json | 3 + .../editor-page/editor-pane/editor-pane.tsx | 32 +++--- .../editor-pane/table-extractor.test.ts | 99 +++++++++++++++++++ .../editor-pane/table-extractor.ts | 58 +++++++++++ .../editor-preference-smart-paste-select.tsx | 33 +++++++ .../editor-preferences/editor-preferences.tsx | 4 + .../tool-bar/utils/codefenceDetection.test.ts | 63 ++++++++++++ .../tool-bar/utils/codefenceDetection.ts | 19 ++++ .../tool-bar/utils/pasteHandlers.ts | 45 +++++++++ src/redux/editor/methods.ts | 15 ++- src/redux/editor/reducers.ts | 9 ++ src/redux/editor/types.ts | 14 ++- 14 files changed, 375 insertions(+), 27 deletions(-) create mode 100644 src/components/editor-page/editor-pane/table-extractor.test.ts create mode 100644 src/components/editor-page/editor-pane/table-extractor.ts create mode 100644 src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx create mode 100644 src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts create mode 100644 src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts create mode 100644 src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db93c73b4..2a153ba01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,9 @@ SPDX-License-Identifier: CC-BY-SA-4.0 - Easier possibility to share notes via native share-buttons on supported devices. - Surround selected text with a link via shortcut (ctrl+k or cmd+k). - A sidebar for menu options -- Improved security by wrapping the markdown rendering into an iframe -- The intro page content can be changed by editing `public/intro.md` +- Improved security by wrapping the markdown rendering into an iframe. +- The intro page content can be changed by editing `public/intro.md`. +- When pasting tables (e.g. from LibreOffice Calc or MS Excel) they get reformatted to markdown tables. ### Changed diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index e3ae96480..f0095bc43 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -54,7 +54,8 @@ describe('File upload', () => { .then((image: string) => { const pasteEvent = { clipboardData: { - files: [Cypress.Blob.base64StringToBlob(image, 'image/png')] + files: [Cypress.Blob.base64StringToBlob(image, 'image/png')], + getData: (_: string) => '' } } cy.get('.CodeMirror-scroll') diff --git a/public/locales/en.json b/public/locales/en.json index c575a7443..808582e2e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -421,6 +421,9 @@ }, "ligatures": { "label": "Show ligatures" + }, + "smartPaste": { + "label": "Auto format pasted content" } } }, diff --git a/src/components/editor-page/editor-pane/editor-pane.tsx b/src/components/editor-page/editor-pane/editor-pane.tsx index 4a527d29f..320ff859e 100644 --- a/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/src/components/editor-page/editor-pane/editor-pane.tsx @@ -41,6 +41,7 @@ import { defaultKeyMap } from './key-map' import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar' import { ToolBar } from './tool-bar/tool-bar' import { handleUpload } from './upload-handler' +import { handleFilePaste, handleTablePaste, PasteEvent } from './tool-bar/utils/pasteHandlers' export interface EditorPaneProps { onContentChange: (content: string) => void @@ -62,23 +63,6 @@ const onChange = (editor: Editor) => { } } -interface PasteEvent { - clipboardData: { - files: FileList - }, - preventDefault: () => void -} - -const onPaste = (pasteEditor: Editor, event: PasteEvent) => { - if (event && event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) { - event.preventDefault() - const files: FileList = event.clipboardData.files - if (files && files.length >= 1) { - handleUpload(files[0], pasteEditor) - } - } -} - interface DropEvent { pageX: number, pageY: number, @@ -92,6 +76,7 @@ interface DropEvent { export const EditorPane: React.FC = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => { const { t } = useTranslation() const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength) + const smartPasteEnabled = useSelector((state: ApplicationState) => state.editorConfig.smartPaste) const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false) const maxLengthWarningAlreadyShown = useRef(false) const [editor, setEditor] = useState() @@ -103,6 +88,19 @@ export const EditorPane: React.FC = ({ onContentC const [editorScroll, setEditorScroll] = useState() const onEditorScroll = useCallback((editor: Editor, data: ScrollInfo) => setEditorScroll(data), []) + const onPaste = useCallback((pasteEditor: Editor, event: PasteEvent) => { + if (!event || !event.clipboardData) { + return + } + if (smartPasteEnabled) { + const tableInserted = handleTablePaste(event, pasteEditor) + if (tableInserted) { + return + } + } + handleFilePaste(event, pasteEditor) + }, [smartPasteEnabled]) + useEffect(() => { if (!editor || !onScroll || !editorScroll) { return diff --git a/src/components/editor-page/editor-pane/table-extractor.test.ts b/src/components/editor-page/editor-pane/table-extractor.test.ts new file mode 100644 index 000000000..f19383986 --- /dev/null +++ b/src/components/editor-page/editor-pane/table-extractor.test.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { convertClipboardTableToMarkdown, isTable } from './table-extractor' + +describe('isTable detection: ', () => { + + it('empty string is no table', () => { + expect(isTable('')) + .toBe(false) + }) + + it('single line is no table', () => { + const input = 'some none table' + expect(isTable(input)) + .toBe(false) + }) + + it('multiple lines without tabs are no table', () => { + const input = 'some none table\nanother line' + expect(isTable(input)) + .toBe(false) + }) + + it('code blocks are no table', () => { + const input = '```python\ndef a:\n\tprint("a")\n\tprint("b")```' + expect(isTable(input)) + .toBe(false) + }) + + it('tab-indented text is no table', () => { + const input = '\tsome tab indented text\n\tabc\n\tdef' + expect(isTable(input)) + .toBe(false) + }) + + it('not equal number of tabs is no table', () => { + const input = '1 ...\n2\tabc\n3\td\te\tf\n4\t16' + expect(isTable(input)) + .toBe(false) + }) + + it('table without newline at end is valid', () => { + const input = '1\t1\n2\t4\n3\t9\n4\t16\n5\t25' + expect(isTable(input)) + .toBe(true) + }) + + it('table with newline at end is valid', () => { + const input = '1\t1\n2\t4\n3\t9\n4\t16\n5\t25\n' + expect(isTable(input)) + .toBe(true) + }) + + it('table with some first cells missing is valid', () => { + const input = '1\t1\n\t0\n\t0\n4\t16\n5\t25\n' + expect(isTable(input)) + .toBe(true) + }) + + it('table with some last cells missing is valid', () => { + const input = '1\t1\n2\t\n3\t\n4\t16\n' + expect(isTable(input)) + .toBe(true) + }) +}) + +describe('Conversion from clipboard table to markdown format', () => { + it('normal table without newline at end converts right', () => { + const input = '1\t1\ta\n2\t4\tb\n3\t9\tc\n4\t16\td' + expect(convertClipboardTableToMarkdown(input)) + .toEqual('| #1 | #2 | #3 |\n| -- | -- | -- |\n| 1 | 1 | a |\n| 2 | 4 | b |\n| 3 | 9 | c |\n| 4 | 16 | d |') + }) + + it('normal table with newline at end converts right', () => { + const input = '1\t1\n2\t4\n3\t9\n4\t16\n' + expect(convertClipboardTableToMarkdown(input)) + .toEqual('| #1 | #2 |\n| -- | -- |\n| 1 | 1 |\n| 2 | 4 |\n| 3 | 9 |\n| 4 | 16 |') + }) + + it('table with some first cells missing converts right', () => { + const input = '1\t1\n\t0\n\t0\n4\t16\n' + expect(convertClipboardTableToMarkdown(input)) + .toEqual('| #1 | #2 |\n| -- | -- |\n| 1 | 1 |\n| | 0 |\n| | 0 |\n| 4 | 16 |') + }) + + it('table with some last cells missing converts right', () => { + const input = '1\t1\n2\t\n3\t\n4\t16\n' + expect(convertClipboardTableToMarkdown(input)) + .toEqual('| #1 | #2 |\n| -- | -- |\n| 1 | 1 |\n| 2 | |\n| 3 | |\n| 4 | 16 |') + }) + + it('empty input results in empty output', () => { + expect(convertClipboardTableToMarkdown('')).toEqual('') + }) +}) diff --git a/src/components/editor-page/editor-pane/table-extractor.ts b/src/components/editor-page/editor-pane/table-extractor.ts new file mode 100644 index 000000000..12d9cbcd7 --- /dev/null +++ b/src/components/editor-page/editor-pane/table-extractor.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createNumberRangeArray } from '../../common/number-range/number-range' + +export const isTable = (text: string): boolean => { + // Tables must consist of multiple rows and columns + if (!text.includes('\n') || !text.includes('\t')) { + return false + } + // Code within code blocks should not be parsed as a table + if (text.startsWith('```')) { + return false + } + + const lines = text.split(/\r?\n/) + .filter(line => line.trim() !== '') + + // Tab-indented text should not be matched as a table + if (lines.every(line => line.startsWith('\t'))) { + return false + } + // Every line should have the same amount of tabs (table columns) + const tabsPerLines = lines.map(line => line.match(/\t/g)?.length ?? 0) + return tabsPerLines.every(line => line === tabsPerLines[0]) +} + +export const convertClipboardTableToMarkdown = (pasteData: string): string => { + if (pasteData.trim() === '') { + return '' + } + const tableRows = pasteData.split(/\r?\n/) + .filter(row => row.trim() !== '') + const tableCells = tableRows.reduce((cellsInRow, row, index) => { + cellsInRow[index] = row.split('\t') + return cellsInRow + }, [] as string[][]) + const arrayMaxRows = createNumberRangeArray(tableCells.length) + const arrayMaxColumns = createNumberRangeArray(Math.max(...tableCells.map(row => row.length))) + + const headRow1 = arrayMaxColumns + .map(col => `| #${ col + 1 } `) + .join('') + '|' + const headRow2 = arrayMaxColumns + .map(col => `| -${ '-'.repeat((col + 1).toString().length) } `) + .join('') + '|' + const body = arrayMaxRows + .map(row => { + return arrayMaxColumns + .map(col => '| ' + tableCells[row][col] + ' ') + .join('') + '|' + }) + .join('\n') + return `${ headRow1 }\n${ headRow2 }\n${ body }` +} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx new file mode 100644 index 000000000..3dbe04de3 --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preference-smart-paste-select.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React, { ChangeEvent, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { ApplicationState } from '../../../../../redux' +import { setEditorSmartPaste } from '../../../../../redux/editor/methods' +import { EditorPreferenceInput, EditorPreferenceInputType } from './editor-preference-input' + +export const EditorPreferenceSmartPasteSelect: React.FC = () => { + const smartPasteEnabled = useSelector((state: ApplicationState) => Boolean(state.editorConfig.smartPaste) + .toString()) + const saveSmartPaste = useCallback((event: ChangeEvent) => { + const smartPasteActivated: boolean = event.target.value === 'true' + setEditorSmartPaste(smartPasteActivated) + }, []) + const { t } = useTranslation() + + return ( + + + + + ) +} diff --git a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx index 5171220be..e92869615 100644 --- a/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx +++ b/src/components/editor-page/editor-pane/tool-bar/editor-preferences/editor-preferences.tsx @@ -19,6 +19,7 @@ import { EditorPreferenceLigaturesSelect } from './editor-preference-ligatures-s import { EditorPreferenceNumberProperty } from './editor-preference-number-property' import { EditorPreferenceProperty } from './editor-preference-property' import { EditorPreferenceSelectProperty } from './editor-preference-select-property' +import { EditorPreferenceSmartPasteSelect } from './editor-preference-smart-paste-select' export const EditorPreferences: React.FC = () => { const { t } = useTranslation() @@ -57,6 +58,9 @@ export const EditorPreferences: React.FC = () => { + + + alert('This feature is not yet implemented.') } property={ EditorPreferenceProperty.SPELL_CHECK } 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 new file mode 100644 index 000000000..25b1b899a --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.test.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Mock } from 'ts-mockery' +import { Editor } from 'codemirror' +import { isCursorInCodefence } from './codefenceDetection' + +Mock.configure('jest') + +const mockEditor = (content: string, line: number) => { + const contentLines = content.split('\n') + return Mock.of({ + getCursor() { + return { + line: line, + ch: 0 + } + }, + getDoc() { + return { + getLine(ln: number) { + return contentLines[ln] ?? '' + } + } + } + }) +} + +describe('Check whether cursor is in codefence', () => { + + it('returns false for empty document', () => { + const editor = mockEditor('', 0) + expect(isCursorInCodefence(editor)) + .toBe(false) + }) + + it('returns true with one open codefence directly above', () => { + const editor = mockEditor('```\n', 1) + expect(isCursorInCodefence(editor)) + .toBe(true) + }) + + it('returns true with one open codefence and empty lines above', () => { + const editor = mockEditor('```\n\n\n', 3) + expect(isCursorInCodefence(editor)) + .toBe(true) + }) + + it('returns false with one completed codefence above', () => { + const editor = mockEditor('```\n\n```\n', 3) + expect(isCursorInCodefence(editor)) + .toBe(false) + }) + + it('returns true with one completed and one open codefence above', () => { + const editor = mockEditor('```\n\n```\n\n```\n\n', 6) + expect(isCursorInCodefence(editor)) + .toBe(true) + }) +}) 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 new file mode 100644 index 000000000..07718ed1b --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/utils/codefenceDetection.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Editor } from 'codemirror' + +export const isCursorInCodefence = (editor: Editor): boolean => { + const currentLine = editor.getCursor().line + let codefenceCount = 0 + for (let line = currentLine; line >= 0; --line) { + const markdownContentLine = editor.getDoc().getLine(line) + if (markdownContentLine.startsWith('```')) { + codefenceCount++ + } + } + return codefenceCount % 2 === 1 +} 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 new file mode 100644 index 000000000..8047cbceb --- /dev/null +++ b/src/components/editor-page/editor-pane/tool-bar/utils/pasteHandlers.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Editor } from 'codemirror' +import { convertClipboardTableToMarkdown, isTable } from '../../table-extractor' +import { handleUpload } from '../../upload-handler' +import { insertAtCursor } from './toolbarButtonUtils' +import { isCursorInCodefence } from './codefenceDetection' + +type ClipboardDataFormats = 'text' | 'url' | 'text/plain' | 'text/uri-list' | 'text/html' + +export interface PasteEvent { + clipboardData: { + files: FileList, + getData: (format: ClipboardDataFormats) => string + }, + preventDefault: () => void +} + +export const handleTablePaste = (event: PasteEvent, editor: Editor): boolean => { + const pasteText = event.clipboardData.getData('text') + if (!pasteText || isCursorInCodefence(editor) || !isTable(pasteText)) { + return false + } + event.preventDefault() + const markdownTable = convertClipboardTableToMarkdown(pasteText) + insertAtCursor(editor, markdownTable) + return true +} + +export const handleFilePaste = (event: PasteEvent, editor: Editor): boolean => { + if (!event.clipboardData.files || event.clipboardData.files.length < 1) { + return false + } + event.preventDefault() + const files: FileList = event.clipboardData.files + if (files && files.length >= 1) { + handleUpload(files[0], editor) + return true + } + return false +} diff --git a/src/redux/editor/methods.ts b/src/redux/editor/methods.ts index 9c64d2742..c3a0bcb80 100644 --- a/src/redux/editor/methods.ts +++ b/src/redux/editor/methods.ts @@ -13,6 +13,7 @@ import { SetEditorConfigAction, SetEditorLigaturesAction, SetEditorPreferencesAction, + SetEditorSmartPasteAction, SetEditorSyncScrollAction } from './types' @@ -48,7 +49,7 @@ export const setEditorMode = (editorMode: EditorMode): void => { export const setEditorSyncScroll = (syncScroll: boolean): void => { const action: SetEditorSyncScrollAction = { type: EditorConfigActionType.SET_SYNC_SCROLL, - syncScroll: syncScroll + syncScroll } store.dispatch(action) } @@ -56,7 +57,15 @@ export const setEditorSyncScroll = (syncScroll: boolean): void => { export const setEditorLigatures = (ligatures: boolean): void => { const action: SetEditorLigaturesAction = { type: EditorConfigActionType.SET_LIGATURES, - ligatures: ligatures + ligatures + } + store.dispatch(action) +} + +export const setEditorSmartPaste = (smartPaste: boolean): void => { + const action: SetEditorSmartPasteAction = { + type: EditorConfigActionType.SET_SMART_PASTE, + smartPaste } store.dispatch(action) } @@ -64,7 +73,7 @@ export const setEditorLigatures = (ligatures: boolean): void => { export const mergeEditorPreferences = (preferences: EditorConfiguration): void => { const action: SetEditorPreferencesAction = { type: EditorConfigActionType.MERGE_EDITOR_PREFERENCES, - preferences: preferences + preferences } store.dispatch(action) } diff --git a/src/redux/editor/reducers.ts b/src/redux/editor/reducers.ts index 14fae2a78..5ba87fe89 100644 --- a/src/redux/editor/reducers.ts +++ b/src/redux/editor/reducers.ts @@ -14,6 +14,7 @@ import { SetEditorConfigAction, SetEditorLigaturesAction, SetEditorPreferencesAction, + SetEditorSmartPasteAction, SetEditorSyncScrollAction } from './types' @@ -21,6 +22,7 @@ const initialState: EditorConfig = { editorMode: EditorMode.BOTH, ligatures: true, syncScroll: true, + smartPaste: true, preferences: { theme: 'one-dark', keyMap: 'sublime', @@ -57,6 +59,13 @@ export const EditorConfigReducer: Reducer = ( } saveToLocalStorage(newState) return newState + case EditorConfigActionType.SET_SMART_PASTE: + newState = { + ...state, + smartPaste: (action as SetEditorSmartPasteAction).smartPaste + } + saveToLocalStorage(newState) + return newState case EditorConfigActionType.MERGE_EDITOR_PREFERENCES: newState = { ...state, diff --git a/src/redux/editor/types.ts b/src/redux/editor/types.ts index 1e9629a96..e0d5528b4 100644 --- a/src/redux/editor/types.ts +++ b/src/redux/editor/types.ts @@ -12,18 +12,20 @@ export enum EditorConfigActionType { SET_EDITOR_VIEW_MODE = 'editor/mode/set', SET_SYNC_SCROLL = 'editor/syncScroll/set', MERGE_EDITOR_PREFERENCES = 'editor/preferences/merge', - SET_LIGATURES = 'editor/preferences/setLigatures' + SET_LIGATURES = 'editor/preferences/setLigatures', + SET_SMART_PASTE = 'editor/preferences/setSmartPaste' } export interface EditorConfig { - editorMode: EditorMode; - syncScroll: boolean; + editorMode: EditorMode + syncScroll: boolean ligatures: boolean + smartPaste: boolean preferences: EditorConfiguration } export interface EditorConfigActions extends Action { - type: EditorConfigActionType; + type: EditorConfigActionType } export interface SetEditorSyncScrollAction extends EditorConfigActions { @@ -34,6 +36,10 @@ export interface SetEditorLigaturesAction extends EditorConfigActions { ligatures: boolean } +export interface SetEditorSmartPasteAction extends EditorConfigActions { + smartPaste: boolean +} + export interface SetEditorConfigAction extends EditorConfigActions { mode: EditorMode }