Skip to content

Commit ad5a8cc

Browse files
authored
fix(super-editor): keep comment range markers around unpaired tracked changes (SD-2528) (#3239)
* fix(super-editor): keep comment range markers as siblings of unpaired tracked changes (SD-2528) mergeConsecutiveTrackedChanges greedily absorbed trailing w:commentRangeEnd and w:r->w:commentReference elements into a w:del/w:ins wrapper even when no same-id wrapper followed to actually merge into. This produced a lopsided structure where w:commentRangeStart sat outside the wrapper but w:commentRangeEnd ended up inside it, breaking comment round-trip on redlined text. Buffer comment markers during the forward scan and only commit them inside the wrapper when a same-id merge actually happens. Otherwise emit them as siblings, matching Word's expected OOXML. SD-1519 merge behavior is unchanged and covered by the new tests. * fix(super-editor): fold commentRangeStart into TC wrapper for round-trip (SD-2528) The previous fix left commentRangeStart as a sibling of w:ins/w:del. documentCommentsImporter.js' extractCommentRangesFromDocument only associates a comment with a tracked change when commentRangeStart sits inside the wrapper, so the sibling shape silently dropped the comment to TC link on re-import. Fold any leading commentRangeStart sibling into the immediately following w:ins/w:del as its first child, matching the shape Word produces. The existing SD-1519 same-id merge for trailing commentRangeEnd and w:r/w:commentReference stays unchanged. Adds an end-to-end test that loads the Google-Docs TC+comment fixture, exports it, re-imports the exported XML, and asserts every comment that was originally inside a tracked change still carries trackedChangeParentId after the round-trip. * fix: cascade accept/reject of a tracked change to its anchored comments (SD-2528) A user comment anchored to a tracked change carries trackedChangeParentId pointing at the TC. Two bugs broke the link end-to-end: 1. docxImporter built two tracked-change id maps independently (trackedChangeIdMap and trackedChangeIdMapsByPart), each minting fresh UUIDs for the same Word w:id. The comments importer used the global map; ins/del translators used the per-part map. The two never matched, so trackedChangeParentId on a comment never pointed at the actual TC mark id in the PM doc. Fix: build the per-part maps first and reuse the document.xml entry as the global map. 2. The comments-store resolve handler only resolved the TC's own redline-display entry. User comments with trackedChangeParentId === the resolved TC stayed open. Fix: after resolving the TC entity, iterate commentsList and resolve every comment whose trackedChangeParentId matches. Defer via Promise.resolve so the cascading resolveComment doesn't dispatch into a still-running accept/rejectTrackedChangeById loop and collide with the loop's mutable tr. E2E browser repro on the real Google-Docs TC+comment fixture: accept TC by id, both the TC and its anchored user comments resolve in one user action. Same for reject. No mismatched-tr errors. The export-side round-trip test also asserts the two id maps are aligned and every comment trackedChangeParentId matches a real tracked-change mark id in the PM doc. * fix(super-editor): thread reply-to-TC under its tracked-change bubble on re-import (SD-2528) A reply that the user typed under a tracked-change bubble has parentCommentId pointing at the synthetic TC entity in the comments store. On export the TC parent is filtered out of comments.xml (TC entries are not real comments), so the reply lands in the file without any paraIdParent. On re-import the reply gets trackedChangeParentId via the document.xml walker (the commentRange wraps the TC text) but parentCommentId was left undefined — the sidebar then renders the reply as a separate top-level bubble next to the TC instead of nested under it, matching the user-reported regression in image 1 of SD-2528. Promote trackedChangeParentId to parentCommentId when no explicit parent is set. CommentDialog already threads via direct parentCommentId === trackedChangeId (line 321), so this is the cheapest path to restore the live pre-export state. Round-trip stable: re-export still filters TC parents but re-emits the commentRange inside the wrapper, which gets re-detected on the next import via extractCommentRangesFromDocument and re-establishes the linkage. * fix(comments): thread tracked-change replies regardless of file origin (SD-2528) The UI guarded TC reply threading with isRangeThreadedComment, which is true only when the source DOCX has no commentsExtended.xml (Google Docs style). SuperDoc-exported DOCX files always write commentsExtended.xml, so on re-import the guard short-circuited and the reply rendered as a top-level bubble next to its TC instead of nested under it. Drop the file-origin guard from the two sites that threaded TC replies: collectTrackedChangeThread in CommentDialog.vue and shouldThreadWithTrackedChange in comments-store.js. trackedChangeParentId pointing at a tracked-change entity is sufficient to thread; file origin should not change whether a comment threads under its TC. Reverts the earlier importer-side patch that promoted trackedChangeParentId into parentCommentId. That patch violated the comment-diffing contract (parentCommentId is diffed; trackedChangeParentId is intentionally ignored because it is regenerated across imports) and broke six existing tests. The UI-side change is surgical and breaks no tests. * fix(comments): preserve TC color on anchored comments + clean up IMPORTED/resolve gates (SD-2528) Three visual round-trip regressions after the SD-2528 fix made TC replies thread again: 1. CommentHighlightDecorator painted its pink (external) / green (internal) inline background on every element with the superdoc-comment-highlight class — including text that already carries a track-insert-dec / track-delete-dec decoration. The inline style won over the TC's own CSS class background, so a green trackInsert came back pink after re-import. Skip the BG override when the element is also a tracked-change decoration: the TC color (green for insert, red for delete) is the right signal for the user, and the comment range is still visually identified by its dashed border + sidebar bubble. 2. CommentHeader's IMPORTED tag fired whenever comment.origin or importedAuthor was set — including comments authored by the current user in a previous session. Round-tripping a file you exported then re-opened should not relabel your own comments as imported. Suppress the tag when the comment's creatorEmail matches the current user's email. 3. CommentHeader's allowResolve guard treated parentCommentId as the only marker of a child comment. A TC-anchored reply on re-import keeps the linkage through trackedChangeParentId only (parentCommentId is left undefined to preserve the comment-diffing contract). The resolve check affordance therefore appeared on re-imported replies even though the pre-export state had no parentCommentId either. Treat trackedChangeParentId as an equivalent child signal. All three are surgical render-side gates — no converter / data-model changes. 1369 super-editor presentation tests pass. * fix(comments): scope cascade to active doc + tighten TC-anchored gates (SD-2528) Addresses Codex's 3 P2 review findings from PR #3239: P2 #1 — comments-store.js cascade scope The new cascade-resolve scan introduced in aa88a58 didn't honour the resolve event's documentId. findTrackedChangeById (line 591) correctly scopes its match by belongsToTrackedChangeSyncDocument; the cascade six lines lower did not. In multi-document sessions where imported tracked-change ids collide across files (each w:id space is local), accepting a change in document A would also resolve comments anchored on the same id in document B. Mirror the same per-document filter when a documentId is provided; single-document callers (no documentId on the event) keep the legacy global behaviour. P2 #2 — CommentHighlightDecorator.ts visual gate The earlier suppression triggered on any of `track-insert-dec`, `track-delete-dec`, or `track-format-dec`. Per layout-engine styles.ts only `.track-insert-dec.highlighted` and `.track-delete-dec.highlighted` paint a background; `.track-format-dec` only paints a border-bottom, and the `.highlighted` modifier is only applied in "review" / All Markup mode (renderer.ts:909-928). In Original / Final modes, and on format-only changes, the suppression cleared the comment fill with nothing to replace it, making the bubble invisible. Tighten the gate to require both `.highlighted` and one of the bg-painting base classes. P2 #3 — collectTrackedChangeThread parent shadowing documentCommentsImporter can produce a comment with BOTH a non-TC `parentCommentId` and a `trackedChangeParentId`: the comment's range lives inside a TC, but its conversational thread starts at a regular comment outside the TC. The previous unconditional pull on trackedChangeParentId placed such replies in both threads. Restrict the direct seed to roots (no parentCommentId) and let the BFS step pick up same-TC-anchored chains via parent links. Extract the helper to a sibling module so the BFS logic can be unit-tested in isolation — previously trapped inside CommentDialog.vue's <script setup>. Verification - 8 new unit tests covering each P2 case (3 in collect-tracked-change-thread.test.js, 4+ in CommentHighlightDecorator.test.ts, 1 cross-doc + 1 single-doc regression in comments-store.test.js). - SD-2528 integration round-trip test still passes (1/1). - super-editor: 12 850 / 12 850 unit tests pass. - superdoc: 966 unit tests pass (1 pre-existing collab-server import failure, unrelated, present on main and other branches). - Browser repro on the corpus fixture: accepting an imported TC still cascade-resolves both anchored user comments end-to-end.
1 parent 87368de commit ad5a8cc

13 files changed

Lines changed: 798 additions & 95 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,105 @@ describe('CommentHighlightDecorator', () => {
352352

353353
// ── Edge cases ─────────────────────────────────────────────────────
354354

355+
// ── SD-2528 P2 #2: comment fill vs tracked-change background ───────
356+
//
357+
// Per layout-engine styles.ts, only `.track-insert-dec.highlighted` and
358+
// `.track-delete-dec.highlighted` paint a background; `.track-format-dec`
359+
// only paints a `border-bottom`. The `.highlighted` modifier is ONLY
360+
// applied in "review" (All Markup) mode — Original and Final modes use
361+
// `.hidden` / `.normal` / `.before` and paint no background.
362+
//
363+
// The decorator must therefore suppress its comment-fill repaint ONLY in
364+
// the narrow case where the TC actually paints a competing background.
365+
// Otherwise the comment highlight is cleared with nothing to replace it
366+
// and the comment becomes invisible in Original / Final / format-only.
367+
describe('tracked-change-anchored elements (SD-2528 P2 #2)', () => {
368+
const commentClass = 'superdoc-comment-highlight';
369+
370+
function tcCommentSpan(opts: { commentIds: string[]; tcClasses: string[] }): HTMLSpanElement {
371+
const el = commentSpan({ commentIds: opts.commentIds });
372+
el.classList.add(...opts.tcClasses);
373+
return el;
374+
}
375+
376+
it('suppresses comment fill when element is track-insert-dec AND highlighted', () => {
377+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'highlighted'] });
378+
container.appendChild(span);
379+
380+
decorator.apply();
381+
382+
expect(span.style.backgroundColor).toBe('');
383+
expect(span.classList.contains(commentClass)).toBe(true);
384+
});
385+
386+
it('suppresses comment fill when element is track-delete-dec AND highlighted', () => {
387+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-delete-dec', 'highlighted'] });
388+
container.appendChild(span);
389+
390+
decorator.apply();
391+
392+
expect(span.style.backgroundColor).toBe('');
393+
});
394+
395+
it('keeps comment fill when track-insert-dec is present but .highlighted is NOT (Original/Final mode)', () => {
396+
// In Original/Final modes the painter applies `.hidden` / `.normal` /
397+
// `.before` instead of `.highlighted`, so no green background is drawn.
398+
// The comment fill must remain visible — otherwise the comment bubble
399+
// disappears with no replacement paint.
400+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'normal'] });
401+
container.appendChild(span);
402+
403+
decorator.apply();
404+
405+
expect(span.style.backgroundColor).toBe(EXT);
406+
});
407+
408+
it('keeps comment fill when track-delete-dec is present but .highlighted is NOT', () => {
409+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-delete-dec', 'hidden'] });
410+
container.appendChild(span);
411+
412+
decorator.apply();
413+
414+
expect(span.style.backgroundColor).toBe(EXT);
415+
});
416+
417+
it('keeps comment fill on track-format-dec.highlighted — format changes paint only a border, not a background', () => {
418+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-format-dec', 'highlighted'] });
419+
container.appendChild(span);
420+
421+
decorator.apply();
422+
423+
expect(span.style.backgroundColor).toBe(EXT);
424+
});
425+
426+
it('still suppresses comment fill in the active highlight branch when TC paints', () => {
427+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'highlighted'] });
428+
container.appendChild(span);
429+
430+
setActiveCommentAndApply(decorator, 'c-1');
431+
432+
expect(span.style.backgroundColor).toBe('');
433+
});
434+
435+
it('still suppresses comment fill in the faded branch when TC paints and a different comment is active', () => {
436+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-delete-dec', 'highlighted'] });
437+
container.appendChild(span);
438+
439+
setActiveCommentAndApply(decorator, 'other');
440+
441+
expect(span.style.backgroundColor).toBe('');
442+
});
443+
444+
it('keeps faded comment fill in Original mode (track-insert-dec without .highlighted)', () => {
445+
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'normal'] });
446+
container.appendChild(span);
447+
448+
setActiveCommentAndApply(decorator, 'other');
449+
450+
expect(span.style.backgroundColor).toBe(EXT_FADED);
451+
});
452+
});
453+
355454
describe('edge cases', () => {
356455
it('apply() is a no-op when no container is set', () => {
357456
const dec = new CommentHighlightDecorator();

packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,39 @@ export class CommentHighlightDecorator {
171171
// Determine if primary (first) comment is internal — used for uniform/faded colors.
172172
const primaryIsInternal = internalIds.has(ids[0]);
173173

174+
// SD-2528: a comment anchored on tracked-change text shows the TC's
175+
// own background (green for trackInsert, red for trackDelete) via
176+
// `.track-insert-dec.highlighted` / `.track-delete-dec.highlighted`.
177+
// The comment highlight stacking on top of that paints pink/green
178+
// over the TC color, making an "insert" look pink after re-import.
179+
// Leave the background alone in that case so the TC color wins
180+
// (matches Word — comments anchored on a redline don't recolor the
181+
// redline).
182+
//
183+
// AIDEV-NOTE: SD-2528 P2 #2. Suppress comment fill ONLY when the TC
184+
// is actually painting a competing background. Per layout-engine
185+
// styles.ts:270-294, that requires both:
186+
// - the base class `track-insert-dec` or `track-delete-dec` (not
187+
// `track-format-dec`, which only paints a `border-bottom`);
188+
// - the `.highlighted` modifier — only applied in "review" / All
189+
// Markup mode per renderer.ts:909-928. In Original/Final modes
190+
// the modifier is `hidden` / `normal` / `before` and no
191+
// background is painted.
192+
// Without this narrower gate the comment highlight was cleared with
193+
// nothing to replace it, making the bubble invisible in Original /
194+
// Final modes and on format-only changes. Hover/focus affordances
195+
// still come from the TC's own `.track-change-focused` class.
196+
const isTrackedChangeAnchored =
197+
el.classList.contains('highlighted') &&
198+
(el.classList.contains('track-insert-dec') || el.classList.contains('track-delete-dec'));
199+
174200
if (activeId == null) {
175201
// No active comment → uniform light highlight
176-
applyBgColor(el, primaryIsInternal ? H.INT : H.EXT);
202+
if (!isTrackedChangeAnchored) {
203+
applyBgColor(el, primaryIsInternal ? H.INT : H.EXT);
204+
} else {
205+
el.style.backgroundColor = '';
206+
}
177207
el.style.boxShadow = '';
178208
continue;
179209
}
@@ -184,7 +214,11 @@ export class CommentHighlightDecorator {
184214
if (matchedId != null) {
185215
// This element belongs to the active comment → bright highlight
186216
const matchIsInternal = internalIds.has(matchedId);
187-
applyBgColor(el, matchIsInternal ? H.INT_ACTIVE : H.EXT_ACTIVE);
217+
if (!isTrackedChangeAnchored) {
218+
applyBgColor(el, matchIsInternal ? H.INT_ACTIVE : H.EXT_ACTIVE);
219+
} else {
220+
el.style.backgroundColor = '';
221+
}
188222

189223
// Nested comments: other IDs besides the active one
190224
const hasNested = ids.length > 1;
@@ -195,7 +229,11 @@ export class CommentHighlightDecorator {
195229
}
196230
} else {
197231
// Active comment is set but doesn't match this element → faded
198-
applyBgColor(el, primaryIsInternal ? H.INT_FADED : H.EXT_FADED);
232+
if (!isTrackedChangeAnchored) {
233+
applyBgColor(el, primaryIsInternal ? H.INT_FADED : H.EXT_FADED);
234+
} else {
235+
el.style.backgroundColor = '';
236+
}
199237
el.style.boxShadow = '';
200238
}
201239
}

packages/super-editor/src/editors/v1/core/super-converter/v2/importer/documentCommentsImporter.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,6 @@ const generateCommentsWithExtendedData = ({ docx, comments, converter, threading
208208
}
209209
}
210210

211-
// Track the tracked change association but don't use it as parentCommentId
212-
// This keeps comments and tracked changes as separate bubbles in the UI
213-
// while preserving the relationship for export and visual purposes
214211
const trackedChangeParentId = isInsideTrackedChange ? trackedChangeParent.trackedChangeId : undefined;
215212

216213
// Only use range-based parenting as fallback when:

packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,16 @@ export const createDocumentJson = (docx, converter, editor) => {
158158
const trackedChangeIdMapOptions = {
159159
replacements: converter.trackedChangesOptions?.replacements ?? 'paired',
160160
};
161-
converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions);
161+
// AIDEV-NOTE: SD-2528. The per-part map and the global map MUST share UUIDs
162+
// for the same w:id, otherwise documentCommentsImporter (uses the global)
163+
// and the ins/del translators (use the per-part) end up with two different
164+
// UUIDs for the same tracked change — the comment's trackedChangeParentId
165+
// never matches the tracked-change mark's id, breaking accept/reject
166+
// cascading.
162167
converter.trackedChangeIdMapsByPart = buildTrackedChangeIdMapsByPart(docx, trackedChangeIdMapOptions);
168+
converter.trackedChangeIdMap =
169+
converter.trackedChangeIdMapsByPart.get('word/document.xml') ??
170+
buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions);
163171
const comments = importCommentData({ docx, nodeListHandler, converter, editor });
164172
const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering });
165173
const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering });

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,103 @@
11
import { translateChildNodes } from '@converter/v2/exporter/helpers/index.js';
22
import { generateParagraphProperties } from './generate-paragraph-properties.js';
33

4+
const isTrackedChangeWrapper = (el) => el?.name === 'w:ins' || el?.name === 'w:del';
5+
6+
const isCommentMarker = (el) => {
7+
if (!el) return false;
8+
if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true;
9+
if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true;
10+
return false;
11+
};
12+
13+
// AIDEV-NOTE: SD-2528. The importer associates a comment with a tracked change
14+
// by walking document.xml and noting commentRangeStart elements that appear
15+
// inside a w:ins/w:del wrapper (see documentCommentsImporter.js'
16+
// extractCommentRangesFromDocument). Word always emits commentRangeStart inside
17+
// the wrapper; emitting it as a sibling silently loses the comment ↔ TC link
18+
// on re-import.
19+
function foldLeadingCommentStartsIntoTrackedChanges(elements) {
20+
const result = [];
21+
let i = 0;
22+
while (i < elements.length) {
23+
if (elements[i]?.name !== 'w:commentRangeStart') {
24+
result.push(elements[i]);
25+
i++;
26+
continue;
27+
}
28+
const leadingStarts = [];
29+
while (i < elements.length && elements[i]?.name === 'w:commentRangeStart') {
30+
leadingStarts.push(elements[i]);
31+
i++;
32+
}
33+
const next = elements[i];
34+
if (isTrackedChangeWrapper(next)) {
35+
result.push({ ...next, elements: [...leadingStarts, ...(next.elements || [])] });
36+
i++;
37+
} else {
38+
result.push(...leadingStarts);
39+
}
40+
}
41+
return result;
42+
}
43+
444
/**
5-
* Merge consecutive tracked change elements (w:ins/w:del) with the same ID.
6-
* Comment range markers between tracked changes with the same ID are included
7-
* inside the merged wrapper, matching Word's OOXML structure.
8-
*
9-
* See SD-1519 for details on the ECMA-376 spec compliance.
45+
* Merge consecutive tracked change elements (w:ins/w:del) with the same ID,
46+
* and fold any commentRangeStart that immediately precedes a tracked-change
47+
* wrapper INTO the wrapper as its first child(ren). Trailing commentRangeEnd
48+
* and w:r→w:commentReference stay as siblings and are only absorbed when a
49+
* same-id successor wrapper triggers an SD-1519 merge.
1050
*
1151
* @param {Array} elements The translated paragraph elements
1252
* @returns {Array} Elements with consecutive tracked changes merged
1353
*/
1454
function mergeConsecutiveTrackedChanges(elements) {
1555
if (!Array.isArray(elements) || elements.length === 0) return elements;
1656

57+
elements = foldLeadingCommentStartsIntoTrackedChanges(elements);
58+
1759
const result = [];
1860
let i = 0;
1961

2062
while (i < elements.length) {
2163
const current = elements[i];
2264

23-
// Check if this is a tracked change wrapper (w:ins or w:del)
24-
if (current?.name === 'w:ins' || current?.name === 'w:del') {
65+
if (isTrackedChangeWrapper(current)) {
2566
const tcId = current.attributes?.['w:id'];
2667
const tcName = current.name;
2768

28-
// Collect consecutive elements that belong to this tracked change
2969
const mergedElements = [...(current.elements || [])];
70+
const pendingComments = [];
71+
let didMerge = false;
3072
let j = i + 1;
3173

3274
while (j < elements.length) {
3375
const next = elements[j];
3476

35-
// Include comment markers - they can sit inside tracked changes per ECMA-376
36-
if (next?.name === 'w:commentRangeStart' || next?.name === 'w:commentRangeEnd') {
37-
mergedElements.push(next);
77+
if (isCommentMarker(next)) {
78+
pendingComments.push(next);
3879
j++;
3980
continue;
4081
}
4182

42-
// Include comment references (w:r containing w:commentReference)
43-
if (next?.name === 'w:r') {
44-
const hasOnlyCommentRef = next.elements?.length === 1 && next.elements[0]?.name === 'w:commentReference';
45-
if (hasOnlyCommentRef) {
46-
mergedElements.push(next);
47-
j++;
48-
continue;
49-
}
50-
}
51-
52-
// Merge with next tracked change if same type and ID
5383
if (next?.name === tcName && next.attributes?.['w:id'] === tcId) {
54-
mergedElements.push(...(next.elements || []));
84+
mergedElements.push(...pendingComments, ...(next.elements || []));
85+
pendingComments.length = 0;
86+
didMerge = true;
5587
j++;
5688
continue;
5789
}
5890

59-
// Stop merging when we hit a different element
6091
break;
6192
}
6293

63-
// Create the merged wrapper
64-
result.push({
65-
name: tcName,
66-
attributes: { ...current.attributes },
67-
elements: mergedElements,
68-
});
69-
94+
if (didMerge) {
95+
result.push({ name: tcName, attributes: { ...current.attributes }, elements: mergedElements });
96+
result.push(...pendingComments);
97+
} else {
98+
result.push(current);
99+
result.push(...pendingComments);
100+
}
70101
i = j;
71102
} else {
72103
result.push(current);

0 commit comments

Comments
 (0)