Skip to content

Commit 9cb061c

Browse files
authored
SF-3608 Fix lynx blot not re-rendering with note embed changes (#3549)
1 parent 1409d53 commit 9cb061c

File tree

4 files changed

+116
-10
lines changed

4 files changed

+116
-10
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { VerseRef } from '@sillsdev/scripture';
33
import { cloneDeep } from 'lodash-es';
44
import Quill, { Delta, EmitterSource, Range } from 'quill';
55
import { DeltaOperation, StringMap } from 'rich-text';
6-
import { BehaviorSubject, Subscription } from 'rxjs';
6+
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
77
import { isString } from '../../../type-utils';
88
import { TextDoc, TextDocId } from '../../core/models/text-doc';
99
import { LynxTextModelConverter } from '../../translate/editor/lynx/insights/lynx-editor';
@@ -128,18 +128,22 @@ class SegmentInfo {
128128
export class TextViewModel implements OnDestroy, LynxTextModelConverter {
129129
editor?: Quill;
130130

131-
private readonly _segments: Map<string, Range> = new Map<string, Range>();
132131
private changesSub?: Subscription;
133132
private onCreateSub?: Subscription;
134133
private textDoc?: TextDoc;
135134
private textDocId?: TextDocId;
135+
136136
/**
137137
* A mapping of IDs of elements embedded into the quill editor to their positions.
138138
* These elements are in addition to the text data i.e. Note threads
139139
*/
140140
private _embeddedElements: Map<string, EmbedPosition> = new Map<string, EmbedPosition>();
141141

142-
segments$ = new BehaviorSubject<ReadonlyMap<string, Range>>(this._segments);
142+
private readonly _segments: Map<string, Range> = new Map<string, Range>();
143+
private readonly segments$ = new BehaviorSubject<ReadonlyMap<string, Range>>(this._segments);
144+
145+
private readonly _embedPositionsChanged$ = new Subject<void>();
146+
readonly embedPositionsChanged$ = this._embedPositionsChanged$.asObservable();
143147

144148
get segments(): IterableIterator<[string, Range]> {
145149
return this._segments.entries();
@@ -780,6 +784,7 @@ export class TextViewModel implements OnDestroy, LynxTextModelConverter {
780784
}
781785

782786
this.segments$.next(this._segments);
787+
this._embedPositionsChanged$.next();
783788

784789
return convertDelta.compose(fixDelta).chop();
785790
}

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable } from '@angular/core';
22
import Quill, { Delta, Op, Range } from 'quill';
3+
import { Observable } from 'rxjs';
34
import { QuillLynxEditorAdapter } from './quill-services/quill-lynx-editor-adapter';
45

56
export type LynxableEditor = Quill; // Add future editor as union type
@@ -44,6 +45,13 @@ export interface LynxTextModelConverter {
4445
* Useful when embeds that are present only in the editor model may affect position offsets from Lynx.
4546
*/
4647
getEmbedCountsToOffsetFunc(): LynxCountToOffsetFunc;
48+
49+
/**
50+
* Observable that emits when embedded element positions change.
51+
* This is useful for detecting when note embeds are added/removed (including remotely),
52+
* which affect the mapping between data model positions and editor positions.
53+
*/
54+
readonly embedPositionsChanged$: Observable<void>;
4755
}
4856

4957
@Injectable({ providedIn: 'root' })

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.spec.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Component, DestroyRef, NO_ERRORS_SCHEMA, ViewChild } from '@angular/cor
22
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
33
import { Delta } from 'quill';
44
import { LynxInsightFilter, LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight';
5-
import { BehaviorSubject } from 'rxjs';
5+
import { BehaviorSubject, Subject } from 'rxjs';
66
import { anything, instance, mock, verify, when } from 'ts-mockito';
77
import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service';
88
import { configureTestingModule } from 'xforge-common/test-utils';
@@ -350,6 +350,76 @@ describe('LynxInsightEditorObjectsComponent', () => {
350350
verify(mockInsightRenderService.removeAllInsightFormatting(anything())).atLeast(1);
351351
}));
352352
});
353+
354+
describe('embed position changes', () => {
355+
it('should re-render insights when embed positions change', fakeAsync(() => {
356+
// Start with editor not ready
357+
const env = new TestEnvironment({ initialEditorReady: false });
358+
const testInsight = env.createTestInsight();
359+
360+
env.setFilteredInsights([testInsight]);
361+
tick();
362+
flush();
363+
364+
// No render calls, as editor not ready yet
365+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).never();
366+
verify(mockInsightRenderService.render(anything(), anything())).never();
367+
368+
// Editor ready should trigger first render of insights and action overlay
369+
env.setEditorReady(true);
370+
tick();
371+
flush();
372+
verify(mockInsightRenderService.render(anything(), anything())).once();
373+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).once();
374+
375+
// Embed position change should trigger second insights render, but not action overlay render
376+
env.triggerEmbedPositionChange();
377+
tick(env.component.embedPositionsChangedDebounceTime);
378+
flush();
379+
verify(mockInsightRenderService.render(anything(), anything())).twice();
380+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).once();
381+
}));
382+
383+
it('should not reset overlay state when embed positions change', fakeAsync(() => {
384+
// Start with editor not ready
385+
const env = new TestEnvironment({ initialEditorReady: false });
386+
const testInsight = env.createTestInsight();
387+
388+
env.setFilteredInsights([testInsight]);
389+
tick();
390+
flush();
391+
392+
// No render calls, as editor not ready yet
393+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).never();
394+
verify(mockInsightRenderService.render(anything(), anything())).never();
395+
396+
// Editor ready should trigger first render of insights and action overlay
397+
env.setEditorReady(true);
398+
tick();
399+
flush();
400+
verify(mockInsightRenderService.render(anything(), anything())).once();
401+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).once();
402+
403+
// Set display state to trigger action overlay
404+
env.setDisplayState({
405+
activeInsightIds: [testInsight.id],
406+
actionOverlayActive: true,
407+
promptActive: true,
408+
cursorActiveInsightIds: []
409+
});
410+
tick();
411+
flush();
412+
verify(mockInsightRenderService.render(anything(), anything())).once(); // No new insight render
413+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).twice();
414+
415+
// Embed position change should trigger second insights render, but not action overlay render
416+
env.triggerEmbedPositionChange();
417+
tick(env.component.embedPositionsChangedDebounceTime);
418+
flush();
419+
verify(mockInsightRenderService.render(anything(), anything())).twice();
420+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).twice();
421+
}));
422+
});
353423
});
354424

355425
@Component({
@@ -390,6 +460,7 @@ class TestEnvironment {
390460
private filterSubject: BehaviorSubject<LynxInsightFilter>;
391461
private filteredInsightCountsByTypeSubject: BehaviorSubject<Record<LynxInsightType, number>>;
392462
private taskRunningStatusSubject: BehaviorSubject<boolean>;
463+
private embedPositionsChangedSubject: Subject<void>;
393464

394465
constructor(args: TestEnvArgs = {}) {
395466
const textModelConverter = instance(mockTextModelConverter);
@@ -415,6 +486,8 @@ class TestEnvironment {
415486
this.filteredInsightCountsByTypeSubject = new BehaviorSubject<any>({ info: 0, warning: 0, error: 0 });
416487
this.taskRunningStatusSubject = new BehaviorSubject<boolean>(false);
417488

489+
this.embedPositionsChangedSubject = new Subject<void>();
490+
418491
// Create mock editor
419492
const mockRoot = document.createElement('div');
420493
const actualEditor = {
@@ -460,6 +533,7 @@ class TestEnvironment {
460533
when(mockOverlayService.isOpen).thenReturn(false);
461534
when(mockLynxWorkspaceService.getOnTypeEdits(anything(), anything())).thenResolve([]);
462535
when(mockTextModelConverter.dataDeltaToEditorDelta(anything())).thenCall((delta: Delta) => delta);
536+
when(mockTextModelConverter.embedPositionsChanged$).thenReturn(this.embedPositionsChangedSubject);
463537

464538
// Setup text model converter to return ranges as-is (prevents null range issues)
465539
when(mockTextModelConverter.dataRangeToEditorRange(anything())).thenCall((range: LynxInsightRange) => range);
@@ -518,6 +592,10 @@ class TestEnvironment {
518592
}
519593
}
520594

595+
triggerEmbedPositionChange(): void {
596+
this.embedPositionsChangedSubject.next();
597+
}
598+
521599
createTestInsight(props: Partial<LynxInsight> = {}): LynxInsight {
522600
return {
523601
id: props.id ?? 'test-insight-1',

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
switchMap,
1515
tap
1616
} from 'rxjs';
17-
import { distinctUntilChanged, map, observeOn, scan } from 'rxjs/operators';
17+
import { debounceTime, distinctUntilChanged, map, observeOn, scan, withLatestFrom } from 'rxjs/operators';
1818
import { ActivatedBookChapterService } from 'xforge-common/activated-book-chapter.service';
1919
import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util';
2020
import { EditorReadyService } from '../base-services/editor-ready.service';
@@ -39,15 +39,16 @@ import { LynxInsightActionPromptComponent } from '../lynx-insight-action-prompt/
3939
]
4040
})
4141
export class LynxInsightEditorObjectsComponent implements OnChanges, OnInit, OnDestroy {
42-
readonly insightSelector = `.${LynxInsightBlot.superClassName}`;
43-
44-
private readonly dataIdProp = LynxInsightBlot.idDatasetPropName;
45-
4642
@Input() editor?: LynxableEditor;
4743
@Input() lynxTextModelConverter?: LynxTextModelConverter;
4844
@Input() autoCorrectionsEnabled: boolean = false;
4945
@Input() insightsEnabled: boolean = false;
5046

47+
readonly embedPositionsChangedDebounceTime = 100;
48+
49+
readonly insightSelector = `.${LynxInsightBlot.superClassName}`;
50+
private readonly dataIdProp = LynxInsightBlot.idDatasetPropName;
51+
5152
private isEditorMouseDown = false;
5253
private insightsEnabled$ = new BehaviorSubject<boolean>(this.insightsEnabled);
5354

@@ -153,7 +154,9 @@ export class LynxInsightEditorObjectsComponent implements OnChanges, OnInit, OnD
153154
}
154155

155156
return merge(
156-
// Render blots when insights change
157+
// Render blots when insights change OR when note embeds are added/removed.
158+
// Only reset chapterInsightsRendered$ when insights change (not embeds)
159+
// to avoid clearing insight overlay.
157160
this.insightState.filteredChapterInsights$.pipe(
158161
switchMap(insights => {
159162
chapterInsightsRendered$.next(false);
@@ -166,6 +169,18 @@ export class LynxInsightEditorObjectsComponent implements OnChanges, OnInit, OnD
166169
).pipe(tap(() => chapterInsightsRendered$.next(true)));
167170
})
168171
),
172+
(this.lynxTextModelConverter?.embedPositionsChanged$ ?? EMPTY).pipe(
173+
debounceTime(this.embedPositionsChangedDebounceTime),
174+
withLatestFrom(this.insightState.filteredChapterInsights$),
175+
switchMap(([_, insights]) => {
176+
return from(
177+
this.insightRenderService.render(
178+
insights.map(insight => this.adjustInsightRange(insight)),
179+
this.editor!
180+
)
181+
);
182+
})
183+
),
169184
// Ensure insights are rendered before responding to display state changes,
170185
// as overlay needs to anchor to insight elements in the editor.
171186
chapterInsightsRendered$.pipe(

0 commit comments

Comments
 (0)