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
15 changes: 14 additions & 1 deletion apps/cli/src/lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

function nextTimerTurn(): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}

// ---------------------------------------------------------------------------
// Bootstrap claim
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -273,7 +277,16 @@ export async function claimBootstrap(

const observer = observeCompetitor(ydoc);
try {
if (settlingMs > 0) await sleep(settlingMs);
if (settlingMs > 0) {
await sleep(settlingMs);

// Give already-due timer callbacks one more turn to run before the
// final ownership check. This makes bootstrap claiming more
// conservative under event-loop jitter, where a competing marker can
// be queued before the settling window ends but execute immediately
// after our sleep resolves.
await nextTimerTurn();
}

const competitor = observer.getCompetitor();
if (competitor) return { granted: false, competitor };
Expand Down
1 change: 0 additions & 1 deletion packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3318,7 +3318,6 @@ export class Editor extends EventEmitter<EditorEventMap> {
}, SYNC_TIMEOUT_MS);
}
});
doReplaceFileSync();
} else {
this.#insertNewFileData();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,6 @@ const extractCommentRangesFromDocument = (docx, converter) => {
let positionIndex = 0;
let lastElementWasCommentMarker = false;
const recentlyClosedComments = new Set();
let lastTrackedChange = null;

const walkElements = (elements, currentTrackedChangeId = null) => {
if (!elements || !Array.isArray(elements)) return;

Expand Down Expand Up @@ -311,61 +309,25 @@ const extractCommentRangesFromDocument = (docx, converter) => {
}
lastElementWasCommentMarker = true;
} else if (isTrackedChange) {
const trackedChangeId = element.attributes?.['w:id'];
const author = element.attributes?.['w:author'];
const date = element.attributes?.['w:date'];
const elementType = element.name;
let mappedId = trackedChangeId;
let isReplacement = false;

if (trackedChangeId !== undefined && converter) {
if (!converter.trackedChangeIdMap) {
converter.trackedChangeIdMap = new Map();
}

// Word uses different IDs for deletion and insertion in replacements, link them by same author/date
if (
lastTrackedChange &&
lastTrackedChange.type !== elementType &&
lastTrackedChange.author === author &&
lastTrackedChange.date === date
) {
mappedId = lastTrackedChange.mappedId;
converter.trackedChangeIdMap.set(String(trackedChangeId), mappedId);
isReplacement = true;
} else {
if (!converter.trackedChangeIdMap.has(String(trackedChangeId))) {
converter.trackedChangeIdMap.set(String(trackedChangeId), uuidv4());
}
mappedId = converter.trackedChangeIdMap.get(String(trackedChangeId));
}
}

if (currentTrackedChangeId === null) {
if (isReplacement) {
lastTrackedChange = null;
} else {
lastTrackedChange = {
type: elementType,
author,
date,
mappedId,
wordId: String(trackedChangeId),
};
}
}
// ID mapping and replacement pairing are handled by trackedChangeIdMapper.
// Here we only associate recently-closed comments with the tracked change.
const wordId = element.attributes?.['w:id'];
const mappedId =
wordId != null
? (converter?.trackedChangeIdMap?.get(String(wordId)) ?? String(wordId))
: currentTrackedChangeId;

if (mappedId && recentlyClosedComments.size > 0) {
recentlyClosedComments.forEach((commentId) => {
if (!commentsInTrackedChanges.has(commentId)) {
commentsInTrackedChanges.set(commentId, String(mappedId));
commentsInTrackedChanges.set(commentId, mappedId);
}
});
}
recentlyClosedComments.clear();

if (element.elements && Array.isArray(element.elements)) {
walkElements(element.elements, mappedId !== undefined ? String(mappedId) : currentTrackedChangeId);
walkElements(element.elements, mappedId);
}
} else {
if (lastElementWasCommentMarker) {
Expand All @@ -375,7 +337,6 @@ const extractCommentRangesFromDocument = (docx, converter) => {

if (element.name === 'w:p') {
recentlyClosedComments.clear();
lastTrackedChange = null;
}

if (element.elements && Array.isArray(element.elements)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumbe
import { pageReferenceEntity } from './pageReferenceImporter.js';
import { pictNodeHandlerEntity } from './pictNodeImporter.js';
import { importCommentData } from './documentCommentsImporter.js';
import { buildTrackedChangeIdMap } from './trackedChangeIdMapper.js';
import { importFootnoteData, importEndnoteData } from './documentFootnotesImporter.js';
import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js';
import { pruneIgnoredNodes } from './ignoredNodes.js';
Expand Down Expand Up @@ -148,6 +149,7 @@ export const createDocumentJson = (docx, converter, editor) => {

patchNumberingDefinitions(docx);
const numbering = getNumberingDefinitions(docx);
converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx);
const comments = importCommentData({ docx, nodeListHandler, converter, editor });
const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering });
const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// @ts-check
import { v4 as uuidv4 } from 'uuid';

/**
* @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry
* @typedef {{ lastTrackedChange: TrackedChangeEntry | null }} WalkContext
*/

const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']);

/**
* Non-content marker elements that can appear between the two halves of a Word
* replacement without breaking the pairing. These are range/annotation markers
* that carry no document content.
*
* Any element NOT in this set (e.g. w:r, w:hyperlink, w:sdt) is treated as
* content and resets the pairing state so unrelated revisions in the same
* paragraph are never falsely linked.
*/
const PAIRING_TRANSPARENT_NAMES = new Set([
'w:commentRangeStart',
'w:commentRangeEnd',
'w:bookmarkStart',
'w:bookmarkEnd',
'w:proofErr',
'w:permStart',
'w:permEnd',
'w:moveFromRangeStart',
'w:moveFromRangeEnd',
'w:moveToRangeStart',
'w:moveToRangeEnd',
]);

/**
* Two adjacent tracked changes form a Word replacement pair when they are
* opposite types (delete vs insert) from the same author at the same timestamp.
*
* @param {TrackedChangeEntry} previous
* @param {{ type: string, author: string, date: string }} current
* @returns {boolean}
*/
function isReplacementPair(previous, current) {
return previous.type !== current.type && previous.author === current.author && previous.date === current.date;
}

/**
* Assigns an internal UUID to a tracked change element. Adjacent replacement
* halves (w:del + w:ins with matching author/date) share the same UUID.
*
* @param {object} element XML element (w:ins or w:del)
* @param {Map<string, string>} idMap Accumulates Word ID → internal UUID
* @param {WalkContext} context Mutable walk state for replacement pairing
* @param {boolean} insideTrackedChange Whether this element is nested in another tracked change
*/
function assignInternalId(element, idMap, context, insideTrackedChange) {
const wordId = String(element.attributes?.['w:id'] ?? '');
if (!wordId) return;

// Nested tracked changes get their own UUID but are never paired.
if (insideTrackedChange) {
if (!idMap.has(wordId)) {
idMap.set(wordId, uuidv4());
}
return;
}

const current = {
type: element.name,
author: element.attributes?.['w:author'] ?? '',
date: element.attributes?.['w:date'] ?? '',
};

if (context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) {
// Second half of a replacement — share the first half's UUID, but only
// if this w:id hasn't already been mapped. A reused id that was already
// part of an earlier pair must keep its original mapping.
if (!idMap.has(wordId)) {
idMap.set(wordId, context.lastTrackedChange.internalId);
}
context.lastTrackedChange = null;
} else {
// Reuse an existing mapping when the same w:id appears more than once
// (Word reuses tracked-change ids across the document). Minting a fresh
// UUID here would overwrite the earlier entry and break any replacement
// pair that was already recorded for this id.
const internalId = idMap.get(wordId) ?? uuidv4();
idMap.set(wordId, internalId);
context.lastTrackedChange = { ...current, internalId };
}
}

/**
* Recursively walks XML elements, assigning internal UUIDs to every tracked
* change and pairing adjacent replacements.
*
* @param {Array} elements
* @param {Map<string, string>} idMap
* @param {WalkContext} context
* @param {boolean} [insideTrackedChange]
*/
function walkElements(elements, idMap, context, insideTrackedChange = false) {
if (!Array.isArray(elements)) return;

for (const element of elements) {
if (TRACKED_CHANGE_NAMES.has(element.name)) {
assignInternalId(element, idMap, context, insideTrackedChange);

if (element.elements) {
// Descend with an isolated context so content inside a tracked change
// cannot clear the outer replacement candidate.
walkElements(element.elements, idMap, { lastTrackedChange: null }, /* insideTrackedChange */ true);
}
} else {
// Content-bearing elements break replacement pairing. Only non-content
// markers (comment/bookmark/permission ranges) are transparent.
if (!PAIRING_TRANSPARENT_NAMES.has(element.name)) {
context.lastTrackedChange = null;
}

if (element.elements) {
walkElements(element.elements, idMap, context, insideTrackedChange);
}
}
}
}

/**
* Builds a map from OOXML `w:id` values to stable internal UUIDs by scanning
* `word/document.xml`.
*
* Word tracked replacements use separate `w:id` values for the delete and
* insert halves. This function detects adjacent opposite-type changes with
* matching author and date and maps both halves to the same internal UUID so
* the editor can resolve them as a single logical change.
*
* Must run before comment import so all consumers — translators, comment
* helpers, and the tracked-change resolver — see a fully populated map.
*
* @param {object} docx Parsed DOCX package
* @returns {Map<string, string>} Word `w:id` → internal UUID
*/
export function buildTrackedChangeIdMap(docx) {
const body = docx?.['word/document.xml']?.elements?.[0];
if (!body?.elements) return new Map();

const idMap = new Map();
walkElements(body.elements, idMap, { lastTrackedChange: null });

return idMap;
}
Loading
Loading