From 18863e4d1b4dbfb77cd51cb93d1786d928cfa2ce Mon Sep 17 00:00:00 2001 From: tomerh Date: Tue, 11 Nov 2025 17:14:02 +0200 Subject: [PATCH 1/5] LMS-3011 WIP --- .../content-viewer-container.component.ts | 256 +++++++++++++++++- .../lib/models/report-iframe-events.models.ts | 28 +- .../src/lib/models/report-view.models.ts | 31 +++ .../src/lib/services/report-view.service.ts | 69 ++++- .../src/lib/utils/one-to-many-iframe-logic.ts | 93 +++++++ 5 files changed, 473 insertions(+), 4 deletions(-) diff --git a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts index 3a5ba657..74ce06cb 100644 --- a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts +++ b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts @@ -15,8 +15,8 @@ import { TemplateRef, ViewChild, } from '@angular/core'; -import { PostMessageEvent, ZoomEvent } from '../../../models/report-iframe-events.models'; -import { IReportViewEvent } from '../../../models/report-view.models'; +import { PostMessageEvent, ScrollPositionEvent, ZoomEvent } from '../../../models/report-iframe-events.models'; +import { IReportViewEvent, IScrollPositionState } from '../../../models/report-view.models'; import { Match, MatchType, @@ -79,6 +79,18 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O if (source !== this.iFrameWindow) { return; } + + // Handle scroll position updates from iframe + if (data.type === 'scrollPosition') { + if (this._ignoreScrollUpdates || this._isRestoringScroll) { + return; + } + + const scrollEvent = data as ScrollPositionEvent; + this._scrollPosition.top = scrollEvent.scrollTop; + this._scrollPosition.left = scrollEvent.scrollLeft; + return; + } this.iFrameMessageEvent.emit(data as PostMessageEvent); } @@ -438,6 +450,12 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O private _zoomIn: boolean; + private _scrollPosition: { top: number; left: number } = { top: 0, left: 0 }; + private _ignoreScrollUpdates = false; + private _isRestoringScroll = false; + private _currentTab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom' = 'matched-text'; + private _pendingScrollRestore: { scrollTop: number; scrollLeft: number } | null = null; + constructor( private _renderer: Renderer2, private _cdr: ChangeDetectorRef, @@ -483,6 +501,13 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this.iFrameWindow = this.contentIFrame?.nativeElement?.contentWindow; this.iframeLoaded = true; this.showLoadingView = false; + + // // // Restore scroll position if we have a pending restoration + if (this._isRestoringScroll && this._pendingScrollRestore) { + setTimeout(() => { + this._restoreScrollPosition(); + }, 100); + } } }, false @@ -542,6 +567,43 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } ngOnChanges(changes: SimpleChanges): void { + // Detect tab changes and save/restore scroll position + if ('isAIView' in changes || 'viewMode' in changes || 'contentHtmlMatches' in changes) { + const previousTab = this._currentTab; + const newTab = this._getCurrentTab(); + + if (previousTab !== newTab) { + let previousIsHtmlView = null; + let previousPage = this.currentPage; + + // Save with the PREVIOUS isHtmlView value + if ('isHtmlView' in changes) { + previousIsHtmlView = changes['isHtmlView'].previousValue; + } else { + previousIsHtmlView = this.isHtmlView; + } + // Check if currentPage changed along with tab change + if ('currentPage' in changes) { + previousPage = changes['currentPage'].previousValue; + } + + this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); + this._currentTab = newTab; + this._prepareForReload(); + this._queueScrollRestore(); + } else { + // Tab didn't change but inputs changed, just update current tab + this._currentTab = newTab; + } + } + + // Detect view mode changes (HTML vs Text) - handle separately from tab changes + if ('isHtmlView' in changes && !changes['isHtmlView']?.firstChange) { + // Prepare for restoration with NEW isHtmlView value + this._prepareForReload(); + this._queueScrollRestore(); + } + if ( (('contentTextMatches' in changes && changes['contentTextMatches'].currentValue != undefined) || ('customViewMatchesData' in changes && changes['customViewMatchesData'].currentValue != undefined)) && @@ -646,6 +708,9 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } onViewChange() { + const currentIsHtmlView = this.isHtmlView; + this._saveScrollPosition(this._currentTab, currentIsHtmlView); + if (!this.iframeLoaded) this.showLoadingView = true; if (this.viewSvc.reportViewMode.alertCode === ALERTS.SUSPECTED_AI_TEXT_DETECTED) { this._highlightService.aiInsightsSelectedResults?.forEach(result => { @@ -741,6 +806,193 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O }); } + private _getCurrentTab(): 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom' { + const selectedCustomTabId = this.viewSvc.reportViewMode$.value.selectedCustomTabId; + if (selectedCustomTabId) return 'custom'; + return this.isAIView ? 'ai-content' : this.viewMode == 'writing-feedback' ? 'writing-assistant' : 'matched-text'; + } + + /** + * Prepare for content reload (view change, page change, tab change) + */ + private _prepareForReload(): void { + this._ignoreScrollUpdates = true; + this._isRestoringScroll = true; + } + + /** + * Queue scroll restoration - will be executed when iframe loads + */ + private _queueScrollRestore(): void { + const savedState = this.viewSvc.getScrollPosition(this._currentTab, this.reportOrigin, this.isHtmlView); + + if (savedState) { + this._pendingScrollRestore = { + scrollTop: savedState.scrollTop, + scrollLeft: savedState.scrollLeft, + }; + + // Store the saved page for text view restoration + if (!this.isHtmlView) { + this._pendingScrollRestore['page'] = savedState.page; + } + + // For wrapper scroll, restore immediately + if (this._isWrapperScroll()) { + setTimeout(() => { + this._restoreScrollPosition(); + }, 0); + } + // For iframe view, restoration will happen when iframeLoaded becomes true + } else { + this._clearRestorationFlags(); + } + } + + /** + * Save current scroll position to sessionStorage + */ + private _saveScrollPosition( + tab?: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom', + isHtmlView?: boolean, + page?: number + ): void { + let scrollTop = 0; + let scrollLeft = 0; + const currentTab = tab || this._currentTab; + const currentIsHtmlView = isHtmlView !== undefined ? isHtmlView : this.isHtmlView; + const currentPage = page !== undefined ? page : this.currentPage; + + // Check if we should use wrapper scroll or iframe scroll + if (this._isWrapperScroll(currentIsHtmlView)) { + // Wrapper container scroll (writing-feedback, ai-content, or text view) + let wrapperContainer = document + .querySelector('.content-container') + ?.querySelector('.content-container') as HTMLElement; + if (!wrapperContainer) { + wrapperContainer = document.querySelector('.content-container') as HTMLElement; + } + if (wrapperContainer) { + scrollTop = wrapperContainer.scrollTop; + scrollLeft = wrapperContainer.scrollLeft; + } + } else { + // HTML/PDF iframe view - use tracked position from postMessage + scrollTop = this._scrollPosition.top; + scrollLeft = this._scrollPosition.left; + } + // Save to service using the interface + const state: IScrollPositionState = { + tab: currentTab, + origin: this.reportOrigin, + page: currentPage, + isHtmlView: currentIsHtmlView, + scrollTop, + scrollLeft, + }; + + this.viewSvc.saveScrollPosition(state); + console.log(`✓ Saved scroll for tab=${currentTab}, page=${currentPage}, isHtml=${currentIsHtmlView}:`, { + scrollTop, + scrollLeft, + }); + } + + /** + * Restore scroll position from pending data + */ + private _restoreScrollPosition(): void { + if (!this._pendingScrollRestore) { + this._clearRestorationFlags(); + return; + } + + const { scrollTop, scrollLeft } = this._pendingScrollRestore; + + // Check if we should use wrapper scroll or iframe scroll + if (this._isWrapperScroll()) { + // Text view - check if we need to change page first + const savedPage = this._pendingScrollRestore['page']; + + if (savedPage && savedPage !== this.currentPage) { + // Emit event to change to the saved page + if (this.reportOrigin === 'original' || this.reportOrigin === 'source') { + this.viewChangeEvent.emit({ + ...this.viewSvc.reportViewMode, + sourcePageIndex: savedPage, + }); + } else { + this.viewChangeEvent.emit({ + ...this.viewSvc.reportViewMode, + suspectPageIndex: savedPage, + }); + } + } + // Wrapper container scroll (writing-feedback, ai-content, or text view) + let wrapperContainer = document + .querySelector('.content-container') + ?.querySelector('.content-container') as HTMLElement; + + if (!wrapperContainer) { + wrapperContainer = document.querySelector('.content-container') as HTMLElement; + } + + if (wrapperContainer) { + wrapperContainer.scrollTop = scrollTop; + wrapperContainer.scrollLeft = scrollLeft; + } + this._pendingScrollRestore = null; + this._clearRestorationFlags(); + } else if (this.isHtmlView && this.iframeLoaded) { + // HTML/PDF iframe view - send postMessage + const iframe = this.contentIFrame?.nativeElement; + if (iframe?.contentWindow) { + // Update tracked position immediately + this._scrollPosition = { top: scrollTop, left: scrollLeft }; + + iframe.contentWindow.postMessage( + { + type: 'setScroll', + scrollTop: scrollTop, + scrollLeft: scrollLeft, + }, + '*' + ); + + // Clear restoration flags (to handle any scroll events from setting position) + setTimeout(() => { + this._pendingScrollRestore = null; + this._clearRestorationFlags(); + }, 100); + } else { + this._clearRestorationFlags(); + } + } + } + + /** + * Clear restoration flags + */ + private _clearRestorationFlags(): void { + this._ignoreScrollUpdates = false; + this._isRestoringScroll = false; + } + + /** + * Check if scroll should be on wrapper container (not iframe) + */ + private _isWrapperScroll(isHtmlView?: boolean): boolean { + const htmlView = isHtmlView !== undefined ? isHtmlView : this.isHtmlView; + + // If it's HTML view, scroll is in the iframe (for all tabs) + if (htmlView && this.hasHtml) { + return false; + } + + // Otherwise, scroll is on the wrapper container (text view for all tabs) + return true; + } + /** * Jump to next match click handler. * @param next if `true` jump to next match, otherwise jumpt to previous match diff --git a/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts b/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts index cbddba84..8cb5bf43 100644 --- a/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts +++ b/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts @@ -9,7 +9,10 @@ export type PostMessageEvent = | MultiMatchSelectEvent | CorrectionSelectEvent | MatchesRefreshEvent - | MatchGroupSelectEvent; + | MatchGroupSelectEvent + | ScrollPositionEvent + | SetScrollEvent + | GetScrollEvent; /** base type of post message event */ interface BasePostMessageEvent { @@ -77,3 +80,26 @@ export interface CorrectionSelectEvent extends BasePostMessageEvent { /** the index of the match that was selected */ gid: number; } + +/** Event type indicating scroll position update from iframe */ +export interface ScrollPositionEvent extends BasePostMessageEvent { + type: 'scrollPosition'; + /** the scroll top position */ + scrollTop: number; + /** the scroll left position */ + scrollLeft: number; +} + +/** Event type indicating a request to set scroll position in iframe */ +export interface SetScrollEvent extends BasePostMessageEvent { + type: 'setScroll'; + /** the scroll top position to set */ + scrollTop: number; + /** the scroll left position to set */ + scrollLeft: number; +} + +/** Event type indicating a request to get current scroll position from iframe */ +export interface GetScrollEvent extends BasePostMessageEvent { + type: 'getScroll'; +} diff --git a/projects/copyleaks-web-report/src/lib/models/report-view.models.ts b/projects/copyleaks-web-report/src/lib/models/report-view.models.ts index 4e885d7c..1d0ca16e 100644 --- a/projects/copyleaks-web-report/src/lib/models/report-view.models.ts +++ b/projects/copyleaks-web-report/src/lib/models/report-view.models.ts @@ -74,3 +74,34 @@ export interface IPercentageModel { /** Indicates whether this percentage is enabled or disabled. */ disabled: boolean; } + +/** + * Represents the scroll position state for a specific view configuration. + */ +export interface IScrollPositionState { + /** The tab type */ + tab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom'; + + /** The report origin (source/original/suspect) */ + origin: 'source' | 'original' | 'suspect'; + + /** The current page number */ + page: number; + + /** Whether HTML view is active */ + isHtmlView: boolean; + + /** Vertical scroll position */ + scrollTop: number; + + /** Horizontal scroll position */ + scrollLeft: number; +} + +/** + * Map of scroll positions keyed by unique view identifier. + * Key format: "tab_origin_page_isHtmlView" + */ +export interface IScrollPositionStateMap { + [key: string]: IScrollPositionState; +} diff --git a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts index 916008c8..085fa26c 100644 --- a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts +++ b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts @@ -1,7 +1,12 @@ import { Injectable, TemplateRef } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { ResultDetailItem } from '../models/report-matches.models'; -import { IReportResponsiveMode, IReportViewEvent } from '../models/report-view.models'; +import { + IReportResponsiveMode, + IReportViewEvent, + IScrollPositionState, + IScrollPositionStateMap, +} from '../models/report-view.models'; @Injectable() export class ReportViewService { @@ -84,12 +89,74 @@ export class ReportViewService { public get documentDirection() { return this._documentDirection$.value; } + private _scrollPositionStates$ = new BehaviorSubject({}); + /** Subject for maintaining scroll position states across different views. */ + public get scrollPositionStates$() { + return this._scrollPositionStates$; + } + /** Getter for scroll position states. */ + public get scrollPositionStates() { + return this._scrollPositionStates$.value; + } private observer: MutationObserver; constructor() { this._observeDocumentDirection(); } + /** + * Save scroll position for a specific view configuration. + * @param state The scroll position state to save + */ + public saveScrollPosition(state: IScrollPositionState): void { + const key = this._getScrollStateKey(state.tab, state.origin, state.isHtmlView); + const currentStates = { ...this.scrollPositionStates }; + currentStates[key] = state; // state still contains the page property + this._scrollPositionStates$.next(currentStates); + console.log(`Saved scroll state: ${key}`, state); + } + + /** + * Get scroll position for a specific view configuration. + * @param tab The tab type + * @param origin The report origin + * @param isHtmlView Whether HTML view is active + * @returns The saved scroll position state, or null if not found + */ + public getScrollPosition( + tab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom', + origin: 'source' | 'original' | 'suspect', + isHtmlView: boolean + ): IScrollPositionState | null { + const key = this._getScrollStateKey(tab, origin, isHtmlView); + const state = this.scrollPositionStates[key] || null; + if (state) { + console.log(`Found scroll state: ${key}`, state); + } else { + console.log(`No scroll state found for: ${key}`); + } + return state; + } + + /** + * Clear all saved scroll positions. + */ + public clearScrollPositions(): void { + this._scrollPositionStates$.next({}); + } + + /** + * Generate a unique key for scroll position state (without page number). + * @private + */ + private _getScrollStateKey( + tab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom', + origin: 'source' | 'original' | 'suspect', + isHtmlView: boolean + ): string { + return `${tab}_${origin}_${isHtmlView}`; + } + /** * Observe the document direction changes. */ diff --git a/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts b/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts index 5751e8fe..90278752 100644 --- a/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts +++ b/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts @@ -83,6 +83,25 @@ function ready() { elem.addEventListener('mouseenter', onMatchHover); elem.addEventListener('mouseleave', onMatchHover); }); + + // Setup scroll tracking with debounce + let scrollTimeout: any; + const handleScroll = () => { + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + sendScrollPosition(); + }, 300); + }; + + // Attach scroll listener to the appropriate element + if (isPdf) { + const pageContainer = document.getElementById('page-container'); + if (pageContainer) { + pageContainer.addEventListener('scroll', handleScroll); + } + } else { + window.addEventListener('scroll', handleScroll); + } } /** @@ -127,6 +146,12 @@ function ready() { case 'correction-select': handelCorrectionSelect(event); break; + case 'setScroll': + handleSetScroll(event); + break; + case 'getScroll': + handleGetScroll(); + break; default: } } @@ -368,6 +393,74 @@ function ready() { elem?.classList?.toggle('hover'); } } + /** + * Get the current scroll position from the appropriate element + */ + function getScrollPosition() { + let scrollTop = 0; + let scrollLeft = 0; + + if (isPdf) { + const pageContainer = document.getElementById('page-container'); + if (pageContainer) { + scrollTop = pageContainer.scrollTop; + scrollLeft = pageContainer.scrollLeft; + } + } else { + scrollTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop; + scrollLeft = window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft; + } + + return { scrollTop, scrollLeft }; + } + + /** + * Set scroll position on the appropriate element + */ + function setScrollPosition(scrollTop: number, scrollLeft: number) { + if (isPdf) { + const pageContainer = document.getElementById('page-container'); + if (pageContainer) { + pageContainer.scrollTop = scrollTop; + pageContainer.scrollLeft = scrollLeft; + } + } else { + window.scrollTo(scrollLeft, scrollTop); + // Fallback for older browsers + document.documentElement.scrollTop = scrollTop; + document.body.scrollTop = scrollTop; + document.documentElement.scrollLeft = scrollLeft; + document.body.scrollLeft = scrollLeft; + } + } + + /** + * Handle setScroll message from parent + */ + function handleSetScroll(event: any) { + const { scrollTop, scrollLeft } = event; + setScrollPosition(scrollTop, scrollLeft); + } + + /** + * Handle getScroll message from parent + */ + function handleGetScroll() { + sendScrollPosition(); + } + + /** + * Send current scroll position to parent + */ + function sendScrollPosition() { + const { scrollTop, scrollLeft } = getScrollPosition(); + + messageParent({ + type: 'scrollPosition', + scrollTop: scrollTop, + scrollLeft: scrollLeft, + }); + } function zoomIn() { currentZoom += 0.1; From f9336c2267a535eec4843e8f68941a777f24e6e8 Mon Sep 17 00:00:00 2001 From: tomerh Date: Thu, 13 Nov 2025 12:58:51 +0200 Subject: [PATCH 2/5] LMS-3011 store tab and scroll in service instead of the component --- .../content-viewer-container.component.ts | 64 ++++++++------- .../src/lib/models/report-view.models.ts | 9 ++- .../src/lib/services/report-view.service.ts | 80 ++++++++++++++----- 3 files changed, 102 insertions(+), 51 deletions(-) diff --git a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts index 74ce06cb..e90d9416 100644 --- a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts +++ b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts @@ -16,7 +16,7 @@ import { ViewChild, } from '@angular/core'; import { PostMessageEvent, ScrollPositionEvent, ZoomEvent } from '../../../models/report-iframe-events.models'; -import { IReportViewEvent, IScrollPositionState } from '../../../models/report-view.models'; +import { EReportViewTab, IReportViewEvent, IScrollPositionState } from '../../../models/report-view.models'; import { Match, MatchType, @@ -87,8 +87,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } const scrollEvent = data as ScrollPositionEvent; - this._scrollPosition.top = scrollEvent.scrollTop; - this._scrollPosition.left = scrollEvent.scrollLeft; + this.viewSvc.setIframeScrollPosition(scrollEvent.scrollTop, scrollEvent.scrollLeft); return; } this.iFrameMessageEvent.emit(data as PostMessageEvent); @@ -450,10 +449,8 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O private _zoomIn: boolean; - private _scrollPosition: { top: number; left: number } = { top: 0, left: 0 }; private _ignoreScrollUpdates = false; private _isRestoringScroll = false; - private _currentTab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom' = 'matched-text'; private _pendingScrollRestore: { scrollTop: number; scrollLeft: number } | null = null; constructor( @@ -569,7 +566,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O ngOnChanges(changes: SimpleChanges): void { // Detect tab changes and save/restore scroll position if ('isAIView' in changes || 'viewMode' in changes || 'contentHtmlMatches' in changes) { - const previousTab = this._currentTab; + const previousTab = this.viewSvc.currentViewTab; const newTab = this._getCurrentTab(); if (previousTab !== newTab) { @@ -588,12 +585,9 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); - this._currentTab = newTab; + this.viewSvc.setCurrentViewTab(newTab); this._prepareForReload(); this._queueScrollRestore(); - } else { - // Tab didn't change but inputs changed, just update current tab - this._currentTab = newTab; } } @@ -604,6 +598,17 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this._queueScrollRestore(); } + // Detect page changes (when page finishes loading after restoration request) + if ('currentPage' in changes && !changes['currentPage']?.firstChange) { + // If we have a pending scroll restore and the page just changed, restore scroll now + if (this._pendingScrollRestore && this._isRestoringScroll) { + const savedPage = this._pendingScrollRestore['page']; + if (savedPage && savedPage === this.currentPage) { + this._restoreScrollPosition(); + } + } + } + if ( (('contentTextMatches' in changes && changes['contentTextMatches'].currentValue != undefined) || ('customViewMatchesData' in changes && changes['customViewMatchesData'].currentValue != undefined)) && @@ -709,7 +714,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O onViewChange() { const currentIsHtmlView = this.isHtmlView; - this._saveScrollPosition(this._currentTab, currentIsHtmlView); + this._saveScrollPosition(this.viewSvc.currentViewTab, currentIsHtmlView); if (!this.iframeLoaded) this.showLoadingView = true; if (this.viewSvc.reportViewMode.alertCode === ALERTS.SUSPECTED_AI_TEXT_DETECTED) { @@ -806,10 +811,14 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O }); } - private _getCurrentTab(): 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom' { + private _getCurrentTab(): EReportViewTab { const selectedCustomTabId = this.viewSvc.reportViewMode$.value.selectedCustomTabId; - if (selectedCustomTabId) return 'custom'; - return this.isAIView ? 'ai-content' : this.viewMode == 'writing-feedback' ? 'writing-assistant' : 'matched-text'; + if (selectedCustomTabId) return EReportViewTab.Custom; + return this.isAIView + ? EReportViewTab.AIContent + : this.viewMode == 'writing-feedback' + ? EReportViewTab.WritingAssistant + : EReportViewTab.MatchedText; } /** @@ -824,7 +833,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O * Queue scroll restoration - will be executed when iframe loads */ private _queueScrollRestore(): void { - const savedState = this.viewSvc.getScrollPosition(this._currentTab, this.reportOrigin, this.isHtmlView); + const savedState = this.viewSvc.getScrollPosition(this.viewSvc.currentViewTab, this.isHtmlView); if (savedState) { this._pendingScrollRestore = { @@ -852,14 +861,10 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O /** * Save current scroll position to sessionStorage */ - private _saveScrollPosition( - tab?: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom', - isHtmlView?: boolean, - page?: number - ): void { + private _saveScrollPosition(tab?: EReportViewTab, isHtmlView?: boolean, page?: number): void { let scrollTop = 0; let scrollLeft = 0; - const currentTab = tab || this._currentTab; + const currentTab = tab || this.viewSvc.currentViewTab; const currentIsHtmlView = isHtmlView !== undefined ? isHtmlView : this.isHtmlView; const currentPage = page !== undefined ? page : this.currentPage; @@ -877,9 +882,10 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O scrollLeft = wrapperContainer.scrollLeft; } } else { - // HTML/PDF iframe view - use tracked position from postMessage - scrollTop = this._scrollPosition.top; - scrollLeft = this._scrollPosition.left; + // HTML/PDF iframe view - use tracked position from service + const iframeScroll = this.viewSvc.iframeScrollPosition; + scrollTop = iframeScroll.top; + scrollLeft = iframeScroll.left; } // Save to service using the interface const state: IScrollPositionState = { @@ -892,10 +898,6 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O }; this.viewSvc.saveScrollPosition(state); - console.log(`✓ Saved scroll for tab=${currentTab}, page=${currentPage}, isHtml=${currentIsHtmlView}:`, { - scrollTop, - scrollLeft, - }); } /** @@ -927,7 +929,9 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O suspectPageIndex: savedPage, }); } + return; } + // Wrapper container scroll (writing-feedback, ai-content, or text view) let wrapperContainer = document .querySelector('.content-container') @@ -947,8 +951,8 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O // HTML/PDF iframe view - send postMessage const iframe = this.contentIFrame?.nativeElement; if (iframe?.contentWindow) { - // Update tracked position immediately - this._scrollPosition = { top: scrollTop, left: scrollLeft }; + // Update service with the position we're restoring to + this.viewSvc.setIframeScrollPosition(scrollTop, scrollLeft); iframe.contentWindow.postMessage( { diff --git a/projects/copyleaks-web-report/src/lib/models/report-view.models.ts b/projects/copyleaks-web-report/src/lib/models/report-view.models.ts index 1d0ca16e..0a040e5a 100644 --- a/projects/copyleaks-web-report/src/lib/models/report-view.models.ts +++ b/projects/copyleaks-web-report/src/lib/models/report-view.models.ts @@ -29,6 +29,13 @@ export interface IReportViewQueryParams { showAIPhrases?: string; } +export enum EReportViewTab { + MatchedText = 'matched-text', + AIContent = 'ai-content', + WritingAssistant = 'writing-assistant', + Custom = 'custom', +} + export interface IReportResponsiveMode { mode: EResponsiveLayoutType; } @@ -80,7 +87,7 @@ export interface IPercentageModel { */ export interface IScrollPositionState { /** The tab type */ - tab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom'; + tab: EReportViewTab; /** The report origin (source/original/suspect) */ origin: 'source' | 'original' | 'suspect'; diff --git a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts index 085fa26c..e143b8ea 100644 --- a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts +++ b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts @@ -2,6 +2,7 @@ import { Injectable, TemplateRef } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { ResultDetailItem } from '../models/report-matches.models'; import { + EReportViewTab, IReportResponsiveMode, IReportViewEvent, IScrollPositionState, @@ -98,6 +99,11 @@ export class ReportViewService { public get scrollPositionStates() { return this._scrollPositionStates$.value; } + + private _currentViewTab$ = new BehaviorSubject(EReportViewTab.MatchedText); + + private _iframeScrollPosition$ = new BehaviorSubject<{ top: number; left: number }>({ top: 0, left: 0 }); + private observer: MutationObserver; constructor() { @@ -109,11 +115,10 @@ export class ReportViewService { * @param state The scroll position state to save */ public saveScrollPosition(state: IScrollPositionState): void { - const key = this._getScrollStateKey(state.tab, state.origin, state.isHtmlView); + const key = this._getScrollStateKey(state.tab, state.isHtmlView); const currentStates = { ...this.scrollPositionStates }; - currentStates[key] = state; // state still contains the page property + currentStates[key] = state; this._scrollPositionStates$.next(currentStates); - console.log(`Saved scroll state: ${key}`, state); } /** @@ -123,18 +128,9 @@ export class ReportViewService { * @param isHtmlView Whether HTML view is active * @returns The saved scroll position state, or null if not found */ - public getScrollPosition( - tab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom', - origin: 'source' | 'original' | 'suspect', - isHtmlView: boolean - ): IScrollPositionState | null { - const key = this._getScrollStateKey(tab, origin, isHtmlView); + public getScrollPosition(tab: EReportViewTab, isHtmlView: boolean): IScrollPositionState | null { + const key = this._getScrollStateKey(tab, isHtmlView); const state = this.scrollPositionStates[key] || null; - if (state) { - console.log(`Found scroll state: ${key}`, state); - } else { - console.log(`No scroll state found for: ${key}`); - } return state; } @@ -149,12 +145,8 @@ export class ReportViewService { * Generate a unique key for scroll position state (without page number). * @private */ - private _getScrollStateKey( - tab: 'matched-text' | 'ai-content' | 'writing-assistant' | 'custom', - origin: 'source' | 'original' | 'suspect', - isHtmlView: boolean - ): string { - return `${tab}_${origin}_${isHtmlView}`; + private _getScrollStateKey(tab: EReportViewTab, isHtmlView: boolean): string { + return `${tab}_${isHtmlView}`; } /** @@ -170,6 +162,54 @@ export class ReportViewService { }); } + /** + * Observable for the current active tab + */ + public get currentViewTab$() { + return this._currentViewTab$.asObservable(); + } + + /** + * Get the current active tab + */ + public get currentViewTab() { + return this._currentViewTab$.value; + } + + /** + * Set the current active tab + */ + public setCurrentViewTab(tab: EReportViewTab) { + this._currentViewTab$.next(tab); + } + + /** + * Observable for iframe scroll position + */ + public get iframeScrollPosition$() { + return this._iframeScrollPosition$.asObservable(); + } + + /** + * Get the current iframe scroll position + */ + public get iframeScrollPosition() { + return this._iframeScrollPosition$.value; + } + + /** + * Update the iframe scroll position + */ + public setIframeScrollPosition(top: number, left: number) { + this._iframeScrollPosition$.next({ top, left }); + } + + /** + * Clear the iframe scroll position + */ + public clearIframeScrollPosition() { + this._iframeScrollPosition$.next({ top: 0, left: 0 }); + } /** * Get the document direction (ltr/rtl). * @returns The document direction (ltr/rtl). From fbe154b4b7c0f505f18ac421ff850d82b9776ecb Mon Sep 17 00:00:00 2001 From: tomerh Date: Mon, 17 Nov 2025 16:45:23 +0200 Subject: [PATCH 3/5] LMS-3011 WIP --- .../content-viewer-container.component.ts | 103 ++++++++++++++++-- .../lib/models/report-iframe-events.models.ts | 14 ++- .../src/lib/services/report-view.service.ts | 53 +++++++++ .../src/lib/utils/one-to-many-iframe-logic.ts | 23 ++++ 4 files changed, 183 insertions(+), 10 deletions(-) diff --git a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts index e90d9416..bdd2a9b1 100644 --- a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts +++ b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts @@ -41,7 +41,7 @@ import { COPYLEAKS_REPORT_IFRAME_STYLES } from '../../../constants/report-iframe import oneToManyIframeJsScript from '../../../utils/one-to-many-iframe-logic'; import oneToOneIframeJsScript from '../../../utils/one-to-one-iframe-logic'; -import { filter } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import * as rangy from 'rangy'; import * as rangyclassapplier from 'rangy/lib/rangy-classapplier'; @@ -80,6 +80,16 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O return; } + if (data.type === 'iframeReady') { + console.log('Iframe ready event received:', data); + if (this._iframeReadyResolver) { + clearTimeout(this._iframeReadyTimeout); + this._iframeReadyResolver(); + this._iframeReadyResolver = null; + } + return; + } + // Handle scroll position updates from iframe if (data.type === 'scrollPosition') { if (this._ignoreScrollUpdates || this._isRestoringScroll) { @@ -452,6 +462,8 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O private _ignoreScrollUpdates = false; private _isRestoringScroll = false; private _pendingScrollRestore: { scrollTop: number; scrollLeft: number } | null = null; + private _iframeReadyTimeout: any; + private _iframeReadyResolver: ((value: void) => void) | null = null; constructor( private _renderer: Renderer2, @@ -498,12 +510,13 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this.iFrameWindow = this.contentIFrame?.nativeElement?.contentWindow; this.iframeLoaded = true; this.showLoadingView = false; - - // // // Restore scroll position if we have a pending restoration + console.log('iFrame loaded'); + // // Restore scroll position if we have a pending restoration if (this._isRestoringScroll && this._pendingScrollRestore) { - setTimeout(() => { + this._waitForIframeReady().then(() => { + console.log('Iframe is ready, restoring scroll position'); this._restoreScrollPosition(); - }, 100); + }); } } }, @@ -564,10 +577,21 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } ngOnChanges(changes: SimpleChanges): void { + // debugger; // Detect tab changes and save/restore scroll position if ('isAIView' in changes || 'viewMode' in changes || 'contentHtmlMatches' in changes) { const previousTab = this.viewSvc.currentViewTab; const newTab = this._getCurrentTab(); + console.log('Previous Tab:', previousTab, 'New Tab:', newTab); + console.log( + 'previous custom tab id:', + this.viewSvc.previousCustomTabId, + 'current custom tab id:', + this.viewSvc.currentCustomTabId + ); + + let previousIsHtmlView = null; + let previousPage = this.currentPage; if (previousTab !== newTab) { let previousIsHtmlView = null; @@ -583,11 +607,53 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O if ('currentPage' in changes) { previousPage = changes['currentPage'].previousValue; } - - this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); + if (this.viewSvc.previousCustomTabId !== 'ai-overview') { + this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); + } this.viewSvc.setCurrentViewTab(newTab); - this._prepareForReload(); - this._queueScrollRestore(); + if (this.viewSvc.currentCustomTabId !== 'ai-overview') { + console.log('Restoring scroll for tab:', newTab); + this._prepareForReload(); + this._queueScrollRestore(); + } + } else if (previousTab === EReportViewTab.Custom && newTab === EReportViewTab.Custom) { + if ('isHtmlView' in changes) { + console.log('isHtmlView changed within Custom tab'); + } + // Save with the PREVIOUS isHtmlView value + if ('isHtmlView' in changes) { + previousIsHtmlView = changes['isHtmlView']?.previousValue; + if (previousIsHtmlView === undefined || previousIsHtmlView === null) { + previousIsHtmlView = changes['isHtmlView']?.currentValue ? false : true; + } + } else { + previousIsHtmlView = this.isHtmlView; + } + // Check if currentPage changed along with tab change + if ('currentPage' in changes) { + previousPage = changes['currentPage'].previousValue; + } + this.viewSvc.counter$.pipe(take(1)).subscribe(counterValue => { + if (counterValue === 0) { + if (this.viewSvc.previousCustomTabId === 'grading-feedback') { + this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); + } + this.viewSvc.setCurrentViewTab(newTab); + if (this.viewSvc.currentCustomTabId === 'grading-feedback') { + console.log('Restoring scroll for tab:', newTab); + this._prepareForReload(); + this._queueScrollRestore(); + } + if ( + this.viewSvc.previousCustomTabId == 'grading-feedback' && + this.viewSvc.currentCustomTabId == 'ai-overview' + ) { + this.viewSvc.incrementCounter(); + } + } else { + this.viewSvc.resetCounter(); + } + }); } } @@ -713,6 +779,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } onViewChange() { + // debugger; const currentIsHtmlView = this.isHtmlView; this._saveScrollPosition(this.viewSvc.currentViewTab, currentIsHtmlView); @@ -751,6 +818,20 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this._cdr.detectChanges(); } + private _waitForIframeReady(): Promise { + return new Promise(resolve => { + // Store the resolver so we can call it when we receive the message + this._iframeReadyResolver = resolve; + + // Set a timeout in case the message never arrives (fallback) + this._iframeReadyTimeout = setTimeout(() => { + console.log('Iframe ready timeout - proceeding anyway'); + this._iframeReadyResolver = null; + resolve(); + }, 2000); // 2 second timeout + }); + } + changeContentDirection(direction: ReportContentDirectionMode) { this.contentDirection = direction; @@ -880,12 +961,14 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O if (wrapperContainer) { scrollTop = wrapperContainer.scrollTop; scrollLeft = wrapperContainer.scrollLeft; + console.log('Saving scroll position from wrapper container', scrollTop, scrollLeft); } } else { // HTML/PDF iframe view - use tracked position from service const iframeScroll = this.viewSvc.iframeScrollPosition; scrollTop = iframeScroll.top; scrollLeft = iframeScroll.left; + console.log('Saving scroll position from iframe', scrollTop, scrollLeft); } // Save to service using the interface const state: IScrollPositionState = { @@ -944,6 +1027,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O if (wrapperContainer) { wrapperContainer.scrollTop = scrollTop; wrapperContainer.scrollLeft = scrollLeft; + console.log('Restoring scroll position in wrapper container', scrollTop, scrollLeft); } this._pendingScrollRestore = null; this._clearRestorationFlags(); @@ -951,6 +1035,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O // HTML/PDF iframe view - send postMessage const iframe = this.contentIFrame?.nativeElement; if (iframe?.contentWindow) { + console.log('Restoring scroll position in iframeeee', scrollTop, scrollLeft); // Update service with the position we're restoring to this.viewSvc.setIframeScrollPosition(scrollTop, scrollLeft); diff --git a/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts b/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts index 8cb5bf43..6e9ccc38 100644 --- a/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts +++ b/projects/copyleaks-web-report/src/lib/models/report-iframe-events.models.ts @@ -12,7 +12,8 @@ export type PostMessageEvent = | MatchGroupSelectEvent | ScrollPositionEvent | SetScrollEvent - | GetScrollEvent; + | GetScrollEvent + | IframeReadyEvent; /** base type of post message event */ interface BasePostMessageEvent { @@ -103,3 +104,14 @@ export interface SetScrollEvent extends BasePostMessageEvent { export interface GetScrollEvent extends BasePostMessageEvent { type: 'getScroll'; } + +/** Event type indicating iframe content is fully loaded and ready */ +export interface IframeReadyEvent extends BasePostMessageEvent { + type: 'iframeReady'; + /** the scroll height of the iframe content */ + scrollHeight: number; + /** current scroll top position */ + scrollTop: number; + /** current scroll left position */ + scrollLeft: number; +} diff --git a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts index e143b8ea..f17da75b 100644 --- a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts +++ b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts @@ -8,6 +8,7 @@ import { IScrollPositionState, IScrollPositionStateMap, } from '../models/report-view.models'; +import { distinctUntilChanged, map } from 'rxjs/operators'; @Injectable() export class ReportViewService { @@ -101,6 +102,9 @@ export class ReportViewService { } private _currentViewTab$ = new BehaviorSubject(EReportViewTab.MatchedText); + private _previousCustomTabId: string | undefined = undefined; + private _currentCustomTabId: string | undefined = undefined; + private _counter$ = new BehaviorSubject(0); private _iframeScrollPosition$ = new BehaviorSubject<{ top: number; left: number }>({ top: 0, left: 0 }); @@ -108,8 +112,57 @@ export class ReportViewService { constructor() { this._observeDocumentDirection(); + // Track custom tab ID changes + this._reportViewMode$ + .pipe( + map(mode => mode.selectedCustomTabId), + distinctUntilChanged() + ) + .subscribe(newCustomTabId => { + if (this._currentCustomTabId !== newCustomTabId) { + this._previousCustomTabId = this._currentCustomTabId; + this._currentCustomTabId = newCustomTabId; + } + }); } + public get counter$() { + return this._counter$.asObservable(); + } + + public incrementCounter(): void { + this._counter$.next(this._counter$.value + 1); + } + + public resetCounter(): void { + this._counter$.next(0); + } + + /** + * Check if we just switched between two different custom tabs + */ + public isCustomTabChange(): boolean { + return ( + this._previousCustomTabId !== undefined && + this._currentCustomTabId !== undefined && + this._previousCustomTabId !== this._currentCustomTabId + ); + } + + public get currentCustomTabId(): string | undefined { + return this._currentCustomTabId; + } + + get previousCustomTabId(): string | undefined { + return this._previousCustomTabId; + } + + /** + * Clear the custom tab change flag + */ + public clearCustomTabChangeFlag(): void { + this._previousCustomTabId = this._currentCustomTabId; + } /** * Save scroll position for a specific view configuration. * @param state The scroll position state to save diff --git a/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts b/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts index 90278752..0df930f1 100644 --- a/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts +++ b/projects/copyleaks-web-report/src/lib/utils/one-to-many-iframe-logic.ts @@ -632,6 +632,29 @@ function ready() { return false; } + + // Add this function at the end of the ready() function, just before the closing brace + function notifyParentReady() { + // Wait for all resources to load + window.addEventListener('load', function () { + // Wait for next animation frame to ensure rendering is complete + requestAnimationFrame(function () { + requestAnimationFrame(function () { + // Send ready message to parent + const { scrollTop, scrollLeft } = getScrollPosition(); + messageParent({ + type: 'iframeReady', + scrollHeight: document.body.scrollHeight, + scrollTop: scrollTop, + scrollLeft: scrollLeft, + }); + }); + }); + }); + } + + // Call it at the end of ready() function + notifyParentReady(); } export default `(${onDocumentReady.toString()})(${ready.toString()})`; From ee8eb60824d6e0a66cb4f6b1adbc4028c700841a Mon Sep 17 00:00:00 2001 From: tomerh Date: Tue, 18 Nov 2025 17:25:00 +0200 Subject: [PATCH 4/5] LMS-3011 WIP --- .../content-viewer-container.component.ts | 42 ++++++++++++------- .../src/lib/services/report-view.service.ts | 9 ++++ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts index bdd2a9b1..3166b54a 100644 --- a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts +++ b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts @@ -594,21 +594,29 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O let previousPage = this.currentPage; if (previousTab !== newTab) { - let previousIsHtmlView = null; - let previousPage = this.currentPage; - - // Save with the PREVIOUS isHtmlView value - if ('isHtmlView' in changes) { - previousIsHtmlView = changes['isHtmlView'].previousValue; + debugger; + if ( + this.viewSvc.currentCustomTabId !== 'grading-feedback' || + this.viewSvc.previousCustomTabId === 'grading-feedback' + ) { + // Save with the PREVIOUS isHtmlView value + if ('isHtmlView' in changes) { + previousIsHtmlView = changes['isHtmlView'].previousValue; + } else { + previousIsHtmlView = this.isHtmlView; + } } else { - previousIsHtmlView = this.isHtmlView; + previousIsHtmlView = + this.viewSvc.previousIsHtmlView !== null ? this.viewSvc.previousIsHtmlView : this.isHtmlView; } + // Check if currentPage changed along with tab change if ('currentPage' in changes) { previousPage = changes['currentPage'].previousValue; } if (this.viewSvc.previousCustomTabId !== 'ai-overview') { this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); + this.viewSvc.setPreviousIsHtmlView(null); } this.viewSvc.setCurrentViewTab(newTab); if (this.viewSvc.currentCustomTabId !== 'ai-overview') { @@ -622,10 +630,8 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } // Save with the PREVIOUS isHtmlView value if ('isHtmlView' in changes) { - previousIsHtmlView = changes['isHtmlView']?.previousValue; - if (previousIsHtmlView === undefined || previousIsHtmlView === null) { - previousIsHtmlView = changes['isHtmlView']?.currentValue ? false : true; - } + previousIsHtmlView = + this.viewSvc.previousIsHtmlView !== null ? this.viewSvc.previousIsHtmlView : this.isHtmlView; } else { previousIsHtmlView = this.isHtmlView; } @@ -634,9 +640,15 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O previousPage = changes['currentPage'].previousValue; } this.viewSvc.counter$.pipe(take(1)).subscribe(counterValue => { + let incrementCounter = false; if (counterValue === 0) { if (this.viewSvc.previousCustomTabId === 'grading-feedback') { this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); + incrementCounter = + this.viewSvc.previousCustomTabId == 'grading-feedback' && + this.viewSvc.currentCustomTabId == 'ai-overview' && + this.viewSvc.previousIsHtmlView !== false; + this.viewSvc.setPreviousIsHtmlView(null); } this.viewSvc.setCurrentViewTab(newTab); if (this.viewSvc.currentCustomTabId === 'grading-feedback') { @@ -644,10 +656,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this._prepareForReload(); this._queueScrollRestore(); } - if ( - this.viewSvc.previousCustomTabId == 'grading-feedback' && - this.viewSvc.currentCustomTabId == 'ai-overview' - ) { + if (incrementCounter) { this.viewSvc.incrementCounter(); } } else { @@ -779,8 +788,9 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } onViewChange() { - // debugger; const currentIsHtmlView = this.isHtmlView; + const previousIsHtmlView = this.isHtmlView ? false : true; + this.viewSvc.setPreviousIsHtmlView(previousIsHtmlView); this._saveScrollPosition(this.viewSvc.currentViewTab, currentIsHtmlView); if (!this.iframeLoaded) this.showLoadingView = true; diff --git a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts index f17da75b..1db5dce4 100644 --- a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts +++ b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts @@ -105,6 +105,7 @@ export class ReportViewService { private _previousCustomTabId: string | undefined = undefined; private _currentCustomTabId: string | undefined = undefined; private _counter$ = new BehaviorSubject(0); + private _previousIsHtmlView: boolean | null = null; private _iframeScrollPosition$ = new BehaviorSubject<{ top: number; left: number }>({ top: 0, left: 0 }); @@ -138,6 +139,14 @@ export class ReportViewService { this._counter$.next(0); } + public get previousIsHtmlView(): boolean | null { + return this._previousIsHtmlView; + } + + public setPreviousIsHtmlView(isHtmlView: boolean | null): void { + this._previousIsHtmlView = isHtmlView; + } + /** * Check if we just switched between two different custom tabs */ From 0e8eefb450799b7f345b5a2607bb02f8a9a2781d Mon Sep 17 00:00:00 2001 From: tomerh Date: Mon, 24 Nov 2025 09:20:32 +0200 Subject: [PATCH 5/5] LMS-3011 done. --- .../content-viewer-container.component.ts | 294 +++++++++--------- .../src/lib/models/report-view.models.ts | 3 + .../src/lib/services/report-view.service.ts | 87 +++--- 3 files changed, 189 insertions(+), 195 deletions(-) diff --git a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts index 3166b54a..7ca4a228 100644 --- a/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts +++ b/projects/copyleaks-web-report/src/lib/components/containers/content-viewer-container/content-viewer-container.component.ts @@ -41,7 +41,7 @@ import { COPYLEAKS_REPORT_IFRAME_STYLES } from '../../../constants/report-iframe import oneToManyIframeJsScript from '../../../utils/one-to-many-iframe-logic'; import oneToOneIframeJsScript from '../../../utils/one-to-one-iframe-logic'; -import { filter, take } from 'rxjs/operators'; +import { filter } from 'rxjs/operators'; import * as rangy from 'rangy'; import * as rangyclassapplier from 'rangy/lib/rangy-classapplier'; @@ -81,7 +81,6 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } if (data.type === 'iframeReady') { - console.log('Iframe ready event received:', data); if (this._iframeReadyResolver) { clearTimeout(this._iframeReadyTimeout); this._iframeReadyResolver(); @@ -461,7 +460,7 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O private _ignoreScrollUpdates = false; private _isRestoringScroll = false; - private _pendingScrollRestore: { scrollTop: number; scrollLeft: number } | null = null; + private _pendingScrollRestore: { scrollTop: number; scrollLeft: number; page?: number } | null = null; private _iframeReadyTimeout: any; private _iframeReadyResolver: ((value: void) => void) | null = null; @@ -476,6 +475,17 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O ngOnInit(): void { if (this.flexGrow !== undefined && this.flexGrow !== null) this.flexGrowProp = this.flexGrow; + const currentTab = this._getCurrentTab(); + const customTabId = this.viewSvc.reportViewMode$.value.selectedCustomTabId; + this.viewSvc.setCurrentViewTab(currentTab); + // Handle initial load for custom tab in text view + if (!this.isHtmlView && currentTab === EReportViewTab.Custom) { + this._prepareForReload(); + this._queueScrollRestore(currentTab, customTabId); + setTimeout(() => { + this._restoreScrollPosition(); + }, 100); + } if (!this.isExportedComponent) { this.viewSvc.selectedCustomTabContent$.pipe(untilDestroy(this)).subscribe(content => { if (this.viewMode !== 'one-to-one') this.customTabContent = content; @@ -510,14 +520,14 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this.iFrameWindow = this.contentIFrame?.nativeElement?.contentWindow; this.iframeLoaded = true; this.showLoadingView = false; - console.log('iFrame loaded'); - // // Restore scroll position if we have a pending restoration - if (this._isRestoringScroll && this._pendingScrollRestore) { - this._waitForIframeReady().then(() => { - console.log('Iframe is ready, restoring scroll position'); - this._restoreScrollPosition(); - }); - } + const currentTab = this._getCurrentTab(); + const customTabId = this.viewSvc.reportViewMode$.value.selectedCustomTabId; + // Handle iframe scroll restoration + this._waitForIframeReady().then(() => { + this._prepareForReload(); + this._queueScrollRestore(currentTab, customTabId); + this._restoreScrollPosition(); + }); } }, false @@ -577,113 +587,103 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } ngOnChanges(changes: SimpleChanges): void { - // debugger; // Detect tab changes and save/restore scroll position - if ('isAIView' in changes || 'viewMode' in changes || 'contentHtmlMatches' in changes) { + let isHandlingTabSwitch = false; + if ( + 'isAIView' in changes || + 'viewMode' in changes || + 'contentHtmlMatches' in changes || + 'contentTextMatches' in changes + ) { const previousTab = this.viewSvc.currentViewTab; const newTab = this._getCurrentTab(); - console.log('Previous Tab:', previousTab, 'New Tab:', newTab); - console.log( - 'previous custom tab id:', - this.viewSvc.previousCustomTabId, - 'current custom tab id:', - this.viewSvc.currentCustomTabId - ); - - let previousIsHtmlView = null; - let previousPage = this.currentPage; - - if (previousTab !== newTab) { - debugger; - if ( - this.viewSvc.currentCustomTabId !== 'grading-feedback' || - this.viewSvc.previousCustomTabId === 'grading-feedback' - ) { - // Save with the PREVIOUS isHtmlView value - if ('isHtmlView' in changes) { - previousIsHtmlView = changes['isHtmlView'].previousValue; - } else { - previousIsHtmlView = this.isHtmlView; - } - } else { - previousIsHtmlView = - this.viewSvc.previousIsHtmlView !== null ? this.viewSvc.previousIsHtmlView : this.isHtmlView; - } + const previousCustomTabId = this.viewSvc.previousCustomTabId; + const currentCustomTabId = this.viewSvc.currentCustomTabId; + + const isSwitchingBetweenSharedTabs = + previousTab !== EReportViewTab.Custom && newTab !== EReportViewTab.Custom && previousTab !== newTab; + + const isSwitchingToCustomTab = previousTab !== EReportViewTab.Custom && newTab === EReportViewTab.Custom; - // Check if currentPage changed along with tab change - if ('currentPage' in changes) { + const isSwitchingFromCustomTab = previousTab === EReportViewTab.Custom && newTab !== EReportViewTab.Custom; + + if (newTab === EReportViewTab.Custom && currentCustomTabId === 'ai-overview') { + this.viewSvc.clearScrollPositions(); + } + + // Handle save/restore for shared instance tabs switching between each other + if (isSwitchingBetweenSharedTabs) { + isHandlingTabSwitch = true; + let previousIsHtmlView = this.isHtmlView; + if ('isHtmlView' in changes && !changes['isHtmlView'].firstChange) { + previousIsHtmlView = changes['isHtmlView'].previousValue; + } + let previousPage = this.currentPage; + if ('currentPage' in changes && !changes['currentPage'].firstChange) { previousPage = changes['currentPage'].previousValue; } - if (this.viewSvc.previousCustomTabId !== 'ai-overview') { - this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); - this.viewSvc.setPreviousIsHtmlView(null); + + this._saveScrollPosition(previousTab, undefined, previousIsHtmlView, previousPage); + + this._prepareForReload(); + this._queueScrollRestore(newTab); + if (!this.isHtmlView) { + // For text view, restore after DOM updates + setTimeout(() => { + this._restoreScrollPosition(); + }, 100); } this.viewSvc.setCurrentViewTab(newTab); - if (this.viewSvc.currentCustomTabId !== 'ai-overview') { - console.log('Restoring scroll for tab:', newTab); - this._prepareForReload(); - this._queueScrollRestore(); - } - } else if (previousTab === EReportViewTab.Custom && newTab === EReportViewTab.Custom) { - if ('isHtmlView' in changes) { - console.log('isHtmlView changed within Custom tab'); - } - // Save with the PREVIOUS isHtmlView value - if ('isHtmlView' in changes) { - previousIsHtmlView = - this.viewSvc.previousIsHtmlView !== null ? this.viewSvc.previousIsHtmlView : this.isHtmlView; - } else { - previousIsHtmlView = this.isHtmlView; - } - // Check if currentPage changed along with tab change - if ('currentPage' in changes) { + } else if (isSwitchingToCustomTab && currentCustomTabId !== 'ai-overview') { + isHandlingTabSwitch = true; + let previousIsHtmlView = this.viewSvc.currentIsHtmlView; + let previousPage = this.currentPage; + if ('currentPage' in changes && !changes['currentPage'].firstChange) { previousPage = changes['currentPage'].previousValue; } - this.viewSvc.counter$.pipe(take(1)).subscribe(counterValue => { - let incrementCounter = false; - if (counterValue === 0) { - if (this.viewSvc.previousCustomTabId === 'grading-feedback') { - this._saveScrollPosition(previousTab, previousIsHtmlView, previousPage); - incrementCounter = - this.viewSvc.previousCustomTabId == 'grading-feedback' && - this.viewSvc.currentCustomTabId == 'ai-overview' && - this.viewSvc.previousIsHtmlView !== false; - this.viewSvc.setPreviousIsHtmlView(null); - } - this.viewSvc.setCurrentViewTab(newTab); - if (this.viewSvc.currentCustomTabId === 'grading-feedback') { - console.log('Restoring scroll for tab:', newTab); - this._prepareForReload(); - this._queueScrollRestore(); - } - if (incrementCounter) { - this.viewSvc.incrementCounter(); - } - } else { - this.viewSvc.resetCounter(); - } - }); + this._saveScrollPosition(previousTab, undefined, previousIsHtmlView, previousPage); + this.viewSvc.setCurrentViewTab(newTab); + } else if (isSwitchingFromCustomTab && previousCustomTabId !== 'ai-overview') { + isHandlingTabSwitch = true; + this._prepareForReload(); + this._queueScrollRestore(newTab, currentCustomTabId); + // For text view, restore after DOM updates + if (!this.isHtmlView) { + setTimeout(() => { + this._restoreScrollPosition(); + }, 100); + } + this.viewSvc.setCurrentViewTab(newTab); + } else { + this.viewSvc.setCurrentViewTab(newTab); } } - // Detect view mode changes (HTML vs Text) - handle separately from tab changes - if ('isHtmlView' in changes && !changes['isHtmlView']?.firstChange) { - // Prepare for restoration with NEW isHtmlView value - this._prepareForReload(); - this._queueScrollRestore(); - } - - // Detect page changes (when page finishes loading after restoration request) - if ('currentPage' in changes && !changes['currentPage']?.firstChange) { - // If we have a pending scroll restore and the page just changed, restore scroll now + // Handle page changes (text view only) + if ('currentPage' in changes && !changes['currentPage']?.firstChange && !isHandlingTabSwitch) { if (this._pendingScrollRestore && this._isRestoringScroll) { - const savedPage = this._pendingScrollRestore['page']; + const savedPage = this._pendingScrollRestore.page; if (savedPage && savedPage === this.currentPage) { this._restoreScrollPosition(); } } } + if ('isHtmlView' in changes && !changes['isHtmlView'].firstChange) { + const currentTab = this._getCurrentTab(); + const customTabId = this.viewSvc.reportViewMode$.value.selectedCustomTabId; + + this._prepareForReload(); + this._queueScrollRestore(currentTab, customTabId); + + // For text view, restore immediately after DOM updates + if (!this.isHtmlView) { + setTimeout(() => { + this._restoreScrollPosition(); + }, 100); + } + } + if ( (('contentTextMatches' in changes && changes['contentTextMatches'].currentValue != undefined) || ('customViewMatchesData' in changes && changes['customViewMatchesData'].currentValue != undefined)) && @@ -788,10 +788,9 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } onViewChange() { - const currentIsHtmlView = this.isHtmlView; - const previousIsHtmlView = this.isHtmlView ? false : true; - this.viewSvc.setPreviousIsHtmlView(previousIsHtmlView); - this._saveScrollPosition(this.viewSvc.currentViewTab, currentIsHtmlView); + const currentTab = this._getCurrentTab(); + const customTabId = this.viewSvc.reportViewMode$.value.selectedCustomTabId; + this._saveScrollPosition(currentTab, customTabId, this.isHtmlView, this.currentPage); if (!this.iframeLoaded) this.showLoadingView = true; if (this.viewSvc.reportViewMode.alertCode === ALERTS.SUSPECTED_AI_TEXT_DETECTED) { @@ -828,20 +827,6 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O this._cdr.detectChanges(); } - private _waitForIframeReady(): Promise { - return new Promise(resolve => { - // Store the resolver so we can call it when we receive the message - this._iframeReadyResolver = resolve; - - // Set a timeout in case the message never arrives (fallback) - this._iframeReadyTimeout = setTimeout(() => { - console.log('Iframe ready timeout - proceeding anyway'); - this._iframeReadyResolver = null; - resolve(); - }, 2000); // 2 second timeout - }); - } - changeContentDirection(direction: ReportContentDirectionMode) { this.contentDirection = direction; @@ -921,10 +906,10 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } /** - * Queue scroll restoration - will be executed when iframe loads + * Queue scroll restoration */ - private _queueScrollRestore(): void { - const savedState = this.viewSvc.getScrollPosition(this.viewSvc.currentViewTab, this.isHtmlView); + private _queueScrollRestore(tab: EReportViewTab, customTabId?: string): void { + const savedState = this.viewSvc.getScrollPosition(tab, this.isHtmlView, customTabId); if (savedState) { this._pendingScrollRestore = { @@ -934,34 +919,32 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O // Store the saved page for text view restoration if (!this.isHtmlView) { - this._pendingScrollRestore['page'] = savedState.page; + this._pendingScrollRestore.page = savedState.page; } - // For wrapper scroll, restore immediately + // For wrapper scroll (text view or custom tabs), restore immediately if (this._isWrapperScroll()) { setTimeout(() => { this._restoreScrollPosition(); }, 0); } - // For iframe view, restoration will happen when iframeLoaded becomes true + // For iframe view, restoration will happen when iframe is ready } else { this._clearRestorationFlags(); } } /** - * Save current scroll position to sessionStorage + * Save current scroll position */ - private _saveScrollPosition(tab?: EReportViewTab, isHtmlView?: boolean, page?: number): void { + private _saveScrollPosition(tab: EReportViewTab, customTabId?: string, isHtmlView?: boolean, page?: number): void { let scrollTop = 0; let scrollLeft = 0; - const currentTab = tab || this.viewSvc.currentViewTab; + const currentIsHtmlView = isHtmlView !== undefined ? isHtmlView : this.isHtmlView; - const currentPage = page !== undefined ? page : this.currentPage; - // Check if we should use wrapper scroll or iframe scroll if (this._isWrapperScroll(currentIsHtmlView)) { - // Wrapper container scroll (writing-feedback, ai-content, or text view) + // Wrapper container scroll (text view) let wrapperContainer = document .querySelector('.content-container') ?.querySelector('.content-container') as HTMLElement; @@ -971,23 +954,22 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O if (wrapperContainer) { scrollTop = wrapperContainer.scrollTop; scrollLeft = wrapperContainer.scrollLeft; - console.log('Saving scroll position from wrapper container', scrollTop, scrollLeft); } } else { // HTML/PDF iframe view - use tracked position from service const iframeScroll = this.viewSvc.iframeScrollPosition; scrollTop = iframeScroll.top; scrollLeft = iframeScroll.left; - console.log('Saving scroll position from iframe', scrollTop, scrollLeft); } - // Save to service using the interface + const state: IScrollPositionState = { - tab: currentTab, + tab: tab, origin: this.reportOrigin, - page: currentPage, + page: page !== undefined ? page : this.currentPage, isHtmlView: currentIsHtmlView, scrollTop, scrollLeft, + customTabId: tab === EReportViewTab.Custom ? customTabId : undefined, }; this.viewSvc.saveScrollPosition(state); @@ -1002,30 +984,26 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O return; } - const { scrollTop, scrollLeft } = this._pendingScrollRestore; + const { scrollTop, scrollLeft, page } = this._pendingScrollRestore; - // Check if we should use wrapper scroll or iframe scroll if (this._isWrapperScroll()) { - // Text view - check if we need to change page first - const savedPage = this._pendingScrollRestore['page']; - - if (savedPage && savedPage !== this.currentPage) { + if (page && page !== this.currentPage && !this.isHtmlView) { // Emit event to change to the saved page if (this.reportOrigin === 'original' || this.reportOrigin === 'source') { this.viewChangeEvent.emit({ ...this.viewSvc.reportViewMode, - sourcePageIndex: savedPage, + sourcePageIndex: page, }); } else { this.viewChangeEvent.emit({ ...this.viewSvc.reportViewMode, - suspectPageIndex: savedPage, + suspectPageIndex: page, }); } return; } - // Wrapper container scroll (writing-feedback, ai-content, or text view) + // Wrapper container scroll let wrapperContainer = document .querySelector('.content-container') ?.querySelector('.content-container') as HTMLElement; @@ -1037,16 +1015,12 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O if (wrapperContainer) { wrapperContainer.scrollTop = scrollTop; wrapperContainer.scrollLeft = scrollLeft; - console.log('Restoring scroll position in wrapper container', scrollTop, scrollLeft); } this._pendingScrollRestore = null; this._clearRestorationFlags(); } else if (this.isHtmlView && this.iframeLoaded) { - // HTML/PDF iframe view - send postMessage const iframe = this.contentIFrame?.nativeElement; if (iframe?.contentWindow) { - console.log('Restoring scroll position in iframeeee', scrollTop, scrollLeft); - // Update service with the position we're restoring to this.viewSvc.setIframeScrollPosition(scrollTop, scrollLeft); iframe.contentWindow.postMessage( @@ -1058,7 +1032,6 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O '*' ); - // Clear restoration flags (to handle any scroll events from setting position) setTimeout(() => { this._pendingScrollRestore = null; this._clearRestorationFlags(); @@ -1083,15 +1056,25 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O private _isWrapperScroll(isHtmlView?: boolean): boolean { const htmlView = isHtmlView !== undefined ? isHtmlView : this.isHtmlView; - // If it's HTML view, scroll is in the iframe (for all tabs) if (htmlView && this.hasHtml) { return false; } - - // Otherwise, scroll is on the wrapper container (text view for all tabs) return true; } + private _waitForIframeReady(): Promise { + return new Promise(resolve => { + // Store the resolver so we can call it when we receive the message + this._iframeReadyResolver = resolve; + + // Set a timeout in case the message never arrives (fallback) + this._iframeReadyTimeout = setTimeout(() => { + this._iframeReadyResolver = null; + resolve(); + }, 2000); // 2 second timeout + }); + } + /** * Jump to next match click handler. * @param next if `true` jump to next match, otherwise jumpt to previous match @@ -1489,6 +1472,17 @@ export class ContentViewerContainerComponent implements OnInit, AfterViewInit, O } ngOnDestroy(): void { + if (this._iframeReadyTimeout) { + clearTimeout(this._iframeReadyTimeout); + } + const currentTab = this.viewSvc.currentViewTab; + const customTabId = this.viewSvc.previousCustomTabId; + + // Save scroll position on destroy for custom tabs (except AI Overview) + if (currentTab === EReportViewTab.Custom && customTabId && customTabId !== 'ai-overview') { + this._saveScrollPosition(currentTab, customTabId); + } + this.docDirObserver?.disconnect(); this.contentTextChangesObserver?.disconnect(); } diff --git a/projects/copyleaks-web-report/src/lib/models/report-view.models.ts b/projects/copyleaks-web-report/src/lib/models/report-view.models.ts index 0a040e5a..50359afa 100644 --- a/projects/copyleaks-web-report/src/lib/models/report-view.models.ts +++ b/projects/copyleaks-web-report/src/lib/models/report-view.models.ts @@ -103,6 +103,9 @@ export interface IScrollPositionState { /** Horizontal scroll position */ scrollLeft: number; + + /** The custom tab ID (for Custom tab type only) */ + customTabId?: string; } /** diff --git a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts index 1db5dce4..c1e84b5f 100644 --- a/projects/copyleaks-web-report/src/lib/services/report-view.service.ts +++ b/projects/copyleaks-web-report/src/lib/services/report-view.service.ts @@ -102,13 +102,15 @@ export class ReportViewService { } private _currentViewTab$ = new BehaviorSubject(EReportViewTab.MatchedText); + private _previousCustomTabId: string | undefined = undefined; private _currentCustomTabId: string | undefined = undefined; - private _counter$ = new BehaviorSubject(0); - private _previousIsHtmlView: boolean | null = null; private _iframeScrollPosition$ = new BehaviorSubject<{ top: number; left: number }>({ top: 0, left: 0 }); + private _previousIsHtmlView: boolean = false; + private _currentIsHtmlView: boolean = false; + private observer: MutationObserver; constructor() { @@ -125,75 +127,67 @@ export class ReportViewService { this._currentCustomTabId = newCustomTabId; } }); - } - - public get counter$() { - return this._counter$.asObservable(); - } - - public incrementCounter(): void { - this._counter$.next(this._counter$.value + 1); - } - public resetCounter(): void { - this._counter$.next(0); + this._reportViewMode$ + .pipe( + map(mode => mode.isHtmlView), + distinctUntilChanged() + ) + .subscribe(newIsHtmlView => { + if (this._currentIsHtmlView !== newIsHtmlView) { + this._previousIsHtmlView = this._currentIsHtmlView; + this._currentIsHtmlView = newIsHtmlView; + } + }); } - public get previousIsHtmlView(): boolean | null { - return this._previousIsHtmlView; + public get currentCustomTabId(): string | undefined { + return this._currentCustomTabId; } - public setPreviousIsHtmlView(isHtmlView: boolean | null): void { - this._previousIsHtmlView = isHtmlView; + public get previousCustomTabId(): string | undefined { + return this._previousCustomTabId; } /** - * Check if we just switched between two different custom tabs + * Get the previous isHtmlView state (before the last change) */ - public isCustomTabChange(): boolean { - return ( - this._previousCustomTabId !== undefined && - this._currentCustomTabId !== undefined && - this._previousCustomTabId !== this._currentCustomTabId - ); - } - - public get currentCustomTabId(): string | undefined { - return this._currentCustomTabId; - } - - get previousCustomTabId(): string | undefined { - return this._previousCustomTabId; + public get previousIsHtmlView(): boolean { + return this._previousIsHtmlView; } /** - * Clear the custom tab change flag + * Get the current isHtmlView state */ - public clearCustomTabChangeFlag(): void { - this._previousCustomTabId = this._currentCustomTabId; + public get currentIsHtmlView(): boolean { + return this._currentIsHtmlView; } + /** - * Save scroll position for a specific view configuration. + * Save scroll position for a specific tab. * @param state The scroll position state to save */ public saveScrollPosition(state: IScrollPositionState): void { - const key = this._getScrollStateKey(state.tab, state.isHtmlView); + const key = this._getScrollStateKey(state.tab, state.isHtmlView, state.customTabId); const currentStates = { ...this.scrollPositionStates }; currentStates[key] = state; this._scrollPositionStates$.next(currentStates); } /** - * Get scroll position for a specific view configuration. + * Get scroll position for a specific tab. * @param tab The tab type - * @param origin The report origin * @param isHtmlView Whether HTML view is active + * @param customTabId The custom tab ID (for Custom tab type only) * @returns The saved scroll position state, or null if not found */ - public getScrollPosition(tab: EReportViewTab, isHtmlView: boolean): IScrollPositionState | null { - const key = this._getScrollStateKey(tab, isHtmlView); - const state = this.scrollPositionStates[key] || null; - return state; + public getScrollPosition( + tab: EReportViewTab, + isHtmlView: boolean, + customTabId?: string + ): IScrollPositionState | null { + const key = this._getScrollStateKey(tab, isHtmlView, customTabId); + return this.scrollPositionStates[key] || null; } /** @@ -204,10 +198,13 @@ export class ReportViewService { } /** - * Generate a unique key for scroll position state (without page number). + * Generate a unique key for scroll position state. * @private */ - private _getScrollStateKey(tab: EReportViewTab, isHtmlView: boolean): string { + private _getScrollStateKey(tab: EReportViewTab, isHtmlView: boolean, customTabId?: string): string { + if (tab === EReportViewTab.Custom && customTabId) { + return `${tab}_${isHtmlView}_${customTabId}`; + } return `${tab}_${isHtmlView}`; }