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
131 changes: 58 additions & 73 deletions packages/superdoc/src/core/SuperDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,20 @@ import type {
Config,
DocumentMode,
Editor,
EditorUpdateEvent,
ExportParams,
InternalConfig,
Modules,
NavigableAddress,
RuntimeDocument,
SearchMatch,
SuperDocAwarenessUpdatePayload,
SuperDocCommentsUpdatePayload,
SuperDocEditorPayload,
SuperDocExceptionPayload,
SuperDocExceptionStorePayload,
SuperDocLockedPayload,
SuperDocReadyPayload,
SuperDocState,
SurfaceHandle,
SurfaceRequest,
Expand All @@ -88,13 +94,8 @@ import type * as Y from 'yjs';
// as a type here without a separate `import type` declaration.
import type { WhiteboardData } from './whiteboard/Whiteboard.js';

// Event payload shapes (formerly JSDoc typedefs above the class).
interface SuperDocReadyPayload {
superdoc: SuperDoc;
}
interface SuperDocEditorPayload {
editor: Editor;
}
// Internal-only event payload shapes (consumer-facing payloads are
// exported from `core/types/index.ts` and imported above).
interface SuperDocWhiteboardPayload {
whiteboard: Whiteboard;
}
Expand All @@ -116,28 +117,6 @@ interface SuperDocContentErrorPayload {
error: unknown;
editor: Editor;
}
interface SuperDocLockedPayload {
isLocked: boolean;
lockedBy?: User | null;
}
interface SuperDocEditorUpdatePayload {
editor?: Editor;
sourceEditor?: Editor;
surface: string;
headerId: string | null;
sectionType: string | null;
}
interface SuperDocAwarenessUpdatePayload {
states: AwarenessState[];
added: number[];
removed: number[];
superdoc: SuperDoc;
}
interface SuperDocCommentsUpdatePayload {
type: string;
comment?: Comment;
changes?: Array<{ key: string; commentId: string; fileId?: string | null }>;
}

/**
* SuperDoc lifecycle event registry. Keys are event names emitted via
Expand All @@ -155,7 +134,7 @@ interface SuperDocEventMap {
zoomChange: [SuperDocZoomPayload];
'formatting-marks-change': [SuperDocFormattingMarksPayload];
'document-mode-change': [SuperDocDocumentModeChangePayload];
'editor-update': [SuperDocEditorUpdatePayload];
'editor-update': [EditorUpdateEvent];
'content-error': [SuperDocContentErrorPayload];
'fonts-resolved': [FontsResolvedPayload];
'pagination-update': [SuperDocPaginationPayload];
Expand Down Expand Up @@ -188,30 +167,6 @@ interface SuperDocEventMap {
// through SuperDoc instead) is a follow-up; typing it here matches the
// current consumer-visible contract.

/**
* Adapts an optional `Config` callback to EventEmitter's
* `(...args: any[]) => void` listener signature.
*
* Every callback wrapped by this helper defaults to `() => null` in the
* class-field initializer, so EventEmitter receives a function in normal
* use. This helper is a runtime identity cast: behavior is unchanged if
* that invariant ever breaks (e.g. a consumer explicitly passes
* `undefined`), and EventEmitter sees the same value it would have
* without the wrapper. Sites with a `null` default (`onFontsResolved`,
* `onTrackedChangeBubbleAccept`, `onTrackedChangeBubbleReject`) use a
* separate `if`-guard pattern instead of this helper.
*
* The `any[]` here is correct: EventEmitter dispatches whatever payload
* each emit site supplies, and the consumer-supplied callback only
* inspects the args its own signature names. Narrower typing would force
* every callsite below to cast.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
function asEventListener(listener: ((...args: any[]) => void) | undefined): (...args: any[]) => void {
return listener as (...args: any[]) => void;
}
/* eslint-enable @typescript-eslint/no-explicit-any */

/**
* SuperDoc class
* Expects a config object
Expand Down Expand Up @@ -857,26 +812,45 @@ export class SuperDoc extends EventEmitter<SuperDocEventMap> {
this.#syncViewingVisibility();
}

/**
* Register an optional `Config` callback as a listener for the matching
* SuperDoc event. The event key constrains `K`, so TypeScript checks
* that the consumer-typed `Config.onX` is assignable to
* `SuperDocEventMap[event]` at the registration site. No-ops on
* `undefined`, so optional callbacks do not register dead listeners.
*
* This catches most event/callback drift at registration sites; the
* earlier `any → any` bridge let mismatches like `lockedBy: User` vs
* runtime `User | null` ship undetected. It does not catch overly
* wide callback types: `(p: {}) => void` is contravariantly
* assignable to any narrower payload, so consumer fixtures still
* need to lock the exact emitted payload shape per callback (see
* `tests/consumer-typecheck/src/config-callback-payloads.ts`).
*/
#onConfig<K extends keyof SuperDocEventMap>(
event: K,
listener: EventEmitter.EventListener<SuperDocEventMap, K> | undefined,
): void {
if (listener) this.on(event, listener);
}

#initListeners() {
this.on('editorBeforeCreate', asEventListener(this.config.onEditorBeforeCreate));
this.on('editorCreate', asEventListener(this.config.onEditorCreate));
this.on('editorDestroy', asEventListener(this.config.onEditorDestroy));
this.on('ready', asEventListener(this.config.onReady));
this.on('comments-update', asEventListener(this.config.onCommentsUpdate));
this.on('awareness-update', asEventListener(this.config.onAwarenessUpdate));
this.on('locked', asEventListener(this.config.onLocked));
this.on('pdf:document-ready', asEventListener(this.config.onPdfDocumentReady));
this.on('sidebar-toggle', asEventListener(this.config.onSidebarToggle));
this.on('collaboration-ready', asEventListener(this.config.onCollaborationReady));
this.on('editor-update', asEventListener(this.config.onEditorUpdate));
this.#onConfig('editorBeforeCreate', this.config.onEditorBeforeCreate);
this.#onConfig('editorCreate', this.config.onEditorCreate);
this.#onConfig('editorDestroy', this.config.onEditorDestroy);
this.#onConfig('ready', this.config.onReady);
this.#onConfig('comments-update', this.config.onCommentsUpdate);
this.#onConfig('awareness-update', this.config.onAwarenessUpdate);
this.#onConfig('locked', this.config.onLocked);
this.#onConfig('pdf:document-ready', this.config.onPdfDocumentReady);
this.#onConfig('sidebar-toggle', this.config.onSidebarToggle);
this.#onConfig('collaboration-ready', this.config.onCollaborationReady);
this.#onConfig('editor-update', this.config.onEditorUpdate);
this.on('content-error', this.onContentError);
this.on('exception', asEventListener(this.config.onException));
this.on('list-definitions-change', asEventListener(this.config.onListDefinitionsChange));
this.on('pagination-update', asEventListener(this.config.onPaginationUpdate));

if (this.config.onFontsResolved) {
this.on('fonts-resolved', this.config.onFontsResolved);
}
this.#onConfig('exception', this.config.onException);
this.#onConfig('list-definitions-change', this.config.onListDefinitionsChange);
this.#onConfig('pagination-update', this.config.onPaginationUpdate);
this.#onConfig('fonts-resolved', this.config.onFontsResolved);
}

/**
Expand Down Expand Up @@ -1624,7 +1598,18 @@ export class SuperDoc extends EventEmitter<SuperDocEventMap> {

this.toolbar = new SuperToolbar(config);

this.toolbar.on('exception', asEventListener(this.config.onException));
// Toolbar bridge: forwards SuperToolbar's exception events into the
// user's Config.onException callback. SuperToolbar's event types are
// not aligned with SuperDocEventMap, so this is intentionally a
// local cast rather than going through the typed `#onConfig` helper.
// Truthy guard mirrors `#onConfig`: skip absent callbacks (consumer
// passes `{ onException: undefined }` explicitly), but pass through
// truthy non-function values so eventemitter3 throws loudly at
// registration time.
if (this.config.onException) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.toolbar.on('exception', this.config.onException as any);
}
// `this.toolbar` infers as `SuperToolbar | null` from the field's
// first assignment in `#addToolbar` (the `null` placeholder a few
// lines up). The closure registers after the SuperToolbar instance
Expand Down
114 changes: 93 additions & 21 deletions packages/superdoc/src/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import type {
TrackedChangeAddress as SuperEditorTrackedChangeAddress,
NavigableAddress as SuperEditorNavigableAddress,
CollaborationProvider as SuperEditorCollaborationProvider,
Comment,
FontConfig,
FontsResolvedPayload,
ListDefinitionsPayload,
ProofingProvider,
User,
} from '@superdoc/super-editor';
Expand Down Expand Up @@ -1280,16 +1282,87 @@ export interface ExportParams {
export type EditorSurface = 'body' | 'header' | 'footer';

export interface EditorUpdateEvent {
/** The primary editor associated with the update. For header/footer edits, this is the main body editor. */
editor: Editor;
/**
* The primary editor associated with the update. For header/footer
* edits, this is the main body editor. Optional because the runtime
* payload builder falls back to `sourceEditor` and emits `undefined`
* when neither is present (defensive in test/stub paths); consumers
* should narrow before use.
*/
editor?: Editor;
/** The editor instance that emitted the update. For body edits, this matches `editor`. */
sourceEditor: Editor;
sourceEditor?: Editor;
/** The surface where the edit originated. */
surface: EditorSurface;
/** Relationship ID for header/footer edits. */
headerId?: string | null;
/** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */
sectionType?: string | null;
/**
* Relationship ID for header/footer edits. Always present (the
* runtime payload builder defaults to `null`); may be `null` for
* body edits.
*/
headerId: string | null;
/**
* Header/footer variant (`default`, `first`, `even`, `odd`) when
* available. Always present (defaults to `null`); may be `null`.
*/
sectionType: string | null;
}

/**
* Payload emitted with the `ready` event and passed to `Config.onReady`.
* Carries the live SuperDoc instance.
*/
export interface SuperDocReadyPayload {
superdoc: SuperDoc;
}

/**
* Payload emitted with the `editorCreate` / `editorBeforeCreate` /
* `collaboration-ready` events and passed to the matching `Config.onX`
* callbacks. The runtime always wraps the editor in this shape; bare
* `Editor` references in earlier callback typings were incorrect.
*/
export interface SuperDocEditorPayload {
editor: Editor;
}

/**
* Payload emitted with the `locked` event and passed to
* `Config.onLocked`. `lockedBy` is non-optional because the runtime
* always includes the key (`lockSuperdoc` defaults `lockedBy` to
* `null`); the value may be `User | null` because unlocking and
* unattributed locks both pass `null`.
*/
export interface SuperDocLockedPayload {
isLocked: boolean;
lockedBy: User | null;
}

/**
* Payload emitted with the `awareness-update` event and passed to
* `Config.onAwarenessUpdate`. Field set differs from older inline
* declarations: the runtime emits `superdoc` (not `context`) and
* includes `added` / `removed` client-id arrays alongside `states`.
*/
export interface SuperDocAwarenessUpdatePayload {
states: AwarenessState[];
added: number[];
removed: number[];
superdoc: SuperDoc;
}

/**
* Payload emitted with the `comments-update` event and passed to
* `Config.onCommentsUpdate`. Field set differs from older inline
* declarations: the runtime emits `comment?` and `changes?` (never a
* `data` field).
*/
export interface SuperDocCommentsUpdatePayload {
/** Update kind (e.g. `'created'`, `'updated'`, `'deleted'`); set by the comments store. */
type: string;
/** The comment object the update refers to, when applicable. */
comment?: Comment;
/** Per-field change set when the update is a mutation. */
changes?: Array<{ key: string; commentId: string; fileId?: string | null }>;
}

export interface EditorTransactionEvent {
Expand Down Expand Up @@ -1496,10 +1569,10 @@ export interface Config {
* routing.
*/
experimental?: { unifiedHistory?: boolean };
/** Callback before an editor is created. */
onEditorBeforeCreate?: (editor: Editor) => void;
/** Callback after an editor is created. */
onEditorCreate?: (editor: Editor) => void;
/** Callback before an editor is created. Receives a wrapper carrying the editor. */
onEditorBeforeCreate?: (params: SuperDocEditorPayload) => void;
/** Callback after an editor is created. Receives a wrapper carrying the editor. */
onEditorCreate?: (params: SuperDocEditorPayload) => void;
/** Callback when a transaction is made. */
onTransaction?: (params: EditorTransactionEvent) => void;
/** Callback after an editor is destroyed. */
Expand All @@ -1519,20 +1592,20 @@ export interface Config {
documentId: string;
file: File | Blob | null | undefined;
}) => void;
/** Callback when the SuperDoc is ready. */
onReady?: (editor: { superdoc: SuperDoc }) => void;
/** Callback when the SuperDoc is ready. Receives a wrapper carrying the live SuperDoc instance. */
onReady?: (params: SuperDocReadyPayload) => void;
/** Callback when comments are updated. */
onCommentsUpdate?: (params: { type: string; data: object }) => void;
onCommentsUpdate?: (params: SuperDocCommentsUpdatePayload) => void;
/** Callback when awareness is updated. */
onAwarenessUpdate?: (params: { context: SuperDoc; states: AwarenessState[] }) => void;
/** Callback when the SuperDoc is locked. */
onLocked?: (params: { isLocked: boolean; lockedBy: User }) => void;
onAwarenessUpdate?: (params: SuperDocAwarenessUpdatePayload) => void;
/** Callback when the SuperDoc is locked or unlocked. */
onLocked?: (params: SuperDocLockedPayload) => void;
/** Callback when the PDF document is ready. */
onPdfDocumentReady?: () => void;
/** Callback when the sidebar is toggled. */
onSidebarToggle?: (isOpened: boolean) => void;
/** Callback when collaboration is ready. */
onCollaborationReady?: (params: { editor: Editor }) => void;
/** Callback when collaboration is ready. Receives a wrapper carrying the editor. */
onCollaborationReady?: (params: SuperDocEditorPayload) => void;
/** Callback when document is updated. */
onEditorUpdate?: (params: EditorUpdateEvent) => void;
/**
Expand All @@ -1550,8 +1623,7 @@ export interface Config {
*/
onPaginationUpdate?: (params: { totalPages: number; superdoc: SuperDoc }) => void;
/** Callback when the list definitions change. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
onListDefinitionsChange?: (params: {}) => void;
onListDefinitionsChange?: (params: ListDefinitionsPayload) => void;
/** The format of the document (docx, pdf, html). */
format?: string;
/** The extensions to load for the editor. */
Expand Down
5 changes: 5 additions & 0 deletions packages/superdoc/src/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,16 @@ export type { ResolvedFindReplaceTexts } from '../core/types/index.js';
export type { ResolvedPasswordPromptTexts } from '../core/types/index.js';
export type { SearchMatch } from '../core/types/index.js';
export type { StoryLocator } from '../core/types/index.js';
export type { SuperDocAwarenessUpdatePayload } from '../core/types/index.js';
export type { SuperDocCommentsUpdatePayload } from '../core/types/index.js';
export type { SuperDocEditorPayload } from '../core/types/index.js';
export type { SuperDocExceptionEditorPayload } from '../core/types/index.js';
export type { SuperDocExceptionPayload } from '../core/types/index.js';
export type { SuperDocExceptionRestorePayload } from '../core/types/index.js';
export type { SuperDocExceptionStorePayload } from '../core/types/index.js';
export type { SuperDocLayoutEngineOptions } from '../core/types/index.js';
export type { SuperDocLockedPayload } from '../core/types/index.js';
export type { SuperDocReadyPayload } from '../core/types/index.js';
export type { SuperDocState } from '../core/types/index.js';
export type { SuperDocTelemetryConfig } from '../core/types/index.js';
export type { SurfaceComponentProps } from '../core/types/index.js';
Expand Down
Loading
Loading