Refactor table size picker (#1662)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-12-02 23:55:16 +01:00 committed by GitHub
parent b68a55aa94
commit 9874d54404
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 281 additions and 160 deletions

View file

@ -175,25 +175,29 @@ describe('Toolbar Buttons', () => {
describe('for new tables', () => { describe('for new tables', () => {
beforeEach(() => { beforeEach(() => {
cy.get('.table-picker-container').should('not.be.visible') cy.getById('table-size-picker-popover').should('not.exist')
cy.getById('show-table-overlay').last().click() cy.getById('table-size-picker-button').last().click()
cy.get('.table-picker-container').should('be.visible') cy.getById('table-size-picker-popover').should('be.visible')
}) })
it('should open an overlay', () => { it('should select table size', () => {
cy.get('.table-container > div:nth-of-type(25)').trigger('mouseover') cy.getById('table-size-picker-popover')
cy.get('.table-cell.bg-primary').should('have.length', 15) .find('.table-container > .table-cell:nth-of-type(25)')
cy.get('.table-picker-container > p').contains('5x3') .trigger('mouseover')
cy.get('.table-container > div:nth-of-type(25)').click() cy.getById('table-size-picker-popover')
.find('.table-container > .table-cell[data-cypress-selected="true"]')
.should('have.length', 15)
cy.getById('table-size-picker-popover').find('.popover-header').contains('5x3')
cy.getById('table-size-picker-popover').find('.table-container > .table-cell:nth-of-type(25)').click()
}) })
it('should open a modal for custom table sizes in the overlay', () => { it('should open a custom table size in the modal', () => {
cy.get('.modal-dialog').should('not.exist') cy.getById('custom-table-size-modal').should('not.exist')
cy.getById('show-custom-table-modal').first().click() cy.getById('show-custom-table-modal').first().click()
cy.get('.modal-dialog').should('be.visible') cy.getById('custom-table-size-modal').should('be.visible')
cy.get('.modal-content > .d-flex > input').first().type('5') cy.getById('custom-table-size-modal').find('input').first().type('5')
cy.get('.modal-content > .d-flex > input').last().type('3') cy.getById('custom-table-size-modal').find('input').last().type('3')
cy.get('.modal-footer > button').click() cy.getById('custom-table-size-modal').find('.modal-footer > button').click()
}) })
afterEach(() => { afterEach(() => {

View file

@ -309,8 +309,8 @@
"image": "Image", "image": "Image",
"uploadImage": "Upload Image", "uploadImage": "Upload Image",
"table": { "table": {
"title": "Table", "titleWithoutSize": "Table",
"size": "{{cols}}x{{rows}} Table", "titleWithSize": "{{cols}}x{{rows}} Table",
"customSize": "Custom Size", "customSize": "Custom Size",
"cols": "Cols", "cols": "Cols",
"rows": "Rows", "rows": "Rows",

View file

@ -4,58 +4,79 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ChangeEvent } from 'react'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Button, Form, ModalFooter } from 'react-bootstrap' import { Button, Form, ModalFooter } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { CommonModal } from '../../../../common/modals/common-modal' import { CommonModal } from '../../../../common/modals/common-modal'
import type { TableSize } from './table-picker' import type { TableSize } from './table-size-picker-popover'
import { cypressId } from '../../../../../utils/cypress-attribute'
export interface CustomTableSizeModalProps { export interface CustomTableSizeModalProps {
showModal: boolean showModal: boolean
onDismiss: () => void onDismiss: () => void
onTablePicked: (row: number, cols: number) => void onSizeSelect: (row: number, cols: number) => void
} }
export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ showModal, onDismiss, onTablePicked }) => { const initialTableSize: TableSize = {
rows: 0,
columns: 0
}
/**
* A modal that lets the user select a custom table size.
*
* @param showModal defines if the modal should be visible or not
* @param onDismiss is called if the modal should be hidden without a selection
* @param onSizeSelect is called if the user entered and confirmed a custom table size
*/
export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ showModal, onDismiss, onSizeSelect }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [tableSize, setTableSize] = useState<TableSize>({ const [tableSize, setTableSize] = useState<TableSize>(() => initialTableSize)
rows: 0,
columns: 0
})
useEffect(() => { useEffect(() => {
setTableSize({ if (showModal) {
rows: 0, setTableSize(initialTableSize)
columns: 0 }
})
}, [showModal]) }, [showModal])
const onClick = useCallback(() => { const onClick = useCallback(() => {
onTablePicked(tableSize.rows, tableSize.columns) onSizeSelect(tableSize.rows, tableSize.columns)
onDismiss() onDismiss()
}, [onDismiss, tableSize, onTablePicked]) }, [onDismiss, tableSize, onSizeSelect])
const onColChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const value = Number.parseInt(event.currentTarget.value)
setTableSize((old) => ({
rows: old.rows,
columns: isNaN(value) ? 0 : value
}))
}, [])
const onRowChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const value = Number.parseInt(event.currentTarget.value)
setTableSize((old) => ({
rows: isNaN(value) ? 0 : value,
columns: old.columns
}))
}, [])
return ( return (
<CommonModal <CommonModal
show={showModal} show={showModal}
onHide={() => onDismiss()} onHide={onDismiss}
title={'editor.editorToolbar.table.customSize'} title={'editor.editorToolbar.table.customSize'}
showCloseButton={true} showCloseButton={true}
titleIcon={'table'}> titleIcon={'table'}
{...cypressId('custom-table-size-modal')}>
<div className={'col-lg-10 d-flex flex-row p-3 align-items-center'}> <div className={'col-lg-10 d-flex flex-row p-3 align-items-center'}>
<Form.Control <Form.Control
type={'number'} type={'number'}
min={1} min={1}
placeholder={t('editor.editorToolbar.table.cols')} placeholder={t('editor.editorToolbar.table.cols')}
isInvalid={tableSize.columns <= 0} isInvalid={tableSize.columns <= 0}
onChange={(event) => { onChange={onColChange}
const value = Number.parseInt(event.currentTarget.value)
setTableSize((old) => ({
rows: old.rows,
columns: isNaN(value) ? 0 : value
}))
}}
/> />
<ForkAwesomeIcon icon='times' className='mx-2' fixedWidth={true} /> <ForkAwesomeIcon icon='times' className='mx-2' fixedWidth={true} />
<Form.Control <Form.Control
@ -63,18 +84,12 @@ export const CustomTableSizeModal: React.FC<CustomTableSizeModalProps> = ({ show
min={1} min={1}
placeholder={t('editor.editorToolbar.table.rows')} placeholder={t('editor.editorToolbar.table.rows')}
isInvalid={tableSize.rows <= 0} isInvalid={tableSize.rows <= 0}
onChange={(event) => { onChange={onRowChange}
const value = Number.parseInt(event.currentTarget.value)
setTableSize((old) => ({
rows: isNaN(value) ? 0 : value,
columns: old.columns
}))
}}
/> />
</div> </div>
<ModalFooter> <ModalFooter>
<Button onClick={onClick} disabled={tableSize.rows <= 0 || tableSize.columns <= 0}> <Button onClick={onClick} disabled={tableSize.rows <= 0 || tableSize.columns <= 0}>
{t('editor.editorToolbar.table.create')} <Trans i18nKey={'editor.editorToolbar.table.create'} />
</Button> </Button>
</ModalFooter> </ModalFooter>
</CommonModal> </CommonModal>

View file

@ -5,39 +5,106 @@
*/ */
import type CodeMirror from 'codemirror' import type CodeMirror from 'codemirror'
import React, { Fragment, useState } from 'react' import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'
import { Button } from 'react-bootstrap' import { Button, Overlay } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { addTable } from '../utils/toolbarButtonUtils' import { addTable } from '../utils/toolbarButtonUtils'
import { TablePicker } from './table-picker'
import { cypressId } from '../../../../../utils/cypress-attribute' import { cypressId } from '../../../../../utils/cypress-attribute'
import { TableSizePickerPopover } from './table-size-picker-popover'
import { CustomTableSizeModal } from './custom-table-size-modal'
import type { OverlayInjectedProps } from 'react-bootstrap/Overlay'
import { ShowIf } from '../../../../common/show-if/show-if'
export interface TablePickerButtonProps { export interface TablePickerButtonProps {
editor: CodeMirror.Editor editor: CodeMirror.Editor
} }
enum PickerMode {
INVISIBLE,
GRID,
CUSTOM
}
/**
* Toggles the visibility of a {@link TableSizePicker table size picker overlay} and inserts the result into the editor.
*
* @param editor The editor in which the result should get inserted
*/
export const TablePickerButton: React.FC<TablePickerButtonProps> = ({ editor }) => { export const TablePickerButton: React.FC<TablePickerButtonProps> = ({ editor }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [showTablePicker, setShowTablePicker] = useState(false) const [pickerMode, setPickerMode] = useState<PickerMode>(PickerMode.INVISIBLE)
const onDismiss = useCallback(() => setPickerMode(PickerMode.INVISIBLE), [])
const onShowModal = useCallback(() => setPickerMode(PickerMode.CUSTOM), [])
const onSizeSelect = useCallback(
(rows: number, columns: number) => {
addTable(editor, rows, columns)
setPickerMode(PickerMode.INVISIBLE)
},
[editor]
)
const tableTitle = useMemo(() => t('editor.editorToolbar.table.title'), [t])
const button = useRef(null)
const toggleOverlayVisibility = useCallback(
() =>
setPickerMode((oldPickerMode) =>
oldPickerMode === PickerMode.INVISIBLE ? PickerMode.GRID : PickerMode.INVISIBLE
),
[]
)
const onOverlayHide = useCallback(() => {
setPickerMode((oldMode) => {
if (oldMode === PickerMode.CUSTOM) {
return PickerMode.CUSTOM
} else {
return PickerMode.INVISIBLE
}
})
}, [])
const createPopoverElement = useCallback<(props: OverlayInjectedProps) => React.ReactElement>(
({ ref, ...popoverProps }) => (
<TableSizePickerPopover
onTableSizeSelected={onSizeSelect}
onShowCustomSizeModal={onShowModal}
onDismiss={onDismiss}
onRefUpdate={ref}
{...popoverProps}
/>
),
[onDismiss, onShowModal, onSizeSelect]
)
return ( return (
<Fragment> <Fragment>
<TablePicker
show={showTablePicker}
onDismiss={() => setShowTablePicker(false)}
onTablePicked={(rows, cols) => {
setShowTablePicker(false)
addTable(editor, rows, cols)
}}
/>
<Button <Button
{...cypressId('show-table-overlay')} {...cypressId('table-size-picker-button')}
variant='light' variant='light'
onClick={() => setShowTablePicker((old) => !old)} onClick={toggleOverlayVisibility}
title={t('editor.editorToolbar.table.title')}> title={tableTitle}
ref={button}>
<ForkAwesomeIcon icon='table' /> <ForkAwesomeIcon icon='table' />
</Button> </Button>
<Overlay
target={button.current}
onHide={onOverlayHide}
show={pickerMode === PickerMode.GRID}
placement={'bottom'}
rootClose={true}>
{createPopoverElement}
</Overlay>
<ShowIf condition={pickerMode === PickerMode.CUSTOM}>
<CustomTableSizeModal
showModal={pickerMode === PickerMode.CUSTOM}
onDismiss={onDismiss}
onSizeSelect={onSizeSelect}
/>
</ShowIf>
</Fragment> </Fragment>
) )
} }

View file

@ -6,10 +6,10 @@
.table-picker-container { .table-picker-container {
z-index: 1111;
@import "../../../../../style/variables.light"; @import "../../../../../style/variables.light";
z-index: 1111;
.table-cell { .table-cell {
border: 1px solid $gray-700; border: 1px solid $gray-700;
margin: 1px; margin: 1px;

View file

@ -1,91 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useClickAway } from 'react-use'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
import { CustomTableSizeModal } from './custom-table-size-modal'
import './table-picker.scss'
import { cypressId } from '../../../../../utils/cypress-attribute'
export interface TablePickerProps {
show: boolean
onDismiss: () => void
onTablePicked: (row: number, cols: number) => void
}
export type TableSize = {
rows: number
columns: number
}
export const TablePicker: React.FC<TablePickerProps> = ({ show, onDismiss, onTablePicked }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [tableSize, setTableSize] = useState<TableSize>()
const [showDialog, setShowDialog] = useState(false)
useClickAway(containerRef, () => {
onDismiss()
})
useEffect(() => {
setTableSize(undefined)
}, [show])
const onClick = useCallback(() => {
if (tableSize) {
onTablePicked(tableSize.rows, tableSize.columns)
}
}, [onTablePicked, tableSize])
return (
<div
className={`position-absolute table-picker-container p-2 ${!show || showDialog ? 'd-none' : ''} bg-light`}
ref={containerRef}
role='grid'>
<p className={'lead'}>
{tableSize
? t('editor.editorToolbar.table.size', { cols: tableSize?.columns, rows: tableSize.rows })
: t('editor.editorToolbar.table.title')}
</p>
<div className={'table-container'}>
{createNumberRangeArray(8).map((row: number) =>
createNumberRangeArray(10).map((col: number) => (
<div
key={`${row}_${col}`}
className={`table-cell ${
tableSize && row < tableSize.rows && col < tableSize.columns ? 'bg-primary border-primary' : ''
}`}
onMouseEnter={() => {
setTableSize({
rows: row + 1,
columns: col + 1
})
}}
title={t('editor.editorToolbar.table.size', { cols: col + 1, rows: row + 1 })}
onClick={onClick}
/>
))
)}
</div>
<div className='d-flex justify-content-center mt-2'>
<Button {...cypressId('show-custom-table-modal')} className={'text-center'} onClick={() => setShowDialog(true)}>
<ForkAwesomeIcon icon='table' />
&nbsp;{t('editor.editorToolbar.table.customSize')}
</Button>
<CustomTableSizeModal
showModal={showDialog}
onDismiss={() => setShowDialog(false)}
onTablePicked={onTablePicked}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { createNumberRangeArray } from '../../../../common/number-range/number-range'
import { Button, Popover } from 'react-bootstrap'
import { TableSizeText } from './table-size-text'
import { Trans, useTranslation } from 'react-i18next'
import { cypressAttribute, cypressId } from '../../../../../utils/cypress-attribute'
import { ForkAwesomeIcon } from '../../../../common/fork-awesome/fork-awesome-icon'
import type { PopoverProps } from 'react-bootstrap/Popover'
import { useOnRefChange } from '../../../../markdown-renderer/hooks/use-on-ref-change'
import './table-picker.scss'
export interface TableSizePickerPopoverProps extends Omit<PopoverProps, 'id'> {
onShowCustomSizeModal: () => void
onTableSizeSelected: (rows: number, cols: number) => void
onDismiss: () => void
onRefUpdate: (newRef: HTMLDivElement | null) => void
}
export interface TableSize {
rows: number
columns: number
}
export const TableSizePickerPopover: React.FC<TableSizePickerPopoverProps> = ({
onShowCustomSizeModal,
onTableSizeSelected,
onDismiss,
onRefUpdate,
...props
}) => {
const { t } = useTranslation()
const [tableSize, setTableSize] = useState<TableSize>()
const onSizeHover = useCallback(
(selectedRows: number, selectedCols: number) => () => {
setTableSize({
rows: selectedRows,
columns: selectedCols
})
},
[]
)
const tableContainer = useMemo(
() =>
createNumberRangeArray(8).map((row: number) =>
createNumberRangeArray(10).map((col: number) => {
const selected = tableSize && row < tableSize.rows && col < tableSize.columns
return (
<div
key={`${row}_${col}`}
className={`table-cell ${selected ? 'bg-primary border-primary' : ''}`}
{...cypressAttribute('selected', selected ? 'true' : 'false')}
onMouseEnter={onSizeHover(row + 1, col + 1)}
title={t('editor.editorToolbar.table.size', { cols: col + 1, rows: row + 1 })}
onClick={() => onTableSizeSelected(row + 1, col + 1)}
/>
)
})
),
[onTableSizeSelected, onSizeHover, t, tableSize]
)
const popoverRef = useRef<HTMLDivElement | null>(null)
useOnRefChange(popoverRef, (newRef) => onRefUpdate(newRef))
return (
<Popover
{...props}
ref={popoverRef}
id={'table-picker'}
{...cypressId('table-size-picker-popover')}
className={`table-picker-container bg-light`}>
<Popover.Title>
<TableSizeText tableSize={tableSize} />
</Popover.Title>
<Popover.Content>
<div className={'table-container'} role='grid'>
{tableContainer}
</div>
<div className='d-flex justify-content-center mt-2'>
<Button {...cypressId('show-custom-table-modal')} className={'text-center'} onClick={onShowCustomSizeModal}>
<ForkAwesomeIcon icon='table' />
&nbsp;
<Trans i18nKey={'editor.editorToolbar.table.customSize'} />
</Button>
</div>
</Popover.Content>
</Popover>
)
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import type { TableSize } from './table-size-picker-popover'
export interface TableSizeProps {
tableSize?: TableSize
}
/**
* Renders an info text that contains the given {@link TableSize table size}.
*
* @param tableSize The table size that should be included
*/
export const TableSizeText: React.FC<TableSizeProps> = ({ tableSize }) => {
useTranslation()
const translationValues = useMemo(() => {
return tableSize ? { cols: tableSize.columns, rows: tableSize.rows } : undefined
}, [tableSize])
if (!translationValues) {
return <Trans i18nKey={'editor.editorToolbar.table.titleWithoutSize'} />
} else {
return <Trans i18nKey={'editor.editorToolbar.table.titleWithSize'} values={translationValues} />
}
}

View file

@ -8,11 +8,8 @@ import equal from 'fast-deep-equal'
import type { MutableRefObject } from 'react' import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
export const useOnRefChange = <T>( export const useOnRefChange = <T>(reference: MutableRefObject<T>, onChange?: (newValue: T) => void): void => {
reference: MutableRefObject<T | undefined>, const lastValue = useRef<T>()
onChange?: (newValue?: T) => void
): void => {
const lastValue = useRef<T | undefined>()
useEffect(() => { useEffect(() => {
if (onChange && !equal(reference, lastValue.current)) { if (onChange && !equal(reference, lastValue.current)) {
lastValue.current = reference.current lastValue.current = reference.current