Skip to content
Merged
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
63 changes: 63 additions & 0 deletions packages/super-editor/src/core/Editor.selection-handle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest';

import { getStarterExtensions } from '@extensions/index.js';
import { loadTestDataForEditorTests } from '@tests/helpers/helpers.js';

import { Editor } from './Editor.js';

let blankDocData: { docx: unknown; mediaFiles: unknown; fonts: unknown };
const editors: Editor[] = [];

beforeAll(async () => {
blankDocData = await loadTestDataForEditorTests('blank-doc.docx');
});

afterEach(() => {
while (editors.length > 0) {
editors.pop()?.destroy();
}
});

function createTestEditor(options: Partial<ConstructorParameters<typeof Editor>[0]> = {}): Editor {
const editor = new Editor({
isHeadless: true,
deferDocumentLoad: true,
mode: 'docx',
extensions: getStarterExtensions(),
suppressDefaultDocxStyles: true,
...options,
});
editors.push(editor);
return editor;
}

function getBlankDocOptions() {
return {
mode: 'docx' as const,
content: blankDocData.docx,
mediaFiles: blankDocData.mediaFiles,
fonts: blankDocData.fonts,
};
}

describe('Editor selection-handle surface inference', () => {
it('defaults direct header editor captures to the header surface', async () => {
const editor = createTestEditor({
isHeaderOrFooter: true,
headerFooterType: 'header',
});
await editor.open(undefined, getBlankDocOptions());

expect(editor.captureCurrentSelectionHandle().surface).toBe('header');
});

it('defaults direct footer editor captures to the footer surface', async () => {
const editor = createTestEditor({
isHeaderOrFooter: true,
headerFooterType: 'footer',
});
await editor.open(undefined, getBlankDocOptions());

expect(editor.captureEffectiveSelectionHandle().surface).toBe('footer');
});
});
147 changes: 144 additions & 3 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ import { ExtensionService } from './ExtensionService.js';
import { CommandService } from './CommandService.js';
import { Attribute } from './Attribute.js';
import { SuperConverter } from '@core/super-converter/SuperConverter.js';
import { Commands, Editable, EditorFocus, Keymap, PositionTrackerExtension } from './extensions/index.js';
import {
Commands,
Editable,
EditorFocus,
Keymap,
PositionTrackerExtension,
SelectionHandleExtension,
} from './extensions/index.js';
import { createDocument } from './helpers/createDocument.js';
import { isActive } from './helpers/isActive.js';
import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js';
Expand Down Expand Up @@ -59,9 +66,18 @@ import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js';
import { BLANK_DOCX_DATA_URI } from './blank-docx.js';
import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js';
import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common';
import type { DocumentApi } from '@superdoc/document-api';
import type { DocumentApi, ResolveRangeOutput } from '@superdoc/document-api';
import { createDocumentApi } from '@superdoc/document-api';
import { getDocumentApiAdapters } from '../document-api-adapters/index.js';
import {
resolveCurrentEditorSelectionRange,
resolveEffectiveEditorSelectionRange,
selectCurrentPmSelection,
selectEffectivePmSelection,
resolvePmSelectionToRange,
} from '../document-api-adapters/helpers/selection-range-resolver.js';
import { captureSelectionHandle, resolveHandleToSelection, releaseSelectionHandle } from './selection-state.js';
import type { SelectionHandle } from './selection-state.js';
import { initPartsRuntime } from './parts/init-parts-runtime.js';
import { syncPackageMetadata } from './opc/sync-package-metadata.js';

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

// -------------------------------------------------------------------
// Selection bridge — tracked handles + snapshot convenience
// -------------------------------------------------------------------

/**
* Infers the default capture surface for this editor instance.
*
* Body editors report `body`. Header/footer child editors created by the
* pagination helpers persist their concrete surface kind in
* `options.headerFooterType`, allowing direct calls on
* `presentationEditor.getActiveEditor()` to produce handles with the
* correct surface label without requiring every caller to pass it manually.
*/
#getDefaultSelectionHandleSurface(): 'body' | 'header' | 'footer' {
const explicitType = this.options.headerFooterType;
return explicitType === 'header' || explicitType === 'footer' ? explicitType : 'body';
}

/**
* Capture the live PM selection as a tracked handle.
*
* The handle's bookmark is automatically mapped through every subsequent
* transaction, so it always reflects the current document. When ready,
* call {@link resolveSelectionHandle} to get a fresh `ResolveRangeOutput`.
*
* Use this for deferred UI flows (AI, confirmation dialogs, async chains)
* where a delay exists between selection capture and mutation.
*
* Local-only — captures from **this** editor's `state.selection`.
*/
captureCurrentSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle {
this.#assertState('ready', 'saving');
const selection = selectCurrentPmSelection(this);
return captureSelectionHandle(this, selection, surface ?? this.#getDefaultSelectionHandleSurface());
}

/**
* Capture the "effective" selection as a tracked handle.
*
* Uses the same fallback chain as {@link getEffectiveSelectionRange}:
* live non-collapsed → preserved → live. The resulting bookmark is then
* mapped through every subsequent transaction.
*
* Local-only — captures from **this** editor.
*/
captureEffectiveSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle {
this.#assertState('ready', 'saving');
const selection = selectEffectivePmSelection(this);
return captureSelectionHandle(this, selection, surface ?? this.#getDefaultSelectionHandleSurface());
}

/**
* Resolve a previously captured handle into a fresh `ResolveRangeOutput`.
*
* The handle's bookmark has been mapped through all intervening transactions
* in the owning editor's plugin state, so the returned target reflects the
* current document — no revision plumbing needed.
*
* The handle is always resolved against its owning editor (the one that
* captured it), regardless of which editor is currently active. This
* ensures correct behavior when header/footer sessions change.
*
* Returns `null` when:
* - the handle was released
* - a previously non-empty selection collapsed (content was deleted)
*
* Always release handles when done via {@link releaseSelectionHandle}.
*/
resolveSelectionHandle(handle: SelectionHandle): ResolveRangeOutput | null {
this.#assertState('ready', 'saving');
const selection = resolveHandleToSelection(handle);
if (!selection) return null;
// Use the owning editor for range resolution, not `this`. The bookmark
// positions are relative to the owner's document — interpreting them
// against a different editor's doc would produce wrong results.
return resolvePmSelectionToRange(handle._owner as Editor, selection);
}

/**
* Release a tracked selection handle, removing it from plugin state.
*
* Always call this when the handle is no longer needed to avoid
* unbounded accumulation of bookmarks.
*/
releaseSelectionHandle(handle: SelectionHandle): void {
this.#assertState('ready', 'saving');
releaseSelectionHandle(handle);
}

/**
* Snapshot convenience: resolve the live PM `state.selection` into a
* canonical Document API range immediately.
*
* Equivalent to `captureCurrentSelectionHandle()` + `resolveSelectionHandle()`
* in one call. Use this for immediate mutations where no delay exists
* between reading the selection and acting on it.
*
* Local-only — always resolves against **this** editor.
*/
getCurrentSelectionRange(): ResolveRangeOutput {
this.#assertState('ready', 'saving');
return resolveCurrentEditorSelectionRange(this);
}

/**
* Snapshot convenience: resolve the "effective" selection into a
* canonical Document API range immediately.
*
* Uses the same fallback chain as `captureEffectiveSelectionHandle`:
* live non-collapsed → preserved → live.
*
* Local-only — always resolves against **this** editor.
*/
getEffectiveSelectionRange(): ResolveRangeOutput {
this.#assertState('ready', 'saving');
return resolveEffectiveEditorSelectionRange(this);
}

/**
* Get extension helpers.
*/
Expand Down Expand Up @@ -1684,7 +1818,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
#createExtensionService(): void {
const allowedExtensions = ['extension', 'node', 'mark'];

const coreExtensions = [Editable, Commands, EditorFocus, Keymap, PositionTrackerExtension];
const coreExtensions = [
Editable,
Commands,
EditorFocus,
Keymap,
PositionTrackerExtension,
SelectionHandleExtension,
];
const externalExtensions = this.options.externalExtensions || [];

const allExtensions = [...coreExtensions, ...this.options.extensions!].filter((extension) => {
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/src/core/extensions/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export const Editable: any;
export const EditorFocus: any;
export const Keymap: any;
export const PositionTrackerExtension: any;
export const SelectionHandleExtension: any;
1 change: 1 addition & 0 deletions packages/super-editor/src/core/extensions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { Keymap } from './keymap.js';
export { Editable } from './editable.js';
export { EditorFocus } from './editorFocus.js';
export { PositionTrackerExtension } from './position-tracker.js';
export { SelectionHandleExtension } from './selection-handle.js';
10 changes: 10 additions & 0 deletions packages/super-editor/src/core/extensions/selection-handle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Extension } from '../Extension.js';
import { createSelectionHandlePlugin } from '../selection-state.js';

export const SelectionHandleExtension = Extension.create({
name: 'selectionHandle',

addPmPlugins() {
return [createSelectionHandlePlugin()];
},
});
Loading
Loading