Skip to content
20 changes: 10 additions & 10 deletions packages/super-editor/src/components/context-menu/menuItems.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,11 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
label: TEXTS.trackChangesAccept,
isDefault: true,
action: (editor, context) => {
if (context?.trackedChangeId) {
editor.commands.acceptTrackedChangeById(context.trackedChangeId);
} else {
editor.commands.acceptTrackedChangeBySelection();
}
editor.commands.acceptTrackedChangeFromContextMenu({
from: context?.selectionStart,
to: context?.selectionEnd,
trackedChangeId: context?.trackedChangeId,
});
},
showWhen: (context) => {
const { trigger, isTrackedChange } = context;
Expand All @@ -167,11 +167,11 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
icon: ICONS.trackChangesReject,
isDefault: true,
action: (editor, context) => {
if (context?.trackedChangeId) {
editor.commands.rejectTrackedChangeById(context.trackedChangeId);
} else {
editor.commands.rejectTrackedChangeOnSelection();
}
editor.commands.rejectTrackedChangeFromContextMenu({
from: context?.selectionStart,
to: context?.selectionEnd,
trackedChangeId: context?.trackedChangeId,
});
},
showWhen: (context) => {
const { trigger, isTrackedChange } = context;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,88 @@ describe('menuItems.js', () => {
expect(itemIds).not.toContain('track-changes-reject');
});

it('routes tracked-change context-menu actions through selection commands when text is selected', () => {
const acceptTrackedChangeFromContextMenu = vi.fn();
const rejectTrackedChangeFromContextMenu = vi.fn();

mockEditor.commands = {
acceptTrackedChangeFromContextMenu,
rejectTrackedChangeFromContextMenu,
};

mockContext = createMockContext({
editor: mockEditor,
trigger: TRIGGERS.click,
hasSelection: true,
isTrackedChange: true,
selectionStart: 10,
selectionEnd: 14,
trackedChangeId: 'tracked-change-1',
});

const sections = getItems(mockContext);
const trackSection = sections.find((section) => section.id === 'track-changes');
const acceptItem = trackSection?.items.find((item) => item.id === 'track-changes-accept');
const rejectItem = trackSection?.items.find((item) => item.id === 'track-changes-reject');

expect(acceptItem).toBeDefined();
expect(rejectItem).toBeDefined();

acceptItem.action(mockEditor, mockContext);
rejectItem.action(mockEditor, mockContext);

expect(acceptTrackedChangeFromContextMenu).toHaveBeenCalledWith({
from: 10,
to: 14,
trackedChangeId: 'tracked-change-1',
});
expect(rejectTrackedChangeFromContextMenu).toHaveBeenCalledWith({
from: 10,
to: 14,
trackedChangeId: 'tracked-change-1',
});
});

it('routes tracked-change context-menu actions through toolbar commands for collapsed selections', () => {
const acceptTrackedChangeFromContextMenu = vi.fn();
const rejectTrackedChangeFromContextMenu = vi.fn();

mockEditor.commands = {
acceptTrackedChangeFromContextMenu,
rejectTrackedChangeFromContextMenu,
};

mockContext = createMockContext({
editor: mockEditor,
trigger: TRIGGERS.click,
hasSelection: false,
isTrackedChange: true,
trackedChangeId: 'tracked-change-2',
});

const sections = getItems(mockContext);
const trackSection = sections.find((section) => section.id === 'track-changes');
const acceptItem = trackSection?.items.find((item) => item.id === 'track-changes-accept');
const rejectItem = trackSection?.items.find((item) => item.id === 'track-changes-reject');

expect(acceptItem).toBeDefined();
expect(rejectItem).toBeDefined();

acceptItem.action(mockEditor, mockContext);
rejectItem.action(mockEditor, mockContext);

expect(acceptTrackedChangeFromContextMenu).toHaveBeenCalledWith({
from: 10,
to: 10,
trackedChangeId: 'tracked-change-2',
});
expect(rejectTrackedChangeFromContextMenu).toHaveBeenCalledWith({
from: 10,
to: 10,
trackedChangeId: 'tracked-change-2',
});
});

it('should filter AI items when AI module is not enabled', () => {
const sections = getItems(mockContext);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,19 @@ import { undoDepth, redoDepth } from 'prosemirror-history';
import { yUndoPluginKey } from 'y-prosemirror';
import { isCellSelection as isCellSelectionMock } from '@extensions/table/tableHelpers/isCellSelection.js';
import { selectedRect as selectedRectMock } from 'prosemirror-tables';
import {
collectTrackedChanges,
collectTrackedChangesForContext,
} from '@extensions/track-changes/permission-helpers.js';

// Get the mocked functions
const mockReadFromClipboard = vi.mocked(readFromClipboard);
const mockSelectionHasNodeOrMark = vi.mocked(selectionHasNodeOrMark);
const mockUndoDepth = vi.mocked(undoDepth);
const mockRedoDepth = vi.mocked(redoDepth);
const mockYUndoPluginKeyGetState = vi.mocked(yUndoPluginKey.getState);
const mockCollectTrackedChanges = vi.mocked(collectTrackedChanges);
const mockCollectTrackedChangesForContext = vi.mocked(collectTrackedChangesForContext);

describe('utils.js', () => {
let mockEditor;
Expand All @@ -84,6 +90,10 @@ describe('utils.js', () => {
mockUndoDepth.mockReturnValue(1);
mockRedoDepth.mockReturnValue(1);
mockYUndoPluginKeyGetState.mockReturnValue({ undoManager: { undoStack: [1], redoStack: [1] } });
mockCollectTrackedChanges.mockReset();
mockCollectTrackedChangesForContext.mockReset();
mockCollectTrackedChanges.mockReturnValue([]);
mockCollectTrackedChangesForContext.mockReturnValue([]);

// Create editor with default configuration
mockEditor = createMockEditor({
Expand Down Expand Up @@ -255,6 +265,57 @@ describe('utils.js', () => {
expect(Array.isArray(context.trackedChanges)).toBe(true);
});

it('prefers preserved selection for right-click context when the live selection collapsed inside it', async () => {
const mockEvent = { clientX: 300, clientY: 400 };

mockSelectionHasNodeOrMark.mockReturnValue(false);
mockEditor.options.preservedSelection = { from: 10, to: 15 };
mockEditor.view.state.selection.empty = true;
mockEditor.view.state.selection.from = 12;
mockEditor.view.state.selection.to = 12;
mockEditor.view.posAtCoords.mockReturnValue({ pos: 12 });
mockEditor.view.state.doc.nodeAt.mockReturnValue({ type: { name: 'text' } });
mockEditor.view.state.doc.textBetween.mockReturnValue('selected text');
mockEditor.view.state.doc.resolve.mockReturnValue({
marks: vi.fn(() => []),
nodeBefore: null,
nodeAfter: null,
});

const context = await getEditorContext(mockEditor, mockEvent);

expect(context.hasSelection).toBe(true);
expect(context.selectionStart).toBe(10);
expect(context.selectionEnd).toBe(15);
expect(context.selectedText).toBe('selected text');
});

it('uses selection-scoped tracked changes for right-click actions inside an expanded selection', async () => {
const mockEvent = { clientX: 300, clientY: 400 };

mockSelectionHasNodeOrMark.mockReturnValue(false);
mockEditor.view.state.selection.empty = false;
mockEditor.view.state.selection.from = 10;
mockEditor.view.state.selection.to = 15;
mockEditor.view.posAtCoords.mockReturnValue({ pos: 12 });
mockEditor.view.state.doc.nodeAt.mockReturnValue({ type: { name: 'text' } });
mockEditor.view.state.doc.textBetween.mockReturnValue('selected text');
mockEditor.view.state.doc.resolve.mockReturnValue({
marks: vi.fn(() => []),
nodeBefore: null,
nodeAfter: null,
});

await getEditorContext(mockEditor, mockEvent);

expect(mockCollectTrackedChanges).toHaveBeenCalledWith({
state: mockEditor.view.state,
from: 10,
to: 15,
});
expect(mockCollectTrackedChangesForContext).not.toHaveBeenCalled();
});

it('should detect tracked change marks directly at the resolved cursor position', async () => {
const mockEvent = { clientX: 150, clientY: 250 };
const trackFormatMark = { type: { name: 'trackFormat' }, attrs: { id: 'track-format-1' } };
Expand Down
49 changes: 40 additions & 9 deletions packages/super-editor/src/components/context-menu/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@extensions/track-changes/permission-helpers.js';
import { isList } from '@core/commands/list-helpers';
import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js';
import { hasExpandedSelection } from '@utils/selectionUtils.js';
import { selectedRect } from 'prosemirror-tables';
/**
* Get props by item id
Expand Down Expand Up @@ -85,9 +86,7 @@ export async function getEditorContext(editor, event) {

const state = editor.state;
if (!state) return null;

const { from, to, empty } = state.selection;
const selectedText = !empty ? state.doc.textBetween(from, to) : '';
const { from } = state.selection;

let pos = null;
let node = null;
Expand All @@ -106,6 +105,10 @@ export async function getEditorContext(editor, event) {
node = state.doc.nodeAt(pos);
}

const selection = getContextSelection({ editor, state, pos, event });
const hasSelection = hasExpandedSelection(selection);
const selectedText = hasSelection ? state.doc.textBetween(selection.from, selection.to) : '';

// Don't read clipboard proactively to avoid permission prompts
// Clipboard will be read only when user actually clicks "Paste"
const clipboardContent = {
Expand Down Expand Up @@ -166,10 +169,15 @@ export async function getEditorContext(editor, event) {
const isTrackedChange =
activeMarks.includes('trackInsert') || activeMarks.includes('trackDelete') || activeMarks.includes('trackFormat');

const trackedChanges =
event && pos !== null
// If there is an expanded selection and the right-click happened inside
// that selection, use collectTrackedChanges for the full selection range
const shouldUseSelectionTrackedChanges =
event && pos !== null ? hasExpandedSelection(selection) && selectionContainsPos(selection, pos) : hasSelection;
const trackedChanges = shouldUseSelectionTrackedChanges
? collectTrackedChanges({ state, from: selection.from, to: selection.to })
: event && pos !== null
? collectTrackedChangesForContext({ state, pos, trackedChangeId })
: collectTrackedChanges({ state, from, to });
: collectTrackedChanges({ state, from: selection.from, to: selection.to });

const cursorCoords = pos !== null ? editor.coordsAtPos?.(pos) : null;
const cursorPosition = cursorCoords
Expand All @@ -183,9 +191,9 @@ export async function getEditorContext(editor, event) {

return {
selectedText,
hasSelection: !empty,
selectionStart: from,
selectionEnd: to,
hasSelection,
selectionStart: selection.from,
selectionEnd: selection.to,
isInTable,
isInList,
isInSectionNode,
Expand All @@ -210,6 +218,29 @@ export async function getEditorContext(editor, event) {
};
}

function selectionContainsPos(selection, pos) {
return hasExpandedSelection(selection) && Number.isFinite(pos) && pos >= selection.from && pos <= selection.to;
}

function getContextSelection({ editor, state, pos, event }) {
const currentSelection = state.selection;
const preservedSelection = editor?.options?.preservedSelection ?? editor?.options?.lastSelection;

if (hasExpandedSelection(currentSelection)) {
return currentSelection;
}

if (!hasExpandedSelection(preservedSelection)) {
return currentSelection;
}

if (event) {
return selectionContainsPos(preservedSelection, pos) ? preservedSelection : currentSelection;
}

return preservedSelection;
}

function computeCanUndo(editor, state) {
if (typeof editor?.can === 'function') {
try {
Expand Down
Loading
Loading