diff --git a/extensions/src/platform-scripture-editor/src/main.ts b/extensions/src/platform-scripture-editor/src/main.ts index 027f812d31..223cf67756 100644 --- a/extensions/src/platform-scripture-editor/src/main.ts +++ b/extensions/src/platform-scripture-editor/src/main.ts @@ -1,4 +1,4 @@ -import papi, { logger } from '@papi/backend'; +import papi, { logger, WebViewFactory } from '@papi/backend'; import type { ExecutionActivationContext, GetWebViewOptions, @@ -6,7 +6,17 @@ import type { SavedWebViewDefinition, WebViewDefinition, } from '@papi/core'; -import { formatReplacementString, LanguageStrings } from 'platform-bible-utils'; +import { + formatReplacementString, + LanguageStrings, + serialize, + UsjReaderWriter, +} from 'platform-bible-utils'; +import { + EditorWebViewMessage, + PlatformScriptureEditorWebViewController, +} from 'platform-scripture-editor'; +import { Canon, VerseRef } from '@sillsdev/scripture'; import platformScriptureEditorWebView from './platform-scripture-editor.web-view?inline'; import platformScriptureEditorWebViewStyles from './platform-scripture-editor.web-view.scss?inline'; @@ -100,14 +110,18 @@ async function open( }; // REVIEW: If an editor is already open for the selected project, we open another. // This matches the current behavior in P9, though it might not be what we want long-term. - return papi.webViews.getWebView(scriptureEditorWebViewType, undefined, options); + return papi.webViews.openWebView(scriptureEditorWebViewType, undefined, options); } return undefined; } /** Simple web view provider that provides Resource web views when papi requests them */ -const scriptureEditorWebViewProvider: IWebViewProvider = { - async getWebView( +class ScriptureEditorWebViewFactory extends WebViewFactory { + constructor() { + super(scriptureEditorWebViewType); + } + + override async getWebViewDefinition( savedWebView: SavedWebViewDefinition, getWebViewOptions: PlatformScriptureEditorOptions, ): Promise { @@ -145,8 +159,166 @@ const scriptureEditorWebViewProvider: IWebViewProvider = { }, projectId, }; - }, -}; + } + + override async createWebViewController( + webViewDefinition: WebViewDefinition, + webViewNonce: string, + ): Promise { + let currentWebViewDefinition: SavedWebViewDefinition = webViewDefinition; + const unsubFromWebViewUpdates = papi.webViews.onDidUpdateWebView(({ webView }) => { + if (webView.id === currentWebViewDefinition.id) currentWebViewDefinition = webView; + }); + return { + async selectRange(range) { + try { + logger.debug( + `Platform Scripture Editor Web View Controller ${currentWebViewDefinition.id} received request to selectRange ${serialize(range)}`, + ); + if (!currentWebViewDefinition.projectId) + throw new Error(`webViewDefinition.projectId is empty!`); + + let targetScrRef = { bookNum: 0, chapterNum: 0, verseNum: 0 }; + + // Figure out the book and chapter + if ('jsonPath' in range.start && 'jsonPath' in range.end) { + // Use the chapter and verse number from the range + if ( + range.start.bookNum !== range.end.bookNum || + range.start.chapterNum !== range.end.chapterNum + ) { + throw new Error( + 'Could not get targetScrRef from jsonPaths! Selection range cannot (yet) span chapters or books', + ); + } + + targetScrRef.bookNum = range.start.bookNum; + targetScrRef.chapterNum = range.start.chapterNum; + } else { + // At least one range location is USFM specification. Will convert to USJ for jsonPath + if ( + 'scrRef' in range.start && + 'scrRef' in range.end && + (range.start.scrRef.bookNum !== range.end.scrRef.bookNum || + range.start.scrRef.chapterNum !== range.end.scrRef.chapterNum) + ) { + throw new Error( + 'Could not get targetScrRef from scrRefs! Selection range cannot (yet) span chapters or books', + ); + } + + // Establish the book and chapter we're working with by what the range says + if ('scrRef' in range.start) { + targetScrRef = range.start.scrRef; + } else if ('scrRef' in range.end) { + targetScrRef = range.end.scrRef; + } else + throw new Error('Could not determine target scrRef to convert scrRef to jsonPath'); + } + + // Get the USJ chapter we're on so we can determine some things + const pdp = await papi.projectDataProviders.get( + 'platformScripture.USJ_Chapter', + currentWebViewDefinition.projectId, + ); + const usjChapter = await pdp.getChapterUSJ( + new VerseRef(targetScrRef.bookNum, targetScrRef.chapterNum, targetScrRef.verseNum), + ); + + if (!usjChapter) + throw new Error( + `USJ Chapter for project id ${currentWebViewDefinition.projectId} target scrRef ${serialize(targetScrRef)} is undefined!`, + ); + + const usjRW = new UsjReaderWriter(usjChapter); + + // Convert the range now - easy conversion if already jsonPath, but need to run conversion + // if in USFM verse ref + let startJsonPath: string; + let endJsonPath: string; + // Assume offset is 0 if not provided + let startOffset = 0; + let endOffset = 0; + + if ('scrRef' in range.start) { + const startContentLocation = usjRW.verseRefToUsjContentLocation( + new VerseRef( + range.start.scrRef.bookNum, + range.start.scrRef.chapterNum, + range.start.scrRef.verseNum, + ), + range.start.offset, + ); + startJsonPath = startContentLocation.jsonPath; + startOffset = startContentLocation.offset; + } else { + startJsonPath = range.start.jsonPath; + if (range.start.offset !== undefined) startOffset = range.start.offset; + } + + if ('scrRef' in range.end) { + const endContentLocation = usjRW.verseRefToUsjContentLocation( + new VerseRef( + range.end.scrRef.bookNum, + range.end.scrRef.chapterNum, + range.end.scrRef.verseNum, + ), + range.end.offset, + ); + endJsonPath = endContentLocation.jsonPath; + endOffset = endContentLocation.offset; + + if (endOffset < (range.end.offset ?? 0) - 50) { + logger.warn( + `Platform Scripture Editor Web View Controller ${currentWebViewDefinition.id} converted range to jsonPath, and calculated endOffset ${endOffset} was over 50 less than the original ${range.end.offset ?? 0}! Setting end position to start position`, + ); + endJsonPath = startJsonPath; + endOffset = startOffset + 1; + } + } else { + endJsonPath = range.end.jsonPath; + if (range.end.offset !== undefined) endOffset = range.end.offset; + else if (range.start.offset !== undefined) endOffset = range.start.offset; + } + + const convertedRange = { + start: { jsonPath: startJsonPath, offset: startOffset }, + end: { jsonPath: endJsonPath, offset: endOffset }, + }; + + // Figure out which verse we're on using the jsonPath + // Note: we could just use the verse if we receive a scrRef in the range, but our + // verseRefToUsjContentLocation doesn't always get the conversion right. So might as well + // use whatever verse it ends up on + const targetScrRefFromJsonPath = usjRW.jsonPathToVerseRefAndOffset( + convertedRange.start.jsonPath, + Canon.bookNumberToId(targetScrRef.bookNum), + ); + targetScrRef.verseNum = targetScrRefFromJsonPath.verseRef.verseNum; + + const message: EditorWebViewMessage = { + method: 'selectRange', + scrRef: targetScrRef, + range: convertedRange, + }; + await papi.webViewProviders.postMessageToWebView( + currentWebViewDefinition.id, + webViewNonce, + message, + ); + } catch (e) { + const message = `Platform Scripture Editor Web View Controller ${currentWebViewDefinition.id} threw while running selectRange! ${e}`; + logger.warn(message); + throw new Error(message); + } + }, + async dispose() { + return unsubFromWebViewUpdates(); + }, + }; + } +} +const scriptureEditorWebViewProvider: IWebViewProvider = new ScriptureEditorWebViewFactory(); export async function activate(context: ExecutionActivationContext): Promise { logger.info('Scripture editor is activating!'); 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 1f6a5f5111..a0bc8ad98d 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 @@ -17,9 +17,16 @@ import { JSX, useCallback, useEffect, useMemo, useRef } from 'react'; import type { WebViewProps } from '@papi/core'; import { logger } from '@papi/frontend'; import { useProjectData, useProjectSetting, useSetting } from '@papi/frontend/react'; -import { deepClone, ScriptureReference, UsjReaderWriter } from 'platform-bible-utils'; +import { + compareScrRefs, + deepClone, + ScriptureReference, + serialize, + UsjReaderWriter, +} from 'platform-bible-utils'; import { Button } from 'platform-bible-react'; import { LegacyComment } from 'legacy-comment-manager'; +import { EditorWebViewMessage, SelectionRange } from 'platform-scripture-editor'; import { convertEditorCommentsToLegacyComments, convertLegacyCommentsToEditorThreads, @@ -83,7 +90,43 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ // Using react's ref api which uses null, so we must use null // eslint-disable-next-line no-null/no-null const editorRef = useRef(null); - const [scrRef, setScrRefInternal] = useWebViewScrollGroupScrRef(); + const [scrRef, setScrRefWithScroll] = useWebViewScrollGroupScrRef(); + + const nextSelectionRange = useRef(undefined); + + // listen to messages from the web view controller + useEffect(() => { + const webViewMessageListener = ({ + data: { method, scrRef: targetScrRef, range }, + }: MessageEvent) => { + switch (method) { + case 'selectRange': + logger.debug(`selectRange targetScrRef ${serialize(targetScrRef)} ${serialize(range)}`); + + if (compareScrRefs(scrRef, targetScrRef) !== 0) { + // Need to update scr ref, let the editor load the Scripture text at the new scrRef, + // and scroll to the new scrRef before setting the range. Set the nextSelectionRange + // which will set the range after a short wait time in a `useEffect` below + setScrRefWithScroll(targetScrRef); + nextSelectionRange.current = range; + } + // We're on the right scr ref. Go ahead and set the selection + else editorRef.current?.setSelection(range); + + break; + default: + // Unknown method name + logger.debug(`Received event with unknown method ${method}`); + break; + } + }; + + window.addEventListener('message', webViewMessageListener); + + return () => { + window.removeEventListener('message', webViewMessageListener); + }; + }, [scrRef, setScrRefWithScroll]); const [commentsEnabled] = useSetting('platform.commentsEnabled', false); @@ -93,12 +136,12 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ */ const internallySetScrRefRef = useRef(undefined); - const setScrRef = useCallback( + const setScrRefNoScroll = useCallback( (newScrRef: ScriptureReference) => { internallySetScrRefRef.current = newScrRef; - return setScrRefInternal(newScrRef); + return setScrRefWithScroll(newScrRef); }, - [setScrRefInternal], + [setScrRefWithScroll], ); /** @@ -273,7 +316,7 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ } }, [usjFromPdp, scrRef]); - // Scroll the selected verse into view + // Scroll the selected verse and selection range into view useEffect(() => { // If we made this latest scrRef change, don't scroll if ( @@ -288,6 +331,11 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ let highlightedVerseElement: HTMLElement | undefined; + // Queue up the next selection range to be set and clear it so we don't accidentally set the + // range to the wrong thing + const nextRange = nextSelectionRange.current; + nextSelectionRange.current = undefined; + // Wait before scrolling to make sure there is time for the editor to load // TODO: hook into the editor and detect when it has loaded somehow const scrollTimeout = setTimeout(() => { @@ -296,6 +344,9 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ highlightedVerseElement?.classList.add('highlighted'); internallySetScrRefRef.current = undefined; + + // Set the selection if the selection was set to something as part of this scr ref change + if (nextRange) editorRef.current?.setSelection(nextRange); }, EDITOR_LOAD_DELAY_TIME); return () => { @@ -327,7 +378,7 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ @@ -342,7 +393,7 @@ globalThis.webViewComponent = function PlatformScriptureEditor({ ; + }>; +} declare module 'papi-shared-types' { + import type { PlatformScriptureEditorWebViewController } from 'platform-scripture-editor'; + export interface CommandHandlers { /** * Opens a new editor WebView and returns the WebView id @@ -26,4 +106,8 @@ declare module 'papi-shared-types' { projectId?: string | undefined, ) => Promise; } + + export interface WebViewControllers { + 'platformScriptureEditor.react': PlatformScriptureEditorWebViewController; + } } diff --git a/extensions/src/platform-scripture/src/checking-results-list.web-view-provider.ts b/extensions/src/platform-scripture/src/checking-results-list.web-view-provider.ts index f2240aa58d..566096c276 100644 --- a/extensions/src/platform-scripture/src/checking-results-list.web-view-provider.ts +++ b/extensions/src/platform-scripture/src/checking-results-list.web-view-provider.ts @@ -12,6 +12,8 @@ import checkingResultsListStyles from './checking-results-list.web-view.scss?inl export const checkResultsListWebViewType = 'platformScripture.checkingResults'; export interface CheckResultsWebViewOptions extends GetWebViewOptions { + /** Id of the editor web view that opened this results list */ + editorWebViewId?: string | undefined; projectId: string | undefined; } @@ -69,6 +71,7 @@ export default class CheckResultsWebViewProvider implements IWebViewProvider { state: { ...savedWebView.state, webViewType: this.webViewType, + editorWebViewId: getWebViewOptions.editorWebViewId ?? savedWebView.state?.editorWebViewId, }, }; } diff --git a/extensions/src/platform-scripture/src/checking-results-list.web-view.tsx b/extensions/src/platform-scripture/src/checking-results-list.web-view.tsx index b191a722db..a5a0b4c51c 100644 --- a/extensions/src/platform-scripture/src/checking-results-list.web-view.tsx +++ b/extensions/src/platform-scripture/src/checking-results-list.web-view.tsx @@ -1,11 +1,12 @@ import { WebViewProps } from '@papi/core'; import { Label, ResultsSet, ScriptureResultsViewer, usePromise } from 'platform-bible-react'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useData, useLocalizedStrings } from '@papi/frontend/react'; +import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { useData, useLocalizedStrings, useWebViewController } from '@papi/frontend/react'; import { CheckRunnerCheckDetails, CheckRunResult } from 'platform-scripture'; import { Canon } from '@sillsdev/scripture'; import { formatReplacementString, LanguageStrings } from 'platform-bible-utils'; import papi, { logger } from '@papi/frontend'; +import { ScriptureLocation } from 'platform-scripture-editor'; const getLabel = ( projectName: string | undefined, @@ -70,12 +71,32 @@ const parseResults = ( offset: checkResult.start.offset, } : { - bookNum: 0, - chapterNum: 0, - verseNum: 0, + // Use `checkResult.verseRef` for the location. This may not necessarily be 100% + // accurate. Need to reconsider if we do a larger check location rework + bookNum: Canon.bookIdToNumber(checkResult.verseRef.book), + chapterNum: checkResult.verseRef.chapterNum, + verseNum: checkResult.verseRef.verseNum, jsonPath: checkResult.start.jsonPath, offset: checkResult.start.offset, }, + end: + 'verseRef' in checkResult.end + ? { + bookNum: Canon.bookIdToNumber(checkResult.end.verseRef.book), + chapterNum: checkResult.end.verseRef.chapterNum, + verseNum: checkResult.end.verseRef.verseNum, + jsonPath: '', + offset: checkResult.end.offset, + } + : { + // Use `checkResult.verseRef` for the location. This may not necessarily be 100% + // accurate. Need to reconsider if we do a larger check location rework + bookNum: Canon.bookIdToNumber(checkResult.verseRef.book), + chapterNum: checkResult.verseRef.chapterNum, + verseNum: checkResult.verseRef.verseNum, + jsonPath: checkResult.end.jsonPath, + offset: checkResult.end.offset, + }, }; // The checking service seems to produce duplicated check results so this is a temporary fix to // get rid of them @@ -91,10 +112,18 @@ const parseResults = ( global.webViewComponent = function CheckingResultsListWebView({ projectId, updateWebViewDefinition, + useWebViewState, }: WebViewProps) { + const [editorWebViewId] = useWebViewState('editorWebViewId', undefined); + + const editorWebViewController = useWebViewController( + 'platformScriptureEditor.react', + editorWebViewId, + ); + const [subscriptionId, setSubscriptionId] = useState(''); - const handleSubscriptionIdChange = (event: React.ChangeEvent) => { + const handleSubscriptionIdChange = (event: ChangeEvent) => { setSubscriptionId(event.target.value); }; @@ -181,7 +210,58 @@ global.webViewComponent = function CheckingResultsListWebView({ placeholder="Enter subscription ID" /> - + { + if (!selectedRow || !editorWebViewController) return; + + try { + const startOffset = 'offset' in selectedRow.start ? selectedRow.start.offset : 0; + const start: ScriptureLocation = selectedRow.start.jsonPath + ? { + bookNum: selectedRow.start.bookNum, + chapterNum: selectedRow.start.chapterNum, + jsonPath: selectedRow.start.jsonPath, + offset: startOffset, + } + : { + scrRef: { + bookNum: selectedRow.start.bookNum, + chapterNum: selectedRow.start.chapterNum, + verseNum: selectedRow.start.verseNum, + }, + offset: startOffset, + }; + let end: ScriptureLocation = { ...start, offset: start.offset ?? 0 }; + if (selectedRow.end) { + const endOffset = 'offset' in selectedRow.end ? selectedRow.end.offset : end.offset; + end = selectedRow.end.jsonPath + ? { + bookNum: selectedRow.end.bookNum, + chapterNum: selectedRow.end.chapterNum, + jsonPath: selectedRow.end.jsonPath, + offset: endOffset, + } + : { + scrRef: { + bookNum: selectedRow.end.bookNum, + chapterNum: selectedRow.end.chapterNum, + verseNum: selectedRow.end.verseNum, + }, + offset: endOffset, + }; + } + editorWebViewController.selectRange({ + start, + end, + }); + } catch (e) { + logger.warn( + `Check results list failed to select range in editor for row ${JSON.stringify(selectedRow)}: ${e}`, + ); + } + }} + /> ); }; diff --git a/extensions/src/platform-scripture/src/main.ts b/extensions/src/platform-scripture/src/main.ts index 9e6a89bb8a..a3978dc866 100644 --- a/extensions/src/platform-scripture/src/main.ts +++ b/extensions/src/platform-scripture/src/main.ts @@ -114,13 +114,16 @@ async function configureChecks(webViewId: string | undefined): Promise { + let editorWebViewId: string | undefined; let projectId: string | undefined; logger.debug('Running checks'); if (webViewId) { - const webViewDefinition = await papi.webViews.getSavedWebViewDefinition(webViewId); + const webViewDefinition = await papi.webViews.getOpenWebViewDefinition(webViewId); projectId = webViewDefinition?.projectId; + if (webViewDefinition?.webViewType === 'platformScriptureEditor.react') + editorWebViewId = webViewId; } if (!projectId) { @@ -128,8 +131,8 @@ async function showCheckResults(webViewId: string | undefined): Promise