Skip to content

Commit 43bb2e6

Browse files
fix: table cell selection with context menu in collab (#2822)
1 parent 344be7a commit 43bb2e6

2 files changed

Lines changed: 148 additions & 2 deletions

File tree

packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script setup>
22
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed, markRaw } from 'vue';
3+
import { Selection } from 'prosemirror-state';
4+
import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js';
35
import { ContextMenuPluginKey } from '../../extensions/context-menu/context-menu.js';
46
import { getPropsByItemId } from './utils.js';
57
import { shouldBypassContextMenu } from '../../utils/contextmenu-helpers.js';
@@ -34,6 +36,39 @@ const sections = ref([]);
3436
const selectedId = ref(null);
3537
const currentContext = ref(null); // Store context for action execution
3638
39+
const TABLE_SURFACE_SELECTOR = '.superdoc-table-fragment, .superdoc-table-cell';
40+
41+
const hasExpandedSelection = (selection) => {
42+
return (
43+
Number.isFinite(selection?.from) &&
44+
Number.isFinite(selection?.to) &&
45+
Number(selection.from) !== Number(selection.to)
46+
);
47+
};
48+
49+
const setSelectionNearPos = (editor, pos, options = {}) => {
50+
if (!editor?.state?.doc || !Number.isFinite(pos)) return false;
51+
const doc = editor.state.doc;
52+
const maxPos = doc.content.size;
53+
const clampedPos = Math.max(0, Math.min(pos, maxPos));
54+
55+
try {
56+
const resolved = doc.resolve(clampedPos);
57+
const nextSelection = Selection.near(resolved, 1);
58+
const tr = editor.state.tr.setSelection(nextSelection);
59+
if (options.addToHistory === false) {
60+
tr.setMeta('addToHistory', false);
61+
}
62+
editor.dispatch?.(tr);
63+
if (options.focus) {
64+
editor.focus?.();
65+
}
66+
return true;
67+
} catch {
68+
return false;
69+
}
70+
};
71+
3772
// Helper to close menu if editor becomes read-only
3873
const handleEditorUpdate = () => {
3974
if (!props.editor?.isEditable && isOpen.value) {
@@ -299,7 +334,7 @@ const handleRightClick = async (event) => {
299334
// Update cursor position to the right-click location before opening context menu,
300335
// unless the click lands inside an active selection (keep selection intact).
301336
const editorState = props.editor?.state;
302-
const hasRangeSelection = editorState?.selection?.from !== editorState?.selection?.to;
337+
const hasRangeSelection = hasExpandedSelection(editorState?.selection);
303338
let isClickInsideSelection = false;
304339
305340
if (hasRangeSelection && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
@@ -310,12 +345,31 @@ const handleRightClick = async (event) => {
310345
}
311346
}
312347
313-
if (!isClickInsideSelection) {
348+
const target = event?.target;
349+
const tableSurface = target instanceof Element ? target.closest(TABLE_SURFACE_SELECTOR) : null;
350+
const tableCandidatePm = target instanceof Element ? target.closest('[data-pm-start]') : null;
351+
const tableAnchorPos = Number.isFinite(Number(tableCandidatePm?.dataset?.pmStart))
352+
? Number(tableCandidatePm.dataset.pmStart)
353+
: null;
354+
const selectionIsCell = isCellSelection(editorState?.selection);
355+
356+
if (tableSurface && Number.isFinite(tableAnchorPos) && !selectionIsCell && !hasRangeSelection) {
357+
setSelectionNearPos(props.editor, tableAnchorPos, { focus: true });
358+
} else if (!isClickInsideSelection) {
314359
moveCursorToMouseEvent(event, props.editor);
315360
}
316361
317362
try {
318363
const context = await getEditorContext(props.editor, event);
364+
const reseatForTable =
365+
Boolean(tableSurface) &&
366+
context?.isInTable &&
367+
Number.isFinite(context?.pos) &&
368+
!isCellSelection(props.editor?.state?.selection) &&
369+
!hasExpandedSelection(props.editor?.state?.selection);
370+
if (reseatForTable) {
371+
setSelectionNearPos(props.editor, context.pos, { focus: true });
372+
}
319373
currentContext.value = context;
320374
sections.value = getItems({ ...context, trigger: 'click' });
321375
selectedId.value = flattenedItems.value[0]?.id || null;
@@ -339,6 +393,18 @@ const handleRightClick = async (event) => {
339393
340394
const executeCommand = async (item) => {
341395
if (props.editor) {
396+
const currentPos = currentContext.value?.pos;
397+
const shouldReseatTableSelection =
398+
currentContext.value?.event?.type === 'contextmenu' &&
399+
currentContext.value?.isInTable &&
400+
Number.isFinite(currentPos) &&
401+
!isCellSelection(props.editor?.state?.selection) &&
402+
!hasExpandedSelection(props.editor?.state?.selection);
403+
404+
if (shouldReseatTableSelection) {
405+
setSelectionNearPos(props.editor, currentPos, { focus: true, addToHistory: false });
406+
}
407+
342408
// First call the action if needed on the item
343409
item.action ? await item.action(props.editor, currentContext.value) : null;
344410

packages/super-editor/src/editors/v1/components/context-menu/tests/ContextMenu.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,86 @@ describe('ContextMenu.vue', () => {
316316
expect(moveCursorToMouseEvent).toHaveBeenCalledWith(rightClickEvent, mockEditor);
317317
});
318318

319+
it('should not reseat selection in table when a text range is selected', async () => {
320+
mount(ContextMenu, { props: mockProps });
321+
322+
const { moveCursorToMouseEvent } = await import('../../cursor-helpers.js');
323+
moveCursorToMouseEvent.mockClear();
324+
mockEditor.state.tr.setSelection.mockClear();
325+
326+
mockEditor.state.selection.from = 5;
327+
mockEditor.state.selection.to = 15;
328+
mockEditor.posAtCoords = vi.fn(() => ({ pos: 10 }));
329+
mockGetEditorContext.mockResolvedValueOnce({
330+
selectedText: 'selected text',
331+
hasSelection: true,
332+
isInTable: true,
333+
pos: 10,
334+
event: { type: 'contextmenu' },
335+
});
336+
337+
const tableFragment = document.createElement('div');
338+
tableFragment.className = 'superdoc-table-fragment';
339+
const target = document.createElement('span');
340+
target.dataset.pmStart = '10';
341+
tableFragment.appendChild(target);
342+
343+
const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
344+
(call) => call[0] === 'contextmenu' && call[2] !== true,
345+
)[1];
346+
347+
await contextMenuHandler({
348+
type: 'contextmenu',
349+
clientX: 120,
350+
clientY: 160,
351+
target,
352+
preventDefault: vi.fn(),
353+
});
354+
355+
expect(moveCursorToMouseEvent).not.toHaveBeenCalled();
356+
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
357+
});
358+
359+
it('should reseat selection in table when selection is collapsed', async () => {
360+
mount(ContextMenu, { props: mockProps });
361+
362+
const pmState = await import('prosemirror-state');
363+
const nearSpy = vi.spyOn(pmState.Selection, 'near').mockReturnValue({ from: 10, to: 10 });
364+
365+
mockEditor.state.tr.setSelection.mockClear();
366+
mockEditor.state.selection.from = 10;
367+
mockEditor.state.selection.to = 10;
368+
mockEditor.state.doc.content = { size: 100 };
369+
mockGetEditorContext.mockResolvedValueOnce({
370+
selectedText: '',
371+
hasSelection: false,
372+
isInTable: true,
373+
pos: 10,
374+
event: { type: 'contextmenu' },
375+
});
376+
377+
const tableFragment = document.createElement('div');
378+
tableFragment.className = 'superdoc-table-fragment';
379+
const target = document.createElement('span');
380+
target.dataset.pmStart = '10';
381+
tableFragment.appendChild(target);
382+
383+
const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
384+
(call) => call[0] === 'contextmenu' && call[2] !== true,
385+
)[1];
386+
387+
await contextMenuHandler({
388+
type: 'contextmenu',
389+
clientX: 120,
390+
clientY: 160,
391+
target,
392+
preventDefault: vi.fn(),
393+
});
394+
395+
expect(mockEditor.state.tr.setSelection).toHaveBeenCalled();
396+
nearSpy.mockRestore();
397+
});
398+
319399
it('should allow native context menu when modifier is pressed', async () => {
320400
mount(ContextMenu, { props: mockProps });
321401

0 commit comments

Comments
 (0)