Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/super-editor/src/components/TableResizeOverlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,34 @@ describe('TableResizeOverlay', () => {

wrapper.unmount();
});

it('should normalize clamped min width while keeping min below width', async () => {
const metadata = {
columns: [
{ i: 0, x: 0, w: 10, min: 10, r: 1 },
{ i: 1, x: 10, w: 2, min: 2, r: 1 },
],
};

const tableElement = createMockTableElement(metadata);
const wrapper = mount(TableResizeOverlay, {
props: {
editor: createMockEditor(),
visible: true,
tableElement,
},
});

await nextTick();

const [firstCol, secondCol] = wrapper.vm.tableMetadata.columns;
expect(firstCol.min).toBeLessThan(firstCol.w);
expect(secondCol.min).toBeLessThan(secondCol.w);
expect(firstCol.min).toBe(9);
expect(secondCol.min).toBe(1);

wrapper.unmount();
});
});

// ==========================================================================
Expand Down
25 changes: 23 additions & 2 deletions packages/super-editor/src/components/TableResizeOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ const overlayRect = ref(null);
*/
const tableMetadata = ref(null);

/**
* Normalize metadata-provided minimum width so resize remains possible.
* Some imported tables report min == current width (e.g. 100/100), which
* clamps drag delta to zero and makes columns feel "stuck".
*
* @param {number} width - Current column width in layout pixels
* @param {number} rawMin - Raw minimum width from metadata
* @returns {number}
*/
function normalizeColumnMinWidth(width, rawMin) {
const safeWidth = Math.max(1, Number(width) || 1);
const safeMin = Math.max(1, Number(rawMin) || 1);
if (safeMin < safeWidth) return safeMin;

// Keep at least a practical shrink budget while guaranteeing min < width.
if (safeWidth <= 2) return 1;

const candidate = Math.max(1, Math.max(25, Math.floor(safeWidth * 0.5)));
return Math.min(safeWidth - 1, candidate);
}

/**
* Get the editor's zoom level for coordinate transformations.
*
Expand Down Expand Up @@ -648,10 +669,10 @@ function parseTableMetadata() {
);
})
.map((col) => ({
w: Math.max(1, col.w),
min: normalizeColumnMinWidth(col.w, col.min),
i: col.i,
x: Math.max(0, col.x),
w: Math.max(1, col.w),
min: Math.max(1, col.min),
r: col.r,
}));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script setup>
import IconGrid from '../toolbar/IconGrid.vue';
import { icons } from '../toolbar/color-dropdown-helpers.js';
import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js';
import { cellAround } from '@extensions/table/tableHelpers/cellAround.js';

const props = defineProps({
editor: {
type: Object,
required: true,
},
closePopover: {
type: Function,
required: true,
},
});

// Plain object with .value — IconGridRow expects a ref-like shape (accesses .value directly).
// A real ref() would be auto-unwrapped by Vue's template compiler before reaching IconGrid.
const activeColor = { value: null };

const ensureCellSelection = () => {
const { state } = props.editor;
if (isCellSelection(state.selection)) return;

const $from = state.selection.$from;
const cell = cellAround($from);
if (cell) {
props.editor.commands.setCellSelection({ anchorCell: cell.pos, headCell: cell.pos });
}
};

const handleSelect = (color) => {
ensureCellSelection();
if (color === 'none') {
props.editor.commands.setCellAttr('background', null);
} else {
props.editor.commands.setCellBackground(color);
}
props.closePopover();
};
</script>

<template>
<IconGrid :icons="icons" :customIcons="[]" :activeColor="activeColor" :hasNoneIcon="true" @select="handleSelect" />
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import copyIconSvg from '@superdoc/common/icons/copy-solid.svg?raw';
import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw';
import checkIconSvg from '@superdoc/common/icons/check-solid.svg?raw';
import xMarkIconSvg from '@superdoc/common/icons/xmark-solid.svg?raw';
import paintRollerIconSvg from '@superdoc/common/icons/paint-roller-solid.svg?raw';

export const ICONS = {
addRowBefore: plusIconSvg,
Expand All @@ -35,6 +36,7 @@ export const ICONS = {
removeDocumentSection: trashIconSvg,
trackChangesAccept: checkIconSvg,
trackChangesReject: xMarkIconSvg,
cellBackground: paintRollerIconSvg,
};

// Table actions constant
Expand Down Expand Up @@ -62,6 +64,7 @@ export const TEXTS = {
createDocumentSection: 'Create section',
trackChangesAccept: 'Accept change',
trackChangesReject: 'Reject change',
cellBackground: 'Cell background',
};

export const tableActionsOptions = [
Expand Down
13 changes: 13 additions & 0 deletions packages/super-editor/src/components/context-menu/menuItems.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import TableGrid from '../toolbar/TableGrid.vue';
import AIWriter from '../toolbar/AIWriter.vue';
import TableActions from '../toolbar/TableActions.vue';
import LinkInput from '../toolbar/LinkInput.vue';
import CellBackgroundPicker from './CellBackgroundPicker.vue';
import { TEXTS, ICONS, TRIGGERS } from './constants.js';
import { isTrackedChangeActionAllowed } from '@extensions/track-changes/permission-helpers.js';
import { readClipboardRaw } from '../../core/utilities/clipboardUtils.js';
Expand Down Expand Up @@ -107,6 +108,8 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
isInTable: context.isInTable ?? false,
isInSectionNode: context.isInSectionNode ?? false,
isTrackedChange: context.isTrackedChange ?? false,
isCellSelection: context.isCellSelection ?? false,
tableSelectionKind: context.tableSelectionKind ?? null,
clipboardContent: context.clipboardContent ?? { hasContent: false },
selectedText: context.selectedText ?? '',
hasSelection: context.hasSelection ?? Boolean(context.selectedText),
Expand Down Expand Up @@ -248,6 +251,16 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
return allowedTriggers.includes(trigger) && isInTable;
},
},
{
id: 'cell-background',
label: TEXTS.cellBackground,
icon: ICONS.cellBackground,
component: CellBackgroundPicker,
isDefault: true,
showWhen: (context) => {
return context.trigger === TRIGGERS.click && (context.isCellSelection || context.isInTable);
},
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';

vi.mock('@extensions/table/tableHelpers/isCellSelection.js', () => ({
isCellSelection: vi.fn(() => false),
}));

vi.mock('@extensions/table/tableHelpers/cellAround.js', () => ({
cellAround: vi.fn(() => null),
}));

vi.mock('../../toolbar/IconGrid.vue', () => ({
default: {
props: ['icons', 'customIcons', 'activeColor', 'hasNoneIcon'],
emits: ['select'],
template: '<div class="icon-grid-stub" />',
},
}));

vi.mock('../../toolbar/color-dropdown-helpers.js', () => ({
icons: [[{ label: 'black', value: '#000000', icon: '<svg/>', style: {} }]],
}));

import CellBackgroundPicker from '../CellBackgroundPicker.vue';
import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js';
import { cellAround } from '@extensions/table/tableHelpers/cellAround.js';

describe('CellBackgroundPicker', () => {
let mockEditor;
let closePopover;

beforeEach(() => {
vi.clearAllMocks();

closePopover = vi.fn();
mockEditor = {
state: {
selection: {
$from: { depth: 3 },
},
},
commands: {
setCellSelection: vi.fn(),
setCellBackground: vi.fn(),
setCellAttr: vi.fn(),
},
};
});

function mountPicker() {
return mount(CellBackgroundPicker, {
props: { editor: mockEditor, closePopover },
});
}

it('should call setCellBackground directly when selection is already a CellSelection', () => {
isCellSelection.mockReturnValue(true);

const wrapper = mountPicker();
wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', '#FF0000');

expect(mockEditor.commands.setCellSelection).not.toHaveBeenCalled();
expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#FF0000');
expect(closePopover).toHaveBeenCalled();
});

it('should select the cell first when cursor is inside a cell without CellSelection', () => {
isCellSelection.mockReturnValue(false);
cellAround.mockReturnValue({ pos: 42 });

const wrapper = mountPicker();
wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', '#00FF00');

expect(cellAround).toHaveBeenCalledWith(mockEditor.state.selection.$from);
expect(mockEditor.commands.setCellSelection).toHaveBeenCalledWith({
anchorCell: 42,
headCell: 42,
});
expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#00FF00');
expect(closePopover).toHaveBeenCalled();
});

it('should still attempt setCellBackground when cellAround returns null', () => {
isCellSelection.mockReturnValue(false);
cellAround.mockReturnValue(null);

const wrapper = mountPicker();
wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', '#0000FF');

expect(mockEditor.commands.setCellSelection).not.toHaveBeenCalled();
expect(mockEditor.commands.setCellBackground).toHaveBeenCalledWith('#0000FF');
expect(closePopover).toHaveBeenCalled();
});

it('should map "none" to setCellAttr(background, null) for removing background', () => {
isCellSelection.mockReturnValue(true);

const wrapper = mountPicker();
wrapper.findComponent({ name: 'IconGrid' }).vm.$emit('select', 'none');

expect(mockEditor.commands.setCellBackground).not.toHaveBeenCalled();
expect(mockEditor.commands.setCellAttr).toHaveBeenCalledWith('background', null);
expect(closePopover).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ vi.mock('../constants.js', () => ({
paste: 'Paste',
trackChangesAccept: 'Accept Tracked Changes',
trackChangesReject: 'Reject Tracked Changes',
cellBackground: 'Cell background',
},
ICONS: {
ai: '<svg>ai-icon</svg>',
Expand All @@ -40,6 +41,7 @@ vi.mock('../constants.js', () => ({
cut: '<svg>cut-icon</svg>',
copy: '<svg>copy-icon</svg>',
paste: '<svg>paste-icon</svg>',
cellBackground: '<svg>cell-background-icon</svg>',
},
TRIGGERS: {
slash: 'slash',
Expand All @@ -51,6 +53,7 @@ vi.mock('../../toolbar/TableGrid.vue', () => ({ default: { template: '<div>Table
vi.mock('../../toolbar/AIWriter.vue', () => ({ default: { template: '<div>AIWriter</div>' } }));
vi.mock('../../toolbar/TableActions.vue', () => ({ default: { template: '<div>TableActions</div>' } }));
vi.mock('../../toolbar/LinkInput.vue', () => ({ default: { template: '<div>LinkInput</div>' } }));
vi.mock('../CellBackgroundPicker.vue', () => ({ default: { template: '<div>CellBackgroundPicker</div>' } }));

vi.mock('../../../core/utilities/clipboardUtils.js', () => ({
readClipboardRaw: clipboardMocks.readClipboardRaw,
Expand Down Expand Up @@ -575,6 +578,71 @@ describe('menuItems.js', () => {
});
});

describe('getItems - cell selection context', () => {
it('should show cell-background when isCellSelection is true and trigger is click', () => {
mockContext = createMockContext({
editor: mockEditor,
trigger: TRIGGERS.click,
isCellSelection: true,
tableSelectionKind: 'cells',
isInTable: true,
});

const sections = getItems(mockContext);
const generalSection = sections.find((s) => s.id === 'general');
const cellBgItem = generalSection?.items.find((item) => item.id === 'cell-background');

expect(cellBgItem).toBeDefined();
expect(cellBgItem.label).toBe('Cell background');
});

it('should show cell-background when right-clicking in a table cell without CellSelection', () => {
mockContext = createMockContext({
editor: mockEditor,
trigger: TRIGGERS.click,
isCellSelection: false,
isInTable: true,
});

const sections = getItems(mockContext);
const generalSection = sections.find((s) => s.id === 'general');
const cellBgItem = generalSection?.items.find((item) => item.id === 'cell-background');

expect(cellBgItem).toBeDefined();
});

it('should hide cell-background when not in a table at all', () => {
mockContext = createMockContext({
editor: mockEditor,
trigger: TRIGGERS.click,
isCellSelection: false,
isInTable: false,
});

const sections = getItems(mockContext);
const generalSection = sections.find((s) => s.id === 'general');
const cellBgItem = generalSection?.items.find((item) => item.id === 'cell-background');

expect(cellBgItem).toBeUndefined();
});

it('should hide cell-background on slash trigger even with cell selection', () => {
mockContext = createMockContext({
editor: mockEditor,
trigger: TRIGGERS.slash,
isCellSelection: true,
tableSelectionKind: 'row',
isInTable: true,
});

const sections = getItems(mockContext);
const allItems = sections.flatMap((s) => s.items);
const cellBgItem = allItems.find((item) => item.id === 'cell-background');

expect(cellBgItem).toBeUndefined();
});
});

describe('getItems - paste selection preservation (SD-1302)', () => {
/**
* Creates a mock editor with doc.content.size and selection.constructor.create
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export function createMockContext(options = {}) {
activeMarks: [],
isTrackedChange: false,
trackedChanges: [],
isCellSelection: false,
tableSelectionKind: null,
documentMode: 'editing',
canUndo: false,
canRedo: false,
Expand Down
Loading
Loading