@@ -18,7 +18,14 @@ import { ExtensionService } from './ExtensionService.js';
1818import { CommandService } from './CommandService.js' ;
1919import { Attribute } from './Attribute.js' ;
2020import { 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' ;
2229import { createDocument } from './helpers/createDocument.js' ;
2330import { isActive } from './helpers/isActive.js' ;
2431import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js' ;
@@ -59,9 +66,18 @@ import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js';
5966import { BLANK_DOCX_DATA_URI } from './blank-docx.js' ;
6067import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js' ;
6168import { Telemetry , COMMUNITY_LICENSE_KEY } from '@superdoc/common' ;
62- import type { DocumentApi } from '@superdoc/document-api' ;
69+ import type { DocumentApi , ResolveRangeOutput } from '@superdoc/document-api' ;
6370import { createDocumentApi } from '@superdoc/document-api' ;
6471import { 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' ;
6581import { initPartsRuntime } from './parts/init-parts-runtime.js' ;
6682import { 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 ) => {
0 commit comments