Skip to content
Closed
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 @@ -2804,6 +2804,22 @@ export class PresentationEditor extends EventEmitter {
handler: handleRemoteHeaderFooterChanged as (...args: unknown[]) => void,
});

// Remote converter metadata arrived via Y.js (numbering, styles, headerFooterIds, etc.)
// Re-render so the style engine and layout pipeline pick up the changes.
// For headerFooterIds: refresh the H/F manager so it discovers new sections.
const handleRemoteConverterMetaChanged = (payload: { key: string }) => {
if (payload?.key === 'headerFooterIds') {
this.#headerFooterSession?.manager?.refresh();
}
this.#pendingDocChange = true;
this.#scheduleRerender();
};
this.#editor.on('remoteConverterMetaChanged', handleRemoteConverterMetaChanged);
this.#editorListeners.push({
event: 'remoteConverterMetaChanged',
handler: handleRemoteConverterMetaChanged as (...args: unknown[]) => void,
});

// Listen for comment selection changes to update Layout Engine highlighting
const handleCommentsUpdate = (payload: { activeCommentId?: string | null }) => {
if (this.#domPainter?.setActiveComment) {
Expand Down
6 changes: 6 additions & 0 deletions packages/super-editor/src/core/types/EditorEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,10 @@ export interface EditorEventMap extends DefaultEventMap {

/** Called when page styles are updated */
pageStyleUpdate: [{ pageMargins?: Record<string, unknown>; pageStyles: Record<string, unknown> }];

/** Called when stylesheet defaults change (local mutation via styles.apply) */
stylesDefaultsChanged: [];

/** Called when remote converter metadata arrives via Y.js (numbering, styles, etc.) */
remoteConverterMetaChanged: [{ key: string; data: unknown }];
}
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ describe('getDocumentApiCapabilities', () => {
expect(reasons).toContain('STYLES_PART_MISSING');
});

it('reports COLLABORATION_ACTIVE when collaboration provider is synced', () => {
it('allows styles.apply when collaboration provider is synced (synced via Y.js)', () => {
const editor = makeEditor();
(editor as unknown as Record<string, unknown>).converter = {
convertedXml: {
Expand All @@ -627,10 +627,8 @@ describe('getDocumentApiCapabilities', () => {
(editor as unknown as { options: Record<string, unknown> }).options.collaborationProvider = { synced: true };

const capabilities = getDocumentApiCapabilities(editor);
const reasons = capabilities.operations['styles.apply'].reasons ?? [];
expect(capabilities.operations['styles.apply'].available).toBe(false);
expect(reasons).toContain('COLLABORATION_ACTIVE');
expect(reasons).toContain('OPERATION_UNAVAILABLE');
// Style mutations are now synced via Y.js (SD-2040), so the collaboration gate was removed.
expect(capabilities.operations['styles.apply'].available).toBe(true);
});

it('styles.apply never reports COMMAND_UNAVAILABLE', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
OPERATION_IDS,
} from '@superdoc/document-api';
import { TrackFormatMarkName } from '../extensions/track-changes/constants.js';
import { isCollaborationActive } from './collaboration-detection.js';

type EditorCommandName = string;
type EditorWithBlockNodeHelper = Editor & {
Expand Down Expand Up @@ -294,7 +293,6 @@ function isStylesApplyAvailable(editor: Editor): boolean {
const converter = (editor as unknown as { converter?: { convertedXml?: Record<string, unknown> } }).converter;
if (!converter?.convertedXml?.['word/styles.xml']) return false;
if (!hasStylesRoot(converter.convertedXml['word/styles.xml'])) return false;
if (isCollaborationActive(editor)) return false;
return true;
}

Expand All @@ -306,7 +304,6 @@ function getStylesApplyUnavailableReason(editor: Editor): CapabilityReasonCode |
if (!converter) return 'OPERATION_UNAVAILABLE';
if (!converter.convertedXml?.['word/styles.xml']) return 'STYLES_PART_MISSING';
if (!hasStylesRoot(converter.convertedXml['word/styles.xml'])) return 'STYLES_PART_MISSING';
if (isCollaborationActive(editor)) return 'COLLABORATION_ACTIVE';
return undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,13 @@ describe('styles adapter: capability gates', () => {
);
});

it('throws CAPABILITY_UNAVAILABLE when collaboration is active', () => {
it('allows mutation when collaboration is active (synced via Y.js)', () => {
const editor = createMockEditor({
stylesXml: makeStylesXml(),
collaborationProvider: { synced: true },
});
expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow(
DocumentApiAdapterError,
);
// Style mutations are now synced via the styleDefinitions Y.js map (SD-2040).
expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).not.toThrow();
});

it('allows mutation when collaboration provider is not synced', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
import { PROPERTY_REGISTRY } from '@superdoc/document-api';
import type { Editor } from '../core/Editor.js';
import { DocumentApiAdapterError } from './errors.js';
import { isCollaborationActive } from './collaboration-detection.js';

import { executeOutOfBandMutation } from './out-of-band-mutation.js';
import { syncDocDefaultsToConvertedXml, type DocDefaultsTranslator } from './styles-xml-sync.js';
import { translator as docDefaultsTranslator } from '../core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.js';
Expand Down Expand Up @@ -310,13 +310,8 @@ export function stylesApplyAdapter(
);
}

if (isCollaborationActive(editor)) {
throw new DocumentApiAdapterError(
'CAPABILITY_UNAVAILABLE',
'styles.apply is unavailable during active collaboration. Stylesheet mutations cannot be synced via Yjs.',
{ reason: 'collaboration_active' },
);
}
// Collaboration gate removed: style mutations are now synced via the
// styleDefinitions Y.js map (see collaboration-helpers.js).

const stylesRoot = stylesPart.elements?.find((el: XmlElement) => el.name === 'w:styles');
if (!stylesRoot) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,127 @@ const _doUpdateYdocDocxData = async (editor, ydoc) => {
}
};

// ---------------------------------------------------------------------------
// Converter metadata real-time sync
// ---------------------------------------------------------------------------
//
// Generic mechanism to sync converter metadata (numbering, styles, and any
// future type) via a SINGLE Y.js map ('converterMeta'). Each metadata type
// is a key in the map. One observer, one flag, one event.
//
// To add a new metadata type:
// 1. Add a key constant to CONVERTER_META_KEYS
// 2. Add apply logic in applyRemoteConverterMetadata
// 3. Add a push trigger in collaboration.js (editor event → pushConverterMetadata)
// 4. Add getLocal logic in pushConverterMetadata
// ---------------------------------------------------------------------------

export const CONVERTER_META_KEYS = /** @type {const} */ (['numbering', 'styles', 'headerFooterIds']);

let isApplyingRemoteConverterMeta = false;

/**
* Check if we're currently applying remote converter metadata.
* Used to prevent push-back (ping-pong) when a remote change
* triggers local events.
*/
export const isApplyingRemoteConverterMetadata = () => isApplyingRemoteConverterMeta;

/**
* Push a specific converter metadata key to the shared Y.js map.
*
* @param {Editor} editor The editor instance
* @param {typeof CONVERTER_META_KEYS[number]} key Which metadata to push
*/
export const pushConverterMetadata = (editor, key) => {
if (isApplyingRemoteConverterMeta) return;

const ydoc = editor?.options?.ydoc;
if (!ydoc || ydoc.isDestroyed) return;
if (!editor?.converter) return;

const map = ydoc.getMap('converterMeta');
let data;

if (key === 'numbering') {
data = {
numbering: editor.converter.numbering,
translatedNumbering: editor.converter.translatedNumbering,
};
} else if (key === 'styles') {
data = {
translatedLinkedStyles: editor.converter.translatedLinkedStyles,
};
} else if (key === 'headerFooterIds') {
data = {
headerIds: editor.converter.headerIds,
footerIds: editor.converter.footerIds,
};
} else {
return;
}

// Skip if unchanged — avoids redundant Y.js transacts (especially for
// headerFooterIds which is triggered on every header keystroke but
// almost never actually changes).
const existing = map.get(key);
if (existing && JSON.stringify(existing) === JSON.stringify(data)) {
return;
}

ydoc.transact(() => map.set(key, data), {
event: `converter-meta-${key}-update`,
user: editor.options.user,
});
};

/**
* Push ALL converter metadata keys. Called once during initializeMetaMap
* so joining clients receive the full state.
*
* @param {Editor} editor
*/
export const pushAllConverterMetadata = (editor) => {
for (const key of CONVERTER_META_KEYS) {
pushConverterMetadata(editor, key);
}
};

/**
* Apply remote converter metadata to the local editor.
*
* @param {Editor} editor
* @param {string} key Which metadata was updated
* @param {object} data The remote payload
*/
export const applyRemoteConverterMetadata = (editor, key, data) => {
if (!editor || editor.isDestroyed || !editor.converter) return;
if (!data) return;

isApplyingRemoteConverterMeta = true;

try {
if (key === 'numbering') {
if (data.numbering) editor.converter.numbering = data.numbering;
if (data.translatedNumbering) editor.converter.translatedNumbering = data.translatedNumbering;
} else if (key === 'styles') {
if (data.translatedLinkedStyles) editor.converter.translatedLinkedStyles = data.translatedLinkedStyles;
} else if (key === 'headerFooterIds') {
if (data.headerIds) editor.converter.headerIds = data.headerIds;
if (data.footerIds) editor.converter.footerIds = data.footerIds;
}

editor.emit('remoteConverterMetaChanged', { key, data });
} finally {
setTimeout(() => {
isApplyingRemoteConverterMeta = false;
}, 0);
}
};

// ---------------------------------------------------------------------------
// Header/footer real-time sync
// ---------------------------------------------------------------------------
// Current approach: last-writer-wins with full JSON replacement.
// Future: CRDT-based sync (like y-prosemirror) for character-level merging.
let isApplyingRemoteChanges = false;
Expand Down Expand Up @@ -142,10 +262,49 @@ export const pushHeaderFooterToYjs = (editor, type, sectionId, content) => {
return;
}

ydoc.transact(() => headerFooterMap.set(key, { type, sectionId, content }), {
event: 'header-footer-update',
user: editor.options.user,
});
// Include headerIds/footerIds in the same transaction so the receiver
// gets both the content and the section-type mapping atomically.
// Without this, the two Y.js maps (headerFooterJson + converterMeta)
// can arrive in separate network messages, causing the receiver to have
// content but no headerIds — the layout pipeline can't resolve which
// header to render.
const headerIds = editor.converter?.headerIds;
const footerIds = editor.converter?.footerIds;

ydoc.transact(
() => {
headerFooterMap.set(key, { type, sectionId, content, headerIds, footerIds });
},
{
event: 'header-footer-update',
user: editor.options.user,
},
);
};

/**
* Push all headers and footers from the converter to the headerFooterJson
* Y.js map. Called once during initializeMetaMap so joining clients receive
* header/footer content immediately (not just after the 30s DOCX sync).
*
* Also pushes headerIds/footerIds so the receiving client knows which
* headers/footers map to which section types (default, first, even, odd).
*
* @param {Editor} editor
*/
export const pushAllHeaderFooterToYjs = (editor) => {
if (!editor?.converter) return;
const { headers, footers } = editor.converter;
if (headers) {
for (const [sectionId, content] of Object.entries(headers)) {
if (content) pushHeaderFooterToYjs(editor, 'header', sectionId, content);
}
}
if (footers) {
for (const [sectionId, content] of Object.entries(footers)) {
if (content) pushHeaderFooterToYjs(editor, 'footer', sectionId, content);
}
}
};

/**
Expand All @@ -158,7 +317,7 @@ export const pushHeaderFooterToYjs = (editor, type, sectionId, content) => {
export const applyRemoteHeaderFooterChanges = (editor, key, data) => {
if (!editor || editor.isDestroyed || !editor.converter) return;

const { type, sectionId, content } = data;
const { type, sectionId, content, headerIds, footerIds } = data;
if (!type || !sectionId || !content) return;

// Prevent ping-pong: replaceContent triggers blur/update which would push back to Yjs
Expand All @@ -169,6 +328,11 @@ export const applyRemoteHeaderFooterChanges = (editor, key, data) => {
const storage = editor.converter[`${type}s`];
if (storage) storage[sectionId] = content;

// Apply headerIds/footerIds atomically with the content so the layout
// pipeline can resolve which header/footer to render immediately.
if (headerIds) editor.converter.headerIds = headerIds;
if (footerIds) editor.converter.footerIds = footerIds;

// Mark as modified so exports include header/footer references
editor.converter.headerFooterModified = true;

Expand Down
Loading
Loading