Merge pull request #14249 from overleaf/mj-delete-row-column

[visual] Enable buttons for deleting and adding columns or rows

GitOrigin-RevId: 5cb68091d79b46eab507510e03180852b97666b9
This commit is contained in:
Mathias Jakobsen 2023-08-14 09:17:08 +01:00 committed by Copybot
parent 31c285871a
commit 5c5c5be594
8 changed files with 246 additions and 42 deletions

View file

@ -66,6 +66,16 @@ export class TableSelection {
return row >= minY && row <= maxY && minX === 0 && maxX === totalColumns - 1
}
isAnyRowSelected(totalColumns: number) {
const { minX, maxX } = this.normalized()
return minX === 0 && maxX === totalColumns - 1
}
isAnyColumnSelected(totalRows: number) {
const { minY, maxY } = this.normalized()
return minY === 0 && maxY === totalRows - 1
}
isColumnSelected(cell: number, totalRows: number) {
const { minX, maxX, minY, maxY } = this.normalized()
return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
@ -146,6 +156,16 @@ export class TableSelection {
{ row: newRow, cell: this.to.cell }
)
}
spansEntireTable(totalColumns: number, totalRows: number) {
const { minX, maxX, minY, maxY } = this.normalized()
return (
minX === 0 &&
maxX === totalColumns - 1 &&
minY === 0 &&
maxY === totalRows - 1
)
}
}
const SelectionContext = createContext<

View file

@ -2,6 +2,7 @@ import { FC, createContext, useContext } from 'react'
import { Positions, TableData } from '../tabular'
import {
CellPosition,
CellSeparator,
RowPosition,
RowSeparator,
generateTable,
@ -16,6 +17,7 @@ const TableContext = createContext<
specification: { from: number; to: number }
rowPositions: RowPosition[]
rowSeparators: RowSeparator[]
cellSeparators: CellSeparator[][]
positions: Positions
}
| undefined

View file

@ -1,7 +1,11 @@
import { EditorView } from '@codemirror/view'
import { ColumnDefinition, Positions } from '../tabular'
import { ChangeSpec } from '@codemirror/state'
import { RowSeparator, parseColumnSpecifications } from '../utils'
import {
CellSeparator,
RowSeparator,
parseColumnSpecifications,
} from '../utils'
import { TableSelection } from '../contexts/selection-context'
/* eslint-disable no-unused-vars */
@ -140,3 +144,154 @@ const generateColumnSpecification = (columns: ColumnDefinition[]) => {
)
.join('')
}
export const removeRowOrColumns = (
view: EditorView,
selection: TableSelection,
positions: Positions,
cellSeparators: CellSeparator[][]
) => {
const {
minX: startCell,
maxX: endCell,
minY: startRow,
maxY: endRow,
} = selection.normalized()
const changes: { from: number; to: number; insert: string }[] = []
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
const numberOfColumns = columnSpecification.length
const numberOfRows = positions.rowPositions.length
if (selection.spansEntireTable(numberOfColumns, numberOfRows)) {
return emptyTable(view, columnSpecification, positions)
}
for (let row = startRow; row <= endRow; row++) {
if (selection.isRowSelected(row, numberOfColumns)) {
const rowPosition = positions.rowPositions[row]
changes.push({
from: rowPosition.from,
to: rowPosition.to,
insert: '',
})
} else {
for (let cell = startCell; cell <= endCell; cell++) {
if (selection.isColumnSelected(cell, numberOfRows)) {
// FIXME: handle multicolumn
if (cell === 0 && cellSeparators[row].length > 0) {
// Remove the cell separator between the first and second cell
changes.push({
from: positions.cells[row][cell].from,
to: cellSeparators[row][0].to,
insert: '',
})
} else {
// Remove the cell separator between the cell before and this if possible
const cellPosition = positions.cells[row][cell]
const from =
cellSeparators[row][cell - 1]?.from ?? cellPosition.from
const to = cellPosition.to
changes.push({
from,
to,
insert: '',
})
}
}
}
}
}
const filteredColumns = columnSpecification.filter(
(_, i) => !selection.isColumnSelected(i, numberOfRows)
)
const newSpecification = generateColumnSpecification(filteredColumns)
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
})
view.dispatch({ changes })
}
const emptyTable = (
view: EditorView,
columnSpecification: ColumnDefinition[],
positions: Positions
) => {
const newColumns = columnSpecification.slice(0, 1)
newColumns[0].borderLeft = 0
newColumns[0].borderRight = 0
const newSpecification = generateColumnSpecification(newColumns)
const changes: ChangeSpec[] = []
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpecification,
})
const from = positions.rowPositions[0].from
const to = positions.rowPositions[positions.rowPositions.length - 1].to
changes.push({
from,
to,
insert: '\\\\',
})
view.dispatch({ changes })
}
export const insertRow = (
view: EditorView,
selection: TableSelection,
positions: Positions,
below: boolean
) => {
// TODO: Handle borders
const { maxY, minY } = selection.normalized()
const from = below
? positions.rowPositions[maxY].to
: positions.rowPositions[minY].from
const numberOfColumns = positions.cells[maxY].length
const insert = `\n${' &'.repeat(numberOfColumns - 1)}\\\\`
view.dispatch({ changes: { from, to: from, insert } })
}
export const insertColumn = (
view: EditorView,
selection: TableSelection,
positions: Positions,
after: boolean
) => {
// TODO: Handle borders
// FIXME: Handle multicolumn
const { maxX, minX } = selection.normalized()
const changes: ChangeSpec[] = []
for (const row of positions.cells) {
const from = after ? row[maxX].to : row[minX].from
changes.push({
from,
to: from,
insert: ' &',
})
}
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(specification)
columnSpecification.splice(after ? maxX + 1 : minX, 0, {
alignment: 'left',
borderLeft: 0,
borderRight: 0,
content: 'l',
})
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: generateColumnSpecification(columnSpecification),
})
view.dispatch({ changes })
}

View file

@ -1,6 +1,6 @@
import { FC, memo, useRef } from 'react'
import useDropdown from '../../../../../shared/hooks/use-dropdown'
import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
import { ListGroup, Overlay, Popover } from 'react-bootstrap'
import Tooltip from '../../../../../shared/components/tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useTabularContext } from '../contexts/tabular-context'
@ -10,17 +10,24 @@ export const ToolbarButtonMenu: FC<{
label: string
icon: string
disabled?: boolean
}> = memo(function ButtonMenu({ icon, id, label, children, disabled }) {
disabledLabel?: string
}> = memo(function ButtonMenu({
icon,
id,
label,
children,
disabled,
disabledLabel,
}) {
const target = useRef<any>(null)
const { open, onToggle, ref } = useDropdown()
const { ref: tableContainerRef } = useTabularContext()
const button = (
<Button
<button
type="button"
className="table-generator-toolbar-button table-generator-toolbar-button-menu"
aria-label={label}
bsStyle={null}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
@ -29,11 +36,12 @@ export const ToolbarButtonMenu: FC<{
onToggle(!open)
}}
disabled={disabled}
aria-disabled={disabled}
ref={target}
>
<MaterialIcon type={icon} />
<MaterialIcon type="expand_more" />
</Button>
</button>
)
const overlay = tableContainerRef.current && (
@ -63,21 +71,14 @@ export const ToolbarButtonMenu: FC<{
</Overlay>
)
if (!label) {
return (
<>
{button}
{overlay}
</>
)
}
return (
<>
<Tooltip
hidden={open}
id={id}
description={<div>{label}</div>}
description={
<div>{disabled && disabledLabel ? disabledLabel : label}</div>
}
overlayProps={{ placement: 'bottom' }}
>
{button}

View file

@ -1,7 +1,6 @@
import { EditorView } from '@codemirror/view'
import classNames from 'classnames'
import { memo, useCallback } from 'react'
import { Button } from 'react-bootstrap'
import Tooltip from '../../../../../shared/components/tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
@ -13,9 +12,9 @@ export const ToolbarButton = memo<{
command?: (view: EditorView) => void
active?: boolean
disabled?: boolean
disabledLabel?: string
icon: string
hidden?: boolean
shortcut?: string
}>(function ToolbarButton({
id,
className,
@ -25,7 +24,7 @@ export const ToolbarButton = memo<{
disabled,
icon,
hidden = false,
shortcut,
disabledLabel,
}) {
const view = useCodeMirrorViewContext()
const handleMouseDown = useCallback(event => {
@ -44,32 +43,24 @@ export const ToolbarButton = memo<{
)
const button = (
<Button
<button
className={classNames('table-generator-toolbar-button', className, {
hidden,
active,
})}
aria-label={label}
onMouseDown={handleMouseDown}
onClick={!disabled ? handleClick : undefined}
bsStyle={null}
active={active}
disabled={disabled}
aria-disabled={disabled}
type="button"
>
<MaterialIcon type={icon} />
</Button>
</button>
)
if (!label) {
return button
}
const description = (
<>
<div>{label}</div>
{shortcut && <div>{shortcut}</div>}
</>
)
const description =
disabled && disabledLabel ? <div>{disabledLabel}</div> : <div>{label}</div>
return (
<Tooltip

View file

@ -12,6 +12,7 @@ export const ToolbarDropdown: FC<{
icon?: string
tooltip?: string
disabled?: boolean
disabledTooltip?: string
}> = ({
id,
label,
@ -20,6 +21,7 @@ export const ToolbarDropdown: FC<{
icon = 'expand_more',
tooltip,
disabled,
disabledTooltip,
}) => {
const { open, onToggle } = useDropdown()
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
@ -81,8 +83,8 @@ export const ToolbarDropdown: FC<{
return (
<>
<Tooltip
id={`${id}-tooltip`}
description={tooltip}
id={id}
description={disabled && disabledTooltip ? disabledTooltip : tooltip}
overlayProps={{ placement: 'bottom' }}
>
{button}

View file

@ -4,14 +4,21 @@ import { ToolbarButton } from './toolbar-button'
import { ToolbarButtonMenu } from './toolbar-button-menu'
import { ToolbarDropdown } from './toolbar-dropdown'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { BorderTheme, setAlignment, setBorders } from './commands'
import {
BorderTheme,
insertColumn,
insertRow,
removeRowOrColumns,
setAlignment,
setBorders,
} from './commands'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTableContext } from '../contexts/table-context'
export const Toolbar = memo(function Toolbar() {
const { selection } = useSelectionContext()
const view = useCodeMirrorViewContext()
const { positions, rowSeparators } = useTableContext()
const { positions, rowSeparators, cellSeparators } = useTableContext()
if (!selection) {
return null
}
@ -87,6 +94,7 @@ export const Toolbar = memo(function Toolbar() {
label="Alignment"
icon="format_align_left"
id="table-generator-align-dropdown"
disabledLabel="Select a column or a merged cell to align"
disabled={
!selection.isColumnSelected(
selection.from.cell,
@ -124,24 +132,37 @@ export const Toolbar = memo(function Toolbar() {
id="table-generator-merge-cells"
label="Merge cells"
disabled
disabledLabel="Select cells in a row to merge"
/>
<ToolbarButton
icon="delete"
id="table-generator-remove-column-row"
label="Remove row or column"
disabled
label="Delete row or column"
disabledLabel="Select a row or a column to delete"
disabled={
!(
positions.cells.length &&
selection.isAnyRowSelected(positions.cells[0].length)
) && !selection.isAnyColumnSelected(positions.rowPositions.length)
}
command={() =>
removeRowOrColumns(view, selection, positions, cellSeparators)
}
/>
<ToolbarDropdown
id="table-generator-add-dropdown"
btnClassName="table-generator-toolbar-button"
icon="add"
tooltip="Insert"
disabled
disabled={!selection}
>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
onClick={() => {
insertColumn(view, selection, positions, false)
}}
>
<span className="table-generator-button-label">
Insert column left
@ -151,6 +172,9 @@ export const Toolbar = memo(function Toolbar() {
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
onClick={() => {
insertColumn(view, selection, positions, true)
}}
>
<span className="table-generator-button-label">
Insert column right
@ -161,6 +185,9 @@ export const Toolbar = memo(function Toolbar() {
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
onClick={() => {
insertRow(view, selection, positions, false)
}}
>
<span className="table-generator-button-label">
Insert row above
@ -170,6 +197,9 @@ export const Toolbar = memo(function Toolbar() {
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
onClick={() => {
insertRow(view, selection, positions, true)
}}
>
<span className="table-generator-button-label">
Insert row below
@ -181,7 +211,7 @@ export const Toolbar = memo(function Toolbar() {
<ToolbarButton
icon="delete_forever"
id="table-generator-remove-table"
label="Remove table"
label="Delete table"
disabled
/>
</div>

View file

@ -99,7 +99,7 @@ type ParsedCell = {
position: Position
}
type CellSeparator = Position
export type CellSeparator = Position
export type RowSeparator = Position
type ParsedRow = {
@ -233,6 +233,7 @@ export function generateTable(
specification: { from: number; to: number }
rowPositions: RowPosition[]
rowSeparators: RowSeparator[]
cellSeparators: CellSeparator[][]
} {
const specification = node
.getChild('BeginEnv')
@ -253,6 +254,7 @@ export function generateTable(
const cellPositions = tableData.rows.map(row =>
row.cells.map(cell => cell.position)
)
const cellSeparators = tableData.rows.map(row => row.cellSeparators)
const rowPositions = tableData.rows.map(row => ({
...row.position,
hlines: row.hlines.map(hline => hline.position),
@ -274,5 +276,6 @@ export function generateTable(
specification,
rowPositions,
rowSeparators: tableData.rowSeparators,
cellSeparators,
}
}