Skip to content

Commit 26cef26

Browse files
authored
feat(super-editor): bridge editor selection into Document API commands (#2458)
* feat(super-editor): bridge editor selection into Document API commands * fix(super-editor): preserve inclusive selection tracking for Document API bridge
1 parent 997714a commit 26cef26

22 files changed

Lines changed: 2084 additions & 30 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
2+
3+
import { getStarterExtensions } from '@extensions/index.js';
4+
import { loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
5+
6+
import { Editor } from './Editor.js';
7+
8+
let blankDocData: { docx: unknown; mediaFiles: unknown; fonts: unknown };
9+
const editors: Editor[] = [];
10+
11+
beforeAll(async () => {
12+
blankDocData = await loadTestDataForEditorTests('blank-doc.docx');
13+
});
14+
15+
afterEach(() => {
16+
while (editors.length > 0) {
17+
editors.pop()?.destroy();
18+
}
19+
});
20+
21+
function createTestEditor(options: Partial<ConstructorParameters<typeof Editor>[0]> = {}): Editor {
22+
const editor = new Editor({
23+
isHeadless: true,
24+
deferDocumentLoad: true,
25+
mode: 'docx',
26+
extensions: getStarterExtensions(),
27+
suppressDefaultDocxStyles: true,
28+
...options,
29+
});
30+
editors.push(editor);
31+
return editor;
32+
}
33+
34+
function getBlankDocOptions() {
35+
return {
36+
mode: 'docx' as const,
37+
content: blankDocData.docx,
38+
mediaFiles: blankDocData.mediaFiles,
39+
fonts: blankDocData.fonts,
40+
};
41+
}
42+
43+
describe('Editor selection-handle surface inference', () => {
44+
it('defaults direct header editor captures to the header surface', async () => {
45+
const editor = createTestEditor({
46+
isHeaderOrFooter: true,
47+
headerFooterType: 'header',
48+
});
49+
await editor.open(undefined, getBlankDocOptions());
50+
51+
expect(editor.captureCurrentSelectionHandle().surface).toBe('header');
52+
});
53+
54+
it('defaults direct footer editor captures to the footer surface', async () => {
55+
const editor = createTestEditor({
56+
isHeaderOrFooter: true,
57+
headerFooterType: 'footer',
58+
});
59+
await editor.open(undefined, getBlankDocOptions());
60+
61+
expect(editor.captureEffectiveSelectionHandle().surface).toBe('footer');
62+
});
63+
});

packages/super-editor/src/core/Editor.ts

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ import { ExtensionService } from './ExtensionService.js';
1818
import { CommandService } from './CommandService.js';
1919
import { Attribute } from './Attribute.js';
2020
import { SuperConverter } from '@core/super-converter/SuperConverter.js';
21-
import { Commands, Editable, EditorFocus, Keymap, PositionTrackerExtension } from './extensions/index.js';
21+
import {
22+
Commands,
23+
Editable,
24+
EditorFocus,
25+
Keymap,
26+
PositionTrackerExtension,
27+
SelectionHandleExtension,
28+
} from './extensions/index.js';
2229
import { createDocument } from './helpers/createDocument.js';
2330
import { isActive } from './helpers/isActive.js';
2431
import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js';
@@ -59,9 +66,18 @@ import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js';
5966
import { BLANK_DOCX_DATA_URI } from './blank-docx.js';
6067
import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js';
6168
import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common';
62-
import type { DocumentApi } from '@superdoc/document-api';
69+
import type { DocumentApi, ResolveRangeOutput } from '@superdoc/document-api';
6370
import { createDocumentApi } from '@superdoc/document-api';
6471
import { getDocumentApiAdapters } from '../document-api-adapters/index.js';
72+
import {
73+
resolveCurrentEditorSelectionRange,
74+
resolveEffectiveEditorSelectionRange,
75+
selectCurrentPmSelection,
76+
selectEffectivePmSelection,
77+
resolvePmSelectionToRange,
78+
} from '../document-api-adapters/helpers/selection-range-resolver.js';
79+
import { captureSelectionHandle, resolveHandleToSelection, releaseSelectionHandle } from './selection-state.js';
80+
import type { SelectionHandle } from './selection-state.js';
6581
import { initPartsRuntime } from './parts/init-parts-runtime.js';
6682
import { syncPackageMetadata } from './opc/sync-package-metadata.js';
6783

@@ -1280,6 +1296,124 @@ export class Editor extends EventEmitter<EditorEventMap> {
12801296
return this.#documentApi;
12811297
}
12821298

1299+
// -------------------------------------------------------------------
1300+
// Selection bridge — tracked handles + snapshot convenience
1301+
// -------------------------------------------------------------------
1302+
1303+
/**
1304+
* Infers the default capture surface for this editor instance.
1305+
*
1306+
* Body editors report `body`. Header/footer child editors created by the
1307+
* pagination helpers persist their concrete surface kind in
1308+
* `options.headerFooterType`, allowing direct calls on
1309+
* `presentationEditor.getActiveEditor()` to produce handles with the
1310+
* correct surface label without requiring every caller to pass it manually.
1311+
*/
1312+
#getDefaultSelectionHandleSurface(): 'body' | 'header' | 'footer' {
1313+
const explicitType = this.options.headerFooterType;
1314+
return explicitType === 'header' || explicitType === 'footer' ? explicitType : 'body';
1315+
}
1316+
1317+
/**
1318+
* Capture the live PM selection as a tracked handle.
1319+
*
1320+
* The handle's bookmark is automatically mapped through every subsequent
1321+
* transaction, so it always reflects the current document. When ready,
1322+
* call {@link resolveSelectionHandle} to get a fresh `ResolveRangeOutput`.
1323+
*
1324+
* Use this for deferred UI flows (AI, confirmation dialogs, async chains)
1325+
* where a delay exists between selection capture and mutation.
1326+
*
1327+
* Local-only — captures from **this** editor's `state.selection`.
1328+
*/
1329+
captureCurrentSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle {
1330+
this.#assertState('ready', 'saving');
1331+
const selection = selectCurrentPmSelection(this);
1332+
return captureSelectionHandle(this, selection, surface ?? this.#getDefaultSelectionHandleSurface());
1333+
}
1334+
1335+
/**
1336+
* Capture the "effective" selection as a tracked handle.
1337+
*
1338+
* Uses the same fallback chain as {@link getEffectiveSelectionRange}:
1339+
* live non-collapsed → preserved → live. The resulting bookmark is then
1340+
* mapped through every subsequent transaction.
1341+
*
1342+
* Local-only — captures from **this** editor.
1343+
*/
1344+
captureEffectiveSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle {
1345+
this.#assertState('ready', 'saving');
1346+
const selection = selectEffectivePmSelection(this);
1347+
return captureSelectionHandle(this, selection, surface ?? this.#getDefaultSelectionHandleSurface());
1348+
}
1349+
1350+
/**
1351+
* Resolve a previously captured handle into a fresh `ResolveRangeOutput`.
1352+
*
1353+
* The handle's bookmark has been mapped through all intervening transactions
1354+
* in the owning editor's plugin state, so the returned target reflects the
1355+
* current document — no revision plumbing needed.
1356+
*
1357+
* The handle is always resolved against its owning editor (the one that
1358+
* captured it), regardless of which editor is currently active. This
1359+
* ensures correct behavior when header/footer sessions change.
1360+
*
1361+
* Returns `null` when:
1362+
* - the handle was released
1363+
* - a previously non-empty selection collapsed (content was deleted)
1364+
*
1365+
* Always release handles when done via {@link releaseSelectionHandle}.
1366+
*/
1367+
resolveSelectionHandle(handle: SelectionHandle): ResolveRangeOutput | null {
1368+
this.#assertState('ready', 'saving');
1369+
const selection = resolveHandleToSelection(handle);
1370+
if (!selection) return null;
1371+
// Use the owning editor for range resolution, not `this`. The bookmark
1372+
// positions are relative to the owner's document — interpreting them
1373+
// against a different editor's doc would produce wrong results.
1374+
return resolvePmSelectionToRange(handle._owner as Editor, selection);
1375+
}
1376+
1377+
/**
1378+
* Release a tracked selection handle, removing it from plugin state.
1379+
*
1380+
* Always call this when the handle is no longer needed to avoid
1381+
* unbounded accumulation of bookmarks.
1382+
*/
1383+
releaseSelectionHandle(handle: SelectionHandle): void {
1384+
this.#assertState('ready', 'saving');
1385+
releaseSelectionHandle(handle);
1386+
}
1387+
1388+
/**
1389+
* Snapshot convenience: resolve the live PM `state.selection` into a
1390+
* canonical Document API range immediately.
1391+
*
1392+
* Equivalent to `captureCurrentSelectionHandle()` + `resolveSelectionHandle()`
1393+
* in one call. Use this for immediate mutations where no delay exists
1394+
* between reading the selection and acting on it.
1395+
*
1396+
* Local-only — always resolves against **this** editor.
1397+
*/
1398+
getCurrentSelectionRange(): ResolveRangeOutput {
1399+
this.#assertState('ready', 'saving');
1400+
return resolveCurrentEditorSelectionRange(this);
1401+
}
1402+
1403+
/**
1404+
* Snapshot convenience: resolve the "effective" selection into a
1405+
* canonical Document API range immediately.
1406+
*
1407+
* Uses the same fallback chain as `captureEffectiveSelectionHandle`:
1408+
* live non-collapsed → preserved → live.
1409+
*
1410+
* Local-only — always resolves against **this** editor.
1411+
*/
1412+
getEffectiveSelectionRange(): ResolveRangeOutput {
1413+
this.#assertState('ready', 'saving');
1414+
return resolveEffectiveEditorSelectionRange(this);
1415+
}
1416+
12831417
/**
12841418
* Get extension helpers.
12851419
*/
@@ -1684,7 +1818,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
16841818
#createExtensionService(): void {
16851819
const allowedExtensions = ['extension', 'node', 'mark'];
16861820

1687-
const coreExtensions = [Editable, Commands, EditorFocus, Keymap, PositionTrackerExtension];
1821+
const coreExtensions = [
1822+
Editable,
1823+
Commands,
1824+
EditorFocus,
1825+
Keymap,
1826+
PositionTrackerExtension,
1827+
SelectionHandleExtension,
1828+
];
16881829
const externalExtensions = this.options.externalExtensions || [];
16891830

16901831
const allExtensions = [...coreExtensions, ...this.options.extensions!].filter((extension) => {

packages/super-editor/src/core/extensions/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export const Editable: any;
33
export const EditorFocus: any;
44
export const Keymap: any;
55
export const PositionTrackerExtension: any;
6+
export const SelectionHandleExtension: any;

packages/super-editor/src/core/extensions/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { Keymap } from './keymap.js';
33
export { Editable } from './editable.js';
44
export { EditorFocus } from './editorFocus.js';
55
export { PositionTrackerExtension } from './position-tracker.js';
6+
export { SelectionHandleExtension } from './selection-handle.js';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Extension } from '../Extension.js';
2+
import { createSelectionHandlePlugin } from '../selection-state.js';
3+
4+
export const SelectionHandleExtension = Extension.create({
5+
name: 'selectionHandle',
6+
7+
addPmPlugins() {
8+
return [createSelectionHandlePlugin()];
9+
},
10+
});

0 commit comments

Comments
 (0)