Bladeren bron

Merge pull request #16048 from overleaf/mj-table-column-width

[visual] Allow setting column widths in table generator

GitOrigin-RevId: 3bd4bb05dd3b29d0bd62fbc41da45eda282fecf4
Mathias Jakobsen 2 jaren geleden
bovenliggende
commit
80a6424343
18 gewijzigde bestanden met toevoegingen van 906 en 44 verwijderingen
  1. 15 0
      services/web/frontend/extracted-translations.json
  2. 1 1
      services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
  3. 61 0
      services/web/frontend/js/features/source-editor/components/table-generator/column-size-indicator.tsx
  4. 36 0
      services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx
  5. 23 1
      services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx
  6. 40 5
      services/web/frontend/js/features/source-editor/components/table-generator/table.tsx
  7. 8 1
      services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx
  8. 45 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/column-width.ts
  9. 211 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx
  10. 152 4
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts
  11. 13 3
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx
  12. 105 16
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx
  13. 72 6
      services/web/frontend/js/features/source-editor/components/table-generator/utils.ts
  14. 93 5
      services/web/frontend/js/features/source-editor/extensions/visual/table-generator.ts
  15. 1 0
      services/web/frontend/stylesheets/app/editor.less
  16. 13 0
      services/web/frontend/stylesheets/app/editor/table-generator-column-width-modal.less
  17. 15 0
      services/web/locales/en.json
  18. 2 2
      services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx

+ 15 - 0
services/web/frontend/extracted-translations.json

@@ -54,6 +54,7 @@
   "additional_licenses": "",
   "address_line_1": "",
   "address_second_line_optional": "",
+  "adjust_column_width": "",
   "aggregate_changed": "",
   "aggregate_to": "",
   "agree_with_the_terms": "",
@@ -164,6 +165,9 @@
   "collabs_per_proj": "",
   "collabs_per_proj_single": "",
   "collapse": "",
+  "column_width": "",
+  "column_width_is_custom_click_to_resize": "",
+  "column_width_is_x_click_to_resize": "",
   "comment": "",
   "comment_submit_error": "",
   "commit": "",
@@ -225,6 +229,7 @@
   "currently_seeing_only_24_hrs_history": "",
   "currently_signed_in_as_x": "",
   "currently_subscribed_to_plan": "",
+  "custom": "",
   "custom_borders": "",
   "customize_your_group_subscription": "",
   "customizing_figures": "",
@@ -344,6 +349,7 @@
   "enabling": "",
   "end_of_document": "",
   "enter_6_digit_code": "",
+  "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
   "entry_point": "",
   "error": "",
@@ -393,6 +399,8 @@
   "fit_to_height": "",
   "fit_to_width": "",
   "fix_issues": "",
+  "fixed_width": "",
+  "fixed_width_wrap_text": "",
   "fold_line": "",
   "folder_location": "",
   "following_paths_conflict": "",
@@ -600,6 +608,7 @@
   "join_project": "",
   "join_team_explanation": "",
   "joining": "",
+  "justify": "",
   "keep_current_plan": "",
   "keep_personal_projects_separate": "",
   "keybindings": "",
@@ -639,6 +648,7 @@
   "license_for_educational_purposes": "",
   "limited_offer": "",
   "line_height": "",
+  "line_width_is_the_width_of_the_line_in_the_current_environment": "",
   "link": "",
   "link_account": "",
   "link_accounts": "",
@@ -854,6 +864,7 @@
   "pending_additional_licenses": "",
   "pending_invite": "",
   "percent_discount_for_groups": "",
+  "percent_is_the_percentage_of_the_line_width": "",
   "plan": "",
   "plan_tooltip": "",
   "please_ask_the_project_owner_to_upgrade_to_track_changes": "",
@@ -1065,6 +1076,7 @@
   "security": "",
   "see_changes_in_your_documents_live": "",
   "select_a_column_or_a_merged_cell_to_align": "",
+  "select_a_column_to_adjust_column_width": "",
   "select_a_file": "",
   "select_a_file_figure_modal": "",
   "select_a_group_optional": "",
@@ -1104,6 +1116,7 @@
   "session_expired_redirecting_to_login": "",
   "sessions": "",
   "set_color": "",
+  "set_column_width": "",
   "set_up_sso": "",
   "settings": "",
   "setup_another_account_under_a_personal_email_address": "",
@@ -1195,6 +1208,7 @@
   "stop_on_first_error_enabled_title": "",
   "stop_on_validation_error": "",
   "store_your_work": "",
+  "stretch_width_to_text": "",
   "student_disclaimer": "",
   "subject": "",
   "subject_area": "",
@@ -1278,6 +1292,7 @@
   "to_confirm_transfer_enter_email_address": "",
   "to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "",
   "to_modify_your_subscription_go_to": "",
+  "to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "",
   "toggle_compile_options_menu": "",
   "token": "",
   "token_limit_reached": "",

+ 1 - 1
services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx

@@ -121,7 +121,7 @@ const Toolbar = memo(function Toolbar() {
 
   // calculate overflow when active element changes to/from inside a table
   const insideTable = document.activeElement?.closest(
-    '.table-generator-help-modal,.table-generator'
+    '.table-generator-help-modal,.table-generator,.table-generator-width-modal'
   )
   useEffect(() => {
     if (resizeRef.current) {

+ 61 - 0
services/web/frontend/js/features/source-editor/components/table-generator/column-size-indicator.tsx

@@ -0,0 +1,61 @@
+import MaterialIcon from '@/shared/components/material-icon'
+import { WidthSelection } from './toolbar/column-width-modal/column-width'
+import { useMemo } from 'react'
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import Tooltip from '@/shared/components/tooltip'
+import { useSelectionContext } from './contexts/selection-context'
+
+function roundIfNeeded(width: number) {
+  return width.toFixed(2).replace(/\.0+$/, '')
+}
+
+export const ColumnSizeIndicator = ({
+  size,
+  onClick,
+}: {
+  size: WidthSelection
+  onClick: () => void
+}) => {
+  const { t } = useTranslation()
+  const { selection } = useSelectionContext()
+  const { unit, width } = size
+  const formattedWidth = useMemo(() => {
+    if (unit === 'custom') {
+      return width
+    }
+    return `${roundIfNeeded(width)}${unit}`
+  }, [unit, width])
+
+  if (!selection) {
+    return null
+  }
+
+  return (
+    <Tooltip
+      id="tooltip-column-width-button"
+      description={
+        unit === 'custom'
+          ? t('column_width_is_custom_click_to_resize')
+          : t('column_width_is_x_click_to_resize', {
+              width: formattedWidth,
+            })
+      }
+      overlayProps={{ delay: 0, placement: 'bottom' }}
+    >
+      <Button
+        bsStyle={null}
+        className="table-generator-column-indicator-button"
+        onClick={onClick}
+      >
+        <MaterialIcon
+          type="format_text_wrap"
+          className="table-generator-column-indicator-icon"
+        />
+        <span className="table-generator-column-indicator-label">
+          {formattedWidth}
+        </span>
+      </Button>
+    </Tooltip>
+  )
+}

+ 36 - 0
services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx

@@ -331,6 +331,42 @@ export class TableSelection {
     return true
   }
 
+  isOnlyFixedWidthColumns(table: TableData) {
+    const { minX, maxX } = this.normalized()
+    for (let cell = minX; cell <= maxX; ++cell) {
+      if (!this.isColumnSelected(cell, table)) {
+        return false
+      }
+      if (!table.columns[cell].isParagraphColumn) {
+        return false
+      }
+    }
+    return true
+  }
+
+  isOnlyParagraphCells(table: TableData) {
+    const { minX, maxX } = this.normalized()
+    for (let cell = minX; cell <= maxX; ++cell) {
+      if (!table.columns[cell].isParagraphColumn) {
+        return false
+      }
+    }
+    return true
+  }
+
+  isOnlyNonFixedWidthColumns(table: TableData) {
+    const { minX, maxX } = this.normalized()
+    for (let cell = minX; cell <= maxX; ++cell) {
+      if (!this.isColumnSelected(cell, table)) {
+        return false
+      }
+      if (table.columns[cell].isParagraphColumn) {
+        return false
+      }
+    }
+    return true
+  }
+
   width() {
     const { minX, maxX } = this.normalized()
     return maxX - minX + 1

+ 23 - 1
services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx

@@ -14,6 +14,9 @@ const TabularContext = createContext<
       showHelp: () => void
       hideHelp: () => void
       helpShown: boolean
+      columnWidthModalShown: boolean
+      openColumnWidthModal: () => void
+      closeColumnWidthModal: () => void
     }
   | undefined
 >(undefined)
@@ -21,10 +24,29 @@ const TabularContext = createContext<
 export const TabularProvider: FC = ({ children }) => {
   const ref = useRef<HTMLDivElement>(null)
   const [helpShown, setHelpShown] = useState(false)
+  const [columnWidthModalShown, setColumnWidthModalShown] = useState(false)
   const showHelp = useCallback(() => setHelpShown(true), [])
   const hideHelp = useCallback(() => setHelpShown(false), [])
+  const openColumnWidthModal = useCallback(
+    () => setColumnWidthModalShown(true),
+    []
+  )
+  const closeColumnWidthModal = useCallback(
+    () => setColumnWidthModalShown(false),
+    []
+  )
   return (
-    <TabularContext.Provider value={{ ref, helpShown, showHelp, hideHelp }}>
+    <TabularContext.Provider
+      value={{
+        ref,
+        helpShown,
+        showHelp,
+        hideHelp,
+        columnWidthModalShown,
+        openColumnWidthModal,
+        closeColumnWidthModal,
+      }}
+    >
       {children}
     </TabularContext.Provider>
   )

+ 40 - 5
services/web/frontend/js/features/source-editor/components/table-generator/table.tsx

@@ -19,6 +19,8 @@ import { useCodeMirrorViewContext } from '../codemirror-editor'
 import { undo, redo } from '@codemirror/commands'
 import { ChangeSpec } from '@codemirror/state'
 import { startCompileKeypress } from '@/features/pdf-preview/hooks/use-compile-triggers'
+import { useTabularContext } from './contexts/tabular-context'
+import { ColumnSizeIndicator } from './column-size-indicator'
 
 type NavigationKey =
   | 'ArrowRight'
@@ -52,6 +54,7 @@ export const Table: FC = () => {
     updateCellData,
   } = useEditingContext()
   const { table: tableData } = useTableContext()
+  const { openColumnWidthModal, columnWidthModalShown } = useTabularContext()
   const tableRef = useRef<HTMLTableElement>(null)
   const view = useCodeMirrorViewContext()
   const { columns: cellWidths, tableWidth } = useMemo(() => {
@@ -276,8 +279,9 @@ export const Table: FC = () => {
       if (view.state.readOnly) {
         return false
       }
-      if (cellData || !selection) {
-        // We're editing a cell, so allow browser to insert there
+      if (cellData || !selection || columnWidthModalShown) {
+        // We're editing a cell, or modifying column widths,
+        // so allow browser to insert there
         return false
       }
       event.preventDefault()
@@ -312,8 +316,9 @@ export const Table: FC = () => {
     }
 
     const onCopy = (event: ClipboardEvent) => {
-      if (cellData || !selection) {
-        // We're editing a cell, so allow browser to insert there
+      if (cellData || !selection || columnWidthModalShown) {
+        // We're editing a cell, or modifying column widths,
+        // so allow browser to copy from there
         return false
       }
       event.preventDefault()
@@ -334,7 +339,22 @@ export const Table: FC = () => {
       window.removeEventListener('paste', onPaste)
       window.removeEventListener('copy', onCopy)
     }
-  }, [cellData, selection, tableData, view])
+  }, [cellData, selection, tableData, view, columnWidthModalShown])
+
+  const hasCustomSizes = useMemo(
+    () => tableData.columns.some(x => x.size),
+    [tableData.columns]
+  )
+
+  const onSizeClick = (index: number) => {
+    setSelection(
+      new TableSelection(
+        { row: 0, cell: index },
+        { row: tableData.rows.length - 1, cell: index }
+      )
+    )
+    openColumnWidthModal()
+  }
 
   return (
     // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
@@ -352,6 +372,21 @@ export const Table: FC = () => {
         ))}
       </colgroup>
       <thead>
+        {hasCustomSizes && (
+          <tr className="table-generator-column-widths-row">
+            <td />
+            {tableData.columns.map((column, columnIndex) => (
+              <td align="center" key={columnIndex}>
+                {column.size && (
+                  <ColumnSizeIndicator
+                    size={column.size}
+                    onClick={() => onSizeClick(columnIndex)}
+                  />
+                )}
+              </td>
+            ))}
+          </tr>
+        )}
         <tr>
           <td />
           {tableData.columns.map((_, columnIndex) => (

+ 8 - 1
services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx

@@ -27,6 +27,8 @@ import { BorderTheme } from './toolbar/commands'
 import { TableGeneratorHelpModal } from './help-modal'
 import { SplitTestProvider } from '../../../../shared/context/split-test-context'
 import { useTranslation } from 'react-i18next'
+import { ColumnWidthModal } from './toolbar/column-width-modal/modal'
+import { WidthSelection } from './toolbar/column-width-modal/column-width'
 
 export type ColumnDefinition = {
   alignment: 'left' | 'center' | 'right' | 'paragraph'
@@ -35,6 +37,9 @@ export type ColumnDefinition = {
   content: string
   cellSpacingLeft: string
   cellSpacingRight: string
+  customCellDefinition: string
+  isParagraphColumn: boolean
+  size?: WidthSelection
 }
 
 export type CellData = {
@@ -252,6 +257,7 @@ export const Tabular: FC<{
                 <EditingContextProvider>
                   <TabularWrapper />
                 </EditingContextProvider>
+                <ColumnWidthModal />
               </SelectionContextProvider>
             </TableProvider>
             <TableGeneratorHelpModal />
@@ -271,7 +277,8 @@ const TabularWrapper: FC = () => {
     const listener: (event: MouseEvent) => void = event => {
       if (
         !ref.current?.contains(event.target as Node) &&
-        !(event.target as HTMLElement).closest('.table-generator-help-modal')
+        !(event.target as HTMLElement).closest('.table-generator-help-modal') &&
+        !(event.target as HTMLElement).closest('.table-generator-width-modal')
       ) {
         if (selection) {
           setSelection(null)

+ 45 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/column-width.ts

@@ -0,0 +1,45 @@
+export const ABSOLUTE_SIZE_REGEX = /[pbm]\{\s*(\d*[.]?\d+)\s*(mm|cm|pt|in)\s*\}/
+
+export const RELATIVE_SIZE_REGEX =
+  /[pbm]\{\s*(\d*[.]?\d+)\s*\\(linewidth|textwidth|columnwidth)\s*\}/
+
+export type AbsoluteWidthUnits = 'mm' | 'cm' | 'in' | 'pt'
+export type RelativeWidthCommand = 'linewidth' | 'textwidth' | 'columnwidth'
+export type WidthUnit = AbsoluteWidthUnits | '%' | 'custom'
+
+const ABSOLUTE_UNITS = ['mm', 'cm', 'in', 'pt'] as const
+export const UNITS: WidthUnit[] = ['%', ...ABSOLUTE_UNITS, 'custom']
+
+type PercentageWidth = {
+  unit: '%'
+  width: number
+  command?: RelativeWidthCommand
+}
+
+type CustomWidth = {
+  unit: 'custom'
+  width: string
+}
+
+type AbsoluteWidth = {
+  unit: Exclude<WidthUnit, '%' | 'custom'>
+  width: number
+}
+
+export type WidthSelection = PercentageWidth | CustomWidth | AbsoluteWidth
+
+export const isPercentageWidth = (
+  width: WidthSelection
+): width is PercentageWidth => {
+  return width.unit === '%'
+}
+
+export const isAbsoluteWidth = (
+  width: WidthSelection
+): width is AbsoluteWidth => {
+  return (ABSOLUTE_UNITS as readonly string[]).includes(width.unit)
+}
+
+export const isCustomWidth = (width: WidthSelection): width is CustomWidth => {
+  return width.unit === 'custom'
+}

+ 211 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx

@@ -0,0 +1,211 @@
+import AccessibleModal from '@/shared/components/accessible-modal'
+import { Button, Modal, Form, FormGroup } from 'react-bootstrap'
+import { useTabularContext } from '../../contexts/tabular-context'
+import { Select } from '@/shared/components/select'
+import { Trans, useTranslation } from 'react-i18next'
+import {
+  FormEventHandler,
+  memo,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import { useSelectionContext } from '../../contexts/selection-context'
+import { useTableContext } from '../../contexts/table-context'
+import { setColumnWidth } from '../commands'
+import { UNITS, WidthSelection, WidthUnit } from './column-width'
+import { useCodeMirrorViewContext } from '../../../codemirror-editor'
+import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
+import Tooltip from '@/shared/components/tooltip'
+import Icon from '@/shared/components/icon'
+
+type UnitDescription = { label: string; tooltip?: string } | undefined
+
+export const ColumnWidthModal = memo(function ColumnWidthModal() {
+  const { columnWidthModalShown } = useTabularContext()
+  if (!columnWidthModalShown) {
+    return null
+  }
+  return <ColumnWidthModalBody />
+})
+
+const ColumnWidthModalBody = () => {
+  const { columnWidthModalShown, closeColumnWidthModal } = useTabularContext()
+  const view = useCodeMirrorViewContext()
+  const { selection } = useSelectionContext()
+  const { positions, table } = useTableContext()
+  const { t } = useTranslation()
+  const [currentUnit, setCurrentUnit] = useState<WidthUnit | undefined | null>(
+    '%'
+  )
+  const [currentWidth, setCurrentWidth] = useState<string>('')
+  const inputRef = useRef<HTMLInputElement>(null)
+
+  const unitHelp: UnitDescription = useMemo(() => {
+    switch (currentUnit) {
+      case '%':
+        return {
+          label: t('percent_is_the_percentage_of_the_line_width'),
+          tooltip: t(
+            'line_width_is_the_width_of_the_line_in_the_current_environment'
+          ),
+        }
+      case 'custom':
+        return {
+          label: t('enter_any_size_including_units_or_valid_latex_command'),
+        }
+      default:
+        return undefined
+    }
+  }, [currentUnit, t])
+
+  useEffect(() => {
+    if (columnWidthModalShown) {
+      inputRef.current?.focus()
+      if (
+        !selection ||
+        selection.width() !== 1 ||
+        !table.columns[selection.to.cell].isParagraphColumn ||
+        !table.columns[selection.to.cell].size
+      ) {
+        setCurrentUnit('%')
+        setCurrentWidth('')
+        return
+      }
+      const { to } = selection
+      const columnIndexToReadWidthAndUnit = to.cell
+      const column = table.columns[columnIndexToReadWidthAndUnit]
+      const size = column.size!
+      if (size.unit === '%') {
+        setCurrentUnit('%')
+        const widthWithUpToTwoDecimalPlaces = Math.round(size.width * 100) / 100
+        setCurrentWidth(widthWithUpToTwoDecimalPlaces.toString())
+      } else if (size.unit === 'custom') {
+        setCurrentUnit('custom')
+        // Slice off p{ and }
+        setCurrentWidth(column.content.slice(2, -1))
+      } else {
+        setCurrentUnit(size.unit)
+        setCurrentWidth(size.width.toString())
+      }
+    }
+  }, [columnWidthModalShown, selection, table])
+
+  const onSubmit: FormEventHandler<Form> = useCallback(
+    e => {
+      e.preventDefault()
+      if (selection && currentUnit) {
+        const currentWidthNumber = parseFloat(currentWidth)
+        let newWidth: WidthSelection
+        if (currentUnit === 'custom') {
+          newWidth = { unit: 'custom', width: currentWidth }
+        } else {
+          newWidth = { unit: currentUnit, width: currentWidthNumber }
+        }
+        setColumnWidth(view, selection, newWidth, positions, table)
+      }
+      closeColumnWidthModal()
+      return false
+    },
+    [
+      closeColumnWidthModal,
+      currentUnit,
+      currentWidth,
+      positions,
+      selection,
+      table,
+      view,
+    ]
+  )
+
+  return (
+    <AccessibleModal
+      show={columnWidthModalShown}
+      onHide={closeColumnWidthModal}
+      className="table-generator-width-modal"
+    >
+      <Form onSubmit={onSubmit}>
+        <Modal.Header closeButton>
+          <Modal.Title>{t('set_column_width')}</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <div className="clearfix">
+            <FormGroup className="col-md-8 p-0 mb-0">
+              <label
+                className="table-generator-width-label"
+                htmlFor="column-width-modal-width"
+              >
+                {t('column_width')}
+              </label>
+              <input
+                id="column-width-modal-width"
+                value={currentWidth}
+                required
+                onChange={e => setCurrentWidth(e.target.value)}
+                type={currentUnit === 'custom' ? 'text' : 'number'}
+                className="form-control"
+                ref={inputRef}
+              />
+            </FormGroup>
+            <FormGroup className="col-md-4 mb-0">
+              <Select
+                label="&nbsp;"
+                items={UNITS}
+                itemToKey={x => x ?? ''}
+                itemToString={x => (x === 'custom' ? t('custom') : x ?? '')}
+                onSelectedItemChanged={item => setCurrentUnit(item)}
+                defaultItem={currentUnit}
+              />
+            </FormGroup>
+          </div>
+          {unitHelp && (
+            <p className="my-1">
+              {unitHelp.label}{' '}
+              {unitHelp.tooltip && (
+                <Tooltip
+                  id="table-generator-unit-tooltip"
+                  description={unitHelp.tooltip}
+                  overlayProps={{ delay: 0, placement: 'top' }}
+                >
+                  <Icon type="question-circle" fw />
+                </Tooltip>
+              )}
+            </p>
+          )}
+          <div className="mt-2">
+            <Trans
+              i18nKey="to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package"
+              // eslint-disable-next-line react/jsx-key
+              components={[<b />, <code />]}
+            />
+          </div>
+          <div className="mt-1 table-generator-usepackage-copy">
+            <code>
+              \usepackage{'{'}array{'}'}
+            </code>
+            <CopyToClipboard
+              content="\\usepackage{array} % required for text wrapping in tables"
+              tooltipId="table-generator-array-copy"
+            />
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <Button
+            bsStyle={null}
+            className="btn-secondary"
+            onClick={() => {
+              closeColumnWidthModal()
+            }}
+          >
+            {t('cancel')}
+          </Button>
+          <Button bsStyle={null} className="btn-primary" type="submit">
+            {t('ok')}
+          </Button>
+        </Modal.Footer>
+      </Form>
+    </AccessibleModal>
+  )
+}

+ 152 - 4
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts

@@ -14,6 +14,7 @@ import {
   extendForwardsOverEmptyLines,
 } from '../../../extensions/visual/selection'
 import { debugConsole } from '@/utils/debugging'
+import { WidthSelection } from './column-width-modal/column-width'
 
 /* eslint-disable no-unused-vars */
 export enum BorderTheme {
@@ -145,11 +146,15 @@ const addColumnBordersToSpecification = (specification: ColumnDefinition[]) => {
 export const setAlignment = (
   view: EditorView,
   selection: TableSelection,
-  alignment: 'left' | 'right' | 'center',
+  alignment: ColumnDefinition['alignment'],
   positions: Positions,
   table: TableData
 ) => {
   if (selection.isMergedCellSelected(table)) {
+    if (alignment === 'paragraph') {
+      // shouldn't happen
+      return
+    }
     // change for mergedColumn
     const { minX, minY } = selection.normalized()
     const cell = table.getCell(minY, minX)
@@ -189,8 +194,16 @@ export const setAlignment = (
         continue
       }
       columnSpecification[i].alignment = alignment
-      // TODO: This won't work for paragraph, which needs width argument
-      columnSpecification[i].content = alignment[0]
+      if (columnSpecification[i].isParagraphColumn) {
+        columnSpecification[i].customCellDefinition =
+          generateParagraphColumnSpecification(alignment)
+      } else {
+        if (alignment === 'paragraph') {
+          // shouldn't happen
+          continue
+        }
+        columnSpecification[i].content = alignment[0]
+      }
     }
   }
   const newSpecification = generateColumnSpecification(columnSpecification)
@@ -214,10 +227,11 @@ const generateColumnSpecification = (columns: ColumnDefinition[]) => {
         content,
         cellSpacingLeft,
         cellSpacingRight,
+        customCellDefinition,
       }) =>
         `${'|'.repeat(
           borderLeft
-        )}${cellSpacingLeft}${content}${cellSpacingRight}${'|'.repeat(
+        )}${cellSpacingLeft}${customCellDefinition}${content}${cellSpacingRight}${'|'.repeat(
           borderRight
         )}`
     )
@@ -439,6 +453,8 @@ export const insertColumn = (
       content: 'l',
       cellSpacingLeft: '',
       cellSpacingRight: '',
+      customCellDefinition: '',
+      isParagraphColumn: false,
     }))
   )
   if (targetIndex === 0 && borderTheme === BorderTheme.FULLY_BORDERED) {
@@ -650,3 +666,135 @@ export const mergeCells = (
     },
   })
 }
+
+const getSuffixForUnit = (
+  unit: WidthSelection['unit'],
+  currentSize?: WidthSelection
+) => {
+  if (unit === 'custom') {
+    return ''
+  }
+  if (unit === '%') {
+    if (currentSize?.unit === '%' && currentSize.command) {
+      return `\\${currentSize.command}`
+    } else {
+      return '\\linewidth'
+    }
+  }
+  return unit
+}
+
+const COMMAND_FOR_PARAGRAPH_ALIGNMENT: Record<
+  ColumnDefinition['alignment'],
+  string
+> = {
+  left: '\\raggedright',
+  right: '\\raggedleft',
+  center: '\\centering',
+  paragraph: '',
+}
+
+const transformColumnWidth = (width: WidthSelection) => {
+  if (width.unit === '%') {
+    return width.width / 100
+  } else {
+    return width.width
+  }
+}
+
+const generateParagraphColumnSpecification = (
+  alignment: ColumnDefinition['alignment']
+) => {
+  if (alignment === 'paragraph') {
+    return ''
+  }
+  return `>{${COMMAND_FOR_PARAGRAPH_ALIGNMENT[alignment]}\\arraybackslash}`
+}
+
+function getParagraphAlignmentCharacter(
+  column: ColumnDefinition
+): 'p' | 'm' | 'b' {
+  if (!column.isParagraphColumn) {
+    return 'p'
+  }
+  const currentAlignmentCharacter = column.content[0]
+  if (currentAlignmentCharacter === 'm' || currentAlignmentCharacter === 'b') {
+    return currentAlignmentCharacter
+  }
+  return 'p'
+}
+
+export const setColumnWidth = (
+  view: EditorView,
+  selection: TableSelection,
+  newWidth: WidthSelection,
+  positions: Positions,
+  table: TableData
+) => {
+  const { minX, maxX } = selection.normalized()
+  const specification = view.state.sliceDoc(
+    positions.columnDeclarations.from,
+    positions.columnDeclarations.to
+  )
+  const columnSpecification = parseColumnSpecifications(specification)
+  for (let i = minX; i <= maxX; i++) {
+    if (selection.isColumnSelected(i, table)) {
+      const suffix = getSuffixForUnit(newWidth.unit, table.columns[i].size)
+      const widthValue = transformColumnWidth(newWidth)
+      columnSpecification[i].customCellDefinition =
+        generateParagraphColumnSpecification(columnSpecification[i].alignment)
+      // Reuse paragraph alignment characters to preserve m and b columns
+      const alignmentCharacter = getParagraphAlignmentCharacter(
+        columnSpecification[i]
+      )
+      columnSpecification[
+        i
+      ].content = `${alignmentCharacter}{${widthValue}${suffix}}`
+    }
+  }
+  const newSpecification = generateColumnSpecification(columnSpecification)
+  view.dispatch({
+    changes: [
+      {
+        from: positions.columnDeclarations.from,
+        to: positions.columnDeclarations.to,
+        insert: newSpecification,
+      },
+    ],
+  })
+}
+
+export const removeColumnWidths = (
+  view: EditorView,
+  selection: TableSelection,
+  positions: Positions,
+  table: TableData
+) => {
+  const { minX, maxX } = selection.normalized()
+  const specification = view.state.sliceDoc(
+    positions.columnDeclarations.from,
+    positions.columnDeclarations.to
+  )
+  const columnSpecification = parseColumnSpecifications(specification)
+  for (let i = minX; i <= maxX; i++) {
+    if (selection.isColumnSelected(i, table)) {
+      columnSpecification[i].customCellDefinition = ''
+      if (columnSpecification[i].alignment === 'paragraph') {
+        columnSpecification[i].content = 'l'
+        columnSpecification[i].alignment = 'left'
+      } else {
+        columnSpecification[i].content = columnSpecification[i].alignment[0]
+      }
+    }
+  }
+  const newSpecification = generateColumnSpecification(columnSpecification)
+  view.dispatch({
+    changes: [
+      {
+        from: positions.columnDeclarations.from,
+        to: positions.columnDeclarations.to,
+        insert: newSpecification,
+      },
+    ],
+  })
+}

+ 13 - 3
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx

@@ -6,6 +6,7 @@ import Tooltip from '../../../../../shared/components/tooltip'
 import { useTabularContext } from '../contexts/tabular-context'
 import { emitTableGeneratorEvent } from '../analytics'
 import { useCodeMirrorViewContext } from '../../codemirror-editor'
+import classNames from 'classnames'
 
 export const ToolbarDropdown: FC<{
   id: string
@@ -15,6 +16,7 @@ export const ToolbarDropdown: FC<{
   tooltip?: string
   disabled?: boolean
   disabledTooltip?: string
+  showCaret?: boolean
 }> = ({
   id,
   label,
@@ -24,6 +26,7 @@ export const ToolbarDropdown: FC<{
   tooltip,
   disabled,
   disabledTooltip,
+  showCaret,
 }) => {
   const { open, onToggle, ref } = useDropdown()
   const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
@@ -48,6 +51,7 @@ export const ToolbarDropdown: FC<{
     >
       {label && <span>{label}</span>}
       <MaterialIcon type={icon} />
+      {showCaret && <MaterialIcon type="expand_more" />}
     </button>
   )
   const overlay = tabularRef.current && (
@@ -110,8 +114,10 @@ export const ToolbarDropdownItem: FC<
   Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
     command: () => void
     id: string
+    icon?: string
+    active?: boolean
   }
-> = ({ children, command, id, ...props }) => {
+> = ({ children, command, id, icon, active, ...props }) => {
   const view = useCodeMirrorViewContext()
   const onClick = useCallback(() => {
     emitTableGeneratorEvent(view, id)
@@ -119,13 +125,17 @@ export const ToolbarDropdownItem: FC<
   }, [view, command, id])
   return (
     <button
-      className="ol-cm-toolbar-menu-item"
+      className={classNames('ol-cm-toolbar-menu-item', {
+        'ol-cm-toolbar-dropdown-option-active': active,
+      })}
       role="menuitem"
       type="button"
       {...props}
       onClick={onClick}
     >
-      {children}
+      {icon && <MaterialIcon type={icon} />}
+      <span className="ol-cm-toolbar-dropdown-option-content">{children}</span>
+      {active && <MaterialIcon type="check" />}
     </button>
   )
 }

+ 105 - 16
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx

@@ -11,6 +11,7 @@ import {
   mergeCells,
   moveCaption,
   removeCaption,
+  removeColumnWidths,
   removeNodes,
   removeRowOrColumns,
   setAlignment,
@@ -22,6 +23,9 @@ import { useTableContext } from '../contexts/table-context'
 import { useTabularContext } from '../contexts/tabular-context'
 import { useTranslation } from 'react-i18next'
 import { FeedbackBadge } from '@/shared/components/feedback-badge'
+import classNames from 'classnames'
+
+type CaptionPosition = 'no_caption' | 'above' | 'below'
 
 export const Toolbar = memo(function Toolbar() {
   const { selection, setSelection } = useSelectionContext()
@@ -34,7 +38,7 @@ export const Toolbar = memo(function Toolbar() {
     table,
     directTableChild,
   } = useTableContext()
-  const { showHelp } = useTabularContext()
+  const { showHelp, openColumnWidthModal } = useTabularContext()
   const { t } = useTranslation()
 
   const borderDropdownLabel = useMemo(() => {
@@ -48,15 +52,26 @@ export const Toolbar = memo(function Toolbar() {
     }
   }, [table, t])
 
-  const captionLabel = useMemo(() => {
+  const captionPosition: CaptionPosition = useMemo(() => {
     if (!tableEnvironment?.caption) {
-      return t('no_caption')
+      return 'no_caption'
     }
     if (tableEnvironment.caption.from < positions.tabular.from) {
-      return t('caption_above')
+      return 'above'
     }
-    return t('caption_below')
-  }, [tableEnvironment, positions.tabular.from, t])
+    return 'below'
+  }, [tableEnvironment, positions])
+
+  const captionLabel = useMemo(() => {
+    switch (captionPosition) {
+      case 'no_caption':
+        return t('no_caption')
+      case 'above':
+        return t('caption_above')
+      case 'below':
+        return t('caption_below')
+    }
+  }, [t, captionPosition])
 
   const currentAlignment = useMemo(() => {
     if (!selection) {
@@ -94,14 +109,27 @@ export const Toolbar = memo(function Toolbar() {
     }
   }, [currentAlignment])
 
+  const hasCustomSizes = useMemo(
+    () => table.columns.some(x => x.size),
+    [table.columns]
+  )
+
   if (!selection) {
     return null
   }
   const columnsToInsert = selection.maximumCellWidth(table)
   const rowsToInsert = selection.height()
 
+  const onlyFixedWidthColumnsSelected = selection.isOnlyFixedWidthColumns(table)
+  const onlyNonFixedWidthColumnsSelected =
+    selection.isOnlyNonFixedWidthColumns(table)
+
   return (
-    <div className="table-generator-floating-toolbar">
+    <div
+      className={classNames('table-generator-floating-toolbar', {
+        'table-generator-toolbar-floating-custom-sizes': hasCustomSizes,
+      })}
+    >
       <div className="table-generator-button-group">
         <ToolbarDropdown
           id="table-generator-caption-dropdown"
@@ -116,6 +144,7 @@ export const Toolbar = memo(function Toolbar() {
             command={() => {
               removeCaption(view, tableEnvironment)
             }}
+            active={captionPosition === 'no_caption'}
           >
             {t('no_caption')}
           </ToolbarDropdownItem>
@@ -124,6 +153,7 @@ export const Toolbar = memo(function Toolbar() {
             command={() => {
               moveCaption(view, positions, 'above', tableEnvironment)
             }}
+            active={captionPosition === 'above'}
           >
             {t('caption_above')}
           </ToolbarDropdownItem>
@@ -132,6 +162,7 @@ export const Toolbar = memo(function Toolbar() {
             command={() => {
               moveCaption(view, positions, 'below', tableEnvironment)
             }}
+            active={captionPosition === 'below'}
           >
             {t('caption_below')}
           </ToolbarDropdownItem>
@@ -151,11 +182,10 @@ export const Toolbar = memo(function Toolbar() {
                 table
               )
             }}
+            active={table.getBorderTheme() === BorderTheme.FULLY_BORDERED}
+            icon="border_all"
           >
-            <MaterialIcon type="border_all" />
-            <span className="table-generator-button-label">
-              {t('all_borders')}
-            </span>
+            {t('all_borders')}
           </ToolbarDropdownItem>
           <ToolbarDropdownItem
             id="table-generator-borders-no-borders"
@@ -168,11 +198,10 @@ export const Toolbar = memo(function Toolbar() {
                 table
               )
             }}
+            active={table.getBorderTheme() === BorderTheme.NO_BORDERS}
+            icon="border_clear"
           >
-            <MaterialIcon type="border_clear" />
-            <span className="table-generator-button-label">
-              {t('no_borders')}
-            </span>
+            {t('no_borders')}
           </ToolbarDropdownItem>
           <div className="table-generator-border-options-coming-soon">
             <div className="info-icon">
@@ -200,6 +229,7 @@ export const Toolbar = memo(function Toolbar() {
             command={() => {
               setAlignment(view, selection, 'left', positions, table)
             }}
+            active={currentAlignment === 'left'}
           />
           <ToolbarButton
             icon="format_align_center"
@@ -208,6 +238,7 @@ export const Toolbar = memo(function Toolbar() {
             command={() => {
               setAlignment(view, selection, 'center', positions, table)
             }}
+            active={currentAlignment === 'center'}
           />
           <ToolbarButton
             icon="format_align_right"
@@ -216,8 +247,66 @@ export const Toolbar = memo(function Toolbar() {
             command={() => {
               setAlignment(view, selection, 'right', positions, table)
             }}
+            active={currentAlignment === 'right'}
           />
+          {onlyFixedWidthColumnsSelected &&
+            !selection.isMergedCellSelected(table) && (
+              <ToolbarButton
+                icon="format_align_justify"
+                id="table-generator-align-justify"
+                label={t('justify')}
+                command={() => {
+                  setAlignment(view, selection, 'paragraph', positions, table)
+                }}
+                active={currentAlignment === 'paragraph'}
+              />
+            )}
         </ToolbarButtonMenu>
+        <ToolbarDropdown
+          id="format_text_wrap"
+          btnClassName="table-generator-toolbar-button"
+          icon={
+            selection.isOnlyParagraphCells(table) ? 'format_text_wrap' : 'width'
+          }
+          tooltip={t('adjust_column_width')}
+          disabled={!selection.isAnyColumnSelected(table)}
+          disabledTooltip={t('select_a_column_to_adjust_column_width')}
+          showCaret
+        >
+          <ToolbarDropdownItem
+            id="table-generator-unwrap-text"
+            icon="width"
+            active={onlyNonFixedWidthColumnsSelected}
+            command={() =>
+              removeColumnWidths(view, selection, positions, table)
+            }
+            disabled={!selection.isAnyColumnSelected(table)}
+          >
+            {t('stretch_width_to_text')}
+          </ToolbarDropdownItem>
+          <ToolbarDropdownItem
+            id="table-generator-wrap-text"
+            icon="format_text_wrap"
+            active={onlyFixedWidthColumnsSelected}
+            command={openColumnWidthModal}
+            disabled={!selection.isAnyColumnSelected(table)}
+          >
+            {onlyFixedWidthColumnsSelected
+              ? t('fixed_width')
+              : t('fixed_width_wrap_text')}
+          </ToolbarDropdownItem>
+          {onlyFixedWidthColumnsSelected && (
+            <>
+              <hr />
+              <ToolbarDropdownItem
+                id="table-generator-resize"
+                command={openColumnWidthModal}
+              >
+                {t('set_column_width')}
+              </ToolbarDropdownItem>
+            </>
+          )}
+        </ToolbarDropdown>
         <ToolbarButton
           icon="cell_merge"
           id="table-generator-merge-cells"
@@ -361,7 +450,7 @@ export const Toolbar = memo(function Toolbar() {
         <div className="toolbar-beta-badge">
           <FeedbackBadge
             id="table-generator-feedback"
-            url="https://forms.gle/ri3fzV1oQDAjmfmD7"
+            url="https://forms.gle/9dHxXPGugxEHgY3L9"
             text={<FeedbackBadgeContent />}
           />
         </div>

+ 72 - 6
services/web/frontend/js/features/source-editor/components/table-generator/utils.ts

@@ -2,8 +2,15 @@ import { EditorState } from '@codemirror/state'
 import { SyntaxNode } from '@lezer/common'
 import { CellData, ColumnDefinition, TableData } from './tabular'
 import { TableEnvironmentData } from './contexts/table-context'
+import {
+  ABSOLUTE_SIZE_REGEX,
+  AbsoluteWidthUnits,
+  RELATIVE_SIZE_REGEX,
+  RelativeWidthCommand,
+  WidthSelection,
+} from './toolbar/column-width-modal/column-width'
 
-const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p']
+const COMMIT_CHARACTERS = ['c', 'l', 'r', 'p', 'm', 'b', '>']
 
 export type CellPosition = { from: number; to: number }
 export type RowPosition = {
@@ -40,6 +47,9 @@ export function parseColumnSpecifications(
   let currentContent = ''
   let currentCellSpacingLeft = ''
   let currentCellSpacingRight = ''
+  let currentCustomCellDefinition = ''
+  let currentIsParagraphColumn = false
+  let currentSize: WidthSelection | undefined
   function maybeCommit() {
     if (currentAlignment !== undefined) {
       columns.push({
@@ -49,6 +59,9 @@ export function parseColumnSpecifications(
         content: currentContent,
         cellSpacingLeft: currentCellSpacingLeft,
         cellSpacingRight: currentCellSpacingRight,
+        customCellDefinition: currentCustomCellDefinition,
+        isParagraphColumn: currentIsParagraphColumn,
+        size: currentSize,
       })
       currentAlignment = undefined
       currentBorderLeft = 0
@@ -56,10 +69,13 @@ export function parseColumnSpecifications(
       currentContent = ''
       currentCellSpacingLeft = ''
       currentCellSpacingRight = ''
+      currentCustomCellDefinition = ''
+      currentIsParagraphColumn = false
+      currentSize = undefined
     }
   }
   for (let i = 0; i < specification.length; i++) {
-    if (ALIGNMENT_CHARACTERS.includes(specification.charAt(i))) {
+    if (COMMIT_CHARACTERS.includes(specification.charAt(i))) {
       maybeCommit()
     }
     const hasAlignment = currentAlignment !== undefined
@@ -85,13 +101,55 @@ export function parseColumnSpecifications(
         currentAlignment = 'right'
         currentContent += 'r'
         break
-      case 'p': {
+      case 'p':
+      case 'm':
+      case 'b': {
+        currentIsParagraphColumn = true
         currentAlignment = 'paragraph'
-        currentContent += 'p'
-        // TODO: Parse these details
+        if (currentCustomCellDefinition !== '') {
+          // Maybe we have another alignment hidden in here
+          const match = currentCustomCellDefinition.match(
+            />\{\s*\\(raggedleft|raggedright|centering)\s*\\arraybackslash\s*\}/
+          )
+          if (match) {
+            switch (match[1]) {
+              case 'raggedleft':
+                currentAlignment = 'right'
+                break
+              case 'raggedright':
+                currentAlignment = 'left'
+                break
+              case 'centering':
+                currentAlignment = 'center'
+                break
+            }
+          }
+        }
+        currentContent += char
         const argumentEnd = parseArgument(specification, i + 1)
+        const columnDefinition = specification.slice(i, argumentEnd + 1)
+        const absoluteSizeMatch = columnDefinition.match(ABSOLUTE_SIZE_REGEX)
+        const relativeSizeMatch = columnDefinition.match(RELATIVE_SIZE_REGEX)
+        if (absoluteSizeMatch) {
+          currentSize = {
+            unit: absoluteSizeMatch[2] as AbsoluteWidthUnits,
+            width: parseFloat(absoluteSizeMatch[1]),
+          }
+        } else if (relativeSizeMatch) {
+          const widthAsFraction = parseFloat(relativeSizeMatch[1]) || 0
+          currentSize = {
+            unit: '%',
+            width: widthAsFraction * 100,
+            command: relativeSizeMatch[2] as RelativeWidthCommand,
+          }
+        } else {
+          currentSize = {
+            unit: 'custom',
+            width: columnDefinition.slice(2, -1),
+          }
+        }
         // Don't include the p twice
-        currentContent += specification.slice(i + 1, argumentEnd + 1)
+        currentContent += columnDefinition.slice(1)
         i = argumentEnd
         break
       }
@@ -109,6 +167,14 @@ export function parseColumnSpecifications(
         }
         break
       }
+      case '>': {
+        const argumentEnd = parseArgument(specification, i + 1)
+        // Include the >
+        const argument = specification.slice(i, argumentEnd + 1)
+        i = argumentEnd
+        currentCustomCellDefinition = argument
+        break
+      }
       case ' ':
       case '\n':
       case '\t':

+ 93 - 5
services/web/frontend/js/features/source-editor/extensions/visual/table-generator.ts

@@ -22,10 +22,21 @@ export const tableGeneratorTheme = EditorView.baseTheme({
     '--table-generator-toolbar-dropdown-disabled-background':
       'rgba(125,125,125,0.3)',
     '--table-generator-toolbar-dropdown-disabled-color': '#999',
+    '--table-generator-toolbar-dropdown-active-background': 'var(--green-10)',
+    '--table-generator-toolbar-dropdown-active-color': 'var(--green-70)',
+    '--table-generator-toolbar-dropdown-active-hover-background':
+      'var(--green-10)',
+    '--table-generator-toolbar-dropdown-active-active-background':
+      'var(--green-20)',
     '--table-generator-toolbar-shadow-color': '#1e253029',
     '--table-generator-error-background': '#2c3645',
     '--table-generator-error-color': '#fff',
     '--table-generator-error-border-color': '#677283',
+    '--table-generator-column-size-indicator-background': 'var(--neutral-80)',
+    '--table-generator-column-size-indicator-hover-background':
+      'var(--neutral-70)',
+    '--table-generator-column-size-indicator-color': 'white',
+    '--table-generator-column-size-indicator-hover-color': 'white',
   },
 
   '&light .table-generator': {
@@ -48,10 +59,20 @@ export const tableGeneratorTheme = EditorView.baseTheme({
     '--table-generator-toolbar-dropdown-border-color': 'var(--neutral-60)',
     '--table-generator-toolbar-dropdown-disabled-background': '#f2f2f2',
     '--table-generator-toolbar-dropdown-disabled-color': 'var(--neutral-40)',
+    '--table-generator-toolbar-dropdown-active-background': 'var(--green-10)',
+    '--table-generator-toolbar-dropdown-active-color': 'var(--green-70)',
+    '--table-generator-toolbar-dropdown-active-hover-background':
+      'var(--green-10)',
+    '--table-generator-toolbar-dropdown-active-active-background':
+      'var(--green-20)',
     '--table-generator-toolbar-shadow-color': '#1e253029',
     '--table-generator-error-background': '#F1F4F9',
     '--table-generator-error-color': 'black',
     '--table-generator-error-border-color': '#C3D0E3',
+    '--table-generator-column-size-indicator-background': '#E7E9EE',
+    '--table-generator-column-size-indicator-hover-background': '#D7DADF',
+    '--table-generator-column-size-indicator-color': 'black',
+    '--table-generator-column-size-indicator-hover-color': 'black',
   },
 
   '.table-generator': {
@@ -162,6 +183,7 @@ export const tableGeneratorTheme = EditorView.baseTheme({
 
         '&::after': {
           width: '4px',
+          bottom: '4px',
           height: 'calc(100% - 8px)',
         },
       },
@@ -172,6 +194,7 @@ export const tableGeneratorTheme = EditorView.baseTheme({
         '&::after': {
           width: 'calc(100% - 8px)',
           height: '4px',
+          right: '4px',
         },
       },
 
@@ -179,8 +202,8 @@ export const tableGeneratorTheme = EditorView.baseTheme({
         content: '""',
         display: 'block',
         position: 'absolute',
-        bottom: '4px',
-        right: '4px',
+        bottom: '8px',
+        right: '8px',
         width: 'calc(100% - 8px)',
         height: 'calc(100% - 8px)',
         'background-color': 'var(--table-generator-selector-background-color)',
@@ -199,7 +222,6 @@ export const tableGeneratorTheme = EditorView.baseTheme({
 
   '.table-generator-floating-toolbar': {
     position: 'absolute',
-    top: '0',
     transform: 'translateY(-100%)',
     left: '0',
     right: '0',
@@ -216,6 +238,9 @@ export const tableGeneratorTheme = EditorView.baseTheme({
     display: 'flex',
     flexWrap: 'wrap',
     rowGap: '8px',
+    '&.table-generator-toolbar-floating-custom-sizes': {
+      top: '-8px',
+    },
   },
 
   '.table-generator-toolbar-button': {
@@ -375,6 +400,7 @@ export const tableGeneratorTheme = EditorView.baseTheme({
     display: 'flex',
     'flex-direction': 'column',
     'min-width': '200px',
+    padding: '4px',
 
     '& > button': {
       border: 'none',
@@ -382,11 +408,11 @@ export const tableGeneratorTheme = EditorView.baseTheme({
       background: 'transparent',
       'white-space': 'nowrap',
       color: 'var(--table-generator-toolbar-button-color)',
-      'border-radius': '0',
+      'border-radius': '4px',
       'font-size': '14px',
       display: 'flex',
       'align-items': 'center',
-      'justify-content': 'space-between',
+      'justify-content': 'flex-start',
       'column-gap': '8px',
       'align-self': 'stretch',
       padding: '12px 8px',
@@ -398,6 +424,12 @@ export const tableGeneratorTheme = EditorView.baseTheme({
         'text-align': 'left',
       },
 
+      '&.ol-cm-toolbar-dropdown-option-active': {
+        'background-color':
+          'var(--table-generator-toolbar-dropdown-active-background)',
+        color: 'var(--table-generator-toolbar-dropdown-active-color)',
+      },
+
       '&:hover, &:focus': {
         'background-color':
           'var(--table-generator-toolbar-button-hover-background)',
@@ -408,6 +440,18 @@ export const tableGeneratorTheme = EditorView.baseTheme({
           'var(--table-generator-toolbar-button-active-background)',
       },
 
+      '&.ol-cm-toolbar-dropdown-option-active:hover, &.ol-cm-toolbar-dropdown-option-active:focus':
+        {
+          'background-color':
+            'var(--table-generator-toolbar-dropdown-active-hover-background)',
+        },
+
+      '&.ol-cm-toolbar-dropdown-option-active:active, &.ol-cm-toolbar-dropdown-option-active.active':
+        {
+          'background-color':
+            'var(--table-generator-toolbar-dropdown-active-active-background)',
+        },
+
       '&:hover, &:focus, &:active, &.active': {
         'box-shadow': 'none',
       },
@@ -428,6 +472,11 @@ export const tableGeneratorTheme = EditorView.baseTheme({
       border: '0',
       height: '1px',
     },
+
+    '& .ol-cm-toolbar-dropdown-option-content': {
+      textAlign: 'left',
+      flexGrow: '1',
+    },
   },
 
   '.ol-cm-environment-table.table-generator-error-container, .ol-cm-environment-table.ol-cm-tabular':
@@ -465,4 +514,43 @@ export const tableGeneratorTheme = EditorView.baseTheme({
       'min-width': '40px',
     },
   },
+
+  '.table-generator-column-indicator-button': {
+    verticalAlign: 'middle',
+    borderRadius: '4px',
+    padding: '2px 4px 2px 4px',
+    background: 'var(--table-generator-column-size-indicator-background)',
+    margin: 0,
+    border: 'none',
+    fontFamily: 'Lato, sans-serif',
+    fontSize: '12px',
+    lineHeight: '16px',
+    fontWeight: 400,
+    display: 'flex',
+    maxWidth: '100%',
+    color: 'var(--table-generator-column-size-indicator-color)',
+
+    '&:hover': {
+      background:
+        'var(--table-generator-column-size-indicator-hover-background)',
+      color: 'var(--table-generator-column-size-indicator-hover-color)',
+    },
+
+    '& .table-generator-column-indicator-icon': {
+      fontSize: '16px',
+      lineHeight: '16px',
+    },
+
+    '& .table-generator-column-indicator-label': {
+      textOverflow: 'ellipsis',
+      overflow: 'hidden',
+      whiteSpace: 'nowrap',
+    },
+  },
+  '.table-generator-column-widths-row': {
+    height: '20px',
+    '& td': {
+      lineHeight: '1',
+    },
+  },
 })

+ 1 - 0
services/web/frontend/stylesheets/app/editor.less

@@ -19,6 +19,7 @@
 @import './editor/dictionary.less';
 @import './editor/compile-button.less';
 @import './editor/figure-modal.less';
+@import './editor/table-generator-column-width-modal.less';
 @import './editor/ide-react.less';
 
 @ui-layout-toggler-def-height: 50px;

+ 13 - 0
services/web/frontend/stylesheets/app/editor/table-generator-column-width-modal.less

@@ -0,0 +1,13 @@
+.table-generator-width-modal {
+  .table-generator-width-label {
+    width: 100%;
+  }
+
+  .table-generator-usepackage-copy {
+    display: flex;
+    justify-content: space-between;
+    padding: 6px 12px;
+    background: #f4f5f6;
+    border: 1px solid #d0d5dd;
+  }
+}

+ 15 - 0
services/web/locales/en.json

@@ -79,6 +79,7 @@
   "address": "Address",
   "address_line_1": "Address",
   "address_second_line_optional": "Address second line (optional)",
+  "adjust_column_width": "Adjust column width",
   "admin": "admin",
   "admin_user_created_message": "Created admin user, <a href=\"__link__\">Log in here</a> to continue",
   "advanced_reference_search": "Advanced <0>reference search</0>",
@@ -263,6 +264,9 @@
   "collabs_per_proj": "__collabcount__ collaborators per project",
   "collabs_per_proj_single": "__collabcount__ collaborator per project",
   "collapse": "Collapse",
+  "column_width": "Column width",
+  "column_width_is_custom_click_to_resize": "Column width is custom. Click to resize",
+  "column_width_is_x_click_to_resize": "Column width is __width__. Click to resize",
   "comment": "Comment",
   "comment_submit_error": "Sorry, there was a problem submitting your comment",
   "commit": "Commit",
@@ -347,6 +351,7 @@
   "currently_seeing_only_24_hrs_history": "You’re currently seeing the last 24 hours of changes in this project.",
   "currently_signed_in_as_x": "Currently signed in as <0>__userEmail__</0>.",
   "currently_subscribed_to_plan": "You are currently subscribed to the <0>__planName__</0> plan.",
+  "custom": "Custom",
   "custom_borders": "Custom borders",
   "custom_resource_portal": "Custom resource portal",
   "custom_resource_portal_info": "You can have your own custom portal page on Overleaf. This is a great place for your users to find out more about Overleaf, access templates, FAQs and Help resources, and sign up to Overleaf.",
@@ -510,6 +515,7 @@
   "enabling": "Enabling",
   "end_of_document": "End of document",
   "enter_6_digit_code": "Enter 6-digit code",
+  "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
   "enter_image_url": "Enter image URL",
   "enter_your_email_address": "Enter your email address",
   "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
@@ -598,6 +604,8 @@
   "fit_to_height": "Fit to height",
   "fit_to_width": "Fit to width",
   "fix_issues": "Fix issues",
+  "fixed_width": "Fixed width",
+  "fixed_width_wrap_text": "Fixed width, wrap text",
   "fold_line": "Fold line",
   "folder_location": "Folder location",
   "folders": "Folders",
@@ -914,6 +922,7 @@
   "joining": "Joining",
   "july": "July",
   "june": "June",
+  "justify": "Justify",
   "kb_suggestions_enquiry": "Have you checked our <0>__kbLink__</0>?",
   "keep_current_plan": "Keep my current plan",
   "keep_personal_projects_separate": "Keep personal projects separate",
@@ -976,6 +985,7 @@
   "license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
   "limited_offer": "Limited offer",
   "line_height": "Line Height",
+  "line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.",
   "link": "Link",
   "link_account": "Link Account",
   "link_accounts": "Link Accounts",
@@ -1301,6 +1311,7 @@
   "per_user_year": "per user / year",
   "per_year": "per year",
   "percent_discount_for_groups": "__appName__ offers a __percent__% educational discount for groups of __size__ or more.",
+  "percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width",
   "personal": "Personal",
   "personalized_onboarding": "Personalized onboarding",
   "personalized_onboarding_info": "We’ll help you get everything set up and then we’re here to answer questions from your users about the platform, templates or LaTeX!",
@@ -1598,6 +1609,7 @@
   "see_changes_in_your_documents_live": "See changes in your documents, live",
   "see_what_has_been": "See what has been ",
   "select_a_column_or_a_merged_cell_to_align": "Select a column or a merged cell to align",
+  "select_a_column_to_adjust_column_width": "Select a column to adjust column width",
   "select_a_file": "Select a File",
   "select_a_file_figure_modal": "Select a file",
   "select_a_group_optional": "Select a Group (optional)",
@@ -1644,6 +1656,7 @@
   "session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds",
   "sessions": "Sessions",
   "set_color": "set color",
+  "set_column_width": "Set column width",
   "set_new_password": "Set new password",
   "set_password": "Set Password",
   "set_up_sso": "Set up SSO",
@@ -1765,6 +1778,7 @@
   "stop_on_first_error_enabled_title": "No PDF: Stop on first error enabled",
   "stop_on_validation_error": "Check syntax before compile",
   "store_your_work": "Store your work on your own infrastructure",
+  "stretch_width_to_text": "Stretch width to text",
   "student": "Student",
   "student_and_faculty_support_make_difference": "Student and faculty support make a difference! We can share this information with our contacts at your university when discussing an Overleaf institutional account.",
   "student_disclaimer": "The educational discount applies to all students at secondary and postsecondary institutions (schools and universities). We may contact you to confirm that you’re eligible for the discount.",
@@ -1889,6 +1903,7 @@
   "to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "To insert or move a caption, make sure \\begin{tabular} is directly within a table environment",
   "to_many_login_requests_2_mins": "This account has had too many login requests. Please wait 2 minutes before trying to log in again",
   "to_modify_your_subscription_go_to": "To modify your subscription go to",
+  "to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "<0>Please note:</0> To use text wrapping in your table, make sure you include the <1>array</1> package in your document preamble:",
   "toggle_compile_options_menu": "Toggle compile options menu",
   "token": "token",
   "token_access_failure": "Cannot grant access; contact the project owner for help",

+ 2 - 2
services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx

@@ -4,7 +4,7 @@ import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/
 import { mockScope } from '../helpers/mock-scope'
 
 const Container: FC = ({ children }) => (
-  <div style={{ width: 785, height: 785 }}>{children}</div>
+  <div style={{ width: 1000, height: 800 }}>{children}</div>
 )
 
 const mountEditor = (content: string | string[]) => {
@@ -16,7 +16,7 @@ const mountEditor = (content: string | string[]) => {
   }
   const scope = mockScope(content)
   scope.editor.showVisual = true
-
+  cy.viewport(1000, 800)
   cy.mount(
     <Container>
       <EditorProviders scope={scope}>