diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 9d5e973803..bd306c8afd 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx index 44b0d70bee..74a44a0c11 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx +++ b/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) { diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/column-size-indicator.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/column-size-indicator.tsx new file mode 100644 index 0000000000..91e303efa0 --- /dev/null +++ b/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 ( + + + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx index c9487dbad5..b7854dc2de 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx +++ b/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 diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx index fb54fbfdfd..be098d0a68 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/tabular-context.tsx +++ b/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(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 ( - + {children} ) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx index e2060a3df4..895b89c077 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx +++ b/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(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 = () => { ))} + {hasCustomSizes && ( + + + {tableData.columns.map((column, columnIndex) => ( + + {column.size && ( + onSizeClick(columnIndex)} + /> + )} + + ))} + + )} {tableData.columns.map((_, columnIndex) => ( diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx index fac8be3ce3..9c5223159d 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx +++ b/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<{ + @@ -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) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/column-width.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/column-width.ts new file mode 100644 index 0000000000..f63110916f --- /dev/null +++ b/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 + 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' +} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx new file mode 100644 index 0000000000..d5101e7b22 --- /dev/null +++ b/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 +}) + +const ColumnWidthModalBody = () => { + const { columnWidthModalShown, closeColumnWidthModal } = useTabularContext() + const view = useCodeMirrorViewContext() + const { selection } = useSelectionContext() + const { positions, table } = useTableContext() + const { t } = useTranslation() + const [currentUnit, setCurrentUnit] = useState( + '%' + ) + const [currentWidth, setCurrentWidth] = useState('') + const inputRef = useRef(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
= 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 ( + + + + {t('set_column_width')} + + +
+ + + setCurrentWidth(e.target.value)} + type={currentUnit === 'custom' ? 'text' : 'number'} + className="form-control" + ref={inputRef} + /> + + +