Merge pull request #25548 from overleaf/mj-add-booktabs

[web] Add support for booktabs table style

GitOrigin-RevId: e3f7e1a867474a86e4b5f8c701d845d55592bb68
This commit is contained in:
Mathias Jakobsen
2025-05-19 11:46:52 +01:00
committed by Copybot
parent 6269901473
commit e71e91ae99
6 changed files with 311 additions and 120 deletions

View File

@@ -183,6 +183,7 @@
"blocked_filename": "",
"blog": "",
"bold": "",
"booktabs": "",
"browser": "",
"bullet_list": "",
"buy_licenses": "",
@@ -1035,7 +1036,6 @@
"more_editor_toolbar_item": "",
"more_info": "",
"more_options": "",
"more_options_for_border_settings_coming_soon": "",
"my_library": "",
"n_items": "",
"n_items_plural": "",

View File

@@ -139,6 +139,33 @@ export class TableData {
if (this.rows.length === 0 || this.columns.length === 0) {
return null
}
const hasTopRule = this.rows[0].borderTop === 1
const hasMidRule =
this.rows[1]?.borderTop === 1 &&
(this.rows.length === 2 || this.rows[1]?.borderBottom === 0)
const hasBottomRule =
this.rows[this.rows.length - 1].borderBottom === 1 &&
(this.rows.length === 2 ||
this.rows[this.rows.length - 1].borderTop === 0)
if (hasTopRule && hasMidRule && hasBottomRule) {
let isBooktabs = true
// Check for no other borders
for (const row of this.rows.slice(2, -1)) {
if (row.borderTop > 0 || row.borderBottom > 0) {
isBooktabs = false
}
}
for (const column of this.columns) {
if (column.borderLeft > 0 || column.borderRight > 0) {
isBooktabs = false
}
}
if (isBooktabs) {
return BorderTheme.BOOKTABS
}
}
const lastRow = this.rows[this.rows.length - 1]
const hasBottomBorder = lastRow.borderBottom > 0
const firstColumn = this.columns[0]
@@ -180,8 +207,10 @@ export class TableData {
if (hasAllRowBorders && hasAllColumnBorders) {
return BorderTheme.FULLY_BORDERED
} else {
} else if (!hasAllRowBorders && !hasAllColumnBorders) {
return BorderTheme.NO_BORDERS
} else {
return null
}
}
}

View File

@@ -20,8 +20,224 @@ import { WidthSelection } from './column-width-modal/column-width'
export enum BorderTheme {
NO_BORDERS = 0,
FULLY_BORDERED = 1,
BOOKTABS = 2,
}
/* eslint-enable no-unused-vars */
type ThemeGenerator = {
column: (
number: number,
numColumns: number
) => { left: boolean; right: boolean }
row: (number: number, numRows: number) => string | false
lastRow?: () => string | false
multicolumn: () => { left: boolean; right: boolean }
}
const themeGenerators: Record<BorderTheme, ThemeGenerator> = {
[BorderTheme.NO_BORDERS]: {
column: () => ({ left: false, right: false }),
row: () => false,
multicolumn: () => ({ left: false, right: false }),
},
[BorderTheme.FULLY_BORDERED]: {
column: (number: number, numColumns: number) => ({
left: true,
right: number === numColumns - 1,
}),
row: (number: number, numRows: number) => '\\hline',
multicolumn: () => ({ left: true, right: true }),
lastRow: () => '\\hline',
},
[BorderTheme.BOOKTABS]: {
column: (number: number, numColumns: number) => ({
left: false,
right: false,
}),
row: (number: number, numRows: number) => {
if (number === 0) {
return '\\toprule'
}
if (number === 1) {
return '\\midrule'
}
return false
},
lastRow: () => '\\bottomrule',
multicolumn: () => ({ left: false, right: false }),
},
}
function applyBorderTheme(
generator: ThemeGenerator,
view: EditorView,
table: TableData,
positions: Positions,
rowSeparators: RowSeparator[]
): ChangeSpec[] {
const changes: ChangeSpec[] = []
// Update specification
const spec = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
const columnSpecification = parseColumnSpecifications(spec)
columnSpecification.forEach((column, index) => {
const { left, right } = generator.column(index, columnSpecification.length)
column.borderLeft = left ? 1 : 0
column.borderRight = right ? 1 : 0
})
const newSpec = generateColumnSpecification(columnSpecification)
if (newSpec !== spec) {
changes.push({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpec,
})
}
for (let i = 0; i < positions.rowPositions.length; i++) {
const row = positions.rowPositions[i]
const topBorder = generator.row(i, positions.rowPositions.length)
if (topBorder) {
let borderPresent = false
for (const hline of row.hlines) {
if (hline.from > rowSeparators[i]?.to) {
continue
}
if (borderPresent) {
// Remove extra border
changes.push({
from: hline.from,
to: hline.to,
insert: '',
})
} else {
const type = view.state.sliceDoc(hline.from, hline.to)
if (type.trim() !== topBorder) {
// Replace with the correct border
changes.push({
from: hline.from,
to: hline.to,
insert: topBorder,
})
}
borderPresent = true
}
}
if (!borderPresent) {
// Add the border
changes.push({
from: row.from,
to: row.from,
insert: topBorder,
})
}
} else {
for (const hline of row.hlines) {
if (hline.from > rowSeparators[i]?.to) {
continue
}
changes.push({
from: hline.from,
to: hline.to,
insert: '',
})
}
}
}
const lastRow = positions.rowPositions[positions.rowPositions.length - 1]
const lastRowBorder = generator.lastRow?.()
const hasLastRowSeparator =
positions.rowPositions.length === rowSeparators.length
if (hasLastRowSeparator) {
if (lastRowBorder) {
let borderPresent = false
for (const hline of lastRow.hlines) {
if (hline.from < rowSeparators[positions.rowPositions.length - 1].to) {
continue
}
if (borderPresent) {
// Remove extra border
changes.push({
from: hline.from,
to: hline.to,
insert: '',
})
} else {
const type = view.state.sliceDoc(hline.from, hline.to)
if (type.trim() !== lastRowBorder) {
// Replace with the correct border
changes.push({
from: hline.from,
to: hline.to,
insert: lastRowBorder,
})
}
borderPresent = true
}
}
if (!borderPresent) {
const rowSeparator = rowSeparators[positions.rowPositions.length - 1]
// Add the border
changes.push({
from: rowSeparator.to,
to: rowSeparator.to,
insert: ` ${lastRowBorder}`,
})
}
} else {
for (const hline of lastRow.hlines) {
if (hline.from < rowSeparators[positions.rowPositions.length - 1].to) {
continue
}
changes.push({
from: hline.from,
to: hline.to,
insert: '',
})
}
}
} else if (lastRowBorder) {
changes.push({
from: lastRow.to,
to: lastRow.to,
insert: `\\\\ ${lastRowBorder}`,
})
}
// Update multicolumn
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
const { left, right } = generator.multicolumn()
const spec = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
const columnSpecification = parseColumnSpecifications(spec)
columnSpecification.forEach(column => {
column.borderLeft = left ? 1 : 0
column.borderRight = right ? 1 : 0
})
const newSpec = generateColumnSpecification(columnSpecification)
if (newSpec !== spec) {
changes.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: newSpec,
})
}
}
}
}
return changes
}
export const setBorders = (
view: EditorView,
theme: BorderTheme,
@@ -29,118 +245,18 @@ export const setBorders = (
rowSeparators: RowSeparator[],
table: TableData
) => {
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
const generator = themeGenerators[theme]
const changes = applyBorderTheme(
generator,
view,
table,
positions,
rowSeparators
)
if (theme === BorderTheme.NO_BORDERS) {
const removeColumnBorders = view.state.changes({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: specification.replace(/\|/g, ''),
})
const removeHlines: ChangeSpec[] = []
for (const row of positions.rowPositions) {
for (const hline of row.hlines) {
removeHlines.push({
from: hline.from,
to: hline.to,
insert: '',
})
}
}
const removeMulticolumnBorders: ChangeSpec[] = []
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
const specification = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
removeMulticolumnBorders.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: specification.replace(/\|/g, ''),
})
}
}
}
view.dispatch({
changes: [
removeColumnBorders,
...removeHlines,
...removeMulticolumnBorders,
],
})
} else if (theme === BorderTheme.FULLY_BORDERED) {
const newSpec = generateColumnSpecification(
addColumnBordersToSpecification(table.columns)
)
const insertColumns = view.state.changes({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpec,
})
const insertHlines: ChangeSpec[] = []
for (const row of positions.rowPositions) {
if (row.hlines.length === 0) {
insertHlines.push(
view.state.changes({
from: row.from,
to: row.from,
insert: ' \\hline ',
})
)
}
}
const lastRow = positions.rowPositions[positions.rowPositions.length - 1]
if (lastRow.hlines.length < 2) {
let toInsert = ' \\hline'
if (rowSeparators.length < positions.rowPositions.length) {
// We need a trailing \\
toInsert = ` \\\\${toInsert}`
}
insertHlines.push(
view.state.changes({
from: lastRow.to,
to: lastRow.to,
insert: toInsert,
})
)
}
const addMulticolumnBorders: ChangeSpec[] = []
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
addMulticolumnBorders.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: generateColumnSpecification(
addColumnBordersToSpecification(
cell.multiColumn.columns.specification
)
),
})
}
}
}
view.dispatch({
changes: [insertColumns, ...insertHlines, ...addMulticolumnBorders],
})
}
}
const addColumnBordersToSpecification = (specification: ColumnDefinition[]) => {
const newSpec = specification.map(column => ({
...column,
borderLeft: 1,
borderRight: 0,
}))
newSpec[newSpec.length - 1].borderRight = 1
return newSpec
view.dispatch({
changes,
})
}
export const setAlignment = (

View File

@@ -3,7 +3,6 @@ import { useSelectionContext } from '../contexts/selection-context'
import { ToolbarButton } from './toolbar-button'
import { ToolbarButtonMenu } from './toolbar-button-menu'
import { ToolbarDropdown, ToolbarDropdownItem } from './toolbar-dropdown'
import MaterialIcon from '../../../../../shared/components/material-icon'
import {
BorderTheme,
insertColumn,
@@ -47,6 +46,8 @@ export const Toolbar = memo(function Toolbar() {
return t('all_borders')
case BorderTheme.NO_BORDERS:
return t('no_borders')
case BorderTheme.BOOKTABS:
return t('booktabs')
default:
return t('custom_borders')
}
@@ -203,12 +204,22 @@ export const Toolbar = memo(function Toolbar() {
>
{t('no_borders')}
</ToolbarDropdownItem>
<div className="table-generator-border-options-coming-soon">
<div className="info-icon">
<MaterialIcon type="info" />
</div>
{t('more_options_for_border_settings_coming_soon')}
</div>
<ToolbarDropdownItem
id="table-generator-borders-booktabs"
command={() => {
setBorders(
view,
BorderTheme.BOOKTABS,
positions,
rowSeparators,
table
)
}}
active={table.getBorderTheme() === BorderTheme.BOOKTABS}
icon="border_top"
>
{t('booktabs')}
</ToolbarDropdownItem>
</ToolbarDropdown>
</div>
<div className="table-generator-button-group">

View File

@@ -237,6 +237,7 @@
"blocked_filename": "This file name is blocked.",
"blog": "Blog",
"bold": "Bold",
"booktabs": "Booktabs",
"brl_discount_offer_plans_page_banner": "__flag__ <b>Great news!</b> Weve applied a 50% discount to premium plans on this page for our users in Brazil. Check out the new lower prices.",
"browser": "Browser",
"built_in": "Built-In",
@@ -1353,7 +1354,6 @@
"more_editor_toolbar_item": "More editor toolbar items",
"more_info": "More Info",
"more_options": "More options",
"more_options_for_border_settings_coming_soon": "More options for border settings coming soon.",
"more_project_collaborators": "<0>More</0> project <0>collaborators</0>",
"more_than_one_kind_of_snippet_was_requested": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.",
"most_popular_uppercase": "Most popular",

View File

@@ -217,6 +217,41 @@ cell 3 & cell 4 \\\\
)
})
it('Correctly sets borders for booktabs', function () {
// Add a blank line above the table to allow room for the table toolbar
mountEditor(`
\\begin{tabular}{c|c}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
cell 5 & cell 6 \\\\
\\end{tabular}
`)
checkBordersWithNoMultiColumn(
[false, false, false, false],
[false, true, false]
)
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('Custom borders').click({ force: true })
cy.get('.table-generator').findByText('Booktabs').click()
// The element is partially covered, but we can still click it
cy.get('.cm-line').first().click({ force: true })
// Table should be unchanged
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
['cell 5', 'cell 6'],
])
checkBordersWithNoMultiColumn(
[true, true, false, true],
[false, false, false]
)
cy.get('.table-generator-cell').first().click()
cy.get('@toolbar').findByText('Booktabs').should('exist')
})
it('Changes the column alignment with dropdown buttons', function () {
// Add a blank line above the table to allow room for the table toolbar
mountEditor(`