diff --git a/apps/vscode-ext/__tests__/superdoc-contract.test.ts b/apps/vscode-ext/__tests__/superdoc-contract.test.ts index 425c6fdc4c..8f2c1d9264 100644 --- a/apps/vscode-ext/__tests__/superdoc-contract.test.ts +++ b/apps/vscode-ext/__tests__/superdoc-contract.test.ts @@ -14,7 +14,7 @@ import { resolve } from 'node:path'; const SUPERDOC_PKG = resolve(import.meta.dirname, '..', '..', '..', 'packages', 'superdoc'); const SUPERDOC_SRC = resolve(SUPERDOC_PKG, 'src'); -const superdocClassSrc = readFileSync(resolve(SUPERDOC_SRC, 'core', 'SuperDoc.js'), 'utf-8'); +const superdocClassSrc = readFileSync(resolve(SUPERDOC_SRC, 'core', 'SuperDoc.ts'), 'utf-8'); describe('SuperDoc API contract', () => { describe('package exports', () => { diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.ts similarity index 80% rename from packages/superdoc/src/core/SuperDoc.js rename to packages/superdoc/src/core/SuperDoc.ts index 3afa4e2bbe..d7bd8b8a93 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -1,4 +1,3 @@ -// @ts-check import '../style.css'; import { EventEmitter } from 'eventemitter3'; @@ -58,165 +57,157 @@ const DEFAULT_AWARENESS_PALETTE = Object.freeze([ '#F39C12', ]); -/** @typedef {import('./types/index.js').User} User */ -/** @typedef {import('./types/index.js').Document} Document */ -/** @typedef {import('./types/index.js').RuntimeDocument} RuntimeDocument */ -/** @typedef {import('./types/index.js').Modules} Modules */ -/** @typedef {import('./types/index.js').Editor} Editor */ -/** @typedef {import('./types/index.js').DocumentMode} DocumentMode */ -/** @typedef {import('./types/index.js').Config} Config */ -/** @typedef {import('./types/index.js').InternalConfig} InternalConfig */ -/** @typedef {import('./types/index.js').ExportParams} ExportParams */ -/** @typedef {import('./types/index.js').UpgradeToCollaborationOptions} UpgradeToCollaborationOptions */ -/** @typedef {import('./types/index.js').SurfaceRequest} SurfaceRequest */ -/** - * @template [T=unknown] - * @typedef {import('./types/index.js').SurfaceHandle} SurfaceHandle - */ -/** @typedef {import('./types/index.js').NavigableAddress} NavigableAddress */ -/** @typedef {import('@superdoc/super-editor/ui').SuperDocLike} SuperDocLike */ +// TS-native type imports for the types this file annotates against. +// The corresponding payload shapes for the SuperDocEventMap are +// declared as interfaces below. +import type { + AwarenessState, + CollaborationProvider, + Config, + DocumentMode, + Editor, + ExportParams, + InternalConfig, + Modules, + NavigableAddress, + RuntimeDocument, + SearchMatch, + SuperDocExceptionPayload, + SuperDocExceptionStorePayload, + SurfaceHandle, + SurfaceRequest, + UpgradeToCollaborationOptions, + User, +} from './types/index.js'; +import type { Comment, FontsResolvedPayload, ListDefinitionsPayload, PresentationEditor } from '@superdoc/super-editor'; +import type * as Y from 'yjs'; +// `Whiteboard` is already imported as a value above (line 19); reuse it +// 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; +} +interface SuperDocWhiteboardPayload { + whiteboard: Whiteboard; +} +interface SuperDocZoomPayload { + zoom: number; +} +interface SuperDocFormattingMarksPayload { + showFormattingMarks: boolean; + superdoc: SuperDoc; +} +interface SuperDocDocumentModeChangePayload { + documentMode: DocumentMode; +} +interface SuperDocPaginationPayload { + totalPages: number; + superdoc: SuperDoc; +} +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 }>; +} -/** @typedef {import('./types/index.js').AwarenessState} AwarenessState */ -/** @typedef {import('@superdoc/super-editor').Comment} Comment */ -/** @typedef {import('@superdoc/super-editor').FontsResolvedPayload} FontsResolvedPayload */ -/** @typedef {import('@superdoc/super-editor').ListDefinitionsPayload} ListDefinitionsPayload */ /** - * Discriminator for `comments-update` payloads. Inlined rather than - * imported from `@superdoc/common` because the dist re-export path for - * `CommentEvent` doesn't survive the facade emit; the source values - * live in `shared/common/event-types.d.ts` (`comments_module_events`). - * - * @typedef {'resolved' | 'new' | 'add' | 'update' | 'deleted' | 'pending' | 'selected' | 'comments-list' | 'change-accepted' | 'change-rejected'} CommentEvent + * SuperDoc lifecycle event registry. Keys are event names emitted via + * `this.emit(...)`; each value is the tuple of arguments. Used as the + * generic parameter of `EventEmitter` so `superdoc.on` + * / `superdoc.emit` reject unknown event names at compile time. */ -/** @typedef {import('./whiteboard/Whiteboard.js').WhiteboardData} WhiteboardData */ +interface SuperDocEventMap { + ready: [SuperDocReadyPayload]; + editorBeforeCreate: [SuperDocEditorPayload]; + editorCreate: [SuperDocEditorPayload]; + editorDestroy: []; + 'pdf:document-ready': []; + 'sidebar-toggle': [boolean]; + zoomChange: [SuperDocZoomPayload]; + 'formatting-marks-change': [SuperDocFormattingMarksPayload]; + 'document-mode-change': [SuperDocDocumentModeChangePayload]; + 'editor-update': [SuperDocEditorUpdatePayload]; + 'content-error': [SuperDocContentErrorPayload]; + 'fonts-resolved': [FontsResolvedPayload]; + 'pagination-update': [SuperDocPaginationPayload]; + 'list-definitions-change': [ListDefinitionsPayload]; + 'comments-update': [SuperDocCommentsUpdatePayload]; + 'collaboration-ready': [SuperDocEditorPayload]; + 'awareness-update': [SuperDocAwarenessUpdatePayload]; + locked: [SuperDocLockedPayload]; + 'whiteboard:init': [SuperDocWhiteboardPayload]; + 'whiteboard:ready': [SuperDocWhiteboardPayload]; + 'whiteboard:change': [WhiteboardData]; + 'whiteboard:enabled': [boolean]; + 'whiteboard:tool': [string]; + exception: [SuperDocExceptionPayload]; +} +// Notes on the event map above: +// +// `exception` is typed as `SuperDocExceptionPayload`, a union of the three +// shapes the runtime currently emits today: `{ error, stage, document }` +// from `superdoc-store.js` document-init failures, `{ error, document }` +// from the catch in `restoreUnsavedChanges()`, and `{ error, editor?, +// code?, documentId? }` from `SuperDoc.vue` editor lifecycle. Normalizing +// these is tracked as a separate follow-up; the union types the current +// reality so consumers can narrow with `'stage' in payload` etc. +// +// `fonts-resolved` uses a listener-transport pattern: SuperDoc never +// emits it directly. `SuperDoc.vue:719` reads +// `superdoc.listeners('fonts-resolved')[0]` and threads it into the new +// editor's `onFontsResolved` option. Cleanup of this transport (relay +// through SuperDoc instead) is a follow-up; typing it here matches the +// current consumer-visible contract. /** - * Typed event map for `superdoc.on(name, fn)` / `superdoc.emit(name, ...)`. - * - * The map is closed: unknown event names (e.g. `superdoc.on('reayd', ...)`, - * a typo) are TS errors at compile time. This is a **TS-only tightening**. - * The runtime `eventemitter3` still accepts any string; it is consumers - * relying on dynamic event names who would see new type errors. Verified - * no internal SuperDoc code emits or subscribes to dynamic event names - * (every emit site is enumerated here). - * - * `exception` is typed as `SuperDocExceptionPayload`, a union of the three - * shapes the runtime currently emits today: `{ error, stage, document }` - * from `superdoc-store.js` document-init failures, `{ error, document }` - * from the catch in `restoreUnsavedChanges()`, and `{ error, editor?, code?, - * documentId? }` from `SuperDoc.vue` editor lifecycle. Normalizing these - * is tracked as a separate follow-up; this map types the current reality - * so consumers get a union they can narrow with `'stage' in payload` etc. - * - * `fonts-resolved` uses a **listener-transport pattern**: SuperDoc never - * emits it directly. Instead, `SuperDoc.vue:719` reads the registered - * `superdoc.listeners('fonts-resolved')[0]` and threads it into the new - * editor's `onFontsResolved` option. Cleanup of this transport (relay - * through SuperDoc instead) is a follow-up; typing it here matches the - * current consumer-visible contract. - * - * @typedef {{ - * ready: [SuperDocReadyPayload], - * editorBeforeCreate: [SuperDocEditorPayload], - * editorCreate: [SuperDocEditorPayload], - * editorDestroy: [], - * 'pdf:document-ready': [], - * 'sidebar-toggle': [boolean], - * zoomChange: [SuperDocZoomPayload], - * 'formatting-marks-change': [SuperDocFormattingMarksPayload], - * 'document-mode-change': [SuperDocDocumentModeChangePayload], - * 'editor-update': [SuperDocEditorUpdatePayload], - * 'content-error': [SuperDocContentErrorPayload], - * 'fonts-resolved': [FontsResolvedPayload], - * 'pagination-update': [SuperDocPaginationPayload], - * 'list-definitions-change': [ListDefinitionsPayload], - * 'comments-update': [SuperDocCommentsUpdatePayload], - * 'collaboration-ready': [SuperDocEditorPayload], - * 'awareness-update': [SuperDocAwarenessUpdatePayload], - * locked: [SuperDocLockedPayload], - * 'whiteboard:init': [SuperDocWhiteboardPayload], - * 'whiteboard:ready': [SuperDocWhiteboardPayload], - * 'whiteboard:change': [WhiteboardData], - * 'whiteboard:enabled': [boolean], - * 'whiteboard:tool': [string], - * exception: [SuperDocExceptionPayload], - * }} SuperDocEventMap - * - * @typedef {{ superdoc: SuperDoc }} SuperDocReadyPayload - * @typedef {{ editor: Editor }} SuperDocEditorPayload - * @typedef {{ whiteboard: Whiteboard }} SuperDocWhiteboardPayload - * @typedef {{ zoom: number }} SuperDocZoomPayload - * @typedef {{ showFormattingMarks: boolean, superdoc: SuperDoc }} SuperDocFormattingMarksPayload - * @typedef {{ documentMode: DocumentMode }} SuperDocDocumentModeChangePayload - * @typedef {{ totalPages: number, superdoc: SuperDoc }} SuperDocPaginationPayload - * @typedef {{ error: unknown, editor: Editor }} SuperDocContentErrorPayload - * @typedef {{ isLocked: boolean, lockedBy?: User | null }} SuperDocLockedPayload - * - * Editor-update envelope produced by `buildEditorPayloadBase` in - * `SuperDoc.vue`. `editor` is `effectiveEditor = editor ?? sourceEditor`, - * which is `undefined` only when neither was provided. - * - * @typedef {{ - * editor?: Editor, - * sourceEditor?: Editor, - * surface: string, - * headerId: string | null, - * sectionType: string | null, - * }} SuperDocEditorUpdatePayload - * - * Awareness payload from `awarenessHandler` in `collaboration.js`. - * `states` is `AwarenessState[]` (the publicly re-exported shape). - * `added` and `removed` are Yjs client IDs (numbers). - * - * @typedef {{ - * states: AwarenessState[], - * added: number[], - * removed: number[], - * superdoc: SuperDoc, - * }} SuperDocAwarenessUpdatePayload - * - * Comments-update envelope from `comments-store.js`. `type` is one of the - * `CommentEvent` literals; `comment` is present for ADD/UPDATE/DELETED/NEW/ - * RESOLVED and absent for PENDING. `changes` is present for DELETED to - * carry the per-document change record. + * Adapts an optional `Config` callback to EventEmitter's + * `(...args: any[]) => void` listener signature. * - * @typedef {{ - * type: CommentEvent, - * comment?: Comment, - * changes?: Array<{ key: string, commentId: string, fileId?: string | null }>, - * }} SuperDocCommentsUpdatePayload + * 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. * - * Union of the three current `exception` payload shapes (debt: should be - * normalized to one shape in a follow-up). Consumers can narrow with - * `'stage' in payload` (store init) or `'code' in payload` (Vue editor - * lifecycle). The interfaces live in `./types/index.ts` so they can be - * referenced by both the JSDoc event registry below and the public - * `Config.onException` callback type. - * - * @typedef {import('./types/index.js').SuperDocExceptionStorePayload} SuperDocExceptionStorePayload - * @typedef {import('./types/index.js').SuperDocExceptionRestorePayload} SuperDocExceptionRestorePayload - * @typedef {import('./types/index.js').SuperDocExceptionEditorPayload} SuperDocExceptionEditorPayload - * @typedef {import('./types/index.js').SuperDocExceptionPayload} SuperDocExceptionPayload + * 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. */ - -/** - * Config callbacks are optional on the public typedef because consumers do - * not need to pass them. The fields wrapped by this helper (every callback - * registered in `#initListeners` plus the toolbar `exception` listener) - * default 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 is ever broken (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. - * - * @param {((...args: any[]) => void) | undefined} listener - * @returns {(...args: any[]) => void} - */ -function asEventListener(listener) { - return /** @type {(...args: any[]) => void} */ (listener); +/* 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 @@ -226,23 +217,18 @@ function asEventListener(listener) { * @extends {EventEmitter} * @implements {SuperDocLike} */ -export class SuperDoc extends EventEmitter { - /** @type {Array} */ +export class SuperDoc extends EventEmitter { static allowedTypes = [DOCX, PDF, HTML]; - /** @type {boolean} */ #destroyed = false; - /** @type {boolean} */ #isUpgrading = false; /** @type {(() => void) | null} — aborts an in-flight upgrade (sync wait or ready wait) */ - #abortUpgrade = null; + #abortUpgrade: (() => void) | null = null; - /** @type {HTMLDivElement | null} */ - #mountWrapper = null; + #mountWrapper: HTMLDivElement | null = null; - /** @type {SurfaceManager} */ #surfaceManager; /** * Build-time SuperDoc version string. Initialized to `'0.0.0'` so the @@ -252,7 +238,6 @@ export class SuperDoc extends EventEmitter { * out of the JSDoc type graph). Consumers reading `superdoc.version` * immediately after `new SuperDoc(...)` see the real version because * `#init` runs synchronously through the overwrite before returning. - * @type {string} */ version = '0.0.0'; @@ -263,12 +248,11 @@ export class SuperDoc extends EventEmitter { * `removeSharedUser` mutations would be silently overwritten by the * re-seed, so those methods guard with `#requireReady('addSharedUser')` * and throw a clear lifecycle error instead. - * @type {User[]} */ - users = []; + users: User[] = []; - /** @type {import('yjs').Doc | undefined} */ - ydoc; + /** Yjs document for collaboration; set in `#init` when collaboration is enabled, otherwise undefined. */ + ydoc: Y.Doc | undefined; /** * Provider for the SuperDoc-level collaboration room (separate from @@ -277,26 +261,23 @@ export class SuperDoc extends EventEmitter { * `Config.modules.collaboration.provider`. Consumers needing Hocuspocus- * specific members must narrow before use. * - * @type {import('./types/index.js').CollaborationProvider | undefined} */ - provider; + provider: CollaborationProvider | undefined; /** * Whiteboard instance, created by `#initWhiteboard()` after the * collaboration await. Initialized to `null` so consumers reading * `superdoc.whiteboard` before the `whiteboard:init` event fires get * a stable null, not `undefined`. - * @type {Whiteboard | null} */ - whiteboard = null; + whiteboard: Whiteboard | null = null; /** * Awareness palette assigned to local users when no explicit color is set. * Defaults to an empty array so `#assignUserColor` falls back to the * built-in `DEFAULT_AWARENESS_PALETTE`. - * @type {string[]} */ - colors = []; + colors: string[] = []; /** * Pinia stores and Vue runtime references. Populated by `#initVueApp` @@ -319,22 +300,19 @@ export class SuperDoc extends EventEmitter { * `HeadlessToolbarSuperdocHost` directly without exposing * `superdocStore` publicly. * - * @type {ReturnType | undefined} * @private */ - superdocStore; + private declare superdocStore: ReturnType['superdocStore'] | undefined; /** - * @type {ReturnType | undefined} * @private */ - commentsStore; + private declare commentsStore: ReturnType['commentsStore'] | undefined; /** - * @type {ReturnType | undefined} * @private */ - highContrastModeStore; + private declare highContrastModeStore: ReturnType['highContrastModeStore'] | undefined; /** * Internal mount handle for the `SuperComments` Vue component, created @@ -351,10 +329,15 @@ export class SuperDoc extends EventEmitter { * `null` after `removeCommentsList()` tears down. No initializer, to * match the convention used by the adjacent `@private` store fields. * - * @type {SuperComments | null | undefined} * @private */ - commentsList; + // `declare` (no runtime initializer): the legacy JS code only sets + // `this.commentsList` when role !== 'viewer', and a test asserts the + // field is `undefined` in the viewer path. An `= null` initializer + // would create an own runtime property up front and flip that to `null`. + // `private`: matches the original `@private` JSDoc; not part of the + // SuperDoc public type surface (consumer-typecheck fixture asserts this). + private declare commentsList: SuperComments | null; /** * Internal Vue app handle created in `#initVueApp()` and used for @@ -369,13 +352,12 @@ export class SuperDoc extends EventEmitter { * `superdocStore` / `commentsStore` / `highContrastModeStore` / * `commentsList`; not runtime privacy. * - * @type {import('vue').App | undefined} * @private */ - app; + private declare app: ReturnType['app'] | undefined; - /** @type {import('pinia').Pinia | undefined} */ - pinia; + /** Pinia store root for the SuperDoc Vue app. Set in `#initVueApp`. */ + pinia: ReturnType['pinia'] | undefined; /** @type {number} Count of editors that have signaled `editorCreate`. */ readyEditors = 0; @@ -383,8 +365,38 @@ export class SuperDoc extends EventEmitter { /** @type {number} Outstanding async saves waiting for collaboration ack. */ pendingCollaborationSaves = 0; - /** @type {Config} */ - config = { + // ─── Runtime fields populated by `#init` ────────────────────────────── + // Declared with `declare` so TS knows the field shape without emitting a + // runtime own-property initializer. Each is assigned during `#init` + // (called synchronously from the constructor), so by the time any + // external callsite reads them they exist. + declare activeEditor: Editor | null; + declare toolbar: SuperToolbar | null; + declare toolbarElement: string | HTMLElement | undefined; + declare userColorMap: Map; + declare colorIndex: number; + declare isCollaborative: boolean; + declare isLocked: boolean; + declare lockedBy: User | null; + declare isDev: boolean; + declare superdocId: string; + declare comments: unknown[]; + declare socket: HocuspocusProviderWebsocket | null; + declare user: User; + declare _cleanupAwareness: (() => void) | null; + declare _commentsCollabInitialized: boolean; + + /** + * The active configuration. Typed as `InternalConfig` because `#init` runs + * synchronously in the constructor and normalizes the consumer-provided + * `Config` into the wider shape (`documents` filled, `modules` defaulted, + * `user` spread with `DEFAULT_USER`, etc.). Any callsite reading + * `this.config` runs after `#init`, so it sees the normalized shape. + * + * Public consumer input shape: `Config` (re-exported from `superdoc`). + * Internal post-normalize shape: `InternalConfig`. + */ + config: InternalConfig = { selector: '#superdoc', documentMode: 'editing', allowSelectionInViewMode: false, @@ -402,6 +414,14 @@ export class SuperDoc extends EventEmitter { // could observe it. users: [], + // `user` and `layoutEngineOptions` are also set in `#init` (where `user` + // is spread with `DEFAULT_USER` and `layoutEngineOptions` defaults to + // `{}` if the consumer passes nothing). Initializing them here too keeps + // the field literal satisfying `InternalConfig` directly, with no + // pre-init gap. + user: { ...DEFAULT_USER }, + layoutEngineOptions: {}, + modules: {}, // Optional: Modules to load. Use modules.ai.{your_key} to pass in your key // License key (resolved downstream; undefined means "not explicitly set") @@ -470,11 +490,7 @@ export class SuperDoc extends EventEmitter { // Internal: toggle layout-engine-powered PresentationEditor in dev shells useLayoutEngine: true, }; - - /** - * @param {Config} config - */ - constructor(config) { + constructor(config: Config) { super(); if (!config.selector) { @@ -499,12 +515,7 @@ export class SuperDoc extends EventEmitter { this.#init(config, container); } - - /** - * @param {Config} config - * @param {HTMLElement} container - */ - async #init(config, container) { + async #init(config: Config, container: HTMLElement) { this.config = { ...this.config, ...config, @@ -571,8 +582,7 @@ export class SuperDoc extends EventEmitter { this.config.modules.comments = {}; } - this.config.colors = shuffleArray(/** @type {`#${string}`[]} */ (this.config.colors)); - /** @type {Map} */ + this.config.colors = shuffleArray(this.config.colors as `#${string}`[]); this.userColorMap = new Map(); this.colorIndex = 0; @@ -580,7 +590,6 @@ export class SuperDoc extends EventEmitter { this.version = __APP_VERSION__; this.#log('🦋 [superdoc] Using SuperDoc version:', this.version); - /** @type {string} */ this.superdocId = config.superdocId || uuidv4(); // Default to an empty palette when no colors are configured so downstream // assignment logic doesn't have to null-check on every access. @@ -608,22 +617,20 @@ export class SuperDoc extends EventEmitter { // --- One-time shell setup (survives upgrade) --- this.user = this.config.user; this.users = this.config.users || []; - /** @type {unknown} */ this.socket = null; this.isDev = this.config.isDev || false; - /** @type {Editor | null | undefined} */ this.activeEditor = null; - /** @type {unknown[]} */ this.comments = []; this.isLocked = this.config.isLocked || false; this.lockedBy = this.config.lockedBy || null; // Mount wrapper created once — Vue apps mount into it on each runtime start - this.#mountWrapper = document.createElement('div'); - this.#mountWrapper.style.display = 'contents'; - container.appendChild(this.#mountWrapper); + const mountWrapper = document.createElement('div'); + mountWrapper.style.display = 'contents'; + container.appendChild(mountWrapper); + this.#mountWrapper = mountWrapper; this.#initListeners(); this.#initWhiteboard(); @@ -668,13 +675,17 @@ export class SuperDoc extends EventEmitter { * @returns {number} The number of required editors */ get requiredNumberOfEditors() { - return this.#requireSuperdocStore('requiredNumberOfEditors').documents.filter((d) => d.type === DOCX).length; + return this.#requireSuperdocStore('requiredNumberOfEditors').documents.filter( + (d: RuntimeDocument) => d.type === DOCX, + ).length; } /** - * @returns {{ documents: RuntimeDocument[], users: User[] }} + * Snapshot of the current SuperDoc state. Always reflects the most + * recent values from the Pinia store; consumers must re-read on + * change rather than caching. */ - get state() { + get state(): { documents: RuntimeDocument[]; users: User[] } { return { documents: this.#requireSuperdocStore('state').documents, users: this.users, @@ -688,13 +699,11 @@ export class SuperDoc extends EventEmitter { * `superdoc.superdocStore.documents[].getPresentationEditor()` reach * for `superdoc/headless-toolbar` host routing (SD-3213f). * - * @param {string} documentId - * @returns {import('@superdoc/super-editor').PresentationEditor | null} */ - getPresentationEditorForDocument(documentId) { + getPresentationEditorForDocument(documentId: string): PresentationEditor | null { if (typeof documentId !== 'string' || documentId.length === 0) return null; const documents = this.superdocStore?.documents ?? []; - const matched = documents.find((doc) => doc?.getEditor?.()?.options?.documentId === documentId); + const matched = documents.find((doc: RuntimeDocument) => doc?.getEditor?.()?.options?.documentId === documentId); return matched?.getPresentationEditor?.() ?? null; } @@ -705,17 +714,14 @@ export class SuperDoc extends EventEmitter { * intentionally wide (`Record | null`) so the public * surface does not pull the Pinia comment model type graph. * - * @param {string} commentId - * @returns {Record | null} */ - getComment(commentId) { + getComment(commentId: string) { if (typeof commentId !== 'string' || commentId.length === 0) return null; return this.commentsStore?.getComment?.(commentId) ?? null; } /** * Get the SuperDoc container element - * @returns {HTMLElement | null} */ get element() { if (typeof this.config.selector === 'string') { @@ -729,10 +735,10 @@ export class SuperDoc extends EventEmitter { const originalCreateElement = document.createElement; /** @param {string} tagName */ - document.createElement = function (tagName) { + document.createElement = function (tagName: string) { const element = originalCreateElement.call(this, tagName); if (tagName.toLowerCase() === 'style') { - element.setAttribute('nonce', /** @type {string} */ (cspNonce)); + element.setAttribute('nonce', cspNonce as string); } return element; }; @@ -766,7 +772,7 @@ export class SuperDoc extends EventEmitter { { id: uuidv4(), type: DOCX, - url: /** @type {string} */ (this.config.document), + url: this.config.document as string, name: 'document.docx', }, ]; @@ -827,12 +833,12 @@ export class SuperDoc extends EventEmitter { this.commentsStore = commentsStore; this.highContrastModeStore = highContrastModeStore; if (typeof this.superdocStore.setExceptionHandler === 'function') { - this.superdocStore.setExceptionHandler((/** @type {SuperDocExceptionStorePayload} */ payload) => + this.superdocStore.setExceptionHandler((payload: SuperDocExceptionStorePayload) => this.emit('exception', payload), ); } this.superdocStore.init(this.config); - const commentsModuleConfig = /** @type {InternalConfig} */ (this.config).modules.comments; + const commentsModuleConfig = this.config.modules.comments; // `commentsModuleConfig` is `false | object | undefined`. A truthy // check already rules out both `false` and `undefined`, so an // explicit `!== false` afterwards is redundant. @@ -869,10 +875,11 @@ export class SuperDoc extends EventEmitter { * Initialize collaboration if configured. Accepts the full * `Config.modules` block so it can read both the collaboration * subkey and the comments subkey at once. - * @param {Modules} [modules] * @returns {Promise} The processed documents with collaboration enabled. Caller awaits for side effects; the return value is informational. */ - async #initCollaboration({ collaboration: collaborationModuleConfig, comments: commentsConfig = {} } = {}) { + async #initCollaboration( + { collaboration: collaborationModuleConfig, comments: commentsConfig = {} }: Modules = {} as Modules, + ) { if (!collaborationModuleConfig) return this.config.documents; // Check for external ydoc/provider (provider-agnostic mode) @@ -906,8 +913,8 @@ export class SuperDoc extends EventEmitter { // Fallback: internal provider creation. // Start a socket for all documents and general metaMap for this SuperDoc if (collaborationModuleConfig.providerType === 'hocuspocus') { - /** @type {InternalConfig} */ (this.config).socket = new HocuspocusProviderWebsocket({ - url: /** @type {string} */ (collaborationModuleConfig.url), + this.config.socket = new HocuspocusProviderWebsocket({ + url: collaborationModuleConfig.url as string, }); } @@ -950,10 +957,8 @@ export class SuperDoc extends EventEmitter { * Does NOT initialize collaboration comments — that happens in `#initVueApp()` * or explicitly after this call during construction. * - * @param {import('yjs').Doc} ydoc - * @param {import('./types/index.js').CollaborationProvider} provider */ - #attachExternalCollaboration(ydoc, provider) { + #attachExternalCollaboration(ydoc: Y.Doc, provider: CollaborationProvider) { this.isCollaborative = true; // Reset comments observer flag so a new observer is created for the new ydoc @@ -965,10 +970,10 @@ export class SuperDoc extends EventEmitter { this.provider = markRaw(provider); this.#assignUserColor(); - const internalConfig = /** @type {InternalConfig} */ (this.config); + const internalConfig = this.config; this._cleanupAwareness = setupAwarenessHandler(provider, this, internalConfig.user); - internalConfig.documents.forEach((doc) => { + internalConfig.documents.forEach((doc: RuntimeDocument) => { doc.ydoc = ydoc; doc.provider = provider; doc.role = this.config.role; @@ -991,10 +996,10 @@ export class SuperDoc extends EventEmitter { this._commentsCollabInitialized = false; this.ydoc = undefined; this.provider = undefined; - const cfg = /** @type {InternalConfig} */ (this.config); + const cfg = this.config; delete cfg.modules.collaboration; - cfg.documents.forEach((doc) => { + cfg.documents.forEach((doc: RuntimeDocument) => { delete doc.ydoc; delete doc.provider; }); @@ -1043,10 +1048,9 @@ export class SuperDoc extends EventEmitter { * - External `{ ydoc, provider }` collaboration * - Overwrite-and-upgrade only (no merge semantics) * - * @param {UpgradeToCollaborationOptions} options * @returns {Promise} Resolves once the collaborative runtime is ready */ - async upgradeToCollaboration({ ydoc, provider }) { + async upgradeToCollaboration({ ydoc, provider }: UpgradeToCollaborationOptions) { this.#validateUpgradePrerequisites({ ydoc, provider }); this.#isUpgrading = true; @@ -1062,7 +1066,7 @@ export class SuperDoc extends EventEmitter { overwriteRoomLockState(ydoc, { isLocked: this.isLocked ?? false, lockedBy: this.lockedBy ?? null }); // --- Attach collaboration config (awareness, flags, config.documents) --- - /** @type {InternalConfig} */ (this.config).modules.collaboration = { ydoc, provider }; + this.config.modules.collaboration = { ydoc, provider }; this.#attachExternalCollaboration(ydoc, provider); // --- Update live store documents in place (no Vue unmount) --- @@ -1128,7 +1132,7 @@ export class SuperDoc extends EventEmitter { * @param {string} methodName The public method name surfaced in * the error so consumers know which call needed the ready state. */ - #requireSuperdocStore(methodName) { + #requireSuperdocStore(methodName: string) { if (!this.superdocStore) { throw new Error( `SuperDoc: ${methodName} requires the instance to be ready; wait for the "ready" event before calling.`, @@ -1143,9 +1147,8 @@ export class SuperDoc extends EventEmitter { * non-optional store members. Pre-ready safe paths (`getComment`, * `setActiveComment`, etc.) keep their existing `?.` pattern. * - * @param {string} methodName */ - #requireCommentsStore(methodName) { + #requireCommentsStore(methodName: string) { if (!this.commentsStore) { throw new Error( `SuperDoc: ${methodName} requires the instance to be ready; wait for the "ready" event before calling.`, @@ -1160,9 +1163,8 @@ export class SuperDoc extends EventEmitter { * The store fields are the most reliable "ready" proxy since they * are the last things `#init` populates. * - * @param {string} methodName */ - #requireReady(methodName) { + #requireReady(methodName: string) { if (!this.superdocStore) { throw new Error( `SuperDoc: ${methodName} requires the instance to be ready; wait for the "ready" event before calling.`, @@ -1181,10 +1183,8 @@ export class SuperDoc extends EventEmitter { * shallowRefs on property access, so we must use `toRaw()` to reach the * underlying ref objects. * - * @param {import('yjs').Doc | null} ydoc - * @param {import('./types/index.js').CollaborationProvider | null} provider */ - #setStoreDocumentCollaboration(ydoc, provider) { + #setStoreDocumentCollaboration(ydoc: Y.Doc | null, provider: CollaborationProvider | null) { const storeDocs = this.superdocStore?.documents; if (!Array.isArray(storeDocs)) return; for (const doc of storeDocs) { @@ -1202,7 +1202,6 @@ export class SuperDoc extends EventEmitter { * Resolve the editor instance that supports `attachCollaboration`. * Prefers PresentationEditor (has cursor/layout support); falls back to raw Editor. * - * @returns {import('@superdoc/super-editor').PresentationEditor | import('@superdoc/super-editor').Editor} */ #resolveUpgradeTarget() { const storeDocs = this.superdocStore?.documents; @@ -1235,27 +1234,24 @@ export class SuperDoc extends EventEmitter { * so the editor IS collaborative. A timeout only means secondary setup * (cursors, presence) is delayed — rolling back would be worse. * - * @param {import('@superdoc/super-editor').Editor | import('@superdoc/super-editor').PresentationEditor} editorInstance - * @returns {Promise} */ - #waitForCollaborationReady(editorInstance) { + #waitForCollaborationReady(editorInstance: Editor | PresentationEditor) { const TIMEOUT_MS = 10_000; - // PresentationEditor wraps Editor; get the underlying editor for event listening. - // PresentationEditor exposes a `get editor(): Editor` accessor; plain - // Editor has no such property, so the runtime `??` fallback returns - // the instance itself in that case. The cast names the structural - // `.editor` lookup without claiming the field exists on the Editor - // arm of the union, so the access type-checks once SuperDoc.js is - // brought under the SD-2863 checkJs gate. - const editor = /** @type {Editor} */ (/** @type {{ editor?: Editor }} */ (editorInstance).editor ?? editorInstance); + // PresentationEditor wraps Editor; get the underlying editor for event + // listening. PresentationEditor exposes a `get editor(): Editor` + // accessor; plain Editor has no such property, so the runtime `??` + // fallback returns the instance itself in that case. The structural + // `{ editor? }` cast names the lookup without claiming the field + // exists on the Editor arm of the union. + const editor = ((editorInstance as { editor?: Editor }).editor ?? editorInstance) as Editor; // If collaborationReady already fired (options flag set by collaboration extension) if (editor.options?.collaborationIsReady) { return Promise.resolve(); } - return new Promise((resolve) => { + return new Promise((resolve) => { let settled = false; const cleanup = () => { @@ -1300,16 +1296,16 @@ export class SuperDoc extends EventEmitter { * provider that exposes on/off but never emits sync cannot hang forever. * destroy() can abort this wait early via #abortUpgrade. * - * @param {import('./types/index.js').CollaborationProvider} provider - * @returns {Promise} */ - #waitForProviderSync(provider) { + #waitForProviderSync(provider: CollaborationProvider) { const SYNC_TIMEOUT_MS = 10_000; - return new Promise((resolve, reject) => { - /** @type {ReturnType | undefined} */ - let timer; + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; let settled = false; + // Initial no-op; reassigned below to the real cleanup once the + // sync observer is registered. + // eslint-disable-next-line @typescript-eslint/no-empty-function let syncCleanup = () => {}; const settle = () => { @@ -1351,7 +1347,7 @@ export class SuperDoc extends EventEmitter { * * @param {{ ydoc: unknown, provider: unknown }} options */ - #validateUpgradePrerequisites({ ydoc, provider }) { + #validateUpgradePrerequisites({ ydoc, provider }: UpgradeToCollaborationOptions) { if (this.#destroyed) { throw new Error('SuperDoc: cannot upgrade a destroyed instance'); } @@ -1365,8 +1361,8 @@ export class SuperDoc extends EventEmitter { throw new Error('SuperDoc: upgradeToCollaboration() requires both ydoc and provider'); } - const cfg = /** @type {InternalConfig} */ (this.config); - const docxDocs = cfg.documents.filter((d) => d.type === DOCX); + const cfg = this.config; + const docxDocs = cfg.documents.filter((d: RuntimeDocument) => d.type === DOCX); if (docxDocs.length === 0) { throw new Error('SuperDoc: no DOCX document found for upgrade'); } @@ -1388,10 +1384,10 @@ export class SuperDoc extends EventEmitter { // Upstream `#assertCanUpgrade` already verified at least one DOCX // document exists; cast the find result to assert non-null without // changing runtime behavior. - const docxDoc = /** @type {Document} */ ( - /** @type {InternalConfig} */ (this.config).documents.find((d) => d.type === DOCX) + const docxDoc = this.config.documents.find((d: RuntimeDocument) => d.type === DOCX) as RuntimeDocument; + const storeDoc = this.#requireSuperdocStore('upgradeToCollaboration').documents.find( + (d: RuntimeDocument) => d.id === docxDoc.id, ); - const storeDoc = this.#requireSuperdocStore('upgradeToCollaboration').documents.find((d) => d.id === docxDoc.id); const editor = storeDoc?.getEditor?.(); if (!editor) { @@ -1406,9 +1402,8 @@ export class SuperDoc extends EventEmitter { * `this.users = this.config.users || []` re-seed inside `#init`. * * @param {User} user The user to add - * @returns {void} */ - addSharedUser(user) { + addSharedUser(user: User) { this.#requireReady('addSharedUser'); if (this.users.some((u) => u.email === user.email)) return; this.users.push(user); @@ -1419,9 +1414,8 @@ export class SuperDoc extends EventEmitter { * to be ready for the same reason as `addSharedUser`. * * @param {String} email The email of the user to remove - * @returns {void} */ - removeSharedUser(email) { + removeSharedUser(email: string) { this.#requireReady('removeSharedUser'); this.users = this.users.filter((u) => u.email !== email); } @@ -1433,15 +1427,14 @@ export class SuperDoc extends EventEmitter { * `Error` consistently (e.g. `insertContentAt` forwards the original * caught value). * - * @param {SuperDocContentErrorPayload} params */ - onContentError({ error, editor }) { + onContentError({ error, editor }: { error: unknown; editor: Editor }) { const { documentId } = editor.options; // The errored editor came from `superdocStore.documents`, so the find // by its `documentId` is expected to hit. Cast the find result to a // RuntimeDocument to assert non-null at the consumer callback. - const doc = /** @type {RuntimeDocument} */ ( - this.#requireSuperdocStore('onContentError').documents.find((d) => d.id === documentId) + const doc = /** @type {RuntimeDocument} */ this.#requireSuperdocStore('onContentError').documents.find( + (d: RuntimeDocument) => d.id === documentId, ); // `onContentError` is typed as optional on the public Config typedef // because consumers don't have to wire a handler. The class field @@ -1457,14 +1450,13 @@ export class SuperDoc extends EventEmitter { this.config.onContentError?.({ error, editor, - documentId: /** @type {string} */ (doc.id), + documentId: /** @type {string} */ doc.id, file: doc.data, }); } /** * Triggered when the PDF document is ready - * @returns {void} */ broadcastPdfDocumentReady() { this.emit('pdf:document-ready'); @@ -1472,7 +1464,6 @@ export class SuperDoc extends EventEmitter { /** * Triggered when the superdoc is ready - * @returns {void} */ broadcastReady() { if (this.readyEditors === this.requiredNumberOfEditors) { @@ -1483,18 +1474,16 @@ export class SuperDoc extends EventEmitter { /** * Triggered before an editor is created * @param {Editor} editor The editor that is about to be created - * @returns {void} */ - broadcastEditorBeforeCreate(editor) { + broadcastEditorBeforeCreate(editor: Editor) { this.emit('editorBeforeCreate', { editor: createDeprecatedEditorProxy(editor) }); } /** * Triggered when an editor is created * @param {Editor} editor The editor that was created - * @returns {void} */ - broadcastEditorCreate(editor) { + broadcastEditorCreate(editor: Editor) { this.readyEditors++; this.broadcastReady(); this.emit('editorCreate', { editor: createDeprecatedEditorProxy(editor) }); @@ -1502,7 +1491,6 @@ export class SuperDoc extends EventEmitter { /** * Triggered when an editor is destroyed - * @returns {void} */ broadcastEditorDestroy() { this.emit('editorDestroy'); @@ -1510,23 +1498,21 @@ export class SuperDoc extends EventEmitter { /** * Triggered when the comments sidebar is toggled - * @param {boolean} isOpened */ - broadcastSidebarToggle(isOpened) { + broadcastSidebarToggle(isOpened: boolean) { this.emit('sidebar-toggle', isOpened); } /** @param {unknown[]} args */ - #log(...args) { + #log(...args: unknown[]) { (console.debug ? console.debug : console.log)('🦋 🦸‍♀️ [superdoc]', ...args); } /** * Set the active editor * @param {Editor} editor The editor to set as active - * @returns {void} */ - setActiveEditor(editor) { + setActiveEditor(editor: Editor) { this.activeEditor = editor; if (this.toolbar) { this.activeEditor.toolbar = this.toolbar; @@ -1537,14 +1523,13 @@ export class SuperDoc extends EventEmitter { /** * Toggle the ruler visibility for SuperEditors * - * @returns {void} */ toggleRuler() { // Guard before mutating `this.config.rulers` so a pre-ready call // throws without partially flipping the config. const store = this.#requireSuperdocStore('toggleRuler'); this.config.rulers = !this.config.rulers; - store.documents.forEach((doc) => { + store.documents.forEach((doc: RuntimeDocument) => { // In Pinia store, refs are auto-unwrapped, so rulers is a plain boolean doc.rulers = this.config.rulers; }); @@ -1568,7 +1553,6 @@ export class SuperDoc extends EventEmitter { * comment?: (object & Record) | null, * trackedChange?: ({ id?: string, commentId?: string, comment?: unknown } & Record) | null, * }} [params] - * @returns {boolean} */ canPerformPermission({ permission, @@ -1576,6 +1560,12 @@ export class SuperDoc extends EventEmitter { isInternal = this.config.isInternal, comment = null, trackedChange = null, + }: { + permission?: string; + role?: string; + isInternal?: boolean; + comment?: (object & Record) | null; + trackedChange?: ({ id?: string; commentId?: string; comment?: unknown } & Record) | null; } = {}) { if (!permission) return false; @@ -1595,7 +1585,7 @@ export class SuperDoc extends EventEmitter { trackedChange: trackedChange ?? null, }; - return isAllowed(permission, /** @type {string} */ (role), /** @type {boolean} */ (isInternal), context); + return isAllowed(permission, role as string, isInternal as boolean, context); } #addToolbar() { @@ -1652,9 +1642,8 @@ export class SuperDoc extends EventEmitter { * Add a comments list to the superdoc * Requires the comments module to be enabled * @param {Element} element The DOM element to render the comments list in - * @returns {void} */ - addCommentsList(element) { + addCommentsList(element: HTMLElement) { if (!this.config?.modules?.comments || this.config.role === 'viewer') return; if (element) this.config.modules.comments.element = element; this.commentsList = new SuperComments(this.config.modules?.comments, this); @@ -1663,7 +1652,6 @@ export class SuperDoc extends EventEmitter { /** * Remove the comments list from the superdoc - * @returns {void} */ removeCommentsList() { if (this.commentsList) { @@ -1680,7 +1668,7 @@ export class SuperDoc extends EventEmitter { * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] * @returns {boolean} Whether a matching element was found */ - scrollToComment(commentId, options = {}) { + scrollToComment(commentId: string, options: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition } = {}) { const commentsConfig = this.config?.modules?.comments; // `commentsConfig` can be `false | object | undefined`; `!commentsConfig` // already covers both `false` and `undefined`, so the secondary @@ -1705,11 +1693,9 @@ export class SuperDoc extends EventEmitter { * Story-aware navigation is currently supported for bookmark and tracked * change targets. Block and comment targets are body-only. * - * @param {NavigableAddress} target * @returns {Promise} Whether the target was found and navigated to. */ - async navigateTo(target) { - /** @type {RuntimeDocument[] | undefined} */ + async navigateTo(target: NavigableAddress): Promise { const storeDocs = this.superdocStore?.documents; if (!storeDocs?.length) return false; const presentationEditor = storeDocs[0].getPresentationEditor?.(); @@ -1734,8 +1720,7 @@ export class SuperDoc extends EventEmitter { * // Navigate to a comment by its entityId * await superdoc.scrollToElement('imported-25def254'); */ - async scrollToElement(elementId) { - /** @type {RuntimeDocument[] | undefined} */ + async scrollToElement(elementId: string): Promise { const storeDocs = this.superdocStore?.documents; if (!storeDocs?.length) return false; const presentationEditor = storeDocs[0].getPresentationEditor?.(); @@ -1746,14 +1731,13 @@ export class SuperDoc extends EventEmitter { /** * Toggle the custom context menu globally. * Updates both flow editors and PresentationEditor instances so downstream listeners can short-circuit early. - * @param {boolean} disabled */ setDisableContextMenu(disabled = true) { const nextValue = Boolean(disabled); if (this.config.disableContextMenu === nextValue) return; this.config.disableContextMenu = nextValue; - this.superdocStore?.documents?.forEach((doc) => { + this.superdocStore?.documents?.forEach((doc: RuntimeDocument) => { const presentationEditor = doc.getPresentationEditor?.(); if (presentationEditor?.setContextMenuDisabled) { presentationEditor.setContextMenuDisabled(nextValue); @@ -1769,8 +1753,6 @@ export class SuperDoc extends EventEmitter { * SD-2454: Toggle bookmark bracket indicators (opt-in, off by default). * Matches Word's "Show bookmarks" option. Triggers a re-layout on change * because the brackets are visible characters participating in text flow. - * @param {boolean} show - * @returns {void} */ setShowBookmarks(show = true) { const nextValue = Boolean(show); @@ -1778,7 +1760,7 @@ export class SuperDoc extends EventEmitter { if (layoutOptions.showBookmarks === nextValue) return; layoutOptions.showBookmarks = nextValue; - this.superdocStore?.documents?.forEach((doc) => { + this.superdocStore?.documents?.forEach((doc: RuntimeDocument) => { const presentationEditor = doc.getPresentationEditor?.(); presentationEditor?.setShowBookmarks?.(nextValue); }); @@ -1787,8 +1769,6 @@ export class SuperDoc extends EventEmitter { /** * Toggle nonprinting formatting marks (spaces, tabs, paragraph marks) in the * rendered layout. This is a view-only setting and is not exported to DOCX. - * @param {boolean} show - * @returns {void} */ setShowFormattingMarks(show = true) { const nextValue = Boolean(show); @@ -1796,7 +1776,7 @@ export class SuperDoc extends EventEmitter { if (layoutOptions.showFormattingMarks === nextValue) return; layoutOptions.showFormattingMarks = nextValue; - this.superdocStore?.documents?.forEach((doc) => { + this.superdocStore?.documents?.forEach((doc: RuntimeDocument) => { const presentationEditor = doc.getPresentationEditor?.(); presentationEditor?.setShowFormattingMarks?.(nextValue); }); @@ -1807,7 +1787,6 @@ export class SuperDoc extends EventEmitter { /** * Toggle nonprinting formatting marks from their current state. - * @returns {void} */ toggleFormattingMarks() { const currentValue = Boolean(this.config.layoutEngineOptions?.showFormattingMarks); @@ -1816,10 +1795,8 @@ export class SuperDoc extends EventEmitter { /** * Set the document mode. - * @param {DocumentMode} type - * @returns {void} */ - setDocumentMode(type) { + setDocumentMode(type: DocumentMode) { if (!type) return; // Guard before mutating `this.config.documentMode` so a pre-ready @@ -1827,7 +1804,7 @@ export class SuperDoc extends EventEmitter { // `#syncViewingVisibility` / tracked-change preference writes. this.#requireReady('setDocumentMode'); - type = /** @type {DocumentMode} */ (type.toLowerCase()); + type = type.toLowerCase() as DocumentMode; this.config.documentMode = type; this.#syncViewingVisibility(); @@ -1849,7 +1826,7 @@ export class SuperDoc extends EventEmitter { * @param {RuntimeDocument} doc - The document object * @param {DocumentMode} mode - The document mode ('editing', 'viewing', 'suggesting') */ - #applyDocumentMode(doc, mode) { + #applyDocumentMode(doc: RuntimeDocument, mode: DocumentMode) { const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; if (presentationEditor) { presentationEditor.setDocumentMode(mode); @@ -1867,13 +1844,13 @@ export class SuperDoc extends EventEmitter { * * @param {{ mode?: 'review' | 'original' | 'final' | 'off', enabled?: boolean }} [preferences] */ - setTrackedChangesPreferences(preferences) { + setTrackedChangesPreferences(preferences?: { mode?: 'review' | 'original' | 'final' | 'off'; enabled?: boolean }) { const normalized = preferences && Object.keys(preferences).length ? { ...preferences } : undefined; if (!this.config.layoutEngineOptions) { this.config.layoutEngineOptions = {}; } this.config.layoutEngineOptions.trackedChanges = normalized; - this.superdocStore?.documents?.forEach((doc) => { + this.superdocStore?.documents?.forEach((doc: RuntimeDocument) => { const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; if (presentationEditor?.setTrackedChangesOverrides) { presentationEditor.setTrackedChangesOverrides(normalized); @@ -1892,8 +1869,8 @@ export class SuperDoc extends EventEmitter { // Enable tracked changes for editing mode this.setTrackedChangesPreferences({ mode: 'review', enabled: true }); - store.documents.forEach((doc) => { - doc.restoreComments(); + store.documents.forEach((doc: RuntimeDocument) => { + doc.restoreComments?.(); this.#applyDocumentMode(doc, 'editing'); }); } @@ -1909,8 +1886,8 @@ export class SuperDoc extends EventEmitter { // Enable tracked changes for suggesting mode this.setTrackedChangesPreferences({ mode: 'review', enabled: true }); - store.documents.forEach((doc) => { - doc.restoreComments(); + store.documents.forEach((doc: RuntimeDocument) => { + doc.restoreComments?.(); this.#applyDocumentMode(doc, 'suggesting'); }); } @@ -1942,11 +1919,11 @@ export class SuperDoc extends EventEmitter { this.commentsStore?.clearEditorCommentPositions?.(); } - store.documents.forEach((doc) => { + store.documents.forEach((doc: RuntimeDocument) => { if (commentsVisible || trackChangesVisible) { - doc.restoreComments(); + doc.restoreComments?.(); } else { - doc.removeComments(); + doc.removeComments?.(); } this.#applyDocumentMode(doc, 'viewing'); }); @@ -1965,7 +1942,6 @@ export class SuperDoc extends EventEmitter { }); } - /** @type {RuntimeDocument[] | undefined} */ const docs = this.superdocStore?.documents; if (Array.isArray(docs) && docs.length > 0) { docs.forEach((doc) => { @@ -1989,7 +1965,7 @@ export class SuperDoc extends EventEmitter { * @param {string | RegExp} text The text or regex to search for * @returns {import('./types/index.js').SearchMatch[] | undefined} The search results */ - search(text) { + search(text: string | RegExp): SearchMatch[] | undefined { return this.activeEditor?.commands.search(text, { searchModel: 'visible' }); } @@ -2003,7 +1979,7 @@ export class SuperDoc extends EventEmitter { * @param {import('./types/index.js').SearchMatch} match The match object returned by `superdoc.search()`. * @returns {boolean | undefined} Whether the command dispatched, or `undefined` if no active editor. */ - goToSearchResult(match) { + goToSearchResult(match: SearchMatch) { return this.activeEditor?.commands.goToSearchResult(match); } @@ -2026,7 +2002,7 @@ export class SuperDoc extends EventEmitter { * superdoc.setZoom(150); // Set zoom to 150% * superdoc.setZoom(50); // Set zoom to 50% */ - setZoom(percent) { + setZoom(percent: number) { if (typeof percent !== 'number' || !Number.isFinite(percent) || percent <= 0) { console.warn('[SuperDoc] setZoom expects a positive number representing percentage'); return; @@ -2043,15 +2019,14 @@ export class SuperDoc extends EventEmitter { /** * Set the document to locked or unlocked - * @param {boolean} lock */ setLocked(lock = true) { - /** @type {InternalConfig} */ (this.config).documents.forEach((doc) => { + this.config.documents.forEach((doc: RuntimeDocument) => { // setLocked is a collaboration-only API; the surrounding flow only // calls it once each document has a Yjs doc attached. Cast away the // optional shape on the public Document typedef without changing // runtime behavior. - const ydoc = /** @type {import('yjs').Doc} */ (doc.ydoc); + const ydoc = doc.ydoc as Y.Doc; const metaMap = ydoc.getMap('meta'); ydoc.transact(() => { metaMap.set('locked', lock); @@ -2065,10 +2040,9 @@ export class SuperDoc extends EventEmitter { * @returns {Array} The HTML content of all editors */ getHTML(options = {}) { - /** @type {Editor[]} */ - const editors = []; - this.#requireSuperdocStore('getHTML').documents.forEach((doc) => { - const editor = doc.getEditor(); + const editors: Editor[] = []; + this.#requireSuperdocStore('getHTML').documents.forEach((doc: RuntimeDocument) => { + const editor = doc.getEditor?.(); if (editor) { editors.push(editor); } @@ -2079,10 +2053,9 @@ export class SuperDoc extends EventEmitter { /** * Lock the current superdoc - * @param {Boolean} isLocked * @param {User} lockedBy The user who locked the superdoc */ - lockSuperdoc(isLocked = false, lockedBy) { + lockSuperdoc(isLocked: boolean = false, lockedBy: User | null = null) { this.isLocked = isLocked; this.lockedBy = lockedBy; this.#log('🦋 [superdoc] Locking superdoc:', isLocked, lockedBy, '\n\n\n'); @@ -2092,20 +2065,21 @@ export class SuperDoc extends EventEmitter { /** * Export the superdoc to a file * @param {ExportParams} params - Export configuration - * @returns {Promise} - */ - async export({ - exportType = ['docx'], - commentsType = 'external', - exportedName, - additionalFiles = [], - additionalFileNames = [], - isFinalDoc = false, - triggerDownload = true, - fieldsHighlightColor = null, - } = {}) { + */ + async export( + { + exportType = ['docx'], + commentsType = 'external', + exportedName, + additionalFiles = [], + additionalFileNames = [], + isFinalDoc = false, + triggerDownload = true, + fieldsHighlightColor = null, + }: ExportParams = {} as ExportParams, + ) { // Get the docx files first - const baseFileName = exportedName ? cleanName(exportedName) : cleanName(/** @type {string} */ (this.config.title)); + const baseFileName = exportedName ? cleanName(exportedName) : cleanName(this.config.title as string); const docxFiles = await this.exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor }); const blobsToZip = [...additionalFiles]; const filenames = [...additionalFileNames]; @@ -2113,7 +2087,9 @@ export class SuperDoc extends EventEmitter { // If we are exporting docx files, add them to the zip if (exportType.includes('docx')) { docxFiles.forEach((blob) => { - blobsToZip.push(blob); + // exportDocx default overload returns Blob; the wider `string | Blob | null` + // shows up only when callers opt into other export modes (not used here). + blobsToZip.push(blob as Blob); filenames.push(`${baseFileName}.docx`); }); } @@ -2139,9 +2115,12 @@ export class SuperDoc extends EventEmitter { /** * Export editors to DOCX format. * @param {{ commentsType?: string, isFinalDoc?: boolean, fieldsHighlightColor?: string | null }} [options] - * @returns {Promise>} */ - async exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor } = {}) { + async exportEditorsToDOCX({ + commentsType, + isFinalDoc, + fieldsHighlightColor, + }: { commentsType?: string; isFinalDoc?: boolean; fieldsHighlightColor?: string | null } = {}) { // The export's job is to pick the correct source of truth for // comments. There are three branches; the third had a latent // ambiguity that resurrected deleted comments and is the @@ -2170,8 +2149,7 @@ export class SuperDoc extends EventEmitter { // `converter.comments` (which the legacy delete path doesn't // clear today; tracked separately under SD-2839). Pass // whatever the store returns, including `[]`. - /** @type {unknown[] | undefined} */ - let comments; + let comments: unknown[] | undefined; const commentsModuleConfig = this.config?.modules?.comments; const uiStoreHydrated = commentsModuleConfig !== false; if (commentsType === 'clean') { @@ -2189,27 +2167,34 @@ export class SuperDoc extends EventEmitter { // else: UI store unhydrated → leave `comments` undefined and // let the engine's `converter.comments` fallback fire. - const docxPromises = this.#requireSuperdocStore('exportEditorsToDOCX').documents.map(async (doc) => { - if (!doc || doc.type !== DOCX) return null; + const docxPromises = this.#requireSuperdocStore('exportEditorsToDOCX').documents.map( + async (doc: RuntimeDocument) => { + if (!doc || doc.type !== DOCX) return null; - const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null; - const fallbackDocx = () => { - if (!doc.data) return null; - if (doc.data.type && doc.data.type !== DOCX) return null; - return doc.data; - }; + const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null; + const fallbackDocx = () => { + if (!doc.data) return null; + if (doc.data.type && doc.data.type !== DOCX) return null; + return doc.data; + }; - if (!editor) return fallbackDocx(); + if (!editor) return fallbackDocx(); - try { - const exported = await editor.exportDocx({ isFinalDoc, comments, commentsType, fieldsHighlightColor }); - if (exported) return exported; - } catch (error) { - this.emit('exception', { error, document: doc }); - } + try { + const exported = await editor.exportDocx({ + isFinalDoc, + comments: comments as import('@superdoc/super-editor').Comment[] | undefined, + commentsType, + fieldsHighlightColor, + }); + if (exported) return exported; + } catch (error) { + this.emit('exception', { error, document: doc }); + } - return fallbackDocx(); - }); + return fallbackDocx(); + }, + ); const docxFiles = await Promise.all(docxPromises); return docxFiles.filter(Boolean); @@ -2222,8 +2207,8 @@ export class SuperDoc extends EventEmitter { async #triggerCollaborationSaves() { this.#log('🦋 [superdoc] Triggering collaboration saves'); const store = this.#requireSuperdocStore('save'); - return new Promise((resolve) => { - store.documents.forEach((doc, index) => { + return new Promise((resolve) => { + store.documents.forEach((doc: RuntimeDocument, index: number) => { this.#log(`Before reset - Doc ${index}: pending = ${this.pendingCollaborationSaves}`); this.pendingCollaborationSaves = 0; if (doc.ydoc) { @@ -2242,7 +2227,7 @@ export class SuperDoc extends EventEmitter { } }); this.#log( - `FINAL pending = ${this.pendingCollaborationSaves}, but we have ${store.documents.filter((d) => d.ydoc).length} docs!`, + `FINAL pending = ${this.pendingCollaborationSaves}, but we have ${store.documents.filter((d: RuntimeDocument) => d.ydoc).length} docs!`, ); }); } @@ -2265,7 +2250,6 @@ export class SuperDoc extends EventEmitter { /** * Clean up collaboration resources (providers, ydocs, sockets) - * @returns {void} */ #cleanupCollaboration() { // Remove the awareness listener so the provider cannot emit events @@ -2275,7 +2259,7 @@ export class SuperDoc extends EventEmitter { this._cleanupAwareness = null; } - const cfg = /** @type {InternalConfig} */ (this.config); + const cfg = this.config; // `cancelWebsocketRetry` is set on `HocuspocusProviderWebsocket` only // while a reconnect timer is pending, and Hocuspocus clears it back to // `undefined` after firing. Destroy from the "already connected, no @@ -2289,7 +2273,7 @@ export class SuperDoc extends EventEmitter { this.provider?.disconnect?.(); this.provider?.destroy?.(); - cfg.documents.forEach((doc) => { + cfg.documents.forEach((doc: RuntimeDocument) => { doc.provider?.disconnect?.(); doc.provider?.destroy?.(); doc.ydoc?.destroy(); @@ -2304,24 +2288,20 @@ export class SuperDoc extends EventEmitter { * Open a surface (dialog or floating) above the document content. * * @template [TResult=unknown] - * @param {SurfaceRequest} request - * @returns {SurfaceHandle} */ - openSurface(request) { - return this.#surfaceManager.open(request); + openSurface(request: SurfaceRequest): SurfaceHandle { + return this.#surfaceManager.open(request) as SurfaceHandle; } /** * Close a surface by id, or the topmost surface if no id is given. - * @param {string} [id] */ - closeSurface(id) { + closeSurface(id?: string) { this.#surfaceManager.close(id); } /** * Destroy the superdoc instance - * @returns {void} */ destroy() { // Mark as destroyed early to prevent in-flight init from mounting @@ -2366,14 +2346,13 @@ export class SuperDoc extends EventEmitter { /** * Focus the active editor or the first editor in the superdoc - * @returns {void} */ focus() { if (this.activeEditor) { this.activeEditor.focus(); } else { - this.#requireSuperdocStore('focus').documents.find((doc) => { - const editor = doc.getEditor(); + this.#requireSuperdocStore('focus').documents.find((doc: RuntimeDocument) => { + const editor = doc.getEditor?.(); if (editor) { editor.focus(); } @@ -2383,10 +2362,8 @@ export class SuperDoc extends EventEmitter { /** * Set the high contrast mode - * @param {boolean} isHighContrast - * @returns {void} */ - setHighContrastMode(isHighContrast) { + setHighContrastMode(isHighContrast: boolean) { if (!this.activeEditor) return; // `setHighContrastMode` is typed as optional on Editor because the // method is only present once the editor's mount hooks run. By the diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 58f2997c77..572f37a7ed 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -160,7 +160,7 @@ export interface RuntimeDocument extends Document { * silently replacing whatever was passed. SD-2872 removed this from * the public `Document` interface so consumers stop trying to use it * as a stable per-document override; it lives on `RuntimeDocument` - * only so internal SuperDoc.js callsites can type the assignment. + * only so internal SuperDoc callsites can type the assignment. */ role?: 'editor' | 'viewer' | 'suggester'; /** @@ -181,6 +181,25 @@ export interface RuntimeDocument extends Document { * Use the Document API (`editor.doc`) instead. */ getPresentationEditor?: () => SuperEditorPresentationEditor | null | undefined; + /** + * Runtime-only flag mirrored from `Config.rulers` per document by the + * Pinia store. SuperDoc writes this on each document during the + * setShowRulers flow; not part of consumer-supplied `Document`. + */ + rulers?: boolean; + /** + * Runtime-only method attached by the comments composable on each + * document. Set after the comments store is ready; called during + * mode switches. Not part of consumer-supplied `Document`. + */ + restoreComments?: () => void; + /** + * Runtime-only method attached by the comments composable on each + * document. Set after the comments store is ready; called during + * DOCX export when comments should be stripped. Not part of + * consumer-supplied `Document`. + */ + removeComments?: () => void; } /** Collaboration module configuration. */ @@ -1568,7 +1587,7 @@ export interface Config { * call sites cast `this.config` to this type so they can access these * invariants without per-site null guards. * - * Use this from internal SuperDoc.js callsites that need the augmented shape + * Use this from internal SuperDoc callsites that need the augmented shape * (e.g. `/** @type {InternalConfig} *\/ (this.config).socket = ...`). */ export interface InternalConfig extends Config { diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index 6ad8bfa5a8..86eb7763b7 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -48,7 +48,8 @@ export { DOCX, PDF, HTML, getFileObject, compareVersions }; // First-class public API. Documented, advertised, supported long-term. // ============================================================================= -// Source: ./core/SuperDoc.js +// Source: ./core/SuperDoc.ts. The `.js` import specifier is intentional +// for ESM output and resolves to the .ts source during TypeScript builds. export { SuperDoc } from '../core/SuperDoc.js'; // Source: ./core/theme/create-theme.ts diff --git a/tests/consumer-typecheck/src/search-match.ts b/tests/consumer-typecheck/src/search-match.ts index eb49125529..33e795a309 100644 --- a/tests/consumer-typecheck/src/search-match.ts +++ b/tests/consumer-typecheck/src/search-match.ts @@ -17,6 +17,11 @@ import type { SearchMatch, SuperDoc } from 'superdoc'; declare const sd: SuperDoc; const results: SearchMatch[] | undefined = sd.search('hello'); +// `search` also accepts a RegExp at runtime; the type must include it. +// Regression guard: a TS-only narrowing to `string` (e.g. during a JS→TS +// migration of SuperDoc) would silently break this call until consumer +// upgrade-time. SD-typecheck-superdoc-ts. +sd.search(/hello/i); if (results && results.length > 0) { const first: SearchMatch = results[0]; @@ -41,6 +46,7 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? type AssertEqual = Equal extends true ? true : never; const _searchReturnTypeIsExact: AssertEqual, SearchMatch[] | undefined> = true; +const _searchParamTypeIsExact: AssertEqual[0], string | RegExp> = true; const _goToParamTypeIsExact: AssertEqual[0], SearchMatch> = true; -void [_searchReturnTypeIsExact, _goToParamTypeIsExact, results]; +void [_searchReturnTypeIsExact, _searchParamTypeIsExact, _goToParamTypeIsExact, results];