Add table formatting on paste of detected table (#957)

This commit is contained in:
Erik Michelson 2021-03-10 22:52:20 +01:00 committed by GitHub
parent 107f0f6fa3
commit 0b4a0afa16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 375 additions and 27 deletions

View file

@ -68,8 +68,9 @@ SPDX-License-Identifier: CC-BY-SA-4.0
- Easier possibility to share notes via native share-buttons on supported devices. - 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). - Surround selected text with a link via shortcut (ctrl+k or cmd+k).
- A sidebar for menu options - A sidebar for menu options
- Improved security by wrapping the markdown rendering into an iframe - Improved security by wrapping the markdown rendering into an iframe.
- The intro page content can be changed by editing `public/intro.md` - 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 ### Changed

View file

@ -54,7 +54,8 @@ describe('File upload', () => {
.then((image: string) => { .then((image: string) => {
const pasteEvent = { const pasteEvent = {
clipboardData: { clipboardData: {
files: [Cypress.Blob.base64StringToBlob(image, 'image/png')] files: [Cypress.Blob.base64StringToBlob(image, 'image/png')],
getData: (_: string) => ''
} }
} }
cy.get('.CodeMirror-scroll') cy.get('.CodeMirror-scroll')

View file

@ -421,6 +421,9 @@
}, },
"ligatures": { "ligatures": {
"label": "Show ligatures" "label": "Show ligatures"
},
"smartPaste": {
"label": "Auto format pasted content"
} }
} }
}, },

View file

@ -41,6 +41,7 @@ import { defaultKeyMap } from './key-map'
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar' import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar' import { ToolBar } from './tool-bar/tool-bar'
import { handleUpload } from './upload-handler' import { handleUpload } from './upload-handler'
import { handleFilePaste, handleTablePaste, PasteEvent } from './tool-bar/utils/pasteHandlers'
export interface EditorPaneProps { export interface EditorPaneProps {
onContentChange: (content: string) => void 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 { interface DropEvent {
pageX: number, pageX: number,
pageY: number, pageY: number,
@ -92,6 +76,7 @@ interface DropEvent {
export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => { export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => {
const { t } = useTranslation() const { t } = useTranslation()
const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength) const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength)
const smartPasteEnabled = useSelector((state: ApplicationState) => state.editorConfig.smartPaste)
const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false) const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false)
const maxLengthWarningAlreadyShown = useRef(false) const maxLengthWarningAlreadyShown = useRef(false)
const [editor, setEditor] = useState<Editor>() const [editor, setEditor] = useState<Editor>()
@ -103,6 +88,19 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
const [editorScroll, setEditorScroll] = useState<ScrollInfo>() const [editorScroll, setEditorScroll] = useState<ScrollInfo>()
const onEditorScroll = useCallback((editor: Editor, data: ScrollInfo) => setEditorScroll(data), []) 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(() => { useEffect(() => {
if (!editor || !onScroll || !editorScroll) { if (!editor || !onScroll || !editorScroll) {
return return

View file

@ -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('')
})
})

View file

@ -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 }`
}

View file

@ -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<HTMLSelectElement>) => {
const smartPasteActivated: boolean = event.target.value === 'true'
setEditorSmartPaste(smartPasteActivated)
}, [])
const { t } = useTranslation()
return (
<EditorPreferenceInput
onChange={ saveSmartPaste }
value={ smartPasteEnabled }
property={ 'smartPaste' }
type={ EditorPreferenceInputType.BOOLEAN }
>
<option value='true'>{ t(`common.yes`) }</option>
<option value='false'>{ t(`common.no`) }</option>
</EditorPreferenceInput>
)
}

View file

@ -19,6 +19,7 @@ import { EditorPreferenceLigaturesSelect } from './editor-preference-ligatures-s
import { EditorPreferenceNumberProperty } from './editor-preference-number-property' import { EditorPreferenceNumberProperty } from './editor-preference-number-property'
import { EditorPreferenceProperty } from './editor-preference-property' import { EditorPreferenceProperty } from './editor-preference-property'
import { EditorPreferenceSelectProperty } from './editor-preference-select-property' import { EditorPreferenceSelectProperty } from './editor-preference-select-property'
import { EditorPreferenceSmartPasteSelect } from './editor-preference-smart-paste-select'
export const EditorPreferences: React.FC = () => { export const EditorPreferences: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -57,6 +58,9 @@ export const EditorPreferences: React.FC = () => {
<ListGroup.Item> <ListGroup.Item>
<EditorPreferenceLigaturesSelect/> <EditorPreferenceLigaturesSelect/>
</ListGroup.Item> </ListGroup.Item>
<ListGroup.Item>
<EditorPreferenceSmartPasteSelect/>
</ListGroup.Item>
<ListGroup.Item> <ListGroup.Item>
<EditorPreferenceInput onChange={ () => alert('This feature is not yet implemented.') } <EditorPreferenceInput onChange={ () => alert('This feature is not yet implemented.') }
property={ EditorPreferenceProperty.SPELL_CHECK } property={ EditorPreferenceProperty.SPELL_CHECK }

View file

@ -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<Editor>({
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)
})
})

View file

@ -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
}

View file

@ -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
}

View file

@ -13,6 +13,7 @@ import {
SetEditorConfigAction, SetEditorConfigAction,
SetEditorLigaturesAction, SetEditorLigaturesAction,
SetEditorPreferencesAction, SetEditorPreferencesAction,
SetEditorSmartPasteAction,
SetEditorSyncScrollAction SetEditorSyncScrollAction
} from './types' } from './types'
@ -48,7 +49,7 @@ export const setEditorMode = (editorMode: EditorMode): void => {
export const setEditorSyncScroll = (syncScroll: boolean): void => { export const setEditorSyncScroll = (syncScroll: boolean): void => {
const action: SetEditorSyncScrollAction = { const action: SetEditorSyncScrollAction = {
type: EditorConfigActionType.SET_SYNC_SCROLL, type: EditorConfigActionType.SET_SYNC_SCROLL,
syncScroll: syncScroll syncScroll
} }
store.dispatch(action) store.dispatch(action)
} }
@ -56,7 +57,15 @@ export const setEditorSyncScroll = (syncScroll: boolean): void => {
export const setEditorLigatures = (ligatures: boolean): void => { export const setEditorLigatures = (ligatures: boolean): void => {
const action: SetEditorLigaturesAction = { const action: SetEditorLigaturesAction = {
type: EditorConfigActionType.SET_LIGATURES, 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) store.dispatch(action)
} }
@ -64,7 +73,7 @@ export const setEditorLigatures = (ligatures: boolean): void => {
export const mergeEditorPreferences = (preferences: EditorConfiguration): void => { export const mergeEditorPreferences = (preferences: EditorConfiguration): void => {
const action: SetEditorPreferencesAction = { const action: SetEditorPreferencesAction = {
type: EditorConfigActionType.MERGE_EDITOR_PREFERENCES, type: EditorConfigActionType.MERGE_EDITOR_PREFERENCES,
preferences: preferences preferences
} }
store.dispatch(action) store.dispatch(action)
} }

View file

@ -14,6 +14,7 @@ import {
SetEditorConfigAction, SetEditorConfigAction,
SetEditorLigaturesAction, SetEditorLigaturesAction,
SetEditorPreferencesAction, SetEditorPreferencesAction,
SetEditorSmartPasteAction,
SetEditorSyncScrollAction SetEditorSyncScrollAction
} from './types' } from './types'
@ -21,6 +22,7 @@ const initialState: EditorConfig = {
editorMode: EditorMode.BOTH, editorMode: EditorMode.BOTH,
ligatures: true, ligatures: true,
syncScroll: true, syncScroll: true,
smartPaste: true,
preferences: { preferences: {
theme: 'one-dark', theme: 'one-dark',
keyMap: 'sublime', keyMap: 'sublime',
@ -57,6 +59,13 @@ export const EditorConfigReducer: Reducer<EditorConfig, EditorConfigActions> = (
} }
saveToLocalStorage(newState) saveToLocalStorage(newState)
return newState return newState
case EditorConfigActionType.SET_SMART_PASTE:
newState = {
...state,
smartPaste: (action as SetEditorSmartPasteAction).smartPaste
}
saveToLocalStorage(newState)
return newState
case EditorConfigActionType.MERGE_EDITOR_PREFERENCES: case EditorConfigActionType.MERGE_EDITOR_PREFERENCES:
newState = { newState = {
...state, ...state,

View file

@ -12,18 +12,20 @@ export enum EditorConfigActionType {
SET_EDITOR_VIEW_MODE = 'editor/mode/set', SET_EDITOR_VIEW_MODE = 'editor/mode/set',
SET_SYNC_SCROLL = 'editor/syncScroll/set', SET_SYNC_SCROLL = 'editor/syncScroll/set',
MERGE_EDITOR_PREFERENCES = 'editor/preferences/merge', 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 { export interface EditorConfig {
editorMode: EditorMode; editorMode: EditorMode
syncScroll: boolean; syncScroll: boolean
ligatures: boolean ligatures: boolean
smartPaste: boolean
preferences: EditorConfiguration preferences: EditorConfiguration
} }
export interface EditorConfigActions extends Action<EditorConfigActionType> { export interface EditorConfigActions extends Action<EditorConfigActionType> {
type: EditorConfigActionType; type: EditorConfigActionType
} }
export interface SetEditorSyncScrollAction extends EditorConfigActions { export interface SetEditorSyncScrollAction extends EditorConfigActions {
@ -34,6 +36,10 @@ export interface SetEditorLigaturesAction extends EditorConfigActions {
ligatures: boolean ligatures: boolean
} }
export interface SetEditorSmartPasteAction extends EditorConfigActions {
smartPaste: boolean
}
export interface SetEditorConfigAction extends EditorConfigActions { export interface SetEditorConfigAction extends EditorConfigActions {
mode: EditorMode mode: EditorMode
} }