Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed, markRaw } from 'vue';
import { Selection } from 'prosemirror-state';
import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js';
import { ContextMenuPluginKey } from '../../extensions/context-menu/context-menu.js';
import { getPropsByItemId } from './utils.js';
import { shouldBypassContextMenu } from '../../utils/contextmenu-helpers.js';
Expand Down Expand Up @@ -34,6 +36,39 @@ const sections = ref([]);
const selectedId = ref(null);
const currentContext = ref(null); // Store context for action execution

const TABLE_SURFACE_SELECTOR = '.superdoc-table-fragment, .superdoc-table-cell';

const hasExpandedSelection = (selection) => {
return (
Number.isFinite(selection?.from) &&
Number.isFinite(selection?.to) &&
Number(selection.from) !== Number(selection.to)
);
};

const setSelectionNearPos = (editor, pos, options = {}) => {
if (!editor?.state?.doc || !Number.isFinite(pos)) return false;
const doc = editor.state.doc;
const maxPos = doc.content.size;
const clampedPos = Math.max(0, Math.min(pos, maxPos));

try {
const resolved = doc.resolve(clampedPos);
const nextSelection = Selection.near(resolved, 1);
const tr = editor.state.tr.setSelection(nextSelection);
if (options.addToHistory === false) {
tr.setMeta('addToHistory', false);
}
editor.dispatch?.(tr);
if (options.focus) {
editor.focus?.();
}
return true;
} catch {
return false;
}
};

// Helper to close menu if editor becomes read-only
const handleEditorUpdate = () => {
if (!props.editor?.isEditable && isOpen.value) {
Expand Down Expand Up @@ -299,7 +334,7 @@ const handleRightClick = async (event) => {
// Update cursor position to the right-click location before opening context menu,
// unless the click lands inside an active selection (keep selection intact).
const editorState = props.editor?.state;
const hasRangeSelection = editorState?.selection?.from !== editorState?.selection?.to;
const hasRangeSelection = hasExpandedSelection(editorState?.selection);
let isClickInsideSelection = false;

if (hasRangeSelection && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
Expand All @@ -310,12 +345,31 @@ const handleRightClick = async (event) => {
}
}

if (!isClickInsideSelection) {
const target = event?.target;
const tableSurface = target instanceof Element ? target.closest(TABLE_SURFACE_SELECTOR) : null;
const tableCandidatePm = target instanceof Element ? target.closest('[data-pm-start]') : null;
const tableAnchorPos = Number.isFinite(Number(tableCandidatePm?.dataset?.pmStart))
? Number(tableCandidatePm.dataset.pmStart)
Comment thread
artem-harbour marked this conversation as resolved.
: null;
const selectionIsCell = isCellSelection(editorState?.selection);

if (tableSurface && Number.isFinite(tableAnchorPos) && !selectionIsCell && !hasRangeSelection) {
setSelectionNearPos(props.editor, tableAnchorPos, { focus: true });
} else if (!isClickInsideSelection) {
moveCursorToMouseEvent(event, props.editor);
}

try {
const context = await getEditorContext(props.editor, event);
const reseatForTable =
Boolean(tableSurface) &&
context?.isInTable &&
Number.isFinite(context?.pos) &&
!isCellSelection(props.editor?.state?.selection) &&
!hasExpandedSelection(props.editor?.state?.selection);
if (reseatForTable) {
setSelectionNearPos(props.editor, context.pos, { focus: true });
}
currentContext.value = context;
sections.value = getItems({ ...context, trigger: 'click' });
selectedId.value = flattenedItems.value[0]?.id || null;
Expand All @@ -339,6 +393,18 @@ const handleRightClick = async (event) => {

const executeCommand = async (item) => {
if (props.editor) {
const currentPos = currentContext.value?.pos;
const shouldReseatTableSelection =
currentContext.value?.event?.type === 'contextmenu' &&
currentContext.value?.isInTable &&
Number.isFinite(currentPos) &&
!isCellSelection(props.editor?.state?.selection) &&
!hasExpandedSelection(props.editor?.state?.selection);

if (shouldReseatTableSelection) {
setSelectionNearPos(props.editor, currentPos, { focus: true, addToHistory: false });
}

// First call the action if needed on the item
item.action ? await item.action(props.editor, currentContext.value) : null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,86 @@ describe('ContextMenu.vue', () => {
expect(moveCursorToMouseEvent).toHaveBeenCalledWith(rightClickEvent, mockEditor);
});

it('should not reseat selection in table when a text range is selected', async () => {
mount(ContextMenu, { props: mockProps });

const { moveCursorToMouseEvent } = await import('../../cursor-helpers.js');
moveCursorToMouseEvent.mockClear();
mockEditor.state.tr.setSelection.mockClear();

mockEditor.state.selection.from = 5;
mockEditor.state.selection.to = 15;
mockEditor.posAtCoords = vi.fn(() => ({ pos: 10 }));
mockGetEditorContext.mockResolvedValueOnce({
selectedText: 'selected text',
hasSelection: true,
isInTable: true,
pos: 10,
event: { type: 'contextmenu' },
});

const tableFragment = document.createElement('div');
tableFragment.className = 'superdoc-table-fragment';
const target = document.createElement('span');
target.dataset.pmStart = '10';
tableFragment.appendChild(target);

const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
(call) => call[0] === 'contextmenu' && call[2] !== true,
)[1];

await contextMenuHandler({
type: 'contextmenu',
clientX: 120,
clientY: 160,
target,
preventDefault: vi.fn(),
});

expect(moveCursorToMouseEvent).not.toHaveBeenCalled();
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
});

it('should reseat selection in table when selection is collapsed', async () => {
mount(ContextMenu, { props: mockProps });

const pmState = await import('prosemirror-state');
const nearSpy = vi.spyOn(pmState.Selection, 'near').mockReturnValue({ from: 10, to: 10 });

mockEditor.state.tr.setSelection.mockClear();
mockEditor.state.selection.from = 10;
mockEditor.state.selection.to = 10;
mockEditor.state.doc.content = { size: 100 };
mockGetEditorContext.mockResolvedValueOnce({
selectedText: '',
hasSelection: false,
isInTable: true,
pos: 10,
event: { type: 'contextmenu' },
});

const tableFragment = document.createElement('div');
tableFragment.className = 'superdoc-table-fragment';
const target = document.createElement('span');
target.dataset.pmStart = '10';
tableFragment.appendChild(target);

const contextMenuHandler = mockEditor.view.dom.addEventListener.mock.calls.find(
(call) => call[0] === 'contextmenu' && call[2] !== true,
)[1];

await contextMenuHandler({
type: 'contextmenu',
clientX: 120,
clientY: 160,
target,
preventDefault: vi.fn(),
});

expect(mockEditor.state.tr.setSelection).toHaveBeenCalled();
nearSpy.mockRestore();
});

it('should allow native context menu when modifier is pressed', async () => {
mount(ContextMenu, { props: mockProps });

Expand Down
Loading