Skip to content

Commit 965ea4c

Browse files
authored
SF-3373 Fix editor text selection over Lynx insight (#3362)
1 parent d2f3d9e commit 965ea4c

File tree

4 files changed

+193
-9
lines changed

4 files changed

+193
-9
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
rtl: isRtl,
2222
'mark-invalid': markInvalid,
2323
'selectable-verses': selectableVerses,
24-
'custom-local-cursor': showInsights
24+
'custom-local-cursor': showInsights && !isCursorMoveKeyDown
2525
}"
2626
[dir]="$any(textDirection)"
2727
[lang]="lang"

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ quill-editor {
1313
}
1414

1515
.ql-cursor-caret {
16-
width: 1px;
16+
width: 1.5px;
1717
background-color: $local-cursor-color;
1818
}
1919

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,86 @@ describe('TextComponent', () => {
15571557

15581558
TestEnvironment.waitForPresenceTimer();
15591559
}));
1560+
1561+
describe('Text selection behavior', () => {
1562+
it('should track shift key state correctly', fakeAsync(() => {
1563+
const env: TestEnvironment = new TestEnvironment();
1564+
1565+
// Initially shift key should be false
1566+
expect((env.component as any).isShiftDown).toBe(false);
1567+
1568+
// Simulate shift key down
1569+
const keyDownEvent = new KeyboardEvent('keydown', { shiftKey: true });
1570+
document.dispatchEvent(keyDownEvent);
1571+
tick();
1572+
1573+
expect((env.component as any).isShiftDown).toBe(true);
1574+
1575+
// Simulate shift key up
1576+
const keyUpEvent = new KeyboardEvent('keyup', { shiftKey: false });
1577+
document.dispatchEvent(keyUpEvent);
1578+
tick();
1579+
1580+
expect((env.component as any).isShiftDown).toBe(false);
1581+
}));
1582+
1583+
it('should not call update() during selection expansion (shift down)', fakeAsync(() => {
1584+
const env: TestEnvironment = new TestEnvironment();
1585+
env.component.onEditorCreated(new MockQuill('quill-editor'));
1586+
env.waitForEditor();
1587+
1588+
spyOn(env.component, 'update' as any);
1589+
1590+
// Simulate shift key down
1591+
(env.component as any).isShiftDown = true;
1592+
1593+
// Call onSelectionChanged with a selection (length > 0)
1594+
const range: QuillRange = { index: 5, length: 3 };
1595+
env.component.onSelectionChanged(range);
1596+
tick();
1597+
1598+
// update() should not have been called
1599+
expect(env.component['update']).not.toHaveBeenCalled();
1600+
}));
1601+
1602+
it('should call update() when shift key is released', fakeAsync(() => {
1603+
const env: TestEnvironment = new TestEnvironment();
1604+
env.component.onEditorCreated(new MockQuill('quill-editor'));
1605+
env.waitForEditor();
1606+
1607+
spyOn(env.component, 'update' as any);
1608+
1609+
// Set shift key down initially
1610+
(env.component as any).isShiftDown = true;
1611+
1612+
// Simulate shift key release
1613+
const keyUpEvent = new KeyboardEvent('keyup', { shiftKey: false });
1614+
document.dispatchEvent(keyUpEvent);
1615+
tick();
1616+
1617+
// update() should have been called once
1618+
expect(env.component['update']).toHaveBeenCalledTimes(1);
1619+
}));
1620+
1621+
it('should call update() when no selection is active (cursor only)', fakeAsync(() => {
1622+
const env: TestEnvironment = new TestEnvironment();
1623+
env.component.onEditorCreated(new MockQuill('quill-editor'));
1624+
env.waitForEditor();
1625+
1626+
spyOn(env.component, 'update' as any);
1627+
1628+
// Simulate shift key not pressed
1629+
(env.component as any).isShiftDown = false;
1630+
1631+
// Call onSelectionChanged with cursor only (length = 0)
1632+
const range: QuillRange = { index: 5, length: 0 };
1633+
env.component.onSelectionChanged(range);
1634+
tick();
1635+
1636+
// update() should have been called
1637+
expect(env.component['update']).toHaveBeenCalledTimes(1);
1638+
}));
1639+
});
15601640
});
15611641

15621642
class MockDragEvent extends DragEvent {

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { DOCUMENT } from '@angular/common';
12
import {
23
AfterViewInit,
34
ChangeDetectorRef,
45
Component,
56
DestroyRef,
67
EventEmitter,
8+
Inject,
79
Input,
810
OnDestroy,
911
Output
@@ -18,9 +20,10 @@ import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-
1820
import { TextAnchor } from 'realtime-server/lib/esm/scriptureforge/models/text-anchor';
1921
import { StringMap } from 'rich-text';
2022
import { fromEvent, Subject, Subscription, timer } from 'rxjs';
21-
import { takeUntil } from 'rxjs/operators';
23+
import { takeUntil, tap } from 'rxjs/operators';
2224
import { LocalPresence, Presence } from 'sharedb/lib/sharedb';
2325
import tinyColor from 'tinycolor2';
26+
import { WINDOW } from 'xforge-common/browser-globals';
2427
import { DialogService } from 'xforge-common/dialog.service';
2528
import { LocaleDirection } from 'xforge-common/models/i18n-locale';
2629
import { UserDoc } from 'xforge-common/models/user-doc';
@@ -110,12 +113,39 @@ export class TextComponent implements AfterViewInit, OnDestroy {
110113
@Output() editorCreated = new EventEmitter<void>();
111114

112115
lang: string = '';
116+
117+
/**
118+
* Flag activated when user presses and holds keys that cause the cursor to move.
119+
* A true value will cause the system cursor to be used instead of
120+
* Quill custom local cursor in order to avoid cursor lag.
121+
*/
122+
isCursorMoveKeyDown = false;
123+
113124
// only use USX formats and not default Quill formats
114125
readonly allowedFormats: string[] = this.quillFormatRegistry.getRegisteredFormats();
115126
// allow for different CSS based on the browser engine
116127
readonly browserEngine: string = getBrowserEngine();
117128
readonly cursorColor: string;
118129

130+
/** Set of currently pressed keys that move the cursor. */
131+
private readonly pressedCursorMoveKeys = new Set<string>();
132+
133+
/** Set of non-printable keys that move the cursor. */
134+
private readonly nonPrintableCursorMoveKeys = new Set<string>([
135+
'ArrowLeft',
136+
'ArrowRight',
137+
'ArrowUp',
138+
'ArrowDown',
139+
'Home',
140+
'End',
141+
'PageUp',
142+
'PageDown',
143+
'Tab'
144+
]);
145+
146+
private cursorMoveKeyHoldTimeout?: any;
147+
private cursorMoveKeyHoldDelay: number = 500; // Press and hold ms delay before switching to system cursor
148+
119149
private clickSubs: Map<string, Subscription[]> = new Map<string, Subscription[]>();
120150
private _isReadOnly: boolean = true;
121151
private _editorStyles: any = { fontSize: '1rem' };
@@ -263,6 +293,7 @@ export class TextComponent implements AfterViewInit, OnDestroy {
263293
private presenceActiveEditor$: Subject<boolean> = new Subject<boolean>();
264294
private onPresenceDocReceive = (_presenceId: string, _range: Range | null): void => {};
265295
private onPresenceChannelReceive = (_presenceId: string, _presenceData: PresenceData | null): void => {};
296+
private isShiftDown = false;
266297

267298
constructor(
268299
private readonly destroyRef: DestroyRef,
@@ -274,7 +305,9 @@ export class TextComponent implements AfterViewInit, OnDestroy {
274305
private readonly userService: UserService,
275306
readonly viewModel: TextViewModel,
276307
private readonly textDocService: TextDocService,
277-
private readonly quillFormatRegistry: QuillFormatRegistryService
308+
private readonly quillFormatRegistry: QuillFormatRegistryService,
309+
@Inject(DOCUMENT) private document: Document,
310+
@Inject(WINDOW) private window: Window
278311
) {
279312
let localCursorColor = localStorage.getItem(this.cursorColorStorageKey);
280313
if (localCursorColor == null) {
@@ -520,11 +553,76 @@ export class TextComponent implements AfterViewInit, OnDestroy {
520553

521554
// Listening to document 'selectionchange' event allows local cursor to change position on mousedown,
522555
// as opposed to quill 'onSelectionChange' event that doesn't fire until mouseup.
523-
fromEvent<MouseEvent>(document, 'selectionchange')
556+
fromEvent<MouseEvent>(this.document, 'selectionchange')
524557
.pipe(quietTakeUntilDestroyed(this.destroyRef))
525558
.subscribe(() => {
526559
this.updateLocalCursor();
527560
});
561+
562+
fromEvent<KeyboardEvent>(this.document, 'keydown')
563+
.pipe(
564+
quietTakeUntilDestroyed(this.destroyRef),
565+
tap(event => (this.isShiftDown = event.shiftKey))
566+
)
567+
.subscribe(event => {
568+
// Set flag to use system cursor when any key is down that would move the cursor (avoids cursor lag issue)
569+
if (this.nonPrintableCursorMoveKeys.has(event.key) || event.key.length === 1) {
570+
this.pressedCursorMoveKeys.add(event.key);
571+
572+
// Only set the flag when the user presses and holds (detect with a short timeout delay)
573+
if (!this.isCursorMoveKeyDown && this.cursorMoveKeyHoldTimeout == null) {
574+
this.cursorMoveKeyHoldTimeout = setTimeout(() => {
575+
if (this.pressedCursorMoveKeys.size > 0) {
576+
this.isCursorMoveKeyDown = true;
577+
}
578+
579+
this.cursorMoveKeyHoldTimeout = undefined;
580+
}, this.cursorMoveKeyHoldDelay);
581+
}
582+
}
583+
});
584+
585+
fromEvent<KeyboardEvent>(this.document, 'keyup')
586+
.pipe(
587+
quietTakeUntilDestroyed(this.destroyRef),
588+
tap(event => {
589+
// Call 'update()' when shift key is released, as update is disabled while shift is down
590+
// to prevent incorrect cursor position updates while selecting text.
591+
if (this.isShiftDown && !event.shiftKey) {
592+
this.update();
593+
}
594+
595+
this.isShiftDown = event.shiftKey;
596+
})
597+
)
598+
.subscribe(event => {
599+
this.pressedCursorMoveKeys.delete(event.key);
600+
601+
// If set is empty, all cursor movement keys are released
602+
if (this.pressedCursorMoveKeys.size === 0) {
603+
if (this.cursorMoveKeyHoldTimeout) {
604+
clearTimeout(this.cursorMoveKeyHoldTimeout);
605+
this.cursorMoveKeyHoldTimeout = undefined;
606+
}
607+
608+
// Helps to not yet show custom local cursor until it has caught up to system cursor that was just visible
609+
requestAnimationFrame(() => {
610+
this.isCursorMoveKeyDown = false;
611+
});
612+
}
613+
});
614+
615+
fromEvent<FocusEvent>(this.window, 'blur')
616+
.pipe(quietTakeUntilDestroyed(this.destroyRef))
617+
.subscribe(() => {
618+
// Treat window blur as releasing all modifiers
619+
const wasShiftDown: boolean = this.isShiftDown === true;
620+
if (wasShiftDown) {
621+
this.update();
622+
}
623+
624+
this.isShiftDown = false;
625+
});
528626
}
529627

530628
ngOnDestroy(): void {
@@ -545,7 +643,7 @@ export class TextComponent implements AfterViewInit, OnDestroy {
545643
fromEvent(this._editor.root, 'scroll')
546644
.pipe(quietTakeUntilDestroyed(this.destroyRef))
547645
.subscribe(() => this.updateHighlightMarkerVisibility());
548-
fromEvent(window, 'resize')
646+
fromEvent(this.window, 'resize')
549647
.pipe(quietTakeUntilDestroyed(this.destroyRef))
550648
.subscribe(() => this.setHighlightMarkerPosition());
551649
this.viewModel.editor = editor;
@@ -827,7 +925,13 @@ export class TextComponent implements AfterViewInit, OnDestroy {
827925
}
828926

829927
async onSelectionChanged(range: Range | null): Promise<void> {
830-
this.update();
928+
// During selection expansion (keyboard or mouse), avoid calling update()
929+
// which can cause incorrect cursor position updates.
930+
// Update will be called once the shift key is released.
931+
if (!this.isShiftDown) {
932+
this.update();
933+
}
934+
831935
this.submitLocalPresenceDoc(range);
832936
}
833937

@@ -1119,7 +1223,7 @@ export class TextComponent implements AfterViewInit, OnDestroy {
11191223
const cursors: QuillCursors = this.editor.getModule('cursors') as QuillCursors;
11201224
cursors.createCursor(this.presenceId, '', '');
11211225

1122-
this.localCursorElement = document.querySelector(`#ql-cursor-${this.presenceId}`);
1226+
this.localCursorElement = this.document.querySelector(`#ql-cursor-${this.presenceId}`);
11231227

11241228
// Add a specific class to the local cursor
11251229
if (this.localCursorElement != null) {
@@ -1133,7 +1237,7 @@ export class TextComponent implements AfterViewInit, OnDestroy {
11331237
return;
11341238
}
11351239

1136-
const sel: Selection | null = window.getSelection();
1240+
const sel: Selection | null = this.window.getSelection();
11371241
if (sel == null) {
11381242
return;
11391243
}

0 commit comments

Comments
 (0)