From d2463c873bb23c4d625501f74bb29f640e32bf36 Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Fri, 29 May 2026 13:58:57 -0600 Subject: [PATCH] SF-3332 Prevent user from deleting footnotes --- .../app/shared/text/text.component.spec.ts | 53 +++++++++++++++++++ .../src/app/shared/text/text.component.ts | 33 ++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts index 2df32343875..40ef5bf75e1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.spec.ts @@ -1036,6 +1036,59 @@ describe('TextComponent', () => { }); })); + it('does not allow selecting a range that includes a footnote', fakeAsync(() => { + const chapterNum = 2; + const segmentRef: string = `verse_${chapterNum}_1`; + const textDocOps: RichText.DeltaOperation[] = [ + { insert: { chapter: { number: chapterNum.toString(), style: 'c' } } }, + { insert: { verse: { number: '1', style: 'v' } } }, + { + insert: `quick brown fox`, + attributes: { + segment: segmentRef + } + }, + { + insert: { + note: { + content: 'footnote on text', + style: 'f' + } + } + }, + { + insert: ' jumped over', + attributes: { + segment: segmentRef + } + } + ]; + + const env = new TestEnvironment({ chapterNum, textDoc: textDocOps }); + + env.waitForEditor(); + env.component.setSegment(segmentRef); + tick(); + const segmentRange: QuillRange | undefined = env.component.getSegmentRange(segmentRef); + if (segmentRange == null) { + fail('setup'); + return; + } + + const expectedRange: QuillRange = { + index: segmentRange.index, + length: 'quick brown fox'.length + }; + const newSel = env.component.conformToValidSelectionForCurrentSegment( + { + index: segmentRange.index, + length: segmentRange.length + }, + false + ); + expect(newSel).toEqual(expectedRange); + })); + it('does not cancel in beforeinput when valid selection', fakeAsync(() => { const { env }: { env: TestEnvironment; segmentRange: QuillRange } = basicSimpleText(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts index 800d32b6e97..6cb2d6e055e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts @@ -1873,7 +1873,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { return; } - const newSel: Range | null = this.conformToValidSelectionForCurrentSegment(sel); + const newSel: Range | null = this.conformToValidSelectionForCurrentSegment(sel, false); if (newSel != null && (sel.index !== newSel.index || sel.length !== newSel.length)) { this._editor.setSelection(newSel, 'user'); } @@ -1881,7 +1881,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { /** Given a selection, return a possibly modified selection that is a valid for editing the current segment. * For example, a selection over a segment boundary is sometimes not valid. */ - conformToValidSelectionForCurrentSegment(sel: Range): Range | null { + conformToValidSelectionForCurrentSegment(sel: Range, allowSelectingTextualNotes: boolean = true): Range | null { if (this._editor == null || this._segment == null) { return null; } @@ -1901,7 +1901,18 @@ export class TextComponent implements AfterViewInit, OnDestroy { if (newStart > segEnd) { newStart = segEnd; } - const newEnd: number = Math.min(oldEnd, segEnd); + let newEnd: number = Math.min(oldEnd, segEnd); + const selectionLength: number = newEnd - newStart; + if (!allowSelectingTextualNotes) { + // Get the content of the range. + const content: Delta = this._editor.getContents(newStart, selectionLength); + const lengthToTextualNote: number = this.calculateTextualNoteIndex(content); + + if (lengthToTextualNote < selectionLength) { + // if the content includes a text note, cut the selection at the note + newEnd = newStart + lengthToTextualNote; + } + } const embedPositions: number[] = Array.from(this.embeddedElements.values()).sort(); if (newStart === this._segment.range.index || embedPositions.includes(newStart - 1)) { @@ -1933,6 +1944,22 @@ export class TextComponent implements AfterViewInit, OnDestroy { } } + private calculateTextualNoteIndex(content: Delta): number { + if (content.ops == null) return 0; + let textualNoteIndex: number = 0; + for (const op of content.ops) { + // count the length from the start of the selection to the textual note if it exists + if (op.insert != null && typeof op.insert === 'string') { + textualNoteIndex += op.insert.length; + } else if (op.insert != null && typeof op.insert === 'object') { + if (op.insert['note'] != null) break; + // any object op counts as length 1 + textualNoteIndex++; + } + } + return textualNoteIndex; + } + /** Returns the number of embedded elements that are located at or after editorStartPos, through length of editor * positions to check. */