@atlaskit/renderer
Version:
Renderer component
369 lines (351 loc) • 18.3 kB
JavaScript
import React, { useContext } from 'react';
import { tableCellBorderWidth, tableCellMinWidth } from '@atlaskit/editor-common/styles';
import { WidthContext } from '@atlaskit/editor-common/ui';
import { akEditorTableNumberColumnWidth, akEditorTableLegacyCellMinWidth, akEditorTableCellMinWidth } from '@atlaskit/editor-shared-styles';
import { getTableContainerWidth } from '@atlaskit/editor-common/node-width';
import { useFeatureFlags } from '../../../use-feature-flags';
import { useRendererContext } from '../../../renderer-context';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
// we allow scaling down column widths by no more than 30%
// this intends to reduce unwanted scrolling in the Renderer in these scenarios:
// User A creates a table with column widths → User B views it on a smaller screen
// User A creates a table with column widths → User A views it with reduced viewport space (eg. Confluence sidebar is open)
const MAX_SCALING_PERCENT = 0.3;
const MAX_SCALING_PERCENT_TABLES_WITH_FIXED_COLUMN_WIDTHS_OPTION = 0.4;
const isTableColumnResized = columnWidths => {
const filteredWidths = columnWidths.filter(width => width !== 0);
return !!filteredWidths.length;
};
const fixColumnWidth = ({
columnWidth,
zeroWidthColumnsCount,
scaleDownPercent,
skipMinWidth
}) => {
if (columnWidth === 0) {
return columnWidth;
}
// If the tables total width (including no zero widths col or cols without width) is less than the current layout
// We scale up the columns to meet the minimum of the table layout.
if (zeroWidthColumnsCount === 0 && scaleDownPercent) {
const scaled = Math.floor((1 - scaleDownPercent) * columnWidth);
return skipMinWidth ? scaled : Math.max(scaled, tableCellMinWidth);
}
const adjusted = columnWidth - tableCellBorderWidth;
if (skipMinWidth) {
return adjusted;
}
return Math.max(adjusted, zeroWidthColumnsCount ? akEditorTableLegacyCellMinWidth : tableCellMinWidth);
};
const calcScalePercent = ({
renderWidth,
tableWidth,
maxScale,
isNumberColumnEnabled
}) => {
const noNumColumnScalePercent = renderWidth / tableWidth;
// when numbered column is enabled, we need to calculate the scale percent without the akEditorTableNumberColumnWidth
// As numbered column width is not scaled down
const numColumnScalePercent = (renderWidth - akEditorTableNumberColumnWidth) / (tableWidth - akEditorTableNumberColumnWidth);
const diffPercent = 1 - noNumColumnScalePercent;
return diffPercent < maxScale ? isNumberColumnEnabled ? 1 - numColumnScalePercent : diffPercent : maxScale;
};
export const colWidthSum = columnWidths => columnWidths.reduce((prev, curr) => curr + prev, 0);
/**
* Returns the data-column available width: total width minus the fixed number column if enabled.
*/
const getDataColumnWidth = (totalWidth, isNumberColumnEnabled) => isNumberColumnEnabled ? totalWidth - akEditorTableNumberColumnWidth : totalWidth;
/**
* Scales column widths proportionally to fit within availableWidth, matching the editor's
* scaleTableTo(): floors each column to the nearest pixel, then redistributes any rounding
* remainder to the first column that can absorb it without going below tableCellMinWidth.
*/
const scaleColumnsToWidth = (columnWidths, availableWidth) => {
const rawTotalWidth = columnWidths.reduce((sum, w) => sum + w, 0);
const scaleFactor = availableWidth / rawTotalWidth;
const scaledWidths = columnWidths.map(colWidth => Math.max(Math.floor(colWidth * scaleFactor), tableCellMinWidth));
const totalScaled = scaledWidths.reduce((sum, w) => sum + w, 0);
const diff = availableWidth - totalScaled;
if (diff !== 0 && Math.abs(diff) < tableCellMinWidth) {
for (let i = 0; i < scaledWidths.length; i++) {
if (scaledWidths[i] + diff > tableCellMinWidth) {
scaledWidths[i] += diff;
break;
}
}
}
return scaledWidths.map(width => ({
width: `${width}px`
}));
};
/**
* Computes column widths for tables inside sync blocks, matching the editor's scaleTableTo() exactly.
* Returns null if not inside a sync block.
*
* For nested tables (isInsideOfTable=true), we use getTableContainerWidth(tableNode) as the
* reference — the width the editor saved, which already accounts for the parent cell's available
* space (colwidth minus tableCellPadding * 2). This matches bodiedSyncBlock where isRendererNested=false,
* so renderScaleDownColgroup uses getTableContainerWidth(tableNode). For syncBlock the nested
* renderer has isRendererNested=true, which incorrectly overrides tableContainerWidth with renderWidth
* (the full container), causing overflow by 2 * tableCellPadding (16px).
*/
const renderSyncBlockColgroup = ({
isInsideOfSyncBlock,
isInsideOfTable,
tableNode,
columnWidths,
isNumberColumnEnabled,
renderWidth: renderWidthProp,
contextWidth
}) => {
if (!isInsideOfSyncBlock) {
return null;
}
const rawTotalWidth = columnWidths.reduce((sum, w) => sum + w, 0);
if (isInsideOfTable) {
return null;
}
// SSR / first client render before WidthObserver measures. Output % of original ADF
// proportions so columns are stable — the CSS container query (100cqw) handles actual
// scaling width. We key off renderWidthProp alone because WidthProvider initialises with
// document.body.offsetWidth (non-zero on the client), so contextWidth > 0 even before
// WidthObserver has measured the real container, which would skip this branch and produce
// column widths scaled to the full viewport.
if (renderWidthProp <= 0) {
const fullTableWidth = isNumberColumnEnabled ? rawTotalWidth + akEditorTableNumberColumnWidth : rawTotalWidth;
return columnWidths.map(colWidth => ({
width: `${colWidth / fullTableWidth * 100}%`
}));
}
// contextWidth measures the sync block content area. Subtract 2 to match the editor's
// getParentNodeWidth() border offset. Fall back to renderWidthProp for the non-CSS path.
const effectiveRenderWidth = contextWidth > 0 ? contextWidth - 2 : renderWidthProp;
const availableWidth = getDataColumnWidth(effectiveRenderWidth, isNumberColumnEnabled);
// Only scale down if the table is actually wider than the available space.
// If the table fits, return original widths with the same tableCellBorderWidth (2px) adjustment
// that renderScaleDownColgroup applies via fixColumnWidth's no-scale path.
if (availableWidth >= rawTotalWidth) {
return columnWidths.map(colWidth => ({
width: `${Math.max(colWidth - tableCellBorderWidth, tableCellMinWidth)}px`
}));
}
// Cap scaling at MAX_SCALING_PERCENT (30%), matching renderScaleDownColgroup's calcScalePercent.
//
// Both `availableWidth` and `rawTotalWidth` are already in data-column space (number column
// excluded via getDataColumnWidth), so `diffPercent = 1 - availableWidth / rawTotalWidth` is
// equivalent to calcScalePercent's `numColumnScalePercent` path when isNumberColumnEnabled,
// and to the `noNumColumnScalePercent` path otherwise. We express the cap as a target width
// (clampedAvailableWidth) rather than a scale-down percentage, but the result is the same:
// columns are scaled by at most MAX_SCALING_PERCENT (30%).
const diffPercent = 1 - availableWidth / rawTotalWidth;
const clampedAvailableWidth = diffPercent <= MAX_SCALING_PERCENT ? availableWidth : Math.floor(rawTotalWidth * (1 - MAX_SCALING_PERCENT));
return scaleColumnsToWidth(columnWidths, clampedAvailableWidth);
};
const renderScaleDownColgroup = props => {
var _props$tableNode;
const {
columnWidths,
isNumberColumnEnabled,
renderWidth,
tableNode,
rendererAppearance,
isInsideOfBlockNode,
isInsideOfTable,
isinsideMultiBodiedExtension,
isTableScalingEnabled,
isTableFixedColumnWidthsOptionEnabled,
allowTableResizing,
isTopLevelRenderer,
isInsideOfSyncBlock
} = props;
const skipMinWidth = !!(isInsideOfTable && isInsideOfSyncBlock);
if (!columnWidths || columnWidths.every(width => width === 0) && fg('platform_editor_numbered_column_in_include')) {
return [];
}
const tableColumnResized = isTableColumnResized(columnWidths);
const noOfColumns = columnWidths.length;
let targetWidths;
// This is a fix for ED-23259
// Some extensions (for ex: Page Properties or Excerpt) do not renderer tables directly inside themselves. They use ReactRenderer.
// So if we add a check like isInsideExtension (similar to exising isInsideBlockNode), it will fail, and to the only way to learn
// if the table is rendered inside another node, is to check if the Renderer itself is nested.
const isRendererNested = isTopLevelRenderer === false;
// appearance == comment && allowTableResizing && !tableNode?.attrs.width, means it is a comment
// appearance == comment && !allowTableResizing && !tableNode?.attrs.width, means it is a inline comment
// When comment and inline comment table width inherits from the parent container, we want tableContainerWidth === renderWidth
// Tables with numbered columns inside of extensions cannot use the renderWidth when it is 0. In this case, it should
// explicitly use the width coming from the table node as the final table container width.
const tableContainerWidth = rendererAppearance === 'comment' && !(tableNode !== null && tableNode !== void 0 && tableNode.attrs.width) || isRendererNested && (!fg('platform_fix_nested_num_column_scaling') || !isNumberColumnEnabled) ? renderWidth : getTableContainerWidth(tableNode);
if (allowTableResizing && !isInsideOfBlockNode && !isInsideOfTable && !isinsideMultiBodiedExtension && !tableColumnResized) {
// when no columns are resized, each column should have equal width, equals to tableWidth / noOfColumns
const tableWidth = (isNumberColumnEnabled ? tableContainerWidth - akEditorTableNumberColumnWidth : tableContainerWidth) - 1;
const defaultColumnWidth = tableWidth / noOfColumns;
targetWidths = new Array(noOfColumns).fill(defaultColumnWidth);
} else if (!tableColumnResized) {
return null;
}
const sumOfColumns = colWidthSum(columnWidths);
// tables in the wild may be smaller than table container width (col resizing bugs, created before custom widths etc.)
// this causes issues with num column scaling as we add a new table column in renderer
const isTableSmallerThanContainer = sumOfColumns < tableContainerWidth - 1;
const forceScaleForNumColumn = isTableScalingEnabled && isNumberColumnEnabled && tableColumnResized;
// when table resized and number column is enabled, we need to scale down the table in render
if (forceScaleForNumColumn) {
const calculatedTableContainerWidth = rendererAppearance === 'comment' ? sumOfColumns : tableContainerWidth;
const scalePercentage = +((calculatedTableContainerWidth - akEditorTableNumberColumnWidth) / calculatedTableContainerWidth);
const targetMaxWidth = calculatedTableContainerWidth - akEditorTableNumberColumnWidth;
let totalWidthAfterScale = 0;
const newScaledTargetWidths = columnWidths.map(width => {
// we need to scale each column UP, to ensure total width of table matches table container
const patchedWidth = isTableSmallerThanContainer ? width / sumOfColumns * (calculatedTableContainerWidth - 1) : width;
const newWidth = Math.floor(patchedWidth * scalePercentage);
totalWidthAfterScale += newWidth;
return newWidth;
});
const diff = targetMaxWidth - totalWidthAfterScale;
targetWidths = newScaledTargetWidths;
if (diff > 0 || diff < 0 && Math.abs(diff) < tableCellMinWidth) {
let updated = false;
targetWidths = targetWidths.map(width => {
if (!updated && width + diff > tableCellMinWidth) {
updated = true;
width += diff;
}
return width;
});
}
}
targetWidths = targetWidths || columnWidths;
// @see ED-6056
const maxTableWidth = renderWidth < tableContainerWidth ? renderWidth : tableContainerWidth;
let tableWidth = isNumberColumnEnabled ? akEditorTableNumberColumnWidth : 0;
let minTableWidth = tableWidth;
let zeroWidthColumnsCount = 0;
targetWidths.forEach(width => {
if (width) {
tableWidth += Math.ceil(width);
} else {
zeroWidthColumnsCount += 1;
}
minTableWidth += Math.ceil(width) || akEditorTableLegacyCellMinWidth;
});
let cellMinWidth = 0;
let scaleDownPercent = 0;
const isTableScalingWithFixedColumnWidthsOptionEnabled = isTableScalingEnabled && isTableFixedColumnWidthsOptionEnabled;
const isTableWidthFixed = isTableScalingWithFixedColumnWidthsOptionEnabled && ((_props$tableNode = props.tableNode) === null || _props$tableNode === void 0 ? void 0 : _props$tableNode.attrs.displayMode) === 'fixed';
const maxScalingPercent = isTableScalingWithFixedColumnWidthsOptionEnabled || isTableScalingEnabled && rendererAppearance === 'comment' ? MAX_SCALING_PERCENT_TABLES_WITH_FIXED_COLUMN_WIDTHS_OPTION : MAX_SCALING_PERCENT;
// fixes migration tables with zero-width columns
if (zeroWidthColumnsCount > 0) {
if (minTableWidth > maxTableWidth) {
const minWidth = Math.ceil((maxTableWidth - tableWidth) / zeroWidthColumnsCount);
cellMinWidth = minWidth < akEditorTableLegacyCellMinWidth ? akEditorTableLegacyCellMinWidth : minWidth;
}
}
// scaling down
else if (renderWidth < tableWidth && !isTableWidthFixed) {
const shouldTable100ScaleDown = rendererAppearance === 'comment' && allowTableResizing && !(tableNode !== null && tableNode !== void 0 && tableNode.attrs.width);
scaleDownPercent = calcScalePercent({
renderWidth,
tableWidth,
maxScale: fg('platform-ssr-table-resize') ? maxScalingPercent : shouldTable100ScaleDown ? 1 : maxScalingPercent,
isNumberColumnEnabled: isNumberColumnEnabled
});
}
if (isNumberColumnEnabled && (tableWidth < maxTableWidth || maxTableWidth === 0)) {
const fixedColWidths = targetWidths.map(width => fixColumnWidth({
columnWidth: width,
zeroWidthColumnsCount,
scaleDownPercent,
skipMinWidth
}) || cellMinWidth);
const sumFixedColumnWidths = colWidthSum(fixedColWidths);
return fixedColWidths.map(colWidth => {
const width = Math.max(colWidth, cellMinWidth);
if (colWidth > akEditorTableCellMinWidth) {
// To make sure the numbered column isn't scaled, use
// percentages for the other columns.
// Calculate the percentage based on the sum of the fixed column widths
return {
width: `${width / sumFixedColumnWidths * 100}%`
};
} else {
// If the column is equal to or less than the minimum cell width, we need to return a fixed pixel width.
// This is to prevent columns being scaled below the minimum cell width.
const style = width ? {
width: `${width}px`
} : {};
return style;
}
});
}
return targetWidths.map(colWidth => {
const width = fixColumnWidth({
columnWidth: colWidth,
zeroWidthColumnsCount,
scaleDownPercent,
skipMinWidth
}) || cellMinWidth;
const style = width ? {
width: `${width}px`
} : {};
return style;
});
};
export const Colgroup = props => {
var _ref, _renderSyncBlockColgr;
const {
isTopLevelRenderer,
nestedRendererType
} = useRendererContext();
const {
columnWidths,
isNumberColumnEnabled
} = props;
const {
width: contextWidth
} = useContext(WidthContext);
const flags = useFeatureFlags();
if (!columnWidths) {
return null;
}
const isTableFixedColumnWidthsOptionEnabled = (_ref = fg('platform_editor_table_fixed_column_width_prop') ? props.allowFixedColumnWidthOption : flags && 'tableWithFixedColumnWidthsOption' in flags && flags.tableWithFixedColumnWidthsOption) !== null && _ref !== void 0 ? _ref : false;
// For referenced sync blocks, nestedRendererType='syncedBlock' is set via RendererContextProvider
// in AKRendererWrapper. ReactSerializer is a class and cannot read React context, so we detect
// it here in the Colgroup component via useRendererContext() instead of prop-drilling.
const isInsideOfSyncBlock = nestedRendererType === 'syncedBlock';
// renderSyncBlockColgroup returns null when not applicable (flag off, SSR, not a sync block),
// in which case ?? falls back to the standard renderScaleDownColgroup path.
const colStyles = (_renderSyncBlockColgr = renderSyncBlockColgroup({
isInsideOfSyncBlock,
isInsideOfTable: !!props.isInsideOfTable,
tableNode: props.tableNode,
columnWidths,
isNumberColumnEnabled: !!isNumberColumnEnabled,
renderWidth: props.renderWidth,
contextWidth
})) !== null && _renderSyncBlockColgr !== void 0 ? _renderSyncBlockColgr : renderScaleDownColgroup({
...props,
isTopLevelRenderer,
isInsideOfSyncBlock,
isTableScalingEnabled: props.rendererAppearance === 'full-page' || props.rendererAppearance === 'full-width' || props.rendererAppearance === 'max' && (expValEquals('editor_tinymce_full_width_mode', 'isEnabled', true) || expValEquals('confluence_max_width_content_appearance', 'isEnabled', true)) || props.rendererAppearance === 'comment',
isTableFixedColumnWidthsOptionEnabled: isTableFixedColumnWidthsOptionEnabled && (props.rendererAppearance === 'full-page' || props.rendererAppearance === 'full-width' || props.rendererAppearance === 'max' && (expValEquals('editor_tinymce_full_width_mode', 'isEnabled', true) || expValEquals('confluence_max_width_content_appearance', 'isEnabled', true)))
});
if (!colStyles) {
return null;
}
return /*#__PURE__*/React.createElement("colgroup", null, isNumberColumnEnabled && /*#__PURE__*/React.createElement("col", {
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop, @atlaskit/ui-styling-standard/no-imported-style-values -- Ignored via go/DSP-18766
style: {
width: akEditorTableNumberColumnWidth
},
"data-test-id": 'num'
}), colStyles.map((style, idx) =>
/*#__PURE__*/
// Ignored via go/ees005
// eslint-disable-next-line react/no-array-index-key, @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766
React.createElement("col", {
key: idx,
style: style
})));
};