Skip to content

Commit 9af59fa

Browse files
authored
feat(ui): context-menu contribution API on ui.commands (SD-2936) (#3145)
* feat(ui): add ui.viewport.entityAt for typed point-to-entity lookup (SD-2936) Right-click menus, hover tooltips, and any UI that asks "what's under this point?" today read `data-track-change-id` and `data-comment-ids` off `event.target.closest(...)` themselves. The attribute layout is an implementation detail of the painter that consumers shouldn't depend on, and the closest() walk makes id collisions silent (a trackedChange inside a comment highlight surfaces only the innermost hit). ui.viewport.entityAt({ x, y }) takes viewport coordinates (matching `MouseEvent.clientX/clientY` and `ViewportRect`) and returns `ViewportEntityHit[]` — every painted entity in the chain, innermost first. Supports `comment` and `trackedChange` today; `link`, `image`, and `tableCell` are reserved as additive union members so consumers switching on `hit.type` with a default branch stay forward-compatible. The DOM walk is a pure helper (`collectEntityHitsFromChain`) so it's testable without stubbing `document.elementFromPoint` (happy-dom in this repo doesn't ship the method natively, and per-realm prototype mutation didn't survive between the test and source files). The controller method composes the helper with `elementFromPoint`. Stacks on top of caio/sd-2936-selection-rects. * fix(ui): scope entityAt to mounted host + publish new types (SD-2936) Two review issues from PR #3139: 1. entityAt previously called document.elementFromPoint globally and walked all ancestors with no check that the controller had a mounted editor or that the hit landed inside this instance's painted DOM. A page mounting two SuperDoc instances would have one's entityAt return ids from the other; post-destroy calls would return stale ids from cached painted nodes. Now resolves the host editor via resolveHostEditor, reads presentationEditor.visibleHost (newly added to the structural type), and returns [] when the host is missing or the hit element isn't inside it. 2. The published `superdoc/ui` declaration barrel at packages/superdoc/src/ui.d.ts didn't list the new public types, so `import type { ViewportEntityHit, ViewportEntityAtInput } from 'superdoc/ui'` failed for consumers. Same gap existed for SelectionAnchorRectOptions from PR #3134. Added all three. * fix(ui): reach the DOM document via globalThis in entityAt (SD-2936) * feat(ui): context-menu contribution API on ui.commands (SD-2936) * fix(ui): stabilize custom-group rank in getContextMenuItems (SD-2936)
1 parent 2ef73ff commit 9af59fa

6 files changed

Lines changed: 446 additions & 1 deletion

File tree

packages/super-editor/src/ui/create-super-doc-ui.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
CommandHandle,
2525
CommandsHandle,
2626
CommentsHandle,
27+
ContextMenuItem,
2728
DocumentExportInput,
2829
DocumentHandle,
2930
DocumentSlice,
@@ -1165,6 +1166,15 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
11651166
return handle;
11661167
};
11671168
}
1169+
// Custom-UI consumers building their own context menu pull
1170+
// contributed items here. Computed against the current snapshot
1171+
// (so `selection` matches what observers just saw) and the
1172+
// caller-supplied entities from `ui.viewport.entityAt`.
1173+
if (prop === 'getContextMenuItems') {
1174+
return (input?: { entities?: ViewportEntityHit[] }): ContextMenuItem[] => {
1175+
return customCommandsRegistry.getContextMenuItems(computeState(), input?.entities ?? []);
1176+
};
1177+
}
11681178
// Custom-registered ids surface a typed handle from the registry.
11691179
// Built-in ids fall through to the existing per-id cache so they
11701180
// keep the same observe/execute shape they had before SD-2802.

packages/super-editor/src/ui/custom-commands.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,3 +949,200 @@ describe('ui.commands.get', () => {
949949
ui.destroy();
950950
});
951951
});
952+
953+
describe('ui.commands.getContextMenuItems', () => {
954+
it('returns [] when no registered command carries a contextMenu field', () => {
955+
const { superdoc } = makeStubs();
956+
const ui = createSuperDocUI({ superdoc });
957+
958+
ui.commands.register({ id: 'company.plain', execute: () => true });
959+
960+
expect(ui.commands.getContextMenuItems()).toEqual([]);
961+
962+
ui.destroy();
963+
});
964+
965+
it('surfaces contributions, filling defaults for group / order', () => {
966+
const { superdoc } = makeStubs();
967+
const ui = createSuperDocUI({ superdoc });
968+
969+
ui.commands.register({
970+
id: 'company.acceptChange',
971+
execute: () => true,
972+
contextMenu: { label: 'Accept suggestion' },
973+
});
974+
975+
expect(ui.commands.getContextMenuItems()).toEqual([
976+
{ id: 'company.acceptChange', label: 'Accept suggestion', group: 'custom', order: 0 },
977+
]);
978+
979+
ui.destroy();
980+
});
981+
982+
it('filters items by the when predicate using caller-supplied entities + current selection', () => {
983+
const { superdoc } = makeStubs();
984+
const ui = createSuperDocUI({ superdoc });
985+
986+
const whenSpy = vi.fn(({ entities }) => entities.some((e) => e.type === 'trackedChange'));
987+
ui.commands.register({
988+
id: 'company.acceptChange',
989+
execute: () => true,
990+
contextMenu: { label: 'Accept suggestion', group: 'review', when: whenSpy },
991+
});
992+
993+
expect(ui.commands.getContextMenuItems({ entities: [{ type: 'comment', id: 'c1' }] })).toEqual([]);
994+
expect(ui.commands.getContextMenuItems({ entities: [{ type: 'trackedChange', id: 'tc1' }] })).toHaveLength(1);
995+
996+
expect(whenSpy).toHaveBeenCalledTimes(2);
997+
expect(whenSpy.mock.calls[0]![0].selection).toBeDefined();
998+
999+
ui.destroy();
1000+
});
1001+
1002+
it('sorts by built-in group order, then order, then registration sequence', () => {
1003+
const { superdoc } = makeStubs();
1004+
const ui = createSuperDocUI({ superdoc });
1005+
1006+
// Built-in group order: format(0), clipboard(1), review(2), comment(3), link(4)
1007+
ui.commands.register({
1008+
id: 'a.review-2',
1009+
execute: () => true,
1010+
contextMenu: { label: 'Review B', group: 'review', order: 20 },
1011+
});
1012+
ui.commands.register({
1013+
id: 'b.format-1',
1014+
execute: () => true,
1015+
contextMenu: { label: 'Format A', group: 'format', order: 0 },
1016+
});
1017+
ui.commands.register({
1018+
id: 'c.review-1',
1019+
execute: () => true,
1020+
contextMenu: { label: 'Review A', group: 'review', order: 10 },
1021+
});
1022+
ui.commands.register({
1023+
id: 'd.review-tie-second',
1024+
execute: () => true,
1025+
contextMenu: { label: 'Review C', group: 'review', order: 10 },
1026+
});
1027+
ui.commands.register({
1028+
id: 'e.custom',
1029+
execute: () => true,
1030+
contextMenu: { label: 'Z', group: 'company.workflow', order: 0 },
1031+
});
1032+
1033+
expect(ui.commands.getContextMenuItems().map((i) => i.id)).toEqual([
1034+
'b.format-1',
1035+
'c.review-1',
1036+
'd.review-tie-second',
1037+
'a.review-2',
1038+
'e.custom',
1039+
]);
1040+
1041+
ui.destroy();
1042+
});
1043+
1044+
it('plain custom commands (no contextMenu) do not anchor a custom group rank', () => {
1045+
const { superdoc } = makeStubs();
1046+
const ui = createSuperDocUI({ superdoc });
1047+
1048+
// Register a plain command first (seq 0) — it has no contextMenu
1049+
// and must not claim the 'custom' fallback group's rank anchor.
1050+
ui.commands.register({ id: 'a.plain', execute: () => true });
1051+
// Register a workflow contribution (seq 1).
1052+
ui.commands.register({
1053+
id: 'b.workflow',
1054+
execute: () => true,
1055+
contextMenu: { label: 'Workflow A', group: 'company.workflow' },
1056+
});
1057+
// Register a 'custom' fallback group contribution (seq 2).
1058+
ui.commands.register({
1059+
id: 'c.custom',
1060+
execute: () => true,
1061+
contextMenu: { label: 'Default A' },
1062+
});
1063+
1064+
// 'company.workflow' (seq 1) must rank before 'custom' (seq 2).
1065+
// If the plain seq=0 command anchored 'custom', the order would
1066+
// flip.
1067+
expect(ui.commands.getContextMenuItems().map((i) => i.id)).toEqual(['b.workflow', 'c.custom']);
1068+
1069+
ui.destroy();
1070+
});
1071+
1072+
it('preserves a group rank anchor when one contributor is replaced and another remains', () => {
1073+
const { superdoc } = makeStubs();
1074+
const ui = createSuperDocUI({ superdoc });
1075+
1076+
// Group 'workflow' opens with two contributors at seq 0 and seq 1.
1077+
ui.commands.register({
1078+
id: 'wf.first',
1079+
execute: () => true,
1080+
contextMenu: { label: 'WF 1', group: 'company.workflow', order: 0 },
1081+
});
1082+
ui.commands.register({
1083+
id: 'wf.second',
1084+
execute: () => true,
1085+
contextMenu: { label: 'WF 2', group: 'company.workflow', order: 1 },
1086+
});
1087+
// A second custom group registers at seq 2.
1088+
ui.commands.register({
1089+
id: 'rev.first',
1090+
execute: () => true,
1091+
contextMenu: { label: 'Rev 1', group: 'company.review-extras', order: 0 },
1092+
});
1093+
1094+
// Now replace `wf.first` — the new seq becomes 3, but `wf.second`
1095+
// still carries the original seq 1, so the workflow group's
1096+
// anchor must stay at 1 and render before 'review-extras' (seq 2).
1097+
ui.commands.register({
1098+
id: 'wf.first',
1099+
execute: () => true,
1100+
contextMenu: { label: 'WF 1 (replaced)', group: 'company.workflow', order: 0 },
1101+
});
1102+
1103+
expect(ui.commands.getContextMenuItems().map((i) => i.id)).toEqual(['wf.first', 'wf.second', 'rev.first']);
1104+
1105+
ui.destroy();
1106+
});
1107+
1108+
it('hides items whose when predicate throws and logs the error once per distinct message', () => {
1109+
const { superdoc } = makeStubs();
1110+
const ui = createSuperDocUI({ superdoc });
1111+
1112+
ui.commands.register({
1113+
id: 'company.flaky',
1114+
execute: () => true,
1115+
contextMenu: {
1116+
label: 'Flaky',
1117+
when: () => {
1118+
throw new Error('boom');
1119+
},
1120+
},
1121+
});
1122+
1123+
expect(ui.commands.getContextMenuItems()).toEqual([]);
1124+
expect(ui.commands.getContextMenuItems()).toEqual([]);
1125+
expect(errorSpy).toHaveBeenCalledTimes(1);
1126+
1127+
ui.destroy();
1128+
});
1129+
1130+
it("refuses 'getContextMenuItems' as a custom command id (Proxy collision)", () => {
1131+
const { superdoc } = makeStubs();
1132+
const ui = createSuperDocUI({ superdoc });
1133+
1134+
const reg = ui.commands.register({
1135+
id: 'getContextMenuItems',
1136+
execute: () => true,
1137+
contextMenu: { label: 'Should not register' },
1138+
});
1139+
1140+
expect(warnSpy).toHaveBeenCalled();
1141+
// Refused registrations get a no-op handle, so the contribution
1142+
// never enters the registry.
1143+
expect(ui.commands.getContextMenuItems()).toEqual([]);
1144+
reg.unregister();
1145+
1146+
ui.destroy();
1147+
});
1148+
});

0 commit comments

Comments
 (0)