diff --git a/extensions/src/legacy-comment-manager/src/comment-list-panel-web-view.factory.ts b/extensions/src/legacy-comment-manager/src/comment-list-panel-web-view.factory.ts new file mode 100644 index 00000000000..e2b062435cb --- /dev/null +++ b/extensions/src/legacy-comment-manager/src/comment-list-panel-web-view.factory.ts @@ -0,0 +1,92 @@ +import papi, { WebViewFactory } from '@papi/backend'; +import type { OpenWebViewOptions, SavedWebViewDefinition, WebViewDefinition } from '@papi/core'; +import type { CommentListWebViewController } from 'legacy-comment-manager'; +import commentListWebView from './comment-list.web-view?inline'; +import tailwindStyles from './tailwind.css?inline'; +import { + COMMENT_LIST_PANEL_WEB_VIEW_TYPE, + resolveCommentListPanelProjectId, +} from './comment-list-panel.utils'; +import { createCommentListWebViewController } from './comment-list-web-view-controller.util'; + +/** Options accepted when opening the fixed Column 3 Comment List panel. */ +export interface CommentListPanelOptions extends OpenWebViewOptions { + projectId?: string; +} + +/** + * Pending projectId consumed by {@link CommentListPanelWebViewFactory.getWebViewDefinition} after + * `reloadWebView`. Set via {@link setPendingCommentListPanelProjectId}, called by + * `openCommentListPanel` in `main.ts` immediately before it calls `reloadWebView`. + * + * Note: `undefined` doubles as the "no pending value" sentinel, so a pending `undefined` cannot + * clear an already-open panel's project — resolution falls back to the saved projectId. This is an + * accepted limitation; see `openCommentListPanel` in `main.ts`. + */ +let pendingProjectId: string | undefined; + +/** + * Sets the projectId {@link CommentListPanelWebViewFactory.getWebViewDefinition} will consume (and + * clear) the next time it runs — used by `openCommentListPanel` to forward a project change through + * `reloadWebView`, which has no other way to pass extra data into `getWebView`. + */ +export function setPendingCommentListPanelProjectId(projectId: string | undefined): void { + pendingProjectId = projectId; +} + +/** + * WebView Factory for the fixed Column 3 Comment List panel, with controller support so external + * callers can bring it to front and select a thread in it without a destructive reload (see + * `selectCommentThreadInPanel` in `main.ts`). + */ +export class CommentListPanelWebViewFactory extends WebViewFactory< + typeof COMMENT_LIST_PANEL_WEB_VIEW_TYPE +> { + constructor() { + super(COMMENT_LIST_PANEL_WEB_VIEW_TYPE); + } + + override async getWebViewDefinition( + savedWebView: SavedWebViewDefinition, + openWebViewOptions: CommentListPanelOptions, + ): Promise { + if (savedWebView.webViewType !== COMMENT_LIST_PANEL_WEB_VIEW_TYPE) + throw new Error( + `${COMMENT_LIST_PANEL_WEB_VIEW_TYPE} provider received request to provide a ${savedWebView.webViewType} web view`, + ); + + const projectId = resolveCommentListPanelProjectId( + pendingProjectId, + openWebViewOptions.projectId, + savedWebView.projectId, + ); + pendingProjectId = undefined; + + const title = await papi.localization.getLocalizedString({ + localizeKey: '%webView_legacyCommentManager_commentListPanel_title%', + }); + + // Re-read every call so mode changes are picked up at open/replace/restore time. + const interfaceMode = await papi.settings.get('platform.interfaceMode'); + + return { + ...savedWebView, + title, + projectId, + content: commentListWebView, + styles: tailwindStyles, + // In simple mode, force the comments panel to scroll group 0 so it stays verse-synced with + // the Scripture editor (which is also forced to 0 in simple mode). Power mode preserves the + // saved value. Without this, a persisted non-zero scroll group (e.g. set while in power + // mode) would survive into simple mode and detach the panel from the editor's navigation. + scrollGroupScrRef: interfaceMode === 'simple' ? 0 : savedWebView.scrollGroupScrRef, + }; + } + + override async createWebViewController( + webViewDefinition: WebViewDefinition, + webViewNonce: string, + ): Promise { + return createCommentListWebViewController(webViewDefinition, webViewNonce); + } +} diff --git a/extensions/src/legacy-comment-manager/src/comment-list-web-view-controller.util.ts b/extensions/src/legacy-comment-manager/src/comment-list-web-view-controller.util.ts new file mode 100644 index 00000000000..830ac659c03 --- /dev/null +++ b/extensions/src/legacy-comment-manager/src/comment-list-web-view-controller.util.ts @@ -0,0 +1,41 @@ +import papi, { logger } from '@papi/backend'; +import type { WebViewDefinition } from '@papi/core'; +import type { + CommentFilters, + CommentListWebViewController, + ScopeFilter, +} from 'legacy-comment-manager'; +import { serialize } from 'platform-bible-utils'; +import { CommentListWebViewMessage } from './comment-list-messages.model'; + +/** + * Builds the WebView Controller shared by both comment-list webview types. The editor-anchored list + * (`legacyCommentManager.commentList`) and the fixed Column 3 panel + * (`legacyCommentManager.commentListPanel`) render the exact same web view component and speak the + * same `selectThread`/`setFilters` message protocol, so one controller implementation serves both. + */ +export function createCommentListWebViewController( + webViewDefinition: WebViewDefinition, + webViewNonce: string, +): CommentListWebViewController { + const postToWebView = (message: CommentListWebViewMessage) => + papi.webViewProviders.postMessageToWebView(webViewDefinition.id, webViewNonce, message); + + return { + async selectThread(threadId: string): Promise { + logger.debug( + `Comment List WebView Controller ${webViewDefinition.id} received request to selectThread ${threadId}`, + ); + await postToWebView({ method: 'selectThread', threadId }); + }, + async setFilters(filters?: Partial, scopeFilter?: ScopeFilter): Promise { + logger.debug( + `Comment List WebView Controller ${webViewDefinition.id} received setFilters ${serialize({ filters, scopeFilter })}`, + ); + await postToWebView({ method: 'setFilters', filters, scopeFilter }); + }, + async dispose(): Promise { + return true; + }, + }; +} diff --git a/extensions/src/legacy-comment-manager/src/main.ts b/extensions/src/legacy-comment-manager/src/main.ts index b08bf7f0925..1b3064feff3 100644 --- a/extensions/src/legacy-comment-manager/src/main.ts +++ b/extensions/src/legacy-comment-manager/src/main.ts @@ -16,17 +16,19 @@ import type { import { serialize } from 'platform-bible-utils'; import commentListWebView from './comment-list.web-view?inline'; import tailwindStyles from './tailwind.css?inline'; -import { CommentListWebViewMessage } from './comment-list-messages.model'; import { SCOPE_FILTER_CURRENT_CHAPTER, UNFILTERED } from './comment-list-filters.model'; import { LEGACY_COMMENT_USJ_PDPF_ID, LegacyCommentManagerUsjProjectDataProviderEngineFactory, } from './project-data-provider/legacy-comment-manager-usj-pdpef.model'; import { LEGACY_COMMENT_USJ_PROJECT_INTERFACES } from './project-data-provider/legacy-comment-manager-usj-pdpe.model'; +import { COMMENT_LIST_PANEL_WEB_VIEW_TYPE } from './comment-list-panel.utils'; +import { createCommentListWebViewController } from './comment-list-web-view-controller.util'; import { - COMMENT_LIST_PANEL_WEB_VIEW_TYPE, - resolveCommentListPanelProjectId, -} from './comment-list-panel.utils'; + CommentListPanelOptions, + CommentListPanelWebViewFactory, + setPendingCommentListPanelProjectId, +} from './comment-list-panel-web-view.factory'; const commentListWebViewType = 'legacyCommentManager.commentList'; const commentListPanelWebViewType = COMMENT_LIST_PANEL_WEB_VIEW_TYPE; @@ -118,31 +120,7 @@ class CommentListWebViewFactory extends WebViewFactory { - // Single message channel for this controller, closing over the id/nonce so the two methods - // can't drift on the plumbing. - const postToWebView = (message: CommentListWebViewMessage) => - papi.webViewProviders.postMessageToWebView(webViewDefinition.id, webViewNonce, message); - - return { - async selectThread(threadId: string): Promise { - logger.debug( - `Comment List WebView Controller ${webViewDefinition.id} received request to selectThread ${threadId}`, - ); - await postToWebView({ method: 'selectThread', threadId }); - }, - async setFilters( - filters?: Partial, - scopeFilter?: ScopeFilter, - ): Promise { - logger.debug( - `Comment List WebView Controller ${webViewDefinition.id} received setFilters ${serialize({ filters, scopeFilter })}`, - ); - await postToWebView({ method: 'setFilters', filters, scopeFilter }); - }, - async dispose(): Promise { - return true; - }, - }; + return createCommentListWebViewController(webViewDefinition, webViewNonce); } } @@ -152,57 +130,7 @@ const commentListWebViewProvider: IWebViewProvider = new CommentListWebViewFacto // #region Comment List Panel WebView (Column 3 fixed tab) -interface CommentListPanelOptions extends OpenWebViewOptions { - projectId?: string; -} - -/** - * Pending projectId consumed by commentListPanelProvider.getWebView() after reloadWebView(). - * - * Note: `undefined` doubles as the "no pending value" sentinel, so a pending `undefined` cannot - * clear an already-open panel's project — resolution falls back to the saved projectId. This is an - * accepted limitation; see {@link openCommentListPanel}. - */ -let currentCommentListPanelProjectId: string | undefined; - -const commentListPanelProvider: IWebViewProvider = { - async getWebView( - savedWebView: SavedWebViewDefinition, - openWebViewOptions: CommentListPanelOptions, - ): Promise { - if (savedWebView.webViewType !== commentListPanelWebViewType) - throw new Error( - `${commentListPanelWebViewType} provider received request to provide a ${savedWebView.webViewType} web view`, - ); - - const projectId = resolveCommentListPanelProjectId( - currentCommentListPanelProjectId, - openWebViewOptions.projectId, - savedWebView.projectId, - ); - currentCommentListPanelProjectId = undefined; - - const title = await papi.localization.getLocalizedString({ - localizeKey: '%webView_legacyCommentManager_commentListPanel_title%', - }); - - // Re-read every call so mode changes are picked up at open/replace/restore time. - const interfaceMode = await papi.settings.get('platform.interfaceMode'); - - return { - ...savedWebView, - title, - projectId, - content: commentListWebView, - styles: tailwindStyles, - // In simple mode, force the comments panel to scroll group 0 so it stays verse-synced with - // the Scripture editor (which is also forced to 0 in simple mode). Power mode preserves the - // saved value. Without this, a persisted non-zero scroll group (e.g. set while in power - // mode) would survive into simple mode and detach the panel from the editor's navigation. - scrollGroupScrRef: interfaceMode === 'simple' ? 0 : savedWebView.scrollGroupScrRef, - }; - }, -}; +const commentListPanelWebViewProvider: IWebViewProvider = new CommentListPanelWebViewFactory(); /** * Opens or updates the fixed Comment List Panel in Column 3 for the given project. If the panel is @@ -227,7 +155,7 @@ async function openCommentListPanel(projectId: string | undefined): Promise { + // Same existingId: '?' probe openCommentListPanel uses to find the singleton Column 3 panel. + const panelWebViewId = await papi.webViews.openWebView( + commentListPanelWebViewType, + { type: 'tab' }, + { existingId: '?', createNewIfNotFound: false, bringToFront }, + ); + if (!panelWebViewId) { + throw new Error('Comment List Panel is not open in the current layout'); + } + + const panelController = await papi.webViews.getWebViewController( + commentListPanelWebViewType, + panelWebViewId, + ); + if (!panelController) { + throw new Error( + `Could not get WebView Controller for comment list panel WebView ${panelWebViewId} to select thread ${threadId}`, + ); + } + + await panelController.selectThread(threadId); + return panelWebViewId; +} + // #endregion Comment List Panel WebView /** @@ -397,7 +366,7 @@ export async function activate(context: ExecutionActivationContext): Promise { // Check if this was one of our tracked comment lists @@ -519,6 +517,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise; + + /** + * Selects/scrolls to a specific thread in the fixed Column 3 Comment List panel, without + * reloading it. Optionally also brings the panel's tab to the front. + * + * @param threadId The ID of the thread to select and scroll to in the panel + * @param bringToFront Whether to also bring the panel's tab to the front. Pass `false` for + * background confirmation (e.g. after inserting a comment, so the user's current Column 3 tab + * isn't interrupted) or `true` for an explicit "show me this comment" navigation. + * @returns The webView ID of the panel, or `undefined` if it isn't open in the current layout + * @throws If the panel's WebView Controller cannot be obtained to apply the selection + */ + 'legacyCommentManager.selectCommentThreadInPanel': ( + threadId: string, + bringToFront: boolean, + ) => Promise; } export interface WebViewControllers { 'legacyCommentManager.commentList': CommentListWebViewController; + 'legacyCommentManager.commentListPanel': CommentListWebViewController; } } diff --git a/extensions/src/platform-scripture-editor/src/platform-scripture-editor.utils.ts b/extensions/src/platform-scripture-editor/src/platform-scripture-editor.utils.ts index f3d81f78987..1893cac6ac0 100644 --- a/extensions/src/platform-scripture-editor/src/platform-scripture-editor.utils.ts +++ b/extensions/src/platform-scripture-editor/src/platform-scripture-editor.utils.ts @@ -136,6 +136,36 @@ export async function openCommentListAndSelectThreadSafe( } } +/** + * Selects/scrolls to a specific thread in the fixed Column 3 Comment List panel, without opening a + * second, editor-anchored Comments panel. Logs and swallows any failure (e.g. the panel isn't in + * the current layout) rather than throwing, since this is used for best-effort navigation feedback + * after an action has already succeeded (e.g. a comment was already created). + * + * @param threadId The ID of the thread to select and scroll to in the panel + * @param bringToFront Whether to also bring the panel's tab to the front. Pass `false` for + * background confirmation (e.g. after inserting a comment, so the user's current Column 3 tab + * isn't interrupted) or `true` for an explicit "show me this comment" navigation. + */ +export async function selectCommentThreadInPanelSafe( + papi: typeof PapiBackend | typeof PapiFrontend, + threadId: string, + bringToFront: boolean, +): Promise { + try { + const panelWebViewId = await papi.commands.sendCommand( + 'legacyCommentManager.selectCommentThreadInPanel', + threadId, + bringToFront, + ); + if (!panelWebViewId) throw new Error('No WebView ID returned'); + } catch (e) { + papi.logger.warn( + `Failed to select thread ${threadId} in the Comment List panel: ${getErrorMessage(e)}`, + ); + } +} + // #region USJ location conversion helper functions /** diff --git a/extensions/src/platform-scripture-editor/src/platform-scripture-editor.web-view.tsx b/extensions/src/platform-scripture-editor/src/platform-scripture-editor.web-view.tsx index 9a379e43201..4a138dd5132 100644 --- a/extensions/src/platform-scripture-editor/src/platform-scripture-editor.web-view.tsx +++ b/extensions/src/platform-scripture-editor/src/platform-scripture-editor.web-view.tsx @@ -120,6 +120,7 @@ import { generateParagraphMenuListItems, openCommentListAndSelectThreadSafe, SCRIPTURE_EDITOR_WEBVIEW_TYPE, + selectCommentThreadInPanelSafe, } from './platform-scripture-editor.utils'; import { ParagraphMarkerTooltipOverlay } from './paragraph-marker-tooltip/paragraph-marker-tooltip-overlay.component'; import { @@ -401,7 +402,10 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ const [interfaceModePossiblyError] = useSetting('platform.interfaceMode', 'simple'); const isPowerMode = useMemo(() => { - if (isPlatformError(interfaceModePossiblyError)) return false; + if (isPlatformError(interfaceModePossiblyError)) { + logger.warn(`Error getting interface mode: ${getErrorMessage(interfaceModePossiblyError)}`); + return false; + } return interfaceModePossiblyError === 'power'; }, [interfaceModePossiblyError]); @@ -1662,8 +1666,20 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ createCommentAnnotationClickHandler(newThreadId), ); - // Open the comment list and select the new thread - await openCommentListAndSelectThreadSafe(papi, webViewId, newThreadId); + // Power mode: open/focus the editor-anchored comment list and select the new thread. + // Simple mode: the new comment already lands in the fixed Column 3 Comments tab via its + // own PDP subscription (opening the editor-anchored panel here would just pop a second + // "Comments" tab and steal focus — PT-4204), but select the new thread in it so + // Simple-mode users get the same "yes, that worked" confirmation Power-mode users + // already get. bringToFront is deliberately false here: forcing the Comments tab to the + // front on every insert would interrupt a user who is actively working in a different + // Column 3 tab (UX feedback on PT-4204) — the selection still applies silently and is + // visible whenever the user next switches to the Comments tab themselves. + if (isPowerMode) { + await openCommentListAndSelectThreadSafe(papi, webViewId, newThreadId); + } else { + await selectCommentThreadInPanelSafe(papi, newThreadId, false); + } } pendingCommentAnnotationRange.current = undefined; @@ -1689,6 +1705,7 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ scrRef, createCommentAnnotationClickHandler, webViewId, + isPowerMode, isSyncBlocked, notifySyncEditBlocked, onCommentEditorCancel,