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
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,36 @@ import { resolveStateEditor } from './context.js';
import { isCommandDisabled } from './general.js';
import type { ToolbarContext } from '../types.js';

// SD-3213f: prefer the narrow `superdoc.getComment(id)` method when
// present (SuperDoc instances and adopting host stubs). Fall back to
// the legacy `commentsStore.getComment(id)` reach for custom host stubs
// that pre-date the narrow method.
const lookupCommentByCommentId = (
superdoc: Record<string, any> | undefined,
commentId: string,
): Record<string, unknown> | null => {
if (typeof superdoc?.getComment === 'function') {
return superdoc.getComment(commentId) ?? null;
}
const store = superdoc?.commentsStore;
if (typeof store?.getComment === 'function') {
return store.getComment(commentId) ?? null;
}
return null;
};

const enrichTrackedChanges = (trackedChanges: Array<Record<string, any>> = [], superdoc?: Record<string, any>) => {
if (!trackedChanges.length) return trackedChanges;
const store = superdoc?.commentsStore;
if (!store?.getComment) return trackedChanges;

return trackedChanges.map((change) => {
const commentId = change.id;
if (!commentId) return change;
const storeComment = store.getComment(commentId);
const storeComment = lookupCommentByCommentId(superdoc, commentId);
if (!storeComment) return change;
const comment = typeof storeComment.getValues === 'function' ? storeComment.getValues() : storeComment;
const comment =
typeof (storeComment as { getValues?: () => unknown }).getValues === 'function'
? (storeComment as { getValues: () => unknown }).getValues()
: storeComment;
return { ...change, comment };
});
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { resolveToolbarSources } from './resolve-toolbar-sources.js';

Expand Down Expand Up @@ -92,4 +92,93 @@ describe('resolveToolbarSources', () => {
expect(result.context?.surface).toBe('note');
expect(result.context?.target.doc).toBe(noteEditor.doc);
});

// SD-3213f: the resolver prefers the narrow
// `getPresentationEditorForDocument` host method when present, falling
// back to the legacy `superdocStore.documents[]` reach for custom host
// stubs that pre-date the narrow method. These two tests pin the
// dispatch logic so a future refactor cannot silently drop either
// branch.
it('uses the narrow getPresentationEditorForDocument host method when present', () => {
const bodyEditor = {
commands: { toggleBold: () => true },
doc: { kind: 'body-doc' },
isEditable: true,
state: {
selection: {
empty: false,
},
},
options: {
documentId: 'doc-narrow',
},
};
const presentationEditor = {
commands: { toggleBold: () => true },
isEditable: true,
state: {
selection: {
empty: false,
},
},
getActiveEditor: () => bodyEditor,
};
const getPresentationEditorForDocument = vi.fn(() => presentationEditor as any);

const result = resolveToolbarSources({
activeEditor: bodyEditor as any,
getPresentationEditorForDocument,
});

expect(getPresentationEditorForDocument).toHaveBeenCalledWith('doc-narrow');
expect(result.presentationEditor).toBe(presentationEditor);
expect(result.activeEditor).toBe(bodyEditor);
});

it('prefers the narrow host method over the legacy superdocStore fallback when both are present', () => {
const bodyEditor = {
commands: { toggleBold: () => true },
doc: { kind: 'body-doc' },
isEditable: true,
state: {
selection: {
empty: false,
},
},
options: {
documentId: 'doc-precedence',
},
};
const narrowPresentationEditor = {
commands: { toggleBold: () => true },
isEditable: true,
state: { selection: { empty: false } },
getActiveEditor: () => bodyEditor,
};
const legacyPresentationEditor = {
commands: { toggleBold: () => true },
isEditable: true,
state: { selection: { empty: false } },
getActiveEditor: () => bodyEditor,
};
const getPresentationEditorForDocument = vi.fn(() => narrowPresentationEditor as any);
const legacyGetPresentationEditor = vi.fn(() => legacyPresentationEditor as any);

const result = resolveToolbarSources({
activeEditor: bodyEditor as any,
getPresentationEditorForDocument,
superdocStore: {
documents: [
{
getEditor: () => bodyEditor as any,
getPresentationEditor: legacyGetPresentationEditor,
},
],
},
});

expect(getPresentationEditorForDocument).toHaveBeenCalledWith('doc-precedence');
expect(legacyGetPresentationEditor).not.toHaveBeenCalled();
expect(result.presentationEditor).toBe(narrowPresentationEditor);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,23 @@ type EditorWithPresentationOwner = Editor & {
_presentationEditor?: PresentationEditor | null;
};

const resolvePresentationEditor = (superdoc: {
// SD-3213f: accept both the narrow SuperDoc method
// (`getPresentationEditorForDocument`) and the legacy `superdocStore`
// shape. The narrow method is preferred when present (SuperDoc
// instances and host stubs that adopt the new API). The legacy fallback
// keeps existing custom host stubs working without forcing a churn.
type ToolbarHostShape = {
activeEditor?: Editor | null;
getPresentationEditorForDocument?: (documentId: string) => PresentationEditor | null;
superdocStore?: {
documents?: Array<{
getPresentationEditor?: () => PresentationEditor | null | undefined;
getEditor?: () => Editor | null | undefined;
}>;
};
}): PresentationEditor | null => {
};

const resolvePresentationEditor = (superdoc: ToolbarHostShape): PresentationEditor | null => {
const activeEditor = (superdoc.activeEditor as EditorWithPresentationOwner | null | undefined) ?? null;
const directPresentationEditor = activeEditor?.presentationEditor ?? activeEditor?._presentationEditor ?? null;
if (directPresentationEditor) {
Expand All @@ -65,21 +73,20 @@ const resolvePresentationEditor = (superdoc: {
const documentId = activeEditor?.options?.documentId;
if (!documentId) return null;

// Resolve the PresentationEditor for the same document as the current raw editor.
// Prefer the narrow public method (SD-3213f) when the host provides it.
if (typeof superdoc.getPresentationEditorForDocument === 'function') {
return superdoc.getPresentationEditorForDocument(documentId);
}

// Legacy fallback: resolve the PresentationEditor for the same document
// as the current raw editor by walking `superdocStore.documents[]`.
// Kept for custom host stubs that pre-date the narrow method.
const documents = superdoc.superdocStore?.documents ?? [];
const matchedDoc = documents.find((doc) => doc.getEditor?.()?.options?.documentId === documentId);
return matchedDoc?.getPresentationEditor?.() ?? null;
};

export const resolveToolbarSources = (superdoc: {
activeEditor?: Editor | null;
superdocStore?: {
documents?: Array<{
getPresentationEditor?: () => PresentationEditor | null | undefined;
getEditor?: () => Editor | null | undefined;
}>;
};
}): ResolvedToolbarSources => {
export const resolveToolbarSources = (superdoc: ToolbarHostShape): ResolvedToolbarSources => {
const presentationEditor = resolvePresentationEditor(superdoc);

if (presentationEditor) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,57 @@ describe('createToolbarRegistry', () => {
);
});

// SD-3213f: the tracked-change enricher prefers the narrow
// `superdoc.getComment(id)` method when present, falling back to
// `commentsStore.getComment(id)` for custom host stubs that pre-date
// the narrow method. Pin precedence so a future refactor cannot flip
// it silently. (The legacy branch above already covers the
// commentsStore path in isolation.)
it('prefers superdoc.getComment over commentsStore.getComment when both are present', () => {
collectTrackedChangesMock.mockReturnValueOnce([{ id: 'tc-narrow', attrs: {} }]);
isTrackedChangeActionAllowedMock.mockReturnValueOnce(true);

const narrowGetComment = vi.fn(() => ({ id: 'tc-narrow', body: 'narrow-body' }));
const legacyGetComment = vi.fn(() => ({
getValues: () => ({ id: 'tc-narrow', body: 'legacy-body' }),
}));

const registry = createToolbarRegistry();
registry['track-changes-accept-selection']?.state({
context: {
...createContext(),
editor: {
state: {
doc: {},
selection: {
from: 1,
to: 3,
},
},
} as any,
},
superdoc: {
getComment: narrowGetComment,
commentsStore: {
getComment: legacyGetComment,
},
},
});

expect(narrowGetComment).toHaveBeenCalledWith('tc-narrow');
expect(legacyGetComment).not.toHaveBeenCalled();
expect(isTrackedChangeActionAllowedMock).toHaveBeenCalledWith(
expect.objectContaining({
trackedChanges: [
expect.objectContaining({
id: 'tc-narrow',
comment: { id: 'tc-narrow', body: 'narrow-body' },
}),
],
}),
);
});

it('derives document-mode value from superdoc config', () => {
const registry = createToolbarRegistry();
const state = registry['document-mode']?.state({
Expand Down
43 changes: 42 additions & 1 deletion packages/super-editor/src/headless-toolbar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,12 @@ export type HeadlessToolbarController = {
*/
export type ToolbarExecuteFn = (id: PublicToolbarItemId, payload?: unknown) => boolean;

export type HeadlessToolbarSuperdocHost = {
/**
* Common fields shared by every accepted `createHeadlessToolbar` host
* shape. Pulled out so the two host branches below stay aligned without
* duplication.
*/
type HeadlessToolbarSuperdocHostBase = {
activeEditor?: Editor | null;
config?: {
layoutEngineOptions?: {
Expand All @@ -257,6 +262,34 @@ export type HeadlessToolbarSuperdocHost = {
toggleFormattingMarks?: () => void;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
};

/**
* Narrow host shape introduced in SD-3213f. `SuperDoc` instances satisfy
* this branch directly: the two narrow methods replace the raw-store
* reach that `resolveToolbarSources` and `track-changes.ts` used before.
*/
type HeadlessToolbarSuperdocHostNarrow = HeadlessToolbarSuperdocHostBase & {
getPresentationEditorForDocument?: (documentId: string) => PresentationEditor | null;
getComment?: (commentId: string) => Record<string, unknown> | null;
};

/**
* Legacy host shape kept for pre-SD-3213f typed custom host stubs that
* pass `superdocStore.documents[]` directly. The runtime still accepts
* this path; the type is retained so inline object-literal custom hosts
* compile without `any` casts.
*
* `commentsStore` was never advertised on this type pre-SD-3213f, so it
* is intentionally not added here even though `track-changes.ts`
* accepts the field at runtime. Adding it now would be public-surface
* growth, not backward-compat.
*
* @deprecated Prefer the narrow host methods on
* `HeadlessToolbarSuperdocHostNarrow` (SD-3213f). Will be removed in
* a future major after custom host stubs adopt the narrow methods.
*/
type HeadlessToolbarSuperdocHostLegacy = HeadlessToolbarSuperdocHostBase & {
superdocStore?: {
documents?: Array<{
getPresentationEditor?: () => PresentationEditor | null | undefined;
Expand All @@ -265,6 +298,14 @@ export type HeadlessToolbarSuperdocHost = {
};
};

/**
* Host accepted by `createHeadlessToolbar({ superdoc })`. Union of the
* narrow SD-3213f shape (preferred; SuperDoc satisfies it) and the
* legacy `superdocStore` shape (deprecated; kept so inline custom host
* stubs from before SD-3213f keep compiling without `any` casts).
*/
export type HeadlessToolbarSuperdocHost = HeadlessToolbarSuperdocHostNarrow | HeadlessToolbarSuperdocHostLegacy;

export type CreateHeadlessToolbarOptions = {
superdoc: HeadlessToolbarSuperdocHost;
commands?: PublicToolbarItemId[];
Expand Down
54 changes: 52 additions & 2 deletions packages/superdoc/src/core/SuperDoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,32 @@ export class SuperDoc extends EventEmitter {
* the systematic soundness fix across all of these fields (declaring them
* `T | undefined` and casting at internal post-init access sites).
*
* `@private` is a TypeScript-surface hide, not runtime privacy: the
* fields still exist on the runtime instance and internal callers
* across the package keep working. Consumers can no longer reach into
* them via `.d.ts`, which collapses the Pinia type graph from the
* public surface (SD-3213f). The headless-toolbar host contract was
* refactored in the same PR to replace raw store reach with the
* narrow methods `getPresentationEditorForDocument(documentId)` and
* `getComment(commentId)` below, so SuperDoc instances satisfy
* `HeadlessToolbarSuperdocHost` directly without exposing
* `superdocStore` publicly.
*
* @type {ReturnType<typeof import('../stores/superdoc-store.js').useSuperdocStore>}
* @private
*/
superdocStore;

/** @type {ReturnType<typeof import('../stores/comments-store.js').useCommentsStore>} */
/**
* @type {ReturnType<typeof import('../stores/comments-store.js').useCommentsStore>}
* @private
*/
commentsStore;

/** @type {ReturnType<typeof import('../composables/use-high-contrast-mode.js').useHighContrastMode>} */
/**
* @type {ReturnType<typeof import('../composables/use-high-contrast-mode.js').useHighContrastMode>}
* @private
*/
highContrastModeStore;

/** @type {import('vue').App} */
Expand Down Expand Up @@ -465,6 +483,38 @@ export class SuperDoc extends EventEmitter {
};
}

/**
* Look up the PresentationEditor associated with a given documentId.
* Returns null if no document matches or the document has no
* presentation editor. Replaces the legacy
* `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) {
if (typeof documentId !== 'string' || documentId.length === 0) return null;
const documents = this.superdocStore?.documents ?? [];
const matched = documents.find((doc) => doc?.getEditor?.()?.options?.documentId === documentId);
return matched?.getPresentationEditor?.() ?? null;
}

/**
* Look up a comment by id. Returns null if not found. Replaces the
* legacy `superdoc.commentsStore.getComment(id)` reach for
* `superdoc/headless-toolbar` helpers (SD-3213f). The return type is
* intentionally wide (`Record<string, unknown> | null`) so the public
* surface does not pull the Pinia comment model type graph.
*
* @param {string} commentId
* @returns {Record<string, unknown> | null}
*/
getComment(commentId) {
if (typeof commentId !== 'string' || commentId.length === 0) return null;
return this.commentsStore?.getComment?.(commentId) ?? null;
}

/**
* Get the SuperDoc container element
* @returns {HTMLElement | null}
Expand Down
Loading
Loading