From 3bfeec4889ddd451989e9170424cd4050b5e2640 Mon Sep 17 00:00:00 2001 From: siltomato Date: Mon, 22 Jul 2024 19:30:48 -0400 Subject: [PATCH 01/41] lynx punctuation checker prototype render when changing chapter always filter rendered editor insights by chapter handle route without chapter create quill 'editor ready' service rename func ensure all overlays cleared when changing book/chapter create abstract render service minor action prompt fix refactor services add editor segment service nav to book/chapter of clicked panel insight display correct panel link text for any book/chapter fix misnamed property create a delta with all insight format changes before re-rendering the DOM use local QuillCursor instead of default browser caret when insights are enabled remove transparency from insight bg colors reposition action overlay when editor scrolls improve problem panel link text generation hard code scrollbar width instead of detecting scrollbar width (simpler, less buggy) prevent display state changes from triggering unnecessary updates prioritize higher severity scroll indicators when on same line scroll to selected insight when clicked in panel enable lynx insights based on feature flag persist lynx insight user state separate display state from insight; rework action prompt and action overlay; handle multiple insights in action overlay (list); rename 'action-menu' to 'action-overlay' use special icon for multi-insight prompt localize problems panel text fix display of active insights over dimmed editor when overlay is open minor text-wrap style fix add dismiss to overlay; add 'dismissed' to panel filter; allow restore of dismissed insights in panel; close overlay on action or dismiss; improve multi-insight action prompt look fix overlay resize after multi-insight selection darken insight bg color on hover fix memory leak with observable include test data that previously caused errors cleanup blot and user event service set 'action-overlay-active' class on all elements when focused insight is split over multiple elements add insight color notch to action overlay improve overlay styling add some vanilla code text for mock codes only display 'more info' when there is text simplify action overlay closing tweak mock insights apply primary action if configured hotkey chord is pressed use document injection token allow less severe, active insight child nodes to show if parent is not active darken the background color on hover of insight, including split-element insights remove old 'updateDisplayState' method highlight hovered insight in multi-insight (overlapping) overlay, even if lower severity keep action overlay open when closing action 'fixes' menu remove unnecessary component input abstract Quill references to use LynxEditor; apply action fix; use custom icons add problem panel header filter menu arrow icon small action prompt ltr rtl tweak add icon by status checkmark for when filters are hiding all insights rebase changes adjust for strict null check --- .vscode/settings.json | 2 + scripts/db_tools/parse-version.ts | 3 +- .../models/lynx-insight-user-data.ts | 12 + .../scriptureforge/models/lynx-insight.ts | 16 + .../sf-project-user-config-test-data.ts | 3 +- .../models/sf-project-user-config.ts | 2 + .../sf-project-user-config-migrations.ts | 14 +- .../sf-project-user-config-service.ts | 39 ++ .../ClientApp/src/app/app.module.ts | 6 +- .../src/app/shared/custom-icon.module.ts | 5 + .../ClientApp/src/app/shared/shared.module.ts | 33 +- .../src/app/shared/svg-icons/lynx-icons.ts | 48 ++ .../quill-format-registry.service.spec.ts | 0 .../quill-format-registry.service.ts | 42 ++ .../quill-registrations.spec.ts | 50 +- .../quill-registrations.ts | 22 +- .../src/app/shared/text/quill-util.ts | 29 +- .../src/app/shared/text/text-view-model.ts | 32 +- .../src/app/shared/text/text.component.html | 7 +- .../src/app/shared/text/text.component.scss | 42 ++ .../src/app/shared/text/text.component.ts | 126 +++- .../translate/editor/editor.component.html | 165 ++--- .../translate/editor/editor.component.scss | 10 + .../app/translate/editor/editor.component.ts | 11 +- .../editor/lynx/insights/_lynx-insights.scss | 138 ++++ .../editor-ready.service.spec.ts | 16 + .../base-services/editor-ready.service.ts | 8 + .../editor-segment.service.spec.ts | 16 + .../base-services/editor-segment.service.ts | 10 + .../insight-render.service.spec.ts | 16 + .../base-services/insight-render.service.ts | 11 + .../lynx/insights/insight-code.pipe.spec.ts | 8 + .../editor/lynx/insights/insight-code.pipe.ts | 14 + .../editor/lynx/insights/lynx-editor.ts | 101 +++ .../lynx-insight-action-prompt.component.html | 9 + .../lynx-insight-action-prompt.component.scss | 47 ++ ...nx-insight-action-prompt.component.spec.ts | 22 + .../lynx-insight-action-prompt.component.ts | 123 ++++ .../lynx-insight-action.service.spec.ts | 16 + .../insights/lynx-insight-action.service.ts | 76 +++ .../lynx-insight-code.service.spec.ts | 16 + .../insights/lynx-insight-code.service.ts | 14 + .../lynx/insights/lynx-insight-codes.ts | 41 ++ ...lynx-insight-editor-objects.component.html | 3 + ...lynx-insight-editor-objects.component.scss | 0 ...x-insight-editor-objects.component.spec.ts | 22 + .../lynx-insight-editor-objects.component.ts | 89 +++ .../lynx-insight-filter.service.spec.ts | 16 + .../insights/lynx-insight-filter.service.ts | 58 ++ .../lynx-insight-overlay.service.spec.ts | 16 + .../insights/lynx-insight-overlay.service.ts | 138 ++++ .../lynx-insight-overlay.component.html | 62 ++ .../lynx-insight-overlay.component.scss | 236 +++++++ .../lynx-insight-overlay.component.spec.ts | 22 + .../lynx-insight-overlay.component.ts | 168 +++++ ...t-scroll-position-indicator.component.html | 3 + ...t-scroll-position-indicator.component.scss | 50 ++ ...croll-position-indicator.component.spec.ts | 22 + ...ght-scroll-position-indicator.component.ts | 73 +++ .../lynx-insight-state.service.spec.ts | 16 + .../insights/lynx-insight-state.service.ts | 601 ++++++++++++++++++ ...nx-insight-status-indicator.component.html | 11 + ...nx-insight-status-indicator.component.scss | 66 ++ ...insight-status-indicator.component.spec.ts | 22 + ...lynx-insight-status-indicator.component.ts | 36 ++ .../lynx-insight-user-event.service.spec.ts | 16 + .../lynx-insight-user-event.service.ts | 106 +++ .../editor/lynx/insights/lynx-insight-util.ts | 15 + .../editor/lynx/insights/lynx-insight.ts | 55 ++ .../lynx-insights-panel-header.component.html | 55 ++ .../lynx-insights-panel-header.component.scss | 107 ++++ ...nx-insights-panel-header.component.spec.ts | 22 + .../lynx-insights-panel-header.component.ts | 73 +++ .../lynx-insights-panel.component.html | 45 ++ .../lynx-insights-panel.component.scss | 134 ++++ .../lynx-insights-panel.component.spec.ts | 22 + .../lynx-insights-panel.component.ts | 370 +++++++++++ .../lynx/insights/lynx-insights.module.ts | 86 +++ .../quill-services/blots/lynx-insight-blot.ts | 63 ++ .../lynx-insight-blot.service.spec.ts | 16 + .../lynx-insight-blot.service.ts | 24 + .../quill-editor-ready.service.spec.ts | 16 + .../quill-editor-ready.service.ts | 22 + .../quill-editor-segment.service.spec.ts | 16 + .../quill-editor-segment.service.ts | 68 ++ .../quill-insight-render.service.spec.ts | 15 + .../quill-insight-render.service.ts | 166 +++++ .../src/app/translate/translate.module.ts | 4 +- .../src/assets/i18n/non_checking_en.json | 17 + .../ClientApp/src/styles.scss | 2 + .../activated-book-chapter.service.spec.ts | 16 + .../activated-book-chapter.service.ts | 86 +++ ...ivated-project-user-config.service.spec.ts | 16 + .../activated-project-user-config.service.ts | 38 ++ .../feature-flags/feature-flag.service.ts | 7 + .../src/xforge-common/i18n.service.ts | 11 +- .../src/xforge-common/includes.pipe.spec.ts | 8 + .../src/xforge-common/includes.pipe.ts | 11 + .../ClientApp/tsconfig.json | 2 +- 99 files changed, 4728 insertions(+), 153 deletions(-) create mode 100644 src/RealtimeServer/scriptureforge/models/lynx-insight-user-data.ts create mode 100644 src/RealtimeServer/scriptureforge/models/lynx-insight.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/_lynx-insights.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-util.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/blots/lynx-insight-blot.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index a6a2d69c036..cd544c8df8e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ "caniuse", "cdnjs", "Charis", + "checkmark", "combobox", "commenters", "compodoc", @@ -29,6 +30,7 @@ "indexeddb", "ldml", "listbox", + "Lynxable", "mailto", "mingo", "mixins", diff --git a/scripts/db_tools/parse-version.ts b/scripts/db_tools/parse-version.ts index 1ccd4ffac96..0ac7ad89a6c 100644 --- a/scripts/db_tools/parse-version.ts +++ b/scripts/db_tools/parse-version.ts @@ -38,7 +38,8 @@ class ParseVersion { 'Upload Paratext Zip Files for Pre-Translation Drafting', 'Allow mixing in an additional training source', 'Updated Learning Rate For Serval', - 'Dark Mode' + 'Dark Mode', + 'Enable Lynx insights' ]; constructor() { diff --git a/src/RealtimeServer/scriptureforge/models/lynx-insight-user-data.ts b/src/RealtimeServer/scriptureforge/models/lynx-insight-user-data.ts new file mode 100644 index 00000000000..93d4547e340 --- /dev/null +++ b/src/RealtimeServer/scriptureforge/models/lynx-insight-user-data.ts @@ -0,0 +1,12 @@ +import { LynxInsightFilter, LynxInsightSortOrder } from './lynx-insight'; + +export interface LynxInsightUserData { + panelData?: LynxInsightPanelUserData; + dismissedInsightIds?: string[]; +} + +export interface LynxInsightPanelUserData { + isOpen: boolean; + filter: LynxInsightFilter; + sortOrder: LynxInsightSortOrder; +} diff --git a/src/RealtimeServer/scriptureforge/models/lynx-insight.ts b/src/RealtimeServer/scriptureforge/models/lynx-insight.ts new file mode 100644 index 00000000000..1f38c3af917 --- /dev/null +++ b/src/RealtimeServer/scriptureforge/models/lynx-insight.ts @@ -0,0 +1,16 @@ +// Ordered by severity, lowest to highest +export const LynxInsightTypes = ['info', 'warning', 'error'] as const; +export type LynxInsightType = (typeof LynxInsightTypes)[number]; + +// Ordered from widest to narrowest scope +export const LynxInsightFilterScopes = ['project', 'book', 'chapter'] as const; +export type LynxInsightFilterScope = (typeof LynxInsightFilterScopes)[number]; + +export const LynxInsightSortOrders = ['severity', 'appearance'] as const; +export type LynxInsightSortOrder = (typeof LynxInsightSortOrders)[number]; + +export interface LynxInsightFilter { + types: LynxInsightType[]; + scope: LynxInsightFilterScope; + includeDismissed?: boolean; +} diff --git a/src/RealtimeServer/scriptureforge/models/sf-project-user-config-test-data.ts b/src/RealtimeServer/scriptureforge/models/sf-project-user-config-test-data.ts index 31c4a35ed0c..3ed892f1823 100644 --- a/src/RealtimeServer/scriptureforge/models/sf-project-user-config-test-data.ts +++ b/src/RealtimeServer/scriptureforge/models/sf-project-user-config-test-data.ts @@ -17,7 +17,8 @@ export function createTestProjectUserConfig(overrides?: RecursivePartial { + if (doc.data.lynxInsightState === undefined) { + const op: ObjectInsertOp = { p: ['lynxInsightState'], oi: {} }; + await submitMigrationOp(SFProjectUserConfigMigration8.VERSION, doc, [op]); + } + } +} + export const SF_PROJECT_USER_CONFIG_MIGRATIONS: MigrationConstructor[] = monotonicallyIncreasingMigrationList([ SFProjectUserConfigMigration1, SFProjectUserConfigMigration2, @@ -92,5 +103,6 @@ export const SF_PROJECT_USER_CONFIG_MIGRATIONS: MigrationConstructor[] = monoton SFProjectUserConfigMigration4, SFProjectUserConfigMigration5, SFProjectUserConfigMigration6, - SFProjectUserConfigMigration7 + SFProjectUserConfigMigration7, + SFProjectUserConfigMigration8 ]); diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-user-config-service.ts b/src/RealtimeServer/scriptureforge/services/sf-project-user-config-service.ts index c839e860ee9..870a1413a16 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-user-config-service.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-user-config-service.ts @@ -112,6 +112,45 @@ export class SFProjectUserConfigService extends SFProjectDataService { + return { + ngModule: SharedModule, + providers: [ + { + provide: APP_INITIALIZER, + useFactory: (formatRegistry: QuillFormatRegistryService) => () => { + registerScriptureFormats(formatRegistry); + }, + deps: [QuillFormatRegistryService], + multi: true + } + ] + }; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts new file mode 100644 index 00000000000..c8cd07b267c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts @@ -0,0 +1,48 @@ +export const lynxIcons = { + lynx_info: ` + + + + + + + + + `, + lynx_warning: ` + + `, + lynx_error: ` + + `, + lynx_checkmark: ` + + ` +}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts new file mode 100644 index 00000000000..7bd06e84262 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { Attributor, Formattable } from 'parchment'; +import Quill from 'quill'; +import { isAttributor } from './quill-formats/quill-attributors'; + +export interface FormattableBlotClass { + new (...args: any[]): Formattable; + blotName: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class QuillFormatRegistryService { + private readonly registeredFormats = new Set(); + private readonly registeredFormatClasses = new Set(); + + registerFormats(formats: (FormattableBlotClass | Attributor)[]): string[] { + const formatNames: string[] = []; + + for (const format of formats) { + if (this.registeredFormatClasses.has(format)) { + continue; + } + + const isAttr = isAttributor(format); + const prefix = isAttr ? 'formats' : 'blots'; + const name = isAttr ? format.attrName : format.blotName; + + Quill.register(`${prefix}/${name}`, format); + this.registeredFormats.add(name); + this.registeredFormatClasses.add(format); + formatNames.push(name); + } + + return formatNames; + } + + getRegisteredFormats(): string[] { + return Array.from(this.registeredFormats); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.spec.ts index 088d2976e96..6822aa1711e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.spec.ts @@ -1,8 +1,10 @@ +import { TestBed } from '@angular/core/testing'; import Quill from 'quill'; import QuillInlineBlot from 'quill/blots/inline'; import QuillScrollBlot from 'quill/blots/scroll'; import { DragAndDrop } from '../drag-and-drop'; import { DisableHtmlClipboard } from './quill-clipboard'; +import { QuillFormatRegistryService } from './quill-format-registry.service'; import { CheckingQuestionSegmentClass, DeleteSegmentClass, @@ -12,14 +14,19 @@ import { } from './quill-formats/quill-attributors'; import { ChapterEmbed, NotNormalizedText, ParaBlock } from './quill-formats/quill-blots'; import { FixSelectionHistory } from './quill-history'; -import { registerScripture } from './quill-registrations'; +import { registerScriptureFormats } from './quill-registrations'; describe('QuillRegistrations', () => { let quillRegisterSpy: jasmine.Spy; let originalOrder: string[]; let originalChildren: any[]; + let formatRegistry: QuillFormatRegistryService; beforeEach(() => { + TestBed.configureTestingModule({ + providers: [QuillFormatRegistryService] + }); + formatRegistry = TestBed.inject(QuillFormatRegistryService); quillRegisterSpy = spyOn(Quill, 'register'); originalOrder = [...QuillInlineBlot.order]; originalChildren = [...QuillScrollBlot.allowedChildren]; @@ -31,28 +38,29 @@ describe('QuillRegistrations', () => { }); it('should register all formats', () => { - const formatNames = registerScripture(); + registerScriptureFormats(formatRegistry); // Verify all expected formats are registered - expect(formatNames).toContain('verse'); - expect(formatNames).toContain('blank'); - expect(formatNames).toContain('empty'); - expect(formatNames).toContain('note'); - expect(formatNames).toContain('note-thread-embed'); - expect(formatNames).toContain('optbreak'); - expect(formatNames).toContain('figure'); - expect(formatNames).toContain('unmatched'); - expect(formatNames).toContain('chapter'); - expect(formatNames).toContain('char'); - expect(formatNames).toContain('ref'); - expect(formatNames).toContain('para-contents'); - expect(formatNames).toContain('segment'); - expect(formatNames).toContain('text-anchor'); - expect(formatNames).toContain('para'); + const registeredFormats = formatRegistry.getRegisteredFormats(); + expect(registeredFormats).toContain('verse'); + expect(registeredFormats).toContain('blank'); + expect(registeredFormats).toContain('empty'); + expect(registeredFormats).toContain('note'); + expect(registeredFormats).toContain('note-thread-embed'); + expect(registeredFormats).toContain('optbreak'); + expect(registeredFormats).toContain('figure'); + expect(registeredFormats).toContain('unmatched'); + expect(registeredFormats).toContain('chapter'); + expect(registeredFormats).toContain('char'); + expect(registeredFormats).toContain('ref'); + expect(registeredFormats).toContain('para-contents'); + expect(registeredFormats).toContain('segment'); + expect(registeredFormats).toContain('text-anchor'); + expect(registeredFormats).toContain('para'); }); it('should register attributors', () => { - registerScripture(); + registerScriptureFormats(formatRegistry); expect(quillRegisterSpy).toHaveBeenCalledWith('formats/insert-segment', InsertSegmentClass); expect(quillRegisterSpy).toHaveBeenCalledWith('formats/delete-segment', DeleteSegmentClass); @@ -62,7 +70,7 @@ describe('QuillRegistrations', () => { }); it('should register core modules', () => { - registerScripture(); + registerScriptureFormats(formatRegistry); expect(quillRegisterSpy).toHaveBeenCalledWith('blots/text', NotNormalizedText, true); expect(quillRegisterSpy).toHaveBeenCalledWith('modules/clipboard', DisableHtmlClipboard, true); @@ -71,7 +79,7 @@ describe('QuillRegistrations', () => { }); it('should update QuillInlineBlot order', () => { - registerScripture(); + registerScriptureFormats(formatRegistry); const orderItems = ['text-anchor', 'char', 'segment', 'para-contents']; @@ -93,7 +101,7 @@ describe('QuillRegistrations', () => { }); it('should update QuillScrollBlot allowed children', () => { - registerScripture(); + registerScriptureFormats(formatRegistry); expect(QuillScrollBlot.allowedChildren).toContain(ParaBlock); expect(QuillScrollBlot.allowedChildren).toContain(ChapterEmbed); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.ts index 7a06da7e1c3..b2d8b23f27e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-registrations.ts @@ -1,10 +1,11 @@ -import { Attributor, Formattable } from 'parchment'; +import { Attributor } from 'parchment'; import Quill from 'quill'; import QuillCursors from 'quill-cursors'; import QuillInlineBlot from 'quill/blots/inline'; import QuillScrollBlot from 'quill/blots/scroll'; import { DragAndDrop } from '../drag-and-drop'; import { DisableHtmlClipboard } from './quill-clipboard'; +import { FormattableBlotClass, QuillFormatRegistryService } from './quill-format-registry.service'; import { CheckingQuestionCountAttribute, CheckingQuestionSegmentClass, @@ -16,7 +17,6 @@ import { InsertSegmentClass, InvalidBlockClass, InvalidInlineClass, - isAttributor, NoteThreadHighlightClass, NoteThreadSegmentClass, ParaStyleDescriptionAttribute @@ -43,12 +43,7 @@ import { } from './quill-formats/quill-blots'; import { FixSelectionHistory } from './quill-history'; -interface FormattableBlotClass { - new (...args: any[]): Formattable; - blotName: string; -} - -export function registerScripture(): string[] { +export function registerScriptureFormats(formatRegistry: QuillFormatRegistryService): void { const formats: (FormattableBlotClass | Attributor)[] = [ // Embed Blots VerseEmbed, @@ -96,13 +91,8 @@ export function registerScripture(): string[] { QuillScrollBlot.allowedChildren.push(...[ParaBlock, ChapterEmbed]); - const formatNames = formats.map(format => { - const isAttr = isAttributor(format); - const prefix = isAttr ? 'formats' : 'blots'; - const name = isAttr ? format.attrName : format.blotName; - Quill.register(`${prefix}/${name}`, format); - return name; - }); + // Register formats through the registry service + formatRegistry.registerFormats(formats); Quill.register('blots/scroll', ScrollBlot, true); Quill.register('blots/text', NotNormalizedText, true); @@ -110,6 +100,4 @@ export function registerScripture(): string[] { Quill.register('modules/cursors', QuillCursors); Quill.register('modules/history', FixSelectionHistory, true); Quill.register('modules/dragAndDrop', DragAndDrop); - - return formatNames; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-util.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-util.ts index ddc406a5012..030df59c6e3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-util.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-util.ts @@ -1,4 +1,4 @@ -import Quill from 'quill'; +import Quill, { Delta, Range } from 'quill'; import { DeltaOperation, StringMap } from 'rich-text'; /** @@ -43,3 +43,30 @@ export function getRetainCount(op: DeltaOperation): number | undefined { return undefined; } + +/** + * Compares two objects with a range based on their range index and length, + * with the most recent first (shortest range if tied). + * @returns {number} negative if a is more recent, positive if b is more recent, 0 if they are the same + */ +export function rangeComparer(a: { range: Range }, b: { range: Range }): number { + const indexDifference = a.range.index - b.range.index; + + if (indexDifference !== 0) { + return indexDifference; + } + + return a.range.length - b.range.length; +} + +/** + * Extracts text from a delta within a range. + */ +export function getText(delta: Delta, range: Range): string { + const { index, length } = range; + return delta + .slice(index, index + length) + .filter(op => typeof op.insert === 'string') + .map(op => op.insert) + .join(''); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts index 554572b7c1c..91aabbd60db 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts @@ -3,7 +3,7 @@ import { VerseRef } from '@sillsdev/scripture'; import { cloneDeep } from 'lodash-es'; import Quill, { Delta, EmitterSource, Range } from 'quill'; import { DeltaOperation, StringMap } from 'rich-text'; -import { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { isString } from '../../../type-utils'; import { TextDoc, TextDocId } from '../../core/models/text-doc'; import { getVerseStrFromSegmentRef, isBadDelta } from '../utils'; @@ -135,6 +135,8 @@ export class TextViewModel implements OnDestroy { */ private _embeddedElements: Map = new Map(); + segments$ = new BehaviorSubject>(this._segments); + get segments(): IterableIterator<[string, Range]> { return this._segments.entries(); } @@ -499,21 +501,25 @@ export class TextViewModel implements OnDestroy { for (const op of delta.ops) { const modelOp: DeltaOperation = cloneDeep(op); for (const attr of [ - 'insert-segment', + 'commenter-selection', 'delete-segment', - 'highlight-segment', + 'direction-block', + 'direction-segment', + 'draft', 'highlight-para', + 'highlight-segment', + 'initial', + 'insert-segment', + 'lynx-insight-error', + 'lynx-insight-info', + 'lynx-insight-warning', + 'note-thread-count', + 'note-thread-segment', 'para-contents', - 'question-segment', 'question-count', - 'note-thread-segment', - 'note-thread-count', - 'text-anchor', - 'commenter-selection', - 'initial', - 'direction-segment', - 'direction-block', - 'style-description' + 'question-segment', + 'style-description', + 'text-anchor' ]) { removeAttribute(modelOp, attr); } @@ -660,6 +666,8 @@ export class TextViewModel implements OnDestroy { convertDelta.retain(len, attrs); } + this.segments$.next(this._segments); + return convertDelta.compose(fixDelta).chop(); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html index 4045a9dc878..5717f7a9447 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html @@ -1,3 +1,7 @@ +@if (showInsights && editor != null) { + +} + (true); @Output() segmentRefChange = new EventEmitter(); - @Output() loaded = new EventEmitter(true); + @Output() loaded = new EventEmitter(true); @Output() focused = new EventEmitter(true); @Output() presenceChange = new EventEmitter(true); @Output() editorCreated = new EventEmitter(); lang: string = ''; // only use USX formats and not default Quill formats - readonly allowedFormats: string[] = USX_FORMATS; + readonly allowedFormats: string[] = this.quillFormatRegistry.getRegisteredFormats(); // allow for different CSS based on the browser engine readonly browserEngine: string = getBrowserEngine(); readonly cursorColor: string; @@ -259,6 +255,8 @@ export class TextComponent implements AfterViewInit, OnDestroy { private isDestroyed: boolean = false; private localPresenceChannel?: LocalPresence; private localPresenceDoc?: LocalPresence; + private localCursorElement?: HTMLElement | null; + private localCursorMovingTimeout?: any; private readonly presenceId: string = objectId(); /** The ShareDB presence information for the TextDoc that the quill is bound to. */ private presenceDoc?: Presence; @@ -268,15 +266,16 @@ export class TextComponent implements AfterViewInit, OnDestroy { private onPresenceChannelReceive = (_presenceId: string, _presenceData: PresenceData | null): void => {}; constructor( + private readonly destroyRef: DestroyRef, private readonly changeDetector: ChangeDetectorRef, private readonly dialogService: DialogService, private readonly projectService: SFProjectService, private readonly onlineStatusService: OnlineStatusService, private readonly transloco: TranslocoService, private readonly userService: UserService, - private readonly viewModel: TextViewModel, + readonly viewModel: TextViewModel, private readonly textDocService: TextDocService, - private destroyRef: DestroyRef + private readonly quillFormatRegistry: QuillFormatRegistryService ) { let localCursorColor = localStorage.getItem(this.cursorColorStorageKey); if (localCursorColor == null) { @@ -514,11 +513,19 @@ export class TextComponent implements AfterViewInit, OnDestroy { ngAfterViewInit(): void { this.onlineStatusService.onlineStatus$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe(isOnline => { this.changeDetector.detectChanges(); + if (!isOnline && this._editor != null) { - const cursors: QuillCursors = this._editor.getModule('cursors') as QuillCursors; - cursors.clearCursors(); + this.clearCursors(false); // Don't clear the local cursor } }); + + // Listening to document 'selectionchange' event allows local cursor to change position on mousedown, + // as opposed to quill 'onSelectionChange' event that doesn't fire until mouseup. + fromEvent(document, 'selectionchange') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.updateLocalCursor(); + }); } ngOnDestroy(): void { @@ -816,11 +823,12 @@ export class TextComponent implements AfterViewInit, OnDestroy { const isUserEdit: boolean = source === 'user'; this.update(delta, preDeltaSegmentCache, preDeltaEmbedCache, isUserEdit); } + + this.updateLocalCursor(); } async onSelectionChanged(range: Range | null): Promise { this.update(); - this.submitLocalPresenceDoc(range); } @@ -1077,7 +1085,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { this.bindQuill(); }); - this.loaded.emit(); + this.loaded.emit(true); this.applyEditorStyles(); // These refer to footnotes, cross-references, and end notes and not actual notes const elements = this.editor?.container.querySelectorAll('usx-note'); @@ -1103,18 +1111,100 @@ export class TextComponent implements AfterViewInit, OnDestroy { ) ); } + + this.createLocalCursor(); + } + + private createLocalCursor(): void { + if (this.editor != null) { + const cursors: QuillCursors = this.editor.getModule('cursors') as QuillCursors; + cursors.createCursor(this.presenceId, '', ''); + + this.localCursorElement = document.querySelector(`#ql-cursor-${this.presenceId}`); + + // Add a specific class to the local cursor + if (this.localCursorElement != null) { + this.localCursorElement.classList.add('local-cursor'); + } + } + } + + private updateLocalCursor(): void { + if (this._editor == null || this._isReadOnly || !this.showInsights || this.localCursorElement == null) { + return; + } + + const sel: Selection | null = window.getSelection(); + if (sel == null) { + return; + } + + const selRangeLength: number = sel.focusOffset - sel.anchorOffset; + + if (selRangeLength !== 0) { + // Hide the local cursor when there is a selection + this.localCursorElement.classList.add('hidden'); + } else { + this.localCursorElement.classList.remove('hidden'); + const blot = this._editor.scroll.find(sel.anchorNode); + + if (blot == null) { + return; + } + + const index: number = this._editor.getIndex(blot) + sel.anchorOffset; + this.moveLocalCursor(index); + } + } + + private moveLocalCursor(index: number): void { + if (this._editor == null || this._isReadOnly || this.localCursorElement == null) { + return; + } + + const cursors: QuillCursors = this._editor.getModule('cursors') as QuillCursors; + cursors.moveCursor(this.presenceId, { index, length: 0 }); + + // Set 'moving' class on caret that clears after a period of non-movement + this.localCursorElement.classList.add('moving'); + + if (this.localCursorMovingTimeout != null) { + clearTimeout(this.localCursorMovingTimeout); + } + + this.localCursorMovingTimeout = setTimeout(() => { + this.localCursorElement?.classList.remove('moving'); + }, 200); + } + + private clearCursors(includeLocal: boolean): void { + if (this.editor != null) { + const cursors: QuillCursors = this.editor.getModule('cursors') as QuillCursors; + + if (includeLocal) { + cursors.clearCursors(); + } else { + cursors.cursors().forEach(cursor => { + if (cursor.id !== this.presenceId) { + cursors.removeCursor(cursor.id); + } + }); + } + } } private async dismissPresences(): Promise { if (!this.isPresenceEnabled) { return; } + await this.submitLocalPresenceChannel(null); await this.submitLocalPresenceDoc(null); + if (this.editor != null) { - const cursors: QuillCursors = this.editor.getModule('cursors') as QuillCursors; - cursors.clearCursors(); + this.clearCursors(true); } + this.presenceChannel?.unsubscribe(error => { if (error) throw error; }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html index 1cce72ea0d4..ad2ef1efd48 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html @@ -158,79 +158,102 @@ {{ t("no_permission_edit_chapter", { userRole: userRoleStr }) }} } -
- - - -
- {{ currentSegmentReference }} - @if (!addingMobileNote && !hasEditRight) { - - } - @if (addingMobileNote) { -
- - {{ t("your_comment") }} - - -
- - +
+ + + +
+ + + +
+ {{ currentSegmentReference }} + @if (!addingMobileNote && !hasEditRight) { + + } + @if (addingMobileNote) { + + + {{ t("your_comment") }} + + +
+ + +
+ + }
- - } -
- - @if (canInsertNote) { - + } +
+ + + - add_comment - - } + + +
} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.scss index 97a6184841f..397be0161d7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.scss @@ -154,6 +154,16 @@ app-copyright-banner, overflow: hidden; } +.container-for-split { + flex-grow: 1; + height: 100%; + overflow: hidden; +} + +as-split-area { + overflow: hidden !important; +} + .text-container { display: flex; position: relative; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts index c4caa21d52d..23fcf40eebb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.ts @@ -73,6 +73,7 @@ import { CONSOLE, ConsoleInterface } from 'xforge-common/browser-globals'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { Breakpoint, MediaBreakpointService } from 'xforge-common/media-breakpoints/media-breakpoint.service'; @@ -125,6 +126,7 @@ import { } from '../../shared/utils'; import { DraftGenerationService } from '../draft-generation/draft-generation.service'; import { EditorHistoryService } from './editor-history/editor-history.service'; +import { LynxInsightStateService } from './lynx/insights/lynx-insight-state.service'; import { MultiCursorViewer } from './multi-viewer/multi-viewer.component'; import { NoteDialogComponent, NoteDialogData, NoteDialogResult } from './note-dialog/note-dialog.component'; import { @@ -216,6 +218,7 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, mobileNoteControl: UntypedFormControl = new UntypedFormControl(''); multiCursorViewers: MultiCursorViewer[] = []; target: TextComponent | undefined; + showInsights = false; @ViewChild('source') source?: TextComponent; @ViewChild('fabButton', { read: ElementRef }) insertNoteFab?: ElementRef; @@ -296,7 +299,9 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, private readonly destroyRef: DestroyRef, private readonly breakpointObserver: BreakpointObserver, private readonly mediaBreakpointService: MediaBreakpointService, - private readonly permissionsService: PermissionsService + private readonly permissionsService: PermissionsService, + private readonly featureFlagService: FeatureFlagService, + readonly editorInsightState: LynxInsightStateService ) { super(noticeService); const wordTokenizer = new LatinWordTokenizer(); @@ -679,6 +684,10 @@ export class EditorComponent extends DataLoadingComponent implements OnDestroy, switchMap(doc => this.initEditorTabs(doc)) ) .subscribe(); + + this.featureFlagService.enableLynxInsights.enabled$ + .pipe(quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(enabled => (this.showInsights = enabled)); } ngAfterViewInit(): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/_lynx-insights.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/_lynx-insights.scss new file mode 100644 index 00000000000..ffda29ec22c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/_lynx-insights.scss @@ -0,0 +1,138 @@ +@use 'sass:list'; + +:root { + --lynx-insights-info-color-h: 221; + --lynx-insights-info-color-s: 93%; + --lynx-insights-info-color-l: 66%; + --lynx-insights-info-text-decoration: dotted underline; + --lynx-insights-info-text-decoration-thickness: 1.2px; + --lynx-insights-info-text-underline-offset: 9px; + --lynx-insights-info-icon-color: #578cff; + + --lynx-insights-warning-color-h: 42; + --lynx-insights-warning-color-s: 89%; + --lynx-insights-warning-color-l: 59%; + --lynx-insights-warning-text-decoration: dashed underline; + --lynx-insights-warning-text-decoration-thickness: 1.2px; + --lynx-insights-warning-text-underline-offset: 7px; + --lynx-insights-warning-icon-color: #f5bc11; + + --lynx-insights-error-color-h: 356; + --lynx-insights-error-color-s: 94%; + --lynx-insights-error-color-l: 41%; + --lynx-insights-error-text-decoration: wavy underline; + --lynx-insights-error-text-decoration-thickness: 1.2px; + --lynx-insights-error-text-underline-offset: 2px; + --lynx-insights-error-icon-color: #b00020; + + --lynx-insights-status-indicator-bg-color: #fff; + --lynx-insights-status-indicator-offset: 19px; + --lynx-insights-checkmark-icon-color: #3bbf3b; + + --lynx-insights-multi-insight-prompt-color: #483d8b; // Something different from the insight colors +} + +$insightTypes: 'info', 'warning', 'error'; +$iconTypes: list.append($insightTypes, 'checkmark'); + +@function insight-color($insight-type, $lightnessFactor: 1) { + @return hsl( + var(--lynx-insights-#{$insight-type}-color-h), + var(--lynx-insights-#{$insight-type}-color-s), + calc(var(--lynx-insights-#{$insight-type}-color-l) * $lightnessFactor) + ); +} + +@function insight-bg-color($insight-type, $bg-color-l: 93%, $alpha: 1) { + $color-h: var(--lynx-insights-#{$insight-type}-color-h); + $color-s: var(--lynx-insights-#{$insight-type}-color-s); + @return hsla($color-h, $color-s, $bg-color-l, $alpha); +} + +@mixin insight-styles($insight-type, $bg-color-l: 93%) { + $color-h: var(--lynx-insights-#{$insight-type}-color-h); + $color-s: var(--lynx-insights-#{$insight-type}-color-s); + $color-l: var(--lynx-insights-#{$insight-type}-color-l); + $text-decoration: var(--lynx-insights-#{$insight-type}-text-decoration); + $text-decoration-thickness: var(--lynx-insights-#{$insight-type}-text-decoration-thickness); + $text-underline-offset: var(--lynx-insights-#{$insight-type}-text-underline-offset); + + $decorationColor: hsl($color-h, $color-s, $color-l); + $bgColor: insight-bg-color($insight-type, $bg-color-l); + + text-decoration: $text-decoration; + text-decoration-color: $decorationColor; + text-decoration-thickness: $text-decoration-thickness; + text-underline-offset: $text-underline-offset; + background-color: $bgColor; + + // Use instead of ':hover', as insights can be represented by multiple elements when + // there are partially overlapping insight ranges. + &.cursor-active { + // Darken the background color on hover of insight + background-color: hsl($color-h, $color-s, $bg-color-l - 4%); + } +} + +.lynx-insight { + // Skipping ink breaks the underline, which can make a single indicator look like multiple + text-decoration-skip-ink: none; + position: relative; + + // Round the borders of non-nested insights + &:not(.lynx-insight .lynx-insight) { + border-radius: 0.1em; + } + + &.info { + @include insight-styles('info'); + } + + &.warning { + @include insight-styles('warning'); + } + + &.error { + @include insight-styles('error'); + } +} + +// Insights should let more severe insights show through, +// unless the child element is 'action-overlay-active' and the parent is not. +:is(.warning, .error) .info:not(.action-overlay-active), +:is(.warning, .error).action-overlay-active .info { + background-color: transparent; +} +.error .warning:not(.action-overlay-active), +.error.action-overlay-active .warning { + background-color: transparent; +} +.lynx-insight.action-overlay-active .lynx-insight:not(.action-overlay-active) { + background-color: transparent; +} + +// Dim all but the insight whose action overlay is open +.lynx-insight-attention { + .action-overlay-active { + z-index: 2; + } + + &::after { + content: ''; + background-color: #fff; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + opacity: 0.8; + z-index: 1; + } +} + +// SVG icons fill set to 'currentColor', so they can be styled with 'color' property +@each $iconType in $iconTypes { + mat-icon[data-mat-icon-name='lynx_#{$iconType}'] { + color: var(--lynx-insights-#{$iconType}-icon-color); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.spec.ts new file mode 100644 index 00000000000..24ab301bef9 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorReadyService } from './editor-ready.service'; + +describe('EditorReadyService', () => { + let service: EditorReadyService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorReadyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.ts new file mode 100644 index 00000000000..0de77cf33a7 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-ready.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { LynxableEditor } from '../lynx-editor'; + +@Injectable() +export abstract class EditorReadyService { + abstract listenEditorReadyState(editor: LynxableEditor): Observable; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.spec.ts new file mode 100644 index 00000000000..3219ddcb6d9 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorSegmentService } from './editor-segment.service'; + +describe('EditorSegmentService', () => { + let service: EditorSegmentService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorSegmentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.ts new file mode 100644 index 00000000000..7be62814742 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/editor-segment.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@angular/core'; +import { DeltaOperation } from 'rich-text'; +import { LynxInsightRange } from '../lynx-insight'; + +@Injectable() +export abstract class EditorSegmentService { + // TODO: generic (non-quill) delta ops? + abstract parseSegments(ops: DeltaOperation[]): Map; + abstract getSegmentRefs(range: LynxInsightRange, segments: Map): string[]; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.spec.ts new file mode 100644 index 00000000000..37005237c3b --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { InsightRenderService } from './insight-render.service'; + +describe('InsightRenderService', () => { + let service: InsightRenderService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(InsightRenderService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts new file mode 100644 index 00000000000..4ec0c61f3f6 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { LynxableEditor } from '../lynx-editor'; +import { LynxInsight } from '../lynx-insight'; + +@Injectable() +export abstract class InsightRenderService { + abstract render(insights: LynxInsight[], editor: LynxableEditor): void; + abstract removeAllInsightFormatting(editor: LynxableEditor): void; + abstract renderActionOverlay(insights: LynxInsight[], editor: LynxableEditor, actionOverlayActive: boolean): void; + abstract renderCursorActiveState(insightIds: string[], editor: LynxableEditor): void; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts new file mode 100644 index 00000000000..192603faae4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts @@ -0,0 +1,8 @@ +import { InsightCodePipe } from './insight-code.pipe'; + +describe('InsightCodePipe', () => { + it('create an instance', () => { + const pipe = new InsightCodePipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts new file mode 100644 index 00000000000..0b59ebfec23 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { LynxInsightCodeService } from './lynx-insight-code.service'; +import { LynxInsightCode } from './lynx-insight-codes'; + +@Pipe({ + name: 'insightCode' +}) +export class InsightCodePipe implements PipeTransform { + constructor(private codeService: LynxInsightCodeService) {} + + transform(code: string, locale: string, prop: keyof LynxInsightCode = 'description'): string { + return this.codeService.lookupCode(code, locale)?.[prop] || code; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts new file mode 100644 index 00000000000..5532a4f9463 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts @@ -0,0 +1,101 @@ +import Quill, { EmitterSource } from 'quill'; + +export type LynxableEditor = Quill; // Add future editor as union type + +export class LynxEditor { + readonly editor: LynxableEditor; + + constructor(editor: LynxableEditor) { + this.editor = editor; + } + + insertText(index: number, text: string, formats?: any): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.insertText(index, text, formats); + break; + default: + throw new Error('Unsupported editor type'); + } + } + + deleteText(index: number, length: number): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.deleteText(index, length); + break; + default: + throw new Error('Unsupported editor type'); + } + } + + getLength(): number { + switch (true) { + case this.isQuill(this.editor): + return this.editor.getLength(); + default: + throw new Error('Unsupported editor type'); + } + } + + formatText(index: number, length: number, formats: any): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.formatText(index, length, formats); + break; + default: + throw new Error('Unsupported editor type'); + } + } + + setContents(delta: any, source: EmitterSource): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.setContents(delta, source); + break; + default: + throw new Error('Unsupported editor type'); + } + } + + setSelection(index: number, length: number, source: EmitterSource): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.setSelection(index, length, source); + break; + default: + throw new Error('Unsupported editor type'); + } + } + + getScrollingContainer(): Element { + switch (true) { + case this.isQuill(this.editor): + return this.editor.root; // TODO: is there a way to get scrolling container in Quill v2? + default: + throw new Error('Unsupported editor type'); + } + } + + getBounds(index: number, length: number): any { + switch (true) { + case this.isQuill(this.editor): + return this.editor.getBounds(index, length); + default: + throw new Error('Unsupported editor type'); + } + } + + get root(): HTMLElement { + switch (true) { + case this.isQuill(this.editor): + return this.editor.root; + default: + throw new Error('Unsupported editor type'); + } + } + + private isQuill(editor: LynxableEditor): editor is Quill { + return editor instanceof Quill; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.html new file mode 100644 index 00000000000..441026b84b2 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.html @@ -0,0 +1,9 @@ +@if (activeInsights.length === 1) { + +} +@if (activeInsights.length > 1) { +
+ tips_and_updates + filter_{{ activeInsights.length < 10 ? activeInsights.length : "9_plus" }} +
+} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.scss new file mode 100644 index 00000000000..5d763c71233 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.scss @@ -0,0 +1,47 @@ +@use '../lynx-insights' as insights; + +:host { + position: absolute; + z-index: 3; // Above the semi-transparent editor 'attention' overlay + inset-inline-end: 0; +} + +mat-icon { + font-size: 1.2em; + width: 0.9em; + height: 0.9em; + cursor: pointer; + + @each $type in insights.$insightTypes { + &.#{$type} { + color: insights.insight-color($type); + } + } + + &:hover { + scale: 1.2; + } +} + +.multi-insight-prompt { + mat-icon { + width: auto; + height: auto; + color: var(--lynx-insights-multi-insight-prompt-color); + + &.count-icon { + position: absolute; + inset-inline-start: 0.6em; + top: 0.5em; + font-size: 0.9em; + background-color: transparent; + } + } + + // Scale both icons on hover of container + &:hover { + mat-icon { + scale: 1.2; + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.spec.ts new file mode 100644 index 00000000000..e9976e628b2 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LynxInsightActionPromptComponent } from './editor-insight-action-prompt.component'; + +describe('EditorInsightActionPromptComponent', () => { + let component: LynxInsightActionPromptComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LynxInsightActionPromptComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(LynxInsightActionPromptComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts new file mode 100644 index 00000000000..e8a09d7e15d --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts @@ -0,0 +1,123 @@ +import { Directionality } from '@angular/cdk/bidi'; +import { Component, DestroyRef, ElementRef, Input, OnInit, Renderer2 } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Bounds } from 'quill'; +import { combineLatest, debounceTime, EMPTY, filter, fromEvent, iif, map, startWith, switchMap, tap } from 'rxjs'; +import { EditorReadyService } from '../base-services/editor-ready.service'; +import { LynxableEditor, LynxEditor } from '../lynx-editor'; +import { LynxInsight } from '../lynx-insight'; +import { LynxInsightStateService } from '../lynx-insight-state.service'; +import { getMostNestedInsight } from '../lynx-insight-util'; + +@Component({ + selector: 'app-lynx-insight-action-prompt', + templateUrl: './lynx-insight-action-prompt.component.html', + styleUrl: './lynx-insight-action-prompt.component.scss' +}) +export class LynxInsightActionPromptComponent implements OnInit { + @Input() set editor(value: LynxableEditor) { + this.lynxEditor = new LynxEditor(value); + } + + activeInsights: LynxInsight[] = []; + isLtr: boolean = this.dir.value === 'ltr'; + + // Adjust to move prompt up so less text is hidden + private readonly defaultLineHeight = 9; + private yOffsetAdjustment = this.defaultLineHeight; + private xOffsetAdjustment = -9; + + private lynxEditor?: LynxEditor; + + constructor( + private readonly destroyRef: DestroyRef, + private readonly renderer: Renderer2, + private readonly el: ElementRef, + private readonly editorInsightState: LynxInsightStateService, + private readonly editorReadyService: EditorReadyService, + private readonly dir: Directionality + ) {} + + ngOnInit(): void { + if (this.lynxEditor == null) { + return; + } + + // Adjust prompt vertical position based on line-height + this.yOffsetAdjustment = -this.getLineHeight() / 2; + + combineLatest([ + this.editorReadyService.listenEditorReadyState(this.lynxEditor.editor).pipe( + filter(loaded => loaded), + switchMap(() => this.editorInsightState.displayState$), + map(displayState => + displayState.activeInsightIds + .map(id => this.editorInsightState.getInsight(id)) + .filter((insight): insight is LynxInsight => insight != null) + ), + tap(activeInsights => { + this.activeInsights = activeInsights; + }) + ), + fromEvent(window, 'resize').pipe(debounceTime(200), startWith(undefined)), + iif( + () => this.lynxEditor?.getScrollingContainer() != null, + fromEvent(this.lynxEditor.getScrollingContainer(), 'scroll').pipe(startWith(undefined)), + EMPTY + ) + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + const offsetBounds: Bounds | undefined = this.getPromptOffset(); + + if (offsetBounds != null) { + const boundsEnd: number = this.isLtr ? offsetBounds.right : offsetBounds.left; + this.setHostStyle('top', `${offsetBounds.top + this.yOffsetAdjustment}px`); + this.setHostStyle('left', `${boundsEnd + this.xOffsetAdjustment}px`); + this.setHostStyle('display', 'flex'); + } else { + this.setHostStyle('display', 'none'); + } + }); + } + + onPromptClick(event: MouseEvent): void { + // Don't bubble, as the 'insight user event service' will clear display state on non-insight clicks that bubble + event.stopPropagation(); + + if (this.activeInsights.length === 0) { + return; + } + + // Toggle action menu + this.editorInsightState.toggleDisplayState(['actionOverlayActive']); + } + + private getPromptOffset(): Bounds | undefined { + if (this.lynxEditor != null) { + const insight: LynxInsight | undefined = getMostNestedInsight(this.activeInsights); + + if (insight?.range != null) { + // Get bounds of last character in range to ensure bounds isn't for multiple lines + const bounds = this.lynxEditor.getBounds(insight.range.index + insight.range.length - 1, 1); + return bounds; + } + } + + return undefined; + } + + private getLineHeight(): number { + if (this.lynxEditor != null) { + const editorElement = this.lynxEditor.editor.root; + const lineHeight = window.getComputedStyle(editorElement).lineHeight; + return Number.parseFloat(lineHeight); + } + + return this.defaultLineHeight; + } + + private setHostStyle(styleName: string, value: string): void { + this.renderer.setStyle(this.el.nativeElement, styleName, value); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts new file mode 100644 index 00000000000..d26c0e9da1a --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorInsightActionService } from './editor-insight-action.service'; + +describe('EditorInsightActionService', () => { + let service: EditorInsightActionService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorInsightActionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts new file mode 100644 index 00000000000..ca148ebff04 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { Observable, of, take } from 'rxjs'; +import { LynxEditor } from './lynx-editor'; +import { LynxInsight, LynxInsightRange } from './lynx-insight'; + +export interface LynxInsightAction { + id: string; + insight: LynxInsight; + label: string; + description?: string; + isPrimary?: boolean; +} + +// TODO: this type will be in Lynx lib +export interface TextEdit { + range: LynxInsightRange; + newText: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightActionService { + constructor() {} + + // TODO: send locale to server along with insightId + getActions(insight: LynxInsight, localeCode: string): Observable { + return of([ + // TODO: confirm that primary action should be an additional action (to have more flexible text) + { + id: '0', + insight, + label: 'Update quotation mark', + isPrimary: true + }, + { + id: '1', + insight, + label: 'Update', + description: 'Quotation mark to " style' + }, + { + id: '2', + insight, + label: 'Update all 48', + description: 'Quotation mark inconsistencies to " style' + }, + { + id: '3', + insight, + label: 'Reject', + description: 'Suggestion not relevant' + } + ]); + } + + performAction(action: LynxInsightAction, editor: LynxEditor): void { + console.log('Performing action', action); + + this.getFix(action.insight) + .pipe(take(1)) + .subscribe(fix => { + console.log('Fix', fix); + + editor.deleteText(fix.range.index, fix.range.length); + editor.insertText(fix.range.index, fix.newText); + }); + } + + getFix(insight: LynxInsight): Observable { + return of({ + range: insight.range, + newText: 'New text' + insight.id + }); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts new file mode 100644 index 00000000000..e7aff158026 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorInsightCodeService } from './editor-insight-code.service'; + +describe('EditorInsightCodeService', () => { + let service: EditorInsightCodeService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorInsightCodeService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts new file mode 100644 index 00000000000..5d121f4ad06 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@angular/core'; +import { EDITOR_INSIGHT_CODES, LynxInsightCode } from './lynx-insight-codes'; + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightCodeService { + constructor(@Inject(EDITOR_INSIGHT_CODES) private codes: Map) {} + + // TODO: send locale to server along with code + lookupCode(code: string, localeCode: string): LynxInsightCode | undefined { + return this.codes.get(code); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts new file mode 100644 index 00000000000..406b66d8b55 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts @@ -0,0 +1,41 @@ +import { InjectionToken } from '@angular/core'; + +export interface LynxInsightCode { + code: string; + description: string; + moreInfo?: string; // Verbose information about the insight (markdown?) +} + +const codes = new Map([ + [ + '1000', + { + code: '1000', + description: 'Inconsistently used quotation mark', + moreInfo: ` + Adhering to typographical standards and guidelines often requires using consistent quotation marks, particularly in publishing and academic writing. + Using the same style throughout maintains the author's intended tone and style, reinforcing the voice and credibility of the writing. + + [Further reading on using quotation marks](https://en.wikipedia.org/wiki/Quotation_mark) + ` + } + ], + ['1002', { code: '1002', description: 'Closing quotation mark not found' }], + ['1001', { code: '1001', description: 'Five-digit numbers are whack.' }], + ['1012', { code: '1012', description: '"Must" is a strong word' }], + ['2001', { code: '2001', description: 'Crazy parens!' }], + ['2011', { code: '2011', description: 'I warned you!' }], + ['2002', { code: '2002', description: 'No such thing as "Information".' }], + ['3001', { code: '3001', description: 'Better to ask forgiveness.' }], + ['3002', { code: '3002', description: 'Some error text.' }], + ['2005', { code: '2005', description: 'Some warning text.' }], + ['1005', { code: '1005', description: 'Some notice text.' }], + ['1006', { code: '1006', description: 'Some notice text.' }], + ['3006', { code: '3006', description: 'Some error text.' }], + ['1011', { code: '1011', description: 'Some notice text.' }] +]); + +export const EDITOR_INSIGHT_CODES = new InjectionToken>('EDITOR_INSIGHT_CODES', { + providedIn: 'root', + factory: () => codes +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.html new file mode 100644 index 00000000000..0819e1c028c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.spec.ts new file mode 100644 index 00000000000..33efc6ec35d --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LynxInsightEditorObjectsComponent } from './editor-insight-editor-objects.component'; + +describe('EditorInsightEditorObjectsComponent', () => { + let component: LynxInsightEditorObjectsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LynxInsightEditorObjectsComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(LynxInsightEditorObjectsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts new file mode 100644 index 00000000000..354fbcfdbae --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts @@ -0,0 +1,89 @@ +import { Component, DestroyRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isEqual } from 'lodash-es'; +import { filter, merge, switchMap, tap } from 'rxjs'; +import { pairwise } from 'rxjs/operators'; +import { EditorReadyService } from '../base-services/editor-ready.service'; +import { InsightRenderService } from '../base-services/insight-render.service'; +import { LynxableEditor } from '../lynx-editor'; +import { LynxInsightOverlayService } from '../lynx-insight-overlay.service'; +import { LynxInsightStateService } from '../lynx-insight-state.service'; + +@Component({ + selector: 'app-lynx-insight-editor-objects', + templateUrl: './lynx-insight-editor-objects.component.html', + styleUrl: './lynx-insight-editor-objects.component.scss' +}) +export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { + @Input() editor?: LynxableEditor; + + constructor( + private readonly destroyRef: DestroyRef, + private readonly insightState: LynxInsightStateService, + private readonly insightRenderService: InsightRenderService, + private readonly editorReadyService: EditorReadyService, + private readonly overlayService: LynxInsightOverlayService + ) {} + + ngOnInit(): void { + if (this.editor == null) { + return; + } + + this.editorReadyService + .listenEditorReadyState(this.editor) + .pipe( + filter(ready => ready), + tap(() => { + // When editor becomes ready, close all action overlays, + // including those for insights from other books/chapters + this.overlayService.close(); + }), + switchMap(() => + merge( + // Render blots when insights change + this.insightState.filteredChapterInsights$.pipe( + tap(insights => { + this.insightRenderService.render(insights, this.editor!); + }) + ), + // Check display state to render action overlay or cursor active state + this.insightState.displayState$.pipe( + pairwise(), + tap(([prev, curr]) => { + const activeInsightsChanged: boolean = !isEqual(prev.activeInsightIds, curr.activeInsightIds); + const actionOverlayActiveChanged: boolean = prev.actionOverlayActive !== curr.actionOverlayActive; + const cursorActiveInsightIdsChanged: boolean = !isEqual( + prev.cursorActiveInsightIds, + curr.cursorActiveInsightIds + ); + + if (activeInsightsChanged || actionOverlayActiveChanged) { + const activeInsights = curr.activeInsightIds + .map(id => this.insightState.getInsight(id)) + .filter(i => i != null); + this.insightRenderService.renderActionOverlay( + activeInsights, + this.editor!, + !!curr.actionOverlayActive + ); + } + + if (cursorActiveInsightIdsChanged) { + this.insightRenderService.renderCursorActiveState(curr.cursorActiveInsightIds, this.editor!); + } + }) + ) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); + } + + ngOnDestroy(): void { + if (this.editor != null) { + this.insightRenderService.removeAllInsightFormatting(this.editor); + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts new file mode 100644 index 00000000000..8d787067266 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorInsightFilterService } from './editor-insight-filter.service'; + +describe('EditorInsightFilterService', () => { + let service: EditorInsightFilterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorInsightFilterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts new file mode 100644 index 00000000000..d0d2bd8f43d --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; +import { Canon } from '@sillsdev/scripture'; +import { LynxInsightFilter, LynxInsightFilterScope } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; +import { LynxInsight } from './lynx-insight'; + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightFilterService { + constructor() {} + + matchesFilter( + insight: LynxInsight, + filter: LynxInsightFilter, + bookChapter: RouteBookChapter, + dismissedIds: string[] + ): boolean { + const routeBookNum: number | undefined = bookChapter.bookId ? Canon.bookIdToNumber(bookChapter.bookId) : undefined; + const routeChapter = bookChapter.chapter; + const dismissedIdSet: Set = new Set(dismissedIds ?? []); + + if (!filter.includeDismissed && dismissedIdSet.has(insight.id)) { + return false; + } + + if (!filter.types.includes(insight.type)) { + return false; + } + + if (filter.scope === 'project') { + return true; + } + + if (filter.scope === 'book' && routeBookNum !== insight.book) { + return false; + } + + if (filter.scope === 'chapter' && (routeBookNum !== insight.book || routeChapter !== insight.chapter)) { + return false; + } + + return true; + } + + getScope(insight: LynxInsight, bookChapter: RouteBookChapter): LynxInsightFilterScope { + const routeBookNum: number | undefined = bookChapter.bookId ? Canon.bookIdToNumber(bookChapter.bookId) : undefined; + const routeChapter = bookChapter.chapter; + + if (insight.book === routeBookNum && insight.chapter === routeChapter) { + return 'chapter'; + } else if (insight.book === routeBookNum) { + return 'book'; + } else { + return 'project'; + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.spec.ts new file mode 100644 index 00000000000..ce15eaadba4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorInsightOverlayService } from './editor-insight-overlay.service'; + +describe('EditorInsightOverlayService', () => { + let service: EditorInsightOverlayService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorInsightOverlayService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts new file mode 100644 index 00000000000..2c09348c009 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts @@ -0,0 +1,138 @@ +import { + CdkScrollable, + Overlay, + OverlayConfig, + OverlayRef, + PositionStrategy, + ScrollDispatcher +} from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { Injectable, NgZone } from '@angular/core'; +import { asyncScheduler, observeOn, Subject, take, takeUntil } from 'rxjs'; +import { LynxEditor } from './lynx-editor'; +import { LynxInsight } from './lynx-insight'; +import { LynxInsightOverlayComponent } from './lynx-insight-overlay/lynx-insight-overlay.component'; + +export interface LynxInsightOverlayRef { + ref: OverlayRef; + closed$: Subject; + hoverMultiInsight$: Subject; +} + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightOverlayService { + private openRef?: LynxInsightOverlayRef; + private scrollableContainer?: CdkScrollable; + + constructor( + private overlay: Overlay, + private scrollDispatcher: ScrollDispatcher, + private ngZone: NgZone + ) {} + + open(origin: HTMLElement, insights: LynxInsight[], editor: LynxEditor): LynxInsightOverlayRef | undefined { + if (insights.length === 0) { + return undefined; + } + + // Close any existing overlay + this.close(); + + this.registerScrollable(editor.getScrollingContainer()); + + const overlayRef: LynxInsightOverlayRef = this.createOverlayRef(origin); + const componentRef = overlayRef.ref.attach(new ComponentPortal(LynxInsightOverlayComponent)); + + componentRef.instance.insights = insights; + componentRef.instance.editor = editor; + componentRef.instance.insightDismiss.pipe(take(1)).subscribe(() => this.close()); + componentRef.instance.insightHover + .pipe(takeUntil(overlayRef.closed$)) + .subscribe(insight => this.openRef?.hoverMultiInsight$.next(insight)); + + // Update overlay position when insight is focused (as in choosing from multi-insight) + componentRef.instance.insightFocus + .pipe( + takeUntil(overlayRef.closed$), + observeOn(asyncScheduler) // Delay to wait for DOM render (like setTimeout) + ) + .subscribe(() => overlayRef.ref.updatePosition()); + + this.openRef = overlayRef; + + return overlayRef; + } + + close(): void { + if (this.openRef != null) { + this.openRef.ref.dispose(); + this.openRef.closed$.next(); + this.openRef.closed$.complete(); + this.openRef = undefined; + } + } + + /** + * Create an overlay ref with an 'on close' callback. + */ + private createOverlayRef(origin: HTMLElement): LynxInsightOverlayRef { + return { + ref: this.overlay.create(this.getConfig(origin)), + closed$: new Subject(), + hoverMultiInsight$: new Subject() + }; + } + + private getConfig(origin: HTMLElement): OverlayConfig { + return { + positionStrategy: this.getPositionStrategy(origin), + hasBackdrop: false, + panelClass: 'lynx-insight-overlay-panel', + scrollStrategy: this.overlay.scrollStrategies.reposition() + }; + } + + private getPositionStrategy(origin: HTMLElement): PositionStrategy { + if (this.scrollableContainer == null) { + throw new Error('Scrollable container is not registered'); + } + + const positionStrategy = this.overlay + .position() + .flexibleConnectedTo(origin) + .withPositions([ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom' } + ]) + .withGrowAfterOpen(true); + + return positionStrategy; + } + + /** + * Converts the scroll container element into a CdkScrollable and registers it with the ScrollDispatcher. + * This allows the overlay to reposition itself when the scroll container is scrolled. + * @param scrollContainer The scrolling element that contains the insight. + */ + private registerScrollable(scrollContainer: Element): void { + if (this.scrollableContainer?.getElementRef().nativeElement === scrollContainer) { + return; + } + + if (this.scrollableContainer != null) { + this.scrollDispatcher.deregister(this.scrollableContainer); + } + + this.scrollableContainer = new CdkScrollable( + { nativeElement: scrollContainer as HTMLElement }, + this.scrollDispatcher, + this.ngZone + ); + + this.scrollDispatcher.register(this.scrollableContainer); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html new file mode 100644 index 00000000000..130cfb6f3d4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html @@ -0,0 +1,62 @@ +@if (focusedInsight != null) { +
+

+ {{ focusedInsight.description }} + ({{ focusedInsight.code }}) + + @if (focusedInsight.moreInfo != null) { + help_outline + } +

+ +
+ more_horiz + + visibility_off + +
+
+ + @if (focusedInsight.moreInfo != null && showMoreInfo) { +
+ {{ focusedInsight.moreInfo }} +
+ } + + + @for (action of menuActions; track action) { +
+ +
+ } +
+} @else if (insightsFlattened.length > 1) { +
+

Select for details

+ @for (insight of insightsFlattened; track insight) { +
+ +

+ {{ insight.description }} + ({{ insight.code }}) +

+ more_horiz +
+ } +
+} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss new file mode 100644 index 00000000000..6713b1edbb7 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss @@ -0,0 +1,236 @@ +@use 'sass:color'; +@use '../lynx-insights' as insights; + +$mainSectionBGColor: #343a32; +$secondaryTextColor: #9b9b9b; +$applyActionColor: #5485e7; +$dismissActionColor: $applyActionColor; +$borderRadius: 0.3em; + +:host { + max-width: 340px; + border-radius: $borderRadius; + overflow: hidden; +} + +mat-icon { + width: auto; + height: auto; + cursor: pointer; +} + +.main-section.focused, +.more-info-section { + padding: 0.7em 0.8em 0.4em 1em; +} + +.main-section { + background-color: $mainSectionBGColor; + + // When prompt is for single insight, or insight is selected from multi-picker + &.focused { + > * { + animation: fade-in 0.3s; + } + + // Color notch beside insight code header text + &::before { + content: ''; + position: absolute; + top: 0.5em; + height: 1.4em; + width: 0.5em; + border-start-end-radius: 0.2em; + border-end-end-radius: 0.2em; + inset-inline-start: 0; + } + + @each $type in insights.$insightTypes { + &.#{$type} { + &::before { + background-color: insights.insight-color($type); + } + } + } + + mat-icon:not(.apply-icon) { + &:hover { + transform: scale(1.1); + } + } + } + + // When prompt is for multiple insights, and one is not yet focused + &.list { + > h1 { + padding: 0.5em 0.8em; + color: #bdbdbd; + border-bottom: 1px solid #777; + font-size: 0.8em; + } + + .insight-item { + position: relative; + padding: 0.3em 0.7em; + display: flex; + align-items: center; + gap: 0.6em; + cursor: pointer; + + h1 { + font-size: 0.8em; + } + + &:not(:last-child) { + border-bottom: 1px solid #555; + } + + &:hover { + background-color: #06395c; + } + + .severity-icon { + width: 1em; + } + + @each $type in insights.$insightTypes { + &.#{$type} { + .severity-icon { + color: insights.insight-color($type); + } + } + } + + .ellipsis-icon { + color: $secondaryTextColor; + margin-inline-start: auto; // Push to right + padding-inline-start: 1em; + } + } + } +} + +.more-info-section { + padding: 0.8em 0.7em; + border: 1px solid #999; + border-width: 0 1px 1px 1px; + border-radius: 0 0 $borderRadius $borderRadius; +} + +.more-info-section { + background-color: #fff; + font-size: 0.8em; +} + +h1 { + display: flex; + align-items: baseline; + gap: 0.5em; + font-size: 0.9em; + color: #fff; + margin: 0; + font-weight: 400; + + .code { + color: $secondaryTextColor; + font-size: 0.9em; + } + + .more-info-icon { + margin-inline-start: auto; + color: $secondaryTextColor; + font-size: 1.2em; + align-self: normal; + padding-inline-start: 1em; + + &:hover { + color: color.adjust($secondaryTextColor, $lightness: 20%); + } + } +} + +.primary-action { + display: flex; + align-items: baseline; + gap: 1em; + font-size: 0.75em; + line-height: 2.5em; + + a { + position: relative; + padding-inline-start: 2.3em; + color: $applyActionColor; + text-wrap: nowrap; + cursor: pointer; + display: flex; + align-items: center; + + .apply-icon { + position: absolute; + inset-inline-start: 0; + font-size: 1.7em; + } + + &:hover { + text-decoration: underline; + + .apply-icon { + transform: scale(1.1); + } + } + } + + .shortcut { + color: $secondaryTextColor; + font-family: monospace; + font-size: 0.9em; + text-wrap: nowrap; + } +} + +.secondary-actions { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 1.2em; + + .action-menu-trigger { + color: $secondaryTextColor; + + &:hover { + color: color.adjust($secondaryTextColor, $lightness: 20%); + } + } +} + +.dismiss-icon { + color: $dismissActionColor; + font-size: 1em; + cursor: pointer; +} + +.menu-item-wrapper { + display: flex; + flex-direction: column; + + h2 { + margin: 0; + font-size: 0.9em; + font-weight: 500; + } + + span { + color: $secondaryTextColor; + font-size: 0.75em; + line-height: normal; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.spec.ts new file mode 100644 index 00000000000..b7f7704021c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditorInsightOverlayComponent } from './editor-insight-overlay.component'; + +describe('EditorInsightOverlayComponent', () => { + let component: EditorInsightOverlayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditorInsightOverlayComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(EditorInsightOverlayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts new file mode 100644 index 00000000000..c1a9659db1f --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts @@ -0,0 +1,168 @@ +import { DOCUMENT } from '@angular/common'; +import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { MAT_TOOLTIP_DEFAULT_OPTIONS } from '@angular/material/tooltip'; +import { take } from 'rxjs'; +import { I18nService } from 'xforge-common/i18n.service'; +import { LynxEditor } from '../lynx-editor'; +import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightConfig } from '../lynx-insight'; +import { LynxInsightAction, LynxInsightActionService } from '../lynx-insight-action.service'; +import { LynxInsightCodeService } from '../lynx-insight-code.service'; +import { LynxInsightOverlayService } from '../lynx-insight-overlay.service'; +import { LynxInsightStateService } from '../lynx-insight-state.service'; + +interface LynxInsightFlattened extends LynxInsight { + description: string; + moreInfo?: string; +} + +@Component({ + selector: 'app-lynx-insight-overlay', + templateUrl: './lynx-insight-overlay.component.html', + styleUrl: './lynx-insight-overlay.component.scss', + providers: [{ provide: MAT_TOOLTIP_DEFAULT_OPTIONS, useValue: { showDelay: 500 } }] +}) +export class LynxInsightOverlayComponent implements OnInit, OnDestroy { + showMoreInfo = false; + + insightsFlattened: LynxInsightFlattened[] = []; + + @Input() + set insights(value: LynxInsight[]) { + console.log(`set insights(${value.map(i => i.id).join(', ')})`); + this.insightsFlattened = value.map(insight => this.flattenInsight(insight)); + + // Focus if single insight + if (value.length === 1) { + this.focusInsight(this.insightsFlattened[0]); + } + } + + @Input() editor?: LynxEditor; + + /** Emits when insight is dismissed by user. */ + @Output() insightDismiss = new EventEmitter(); + + /** Emits when overlay goes to single insight mode. */ + @Output() insightFocus = new EventEmitter(); + + /** Emits hovered insight when overlay displays multi-insight selection list. Emits `null` when hover ceases. */ + @Output() insightHover = new EventEmitter(); + + focusedInsight?: LynxInsightFlattened; + menuActions: LynxInsightAction[] = []; + primaryAction?: LynxInsightAction; + + // Create single reference to bound method so it can be removed in ngOnDestroy + private readonly handleKeyDownBound = this.handleKeyDown.bind(this); + + constructor( + private readonly insightState: LynxInsightStateService, + private readonly codeService: LynxInsightCodeService, + private readonly actionService: LynxInsightActionService, + private readonly overlayService: LynxInsightOverlayService, + private readonly i18n: I18nService, + @Inject(DOCUMENT) private readonly document: Document, + @Inject(EDITOR_INSIGHT_DEFAULTS) private readonly config: LynxInsightConfig + ) {} + + ngOnInit(): void { + this.document.addEventListener('keydown', this.handleKeyDownBound); + } + + ngOnDestroy(): void { + this.document.removeEventListener('keydown', this.handleKeyDownBound); + } + + handleKeyDown(event: KeyboardEvent): void { + if (this.primaryAction == null) { + return; + } + + // Apply primary action if configured hotkey chord is pressed + if (this.chordPressed(event, this.config.actionOverlayApplyPrimaryActionChord)) { + this.selectAction(this.primaryAction); + } + } + + toggleMoreInfo(): void { + this.showMoreInfo = !this.showMoreInfo; + } + + focusInsight(insight: LynxInsightFlattened): void { + this.focusedInsight = insight; + this.fetchInsightActions(insight); + this.insightFocus.emit(insight); + } + + /** + * Highlight the specified insight. Brings lower severity insights to the front. `null` means hover ceased. + */ + highlightInsight(insight: LynxInsight | null): void { + this.insightHover.emit(insight); + } + + selectAction(action: LynxInsightAction): void { + if (this.editor == null) { + return; + } + + this.actionService.performAction(action, this.editor); + + if (this.focusedInsight == null) { + throw new Error('No focused insight'); + } + + this.overlayService.close(); + } + + dismissInsight(insight: LynxInsight): void { + console.log('Dismiss', insight.id); + this.insightDismiss.emit(insight); + this.insightState.dismissInsights([insight.id]); + } + + private flattenInsight(insight: LynxInsight): LynxInsightFlattened { + const insightCode = this.codeService.lookupCode(insight.code, this.i18n.localeCode); + return { + ...insight, + description: insightCode?.description ?? '', + moreInfo: insightCode?.moreInfo + }; + } + + private fetchInsightActions(insight: LynxInsight | undefined): void { + if (insight == null) { + return; + } + + this.actionService + .getActions(insight, this.i18n.localeCode) + .pipe(take(1)) + .subscribe(actions => { + const menuActions: LynxInsightAction[] = []; + + for (const action of actions) { + if (action.isPrimary) { + this.primaryAction = action; + } else { + menuActions.push(action); + } + } + + this.menuActions = menuActions; + }); + } + + /** + * Check if the keyboard event matches the given chord. + */ + private chordPressed(event: KeyboardEvent, chord: Partial): boolean { + for (const key of Object.keys(chord)) { + if (event[key] !== chord[key]) { + return false; + } + } + + return true; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html new file mode 100644 index 00000000000..3434338c081 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html @@ -0,0 +1,3 @@ +@for (pos of scrollPositions; track pos) { +
+} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.scss new file mode 100644 index 00000000000..d470098a048 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.scss @@ -0,0 +1,50 @@ +// These are approximations +$scrollButtonHeight: 16px; +$trackWidth: 18px; // Width of the scrollbar track + +:host { + position: absolute; + z-index: 1; + top: $scrollButtonHeight; + bottom: $scrollButtonHeight; + inset-inline-end: 0; + width: $trackWidth; + box-sizing: border-box; + pointer-events: none; // Let clicks through to scrollbar +} + +.indicator { + height: 2px; + width: 100%; + position: absolute; + background-color: blue; + top: var(--scroll-position-percent); + border-radius: 4px; +} + +.info { + background-color: hsl( + var(--lynx-insights-info-color-h), + var(--lynx-insights-info-color-s), + var(--lynx-insights-info-color-l) + ); + z-index: 1; +} + +.warning { + background-color: hsl( + var(--lynx-insights-warning-color-h), + var(--lynx-insights-warning-color-s), + var(--lynx-insights-warning-color-l) + ); + z-index: 2; +} + +.error { + background-color: hsl( + var(--lynx-insights-error-color-h), + var(--lynx-insights-error-color-s), + var(--lynx-insights-error-color-l) + ); + z-index: 3; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.spec.ts new file mode 100644 index 00000000000..a38e0200f17 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditorInsightScrollPositionIndicatorComponent } from './editor-insight-scroll-position-indicator.component'; + +describe('EditorInsightScrollPositionIndicatorComponent', () => { + let component: EditorInsightScrollPositionIndicatorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditorInsightScrollPositionIndicatorComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(EditorInsightScrollPositionIndicatorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts new file mode 100644 index 00000000000..aa1306ef4b6 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts @@ -0,0 +1,73 @@ +import { Component, DestroyRef, Input, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { combineLatest, debounceTime, filter, fromEvent, map, startWith, switchMap } from 'rxjs'; +import { EditorReadyService } from '../base-services/editor-ready.service'; +import { LynxableEditor, LynxEditor } from '../lynx-editor'; +import { LynxInsight } from '../lynx-insight'; +import { LynxInsightStateService } from '../lynx-insight-state.service'; + +interface LynxInsightScrollPosition { + id: string; + type: LynxInsightType; + percent: number | undefined; +} + +@Component({ + selector: 'app-lynx-insight-scroll-position-indicator', + templateUrl: './lynx-insight-scroll-position-indicator.component.html', + styleUrl: './lynx-insight-scroll-position-indicator.component.scss' +}) +export class LynxInsightScrollPositionIndicatorComponent implements OnInit { + @Input() set editor(value: LynxableEditor) { + this.lynxEditor = new LynxEditor(value); + } + + scrollPositions: LynxInsightScrollPosition[] = []; + + private lynxEditor?: LynxEditor; + + constructor( + private readonly destroyRef: DestroyRef, + private readonly editorInsightState: LynxInsightStateService, + private readonly editorReadyService: EditorReadyService + ) {} + + ngOnInit(): void { + if (this.lynxEditor == null) { + return; + } + + combineLatest([ + this.editorReadyService.listenEditorReadyState(this.lynxEditor.editor).pipe(filter(loaded => loaded)), + fromEvent(window, 'resize').pipe(debounceTime(200), startWith(undefined)) + ]) + .pipe( + switchMap(() => this.editorInsightState.filteredChapterInsights$), + map(insights => insights.map(insight => this.getScrollPosition(insight, this.lynxEditor!))), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(scrollPositions => { + this.scrollPositions = scrollPositions; + }); + } + + /** + * Gets a map of insight id -> vertical scroll position as a percent of the total scroll height. + * TODO: move to service? + */ + private getScrollPosition(insight: LynxInsight, editor: LynxEditor): LynxInsightScrollPosition { + const scrollPosition: LynxInsightScrollPosition = { + id: insight.id, + type: insight.type, + percent: undefined + }; + + if (insight.range != null) { + const bounds = editor.getBounds(insight.range.index, 1); + scrollPosition.percent = (bounds.top / editor.getScrollingContainer().scrollHeight) * 100; + } + + return scrollPosition; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.spec.ts new file mode 100644 index 00000000000..0549c72e774 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorInsightStateService } from './editor-insight-state.service'; + +describe('EditorInsightStateService', () => { + let service: EditorInsightStateService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorInsightStateService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts new file mode 100644 index 00000000000..f2b4f7e5b72 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts @@ -0,0 +1,601 @@ +import { Inject, Injectable } from '@angular/core'; +import { isEqual } from 'lodash-es'; +import { + LynxInsightFilter, + LynxInsightFilterScope, + LynxInsightSortOrder, + LynxInsightType +} from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { LynxInsightUserData } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight-user-data'; +import { + BehaviorSubject, + Observable, + combineLatest, + distinctUntilChanged, + filter, + map, + shareReplay, + take, + tap, + withLatestFrom +} from 'rxjs'; +import { ActivatedBookChapterService } from 'xforge-common/activated-book-chapter.service'; +import { ActivatedProjectUserConfigService } from 'xforge-common/activated-project-user-config.service'; +import { filterNullish } from 'xforge-common/util/rxjs-util'; +import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightConfig, LynxInsightDisplayState } from './lynx-insight'; +import { LynxInsightFilterService } from './lynx-insight-filter.service'; + +type BooleanProp = { [K in keyof T]: T[K] extends boolean | undefined ? K : never }[keyof T]; + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightStateService { + private rawInsightSource$ = new BehaviorSubject([ + // Mark 1 + { + id: '0a', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 300, + length: 5 + }, + code: '1011' + }, + { + id: '0b', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 314, + length: 3 + }, + code: '1011' + }, + { + id: '0c', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 318, + length: 10 + }, + code: '1011' + }, + { + id: '1', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 0, + length: 5 + }, + code: '1001' + }, + // { + // id: '1b', + // type: 'info', + // chapter: 1, + // book: 41, + // range: { + // index: 1, + // length: 6 + // }, + // code: '1001' + // }, + { + id: '2', + type: 'warning', + chapter: 1, + book: 41, + range: { + index: 16, + length: 1 + }, + code: '2001' + }, + { + id: '2a', + type: 'warning', + chapter: 1, + book: 41, + range: { + index: 22, + length: 1 + }, + code: '2001' + }, + { + id: '2b', + type: 'error', + chapter: 1, + book: 41, + range: { + index: 40, + length: 10 + }, + code: '3002' + }, + { + id: '3', + type: 'error', + chapter: 1, + book: 41, + range: { + index: 86, + length: 10 + }, + code: '3001' + }, + { + id: '3a', + type: 'warning', + chapter: 1, + book: 41, + range: { + index: 76, + length: 30 + }, + code: '2011' + }, + { + id: '3b', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 88, + length: 13 + }, + code: '1012' + }, + { + id: '4', + type: 'warning', + chapter: 1, + book: 41, + range: { + index: 34, + length: 11 + }, + code: '1000' + // code: '2002' + }, + { + id: '5', + type: 'warning', + chapter: 1, + book: 41, + range: { + index: 110, + length: 11 + }, + code: '2005' + }, + { + id: '5a', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 112, + length: 5 + }, + code: '1005' + }, + { + id: '6', + type: 'info', + chapter: 1, + book: 41, + range: { + index: 125, + length: 10 + }, + code: '1006' + }, + { + id: '6a', + type: 'error', + chapter: 1, + book: 41, + range: { + index: 127, + length: 5 + }, + code: '3006' + }, + // Mark 2 + { + id: '11', + type: 'info', + chapter: 2, + book: 41, + range: { + index: 2, + length: 5 + }, + code: '1001' + }, + { + id: '22', + type: 'warning', + chapter: 2, + book: 41, + range: { + index: 16, + length: 1 + }, + code: '2001' + }, + { + id: '33', + type: 'warning', + chapter: 2, + book: 41, + range: { + index: 22, + length: 1 + }, + code: '2001' + }, + { + id: '44', + type: 'error', + chapter: 2, + book: 41, + range: { + index: 86, + length: 10 + }, + code: '3001' + }, + { + id: '55', + type: 'warning', + chapter: 2, + book: 41, + range: { + index: 34, + length: 11 + }, + code: '2002' + }, + // Luke 2 + { + id: '111', + type: 'info', + chapter: 2, + book: 42, + range: { + index: 0, + length: 5 + }, + code: '1001' + }, + { + id: '222', + type: 'warning', + chapter: 2, + book: 42, + range: { + index: 16, + length: 1 + }, + code: '2001' + }, + { + id: '333', + type: 'warning', + chapter: 2, + book: 42, + range: { + index: 22, + length: 1 + }, + code: '2001' + }, + { + id: '444', + type: 'error', + chapter: 2, + book: 42, + range: { + index: 86, + length: 10 + }, + code: '3001' + }, + { + id: '555', + type: 'warning', + chapter: 2, + book: 42, + range: { + index: 34, + length: 11 + }, + code: '2002' + } + ]); + + private rawInsights$: Observable = this.rawInsightSource$.pipe( + distinctUntilChanged(isEqual), + tap(insights => console.log('rawInsights$ changed (LynxInsightStateService)', insights)), + shareReplay(1) + ); + + // Stored filter and order are loaded from project user config + private filterSource$ = new BehaviorSubject(this.defaults.filter); + readonly filter$ = this.filterSource$.pipe(distinctUntilChanged()); + private orderBySource$ = new BehaviorSubject(this.defaults.sortOrder); + readonly orderBy$ = this.orderBySource$.pipe(distinctUntilChanged()); + + private readonly dismissedInsightIdsSource$ = new BehaviorSubject([]); + readonly dismissedInsightIds$ = this.dismissedInsightIdsSource$.pipe(distinctUntilChanged(), shareReplay(1)); + + readonly filteredChapterInsights$: Observable = combineLatest([ + this.rawInsights$, + this.filter$, + this.activatedBookChapter.activatedBookChapter$.pipe(filterNullish()), + this.dismissedInsightIds$ + ]).pipe( + map(([insights, filter, routeBookChapter, dismissedIds]) => + insights.filter(insight => + this.insightFilterService.matchesFilter( + insight, + { ...filter, scope: 'chapter' }, + routeBookChapter, + dismissedIds + ) + ) + ), + distinctUntilChanged(isEqual), + tap(insights => console.log('filteredChapterInsights$ changed (LynxInsightStateService)', insights)), + shareReplay(1) + ); + + readonly filteredInsights$: Observable = combineLatest([ + this.rawInsights$, + this.filter$, + this.activatedBookChapter.activatedBookChapter$.pipe(filterNullish()), + this.dismissedInsightIds$ + ]).pipe( + map(([insights, filter, routeBookChapter, dismissedIds]) => + insights.filter(insight => + this.insightFilterService.matchesFilter(insight, filter, routeBookChapter, dismissedIds) + ) + ), + distinctUntilChanged(isEqual), + tap(val => console.log('filteredInsights$ changed (LynxInsightStateService)', val)), + shareReplay(1) + ); + + /** + * Insight counts for the currently filtered types grouped by scope. + */ + readonly filteredInsightCountsByScope$: Observable> = combineLatest([ + this.rawInsights$, + this.filter$, + this.activatedBookChapter.activatedBookChapter$.pipe(filterNullish()), + this.dismissedInsightIds$ + ]).pipe( + map(([insights, filter, routeBookChapter, dismissedIds]) => { + const result: Record = { project: 0, book: 0, chapter: 0 }; + const filterTypes = new Set(filter.types); + const dismissedIdSet: Set = new Set(dismissedIds); + + for (const insight of insights) { + if (!filter.includeDismissed && dismissedIdSet.has(insight.id)) { + continue; + } + + if (!filterTypes.has(insight.type)) { + continue; + } + + const scope: LynxInsightFilterScope = this.insightFilterService.getScope(insight, routeBookChapter); + + result.project++; + + if (scope === 'chapter' || scope === 'book') { + result.book++; + } + + if (scope === 'chapter') { + result.chapter++; + } + } + + return result; + }), + distinctUntilChanged(isEqual), + tap(val => console.log('filteredInsightCountsByScope$ changed (LynxInsightStateService)', val)), + shareReplay(1) + ); + + /** + * Insight counts for the currently filtered types and scope, grouped by type. + */ + readonly filteredInsightCountsByType$: Observable> = this.filteredInsights$.pipe( + map((insights: LynxInsight[]) => { + const result: Record = { error: 0, warning: 0, info: 0 }; + + for (const insight of insights) { + result[insight.type]++; + } + + return result; + }), + distinctUntilChanged(isEqual), + tap(val => console.log('filteredInsightCountsByType$ changed (LynxInsightStateService)', val)), + shareReplay(1) + ); + + private readonly insightPanelVisibleSource$ = new BehaviorSubject(false); + readonly insightPanelVisible$ = this.insightPanelVisibleSource$.pipe(distinctUntilChanged()); + + private readonly displayStateSource$ = new BehaviorSubject({ + activeInsightIds: [], + cursorActiveInsightIds: [] + }); + readonly displayState$: Observable = this.displayStateSource$.pipe( + distinctUntilChanged(isEqual), + shareReplay(1), + tap(displayState => console.log('displayStateSource$ changed (LynxInsightStateService)', displayState)) + ); + + constructor( + @Inject(EDITOR_INSIGHT_DEFAULTS) private defaults: LynxInsightConfig, + private readonly insightFilterService: LynxInsightFilterService, + private readonly activatedBookChapter: ActivatedBookChapterService, + private readonly activatedProjectUserConfig: ActivatedProjectUserConfigService + ) { + this.init(); + } + + getInsight(id: string): LynxInsight | undefined { + return this.rawInsightSource$.value.find(i => i.id === id); + } + + addInsight(insight: LynxInsight): void { + this.rawInsightSource$.next([...this.rawInsightSource$.value, insight]); + } + + updateInsight(newValue: LynxInsight): void { + this.rawInsightSource$.next( + this.rawInsightSource$.value.map(i => (i.id === newValue.id ? { ...i, ...newValue } : i)) + ); + } + + setActiveInsights(ids: string[]): void { + this.displayStateSource$.next({ ...this.displayStateSource$.value, activeInsightIds: ids }); + } + + /** + * Updates the display state with the given values. + * @param displayStateChanges The changes to apply to the display state. + */ + updateDisplayState(displayStateChanges: Partial): void { + this.displayStateSource$.next({ ...this.displayStateSource$.value, ...displayStateChanges }); + } + + /** + * Toggle the listed bool display state props. + */ + toggleDisplayState(props: BooleanProp[]): void { + const displayStateChanges: Partial = {}; + + for (const prop of props) { + if (prop != null) { + const currentVal = this.displayStateSource$.value[prop]; + + if (typeof currentVal === 'boolean') { + displayStateChanges[prop] = !currentVal; + } + } + } + + this.updateDisplayState(displayStateChanges); + } + + clearDisplayState(): void { + this.displayStateSource$.next({ activeInsightIds: [], cursorActiveInsightIds: [] }); + } + + togglePanelVisibility(): void { + this.insightPanelVisibleSource$.next(!this.insightPanelVisibleSource$.value); + } + + dismissInsights(ids: string[]): void { + // Ensure no duplicates + const dismissedIds = new Set(this.dismissedInsightIdsSource$.value); + ids.forEach(id => dismissedIds.add(id)); + this.dismissedInsightIdsSource$.next(Array.from(dismissedIds)); + } + + restoreDismissedInsights(ids: string[]): void { + const dismissedIds = new Set(this.dismissedInsightIdsSource$.value); + ids.forEach(id => dismissedIds.delete(id)); + this.dismissedInsightIdsSource$.next(Array.from(dismissedIds)); + } + + updateFilter(filter: Partial): void { + this.filterSource$.next({ ...this.filterSource$.value, ...filter }); + } + + updateSort(sortOrder: LynxInsightSortOrder): void { + this.orderBySource$.next(sortOrder); + } + + /** + * Toggles the type in filter types. + * @param insightType The type to toggle. + */ + toggleFilterType(insightType: LynxInsightType): void { + const types = this.filterSource$.value.types; + const updatedTypes = types.includes(insightType) ? types.filter(t => t !== insightType) : [...types, insightType]; + + this.filterSource$.next({ ...this.filterSource$.value, types: updatedTypes }); + } + + /** + * Toggles whether or not dismissed insights are included in the filter. + */ + toggleFilterDismissed(): void { + const includeDismissed: boolean = !this.filterSource$.value.includeDismissed; + this.filterSource$.next({ + ...this.filterSource$.value, + includeDismissed + }); + } + + private init(): void { + const stateLoaded$ = new BehaviorSubject(false); + + // Load stored state from project user config + this.activatedProjectUserConfig.projectUserConfig$.pipe(filterNullish(), take(1)).subscribe(puc => { + const persistedUserState: LynxInsightUserData | undefined = puc?.lynxInsightState; + + if (persistedUserState?.panelData != null) { + this.filterSource$.next(persistedUserState.panelData.filter); + this.orderBySource$.next(persistedUserState.panelData.sortOrder); + this.insightPanelVisibleSource$.next(persistedUserState.panelData.isOpen); + } + + if (persistedUserState?.dismissedInsightIds != null) { + this.dismissedInsightIdsSource$.next(persistedUserState.dismissedInsightIds); + } + + // Notify to start persisting changes to user state data + stateLoaded$.next(true); + }); + + // Save state to project user config + combineLatest([ + this.filter$, + this.orderBy$, + this.insightPanelVisible$, + this.dismissedInsightIds$, + stateLoaded$.pipe(filter(loaded => loaded)) + ]) + .pipe(withLatestFrom(this.activatedProjectUserConfig.projectUserConfigDoc$)) + .subscribe(([[filter, sortOrder, isOpen, dismissedInsightIds], pucDoc]) => { + pucDoc?.submitJson0Op(op => + op.set(puc => puc.lynxInsightState, { + panelData: { + isOpen, + filter, + sortOrder + }, + dismissedInsightIds + }) + ); + }); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html new file mode 100644 index 00000000000..943b4a67173 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html @@ -0,0 +1,11 @@ +@for (insight of insightCountsByType$ | async; track insight) { +
+ + {{ insight.count }} +
+} @empty { + + @if (isFilterHidingInsights$ | async) { + visibility_off + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.scss new file mode 100644 index 00000000000..d2fd9f06188 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.scss @@ -0,0 +1,66 @@ +@use '../lynx-insights' as insights; + +:host { + position: absolute; + z-index: 1; + inset-inline-end: var(--lynx-insights-status-indicator-offset); + top: 0; + display: flex; + gap: 0.5em; + padding: 0.5em; + background-color: var(--lynx-insights-status-indicator-bg-color); + border-radius: 0 0 0.4em 0.4em; + box-shadow: 0 0 5px 0px #ccc; + cursor: pointer; + + &:hover { + box-shadow: 0 0 10px 0px #bbb; + } + + &:active { + box-shadow: 0 0 5px 0px #bbb; + } +} + +mat-icon { + width: 0.9em; + height: 1em; + + &.check-icon { + width: 1.2em; + height: 1.2em; + } + + &.hidden-indicator-icon { + width: auto; + height: auto; + font-size: 0.7em; + position: absolute; + bottom: 0.3em; + inset-inline-end: 0.3em; + color: #a4a4a4; + } +} + +.type-count { + display: flex; + align-items: center; + gap: 0.1em; + + span { + font-size: 0.75em; + font-weight: 500; + border-radius: 50%; + width: 1.5em; + height: 1.5em; + display: flex; + justify-content: center; + align-items: center; + + @each $type in insights.$insightTypes { + &.#{$type} { + background-color: insights.insight-bg-color($type); + } + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.spec.ts new file mode 100644 index 00000000000..06dbabcc3c6 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditorInsightStatusIndicatorComponent } from './editor-insight-status-indicator.component'; + +describe('EditorInsightStatusIndicatorComponent', () => { + let component: EditorInsightStatusIndicatorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditorInsightStatusIndicatorComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(EditorInsightStatusIndicatorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.ts new file mode 100644 index 00000000000..daac2060e1e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.ts @@ -0,0 +1,36 @@ +import { Component, HostListener } from '@angular/core'; +import { LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { Observable, map } from 'rxjs'; +import { LynxInsightStateService } from '../lynx-insight-state.service'; + +interface InsightCount { + type: LynxInsightType; + count: number; +} + +@Component({ + selector: 'app-lynx-insight-status-indicator', + templateUrl: './lynx-insight-status-indicator.component.html', + styleUrl: './lynx-insight-status-indicator.component.scss' +}) +export class LynxInsightStatusIndicatorComponent { + isFilterHidingInsights$: Observable = this.editorInsightState.filter$.pipe( + map(filter => filter.types.length === 0) + ); + + private insightTypeOrder: LynxInsightType[] = ['info', 'warning', 'error']; + readonly insightCountsByType$: Observable = this.editorInsightState.filteredInsightCountsByType$.pipe( + map(counts => + this.insightTypeOrder + .filter(insightType => counts[insightType] > 0) + .map(insightType => ({ type: insightType, count: counts[insightType] })) + ) + ); + + constructor(private readonly editorInsightState: LynxInsightStateService) {} + + @HostListener('click') + onClick(): void { + this.editorInsightState.togglePanelVisibility(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts new file mode 100644 index 00000000000..03d3f219ce4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditorInsightUserEventService } from './editor-insight-user-event.service'; + +describe('EditorInsightUserEventService', () => { + let service: EditorInsightUserEventService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EditorInsightUserEventService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts new file mode 100644 index 00000000000..6e7fd0b0650 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts @@ -0,0 +1,106 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; +import { LynxInsightDisplayState } from './lynx-insight'; +import { LynxInsightStateService } from './lynx-insight-state.service'; +import { LynxInsightBlot } from './quill-services/blots/lynx-insight-blot'; + +type EventType = 'click' | 'mouseover'; + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightUserEventService { + readonly insightSelector = `.${LynxInsightBlot.superClassName}`; + readonly overlaySelector = '.lynx-insight-overlay-panel'; + + private readonly dataIdProp = LynxInsightBlot.idDatasetPropName; + + constructor( + private readonly insightState: LynxInsightStateService, + @Inject(DOCUMENT) private readonly document: Document + ) { + console.log('LynxInsightUserEventService initialized'); + this.addEventListeners(); + } + + private addEventListeners(): void { + this.addEventListener('click'); + this.addEventListener('mouseover'); + } + + private addEventListener(eventType: EventType): void { + this.document.addEventListener(eventType, this.handleEvent.bind(this, eventType)); + } + + private handleEvent(eventType: EventType, event: MouseEvent): void { + const target = event.target as HTMLElement; + + switch (eventType) { + case 'click': + this.handleClick(target); + break; + case 'mouseover': + this.handleMouseOver(target); + break; + } + } + + private handleClick(target: HTMLElement): void { + console.log('Click', target); + const ids: string[] = this.getInsightIds(target); + + if (ids.length === 0) { + // Non- 'insights panel' clicks that are not in action overlay should clear display state + // unless action 'fixes' menu is open (indicated by '.cdk-overlay-backdrop'). + if (target?.closest(`${this.overlaySelector}, .cdk-overlay-backdrop`) == null) { + this.insightState.clearDisplayState(); + } + return; + } + + let displayStateChanges: Partial = { + activeInsightIds: ids, + promptActive: true, + actionOverlayActive: false + }; + + this.insightState.updateDisplayState(displayStateChanges); + } + + private handleMouseOver(target: HTMLElement): void { + // Clear any 'hover-insight' classes if the target is not an insight element + if (!target.matches('.' + LynxInsightBlot.superClassName)) { + this.insightState.updateDisplayState({ cursorActiveInsightIds: [] }); + return; + } + + console.log('MouseOver', target); + const ids: string[] = this.getInsightIds(target); + + // Set 'hover-insight' class on the affected insight elements (clear others) + this.insightState.updateDisplayState({ cursorActiveInsightIds: ids }); + } + + /** + * Get all insight ids from the element and its parents that match the lynx insight selector. + */ + private getInsightIds(el: HTMLElement): string[] { + const ids: string[] = []; + + if (el.matches(this.insightSelector)) { + let currentEl: HTMLElement | null | undefined = el; + + while (currentEl != null) { + const id = currentEl.dataset[this.dataIdProp]; + + if (id != null) { + ids.push(id); + } + + currentEl = currentEl.parentElement?.closest(this.insightSelector); + } + } + + return ids; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-util.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-util.ts new file mode 100644 index 00000000000..34b2a78dda0 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-util.ts @@ -0,0 +1,15 @@ +import { LynxInsight } from './lynx-insight'; + +/** + * Gets the insight that has the earliest range start. + */ +export function getLeadingInsight(insights: LynxInsight[]): LynxInsight | undefined { + return insights.sort((a, b) => a.range.index - b.range.index)[0]; +} + +/** + * Gets the insight that has the earliest range end. This is the insight that the action prompt with be anchored to. + */ +export function getMostNestedInsight(insights: LynxInsight[]): LynxInsight | undefined { + return insights.sort((a, b) => a.range.index + a.range.length - (b.range.index + b.range.length))[0]; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts new file mode 100644 index 00000000000..7f6f1e11e5f --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts @@ -0,0 +1,55 @@ +import { InjectionToken } from '@angular/core'; +import { + LynxInsightFilter, + LynxInsightSortOrder, + LynxInsightType +} from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; + +export interface LynxInsightRange { + index: number; + length: number; +} + +// Interface whose props are all boolean or undefined +export interface LynxInsightDisplayState { + /** State on click. */ + activeInsightIds: string[]; + promptActive?: boolean; + actionOverlayActive?: boolean; + /** State on hover or keyboard caret over. */ + cursorActiveInsightIds: string[]; +} + +// TODO: include something like TextDocId? +export interface LynxInsight { + id: string; + type: LynxInsightType; + chapter: number; + book: number; + range: LynxInsightRange; + code: string; +} + +export interface LynxInsightNode { + code: string; + children?: LynxInsight[]; +} + +export interface LynxInsightConfig { + filter: LynxInsightFilter; + sortOrder: LynxInsightSortOrder; + queryParamName: string; + panelLinkTextMaxLength: number; + actionOverlayApplyPrimaryActionChord: Partial; +} + +export const EDITOR_INSIGHT_DEFAULTS = new InjectionToken('EDITOR_INSIGHT_DEFAULTS', { + providedIn: 'root', + factory: () => ({ + filter: { types: ['info', 'warning', 'error'], scope: 'chapter' }, + sortOrder: 'severity', + queryParamName: 'insight', + panelLinkTextMaxLength: 30, + actionOverlayApplyPrimaryActionChord: { altKey: true, shiftKey: true, key: 'Enter' } + }) +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html new file mode 100644 index 00000000000..3e793319197 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html @@ -0,0 +1,55 @@ + +
+ {{ t("insights") }} + +
+ + @for (scope of scopes; track scope) { + + + + {{ t("scope_" + scope) }} + {{ scopeCounts?.[scope] }} + + + + } + + + + +
+

{{ t("filter_menu_filter_header") }}

+ @for (insightType of insightTypes; track insightType) { + + } + + +

{{ t("filter_menu_sort_header") }}

+ @for (insightOrder of insightOrders; track insightOrder) { + + } +
+
+
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.scss new file mode 100644 index 00000000000..22b905371be --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.scss @@ -0,0 +1,107 @@ +:host { + display: flex; + align-items: center; + background-color: #e0e0e0; + gap: 2em; + padding: 0 0.5em; + + &::ng-deep { + .mdc-tab { + padding: 0 1em; + min-width: auto; + } + + // TODO: make responsive (tabs header is cut off on small screens) + .mat-mdc-tab-header { + --mdc-secondary-navigation-tab-container-height: 2.2em; + --mat-tab-header-label-text-size: 0.9em; + --mat-tab-header-label-text-weight: 400; + --mat-tab-header-active-label-text-color: var(--mat-app-text-color); + --mat-tab-header-inactive-label-text-color: var(--mat-app-text-color); + --mdc-tab-indicator-active-indicator-height: 3px; + --mdc-tab-indicator-active-indicator-color: #a3a3a3; + text-transform: capitalize; + border-bottom: 0; + } + + .mat-mdc-tab-body-wrapper { + height: 0; // Prevent height glitch while animating tab change + } + } +} + +.title { + display: flex; + align-items: center; + gap: 0.5em; + font-size: 0.9em; + font-weight: 700; + + button { + min-width: 0; + height: auto; + padding: 0.3em; + } + + mat-icon { + width: auto; + height: auto; + font-size: 1.2em; + margin: 0; + + &.menu-arrow { + position: absolute; + right: -0.3em; + bottom: -0.2em; + transform: rotate(45deg); + } + } +} + +.tab-header-label { + display: flex; + align-items: baseline; + gap: 0.8em; +} + +.scope-count { + color: #707070; + font-size: 0.85em; +} + +// Filter menu + +h2 { + color: #a3a3a3; + margin: 0; + font-size: 0.8em; + font-weight: 400; + padding: 0 var(--mat-menu-item-with-icon-leading-spacing) 0.2em; + + &:not(:first-of-type) { + margin-top: 0.5em; + } +} + +.mat-mdc-menu-item { + min-height: auto; + --mat-menu-item-icon-size: auto; + --mat-menu-item-spacing: 0.5em; + --mat-menu-item-label-text-size: 0.9em; + padding-block: 0.1em; + + &:not(.selected) { + mat-icon { + visibility: hidden; + } + } + + mat-icon { + font-size: 0.9em; + font-weight: 700; + } +} + +.mat-divider { + margin: 0.4em 0; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.spec.ts new file mode 100644 index 00000000000..48d1af94de8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditorInsightsPanelHeaderComponent } from './editor-insights-panel-header.component'; + +describe('EditorInsightsPanelHeaderComponent', () => { + let component: EditorInsightsPanelHeaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditorInsightsPanelHeaderComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(EditorInsightsPanelHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.ts new file mode 100644 index 00000000000..1aeaf584b1b --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.ts @@ -0,0 +1,73 @@ +import { Component, DestroyRef, OnInit, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { + LynxInsightFilter, + LynxInsightFilterScope, + LynxInsightFilterScopes, + LynxInsightSortOrder, + LynxInsightSortOrders, + LynxInsightType, + LynxInsightTypes +} from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { I18nService } from 'xforge-common/i18n.service'; +import { LynxInsightStateService } from '../../lynx-insight-state.service'; + +@Component({ + selector: 'app-lynx-insights-panel-header', + templateUrl: './lynx-insights-panel-header.component.html', + styleUrl: './lynx-insights-panel-header.component.scss' +}) +export class LynxInsightsPanelHeaderComponent implements OnInit { + @ViewChild(MatMenuTrigger) menuTrigger?: MatMenuTrigger; + + readonly scopes: LynxInsightFilterScope[] = [...LynxInsightFilterScopes].reverse(); // Order narrowest first + readonly insightTypes: LynxInsightType[] = [...LynxInsightTypes]; + readonly insightOrders: LynxInsightSortOrder[] = [...LynxInsightSortOrders]; + + selectedScopeIndex: number = 0; + filter?: LynxInsightFilter; + orderBy?: LynxInsightSortOrder; + scopeCounts?: Record; + + constructor( + private readonly destroyRef: DestroyRef, + private readonly i18n: I18nService, + readonly state: LynxInsightStateService + ) {} + + ngOnInit(): void { + this.state.filter$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(filter => { + this.filter = filter; + this.selectedScopeIndex = this.scopes.indexOf(filter.scope); + }); + + this.state.orderBy$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(orderBy => { + this.orderBy = orderBy; + }); + + this.state.filteredInsightCountsByScope$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(counts => (this.scopeCounts = counts)); + } + + setScopeIndex(index: number): void { + this.state.updateFilter({ scope: this.scopes[index] }); + } + + setOrder(orderBy: LynxInsightSortOrder): void { + this.state.updateSort(orderBy); + + // Event bubbling is stopped in the template to prevent clicks other than 'order by' from closing the menu, + // so close the menu manually here. + this.menuTrigger?.closeMenu(); + } + + toggleFilterType(insightType: LynxInsightType): void { + this.state.toggleFilterType(insightType); + } + + toggleFilterDismissed(): void { + this.state.toggleFilterDismissed(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html new file mode 100644 index 00000000000..dde12d626b8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss new file mode 100644 index 00000000000..116f14c23b7 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss @@ -0,0 +1,134 @@ +@use 'src/variables' as vars; +@use '../lynx-insights' as insights; + +$indentPadding: 2.5rem; // Per level indentation +$restoreActionColor: #5485e7; + +:host { + display: flex; + flex-direction: column; + height: 100%; +} + +.mat-tree { + height: 100%; + overflow: auto; +} + +.mat-tree-node { + &:hover { + background-color: #eee; + } +} + +.tree-toggle { + display: flex; + align-items: center; + padding: 0; + height: 1.5rem; + width: 100%; + border: 0; + background-color: transparent; + cursor: pointer; + font-family: inherit; +} + +.tree-toggle-icon { + width: auto; + height: auto; + font-size: 1.2rem; + flex-shrink: 0; +} + +// Increase indentation for deeper levels +@for $i from 0 through 1 { + .level-#{$i} .tree-toggle { + padding-inline-start: $indentPadding * $i; + } +} + +// Link to insight in editor +.tree-toggle.leaf { + &:hover { + color: vars.$blueMedium; + } + + .restore-icon { + display: none; + } + + &.dismissed { + color: #999; + font-style: italic; + + .restore-icon { + display: flex; + } + } +} + +.level-ref { + display: flex; + align-items: center; + gap: 1rem; +} + +.level-code { + display: flex; + align-items: center; + gap: 0.5rem; + text-transform: capitalize; // TODO: use i18n instead + overflow: hidden; + padding-inline-end: 0.5rem; + + mat-icon { + width: 0.9rem; + flex-shrink: 0; + + @each $type in insights.$insightTypes { + &.#{$type} { + color: insights.insight-color($type); + } + } + + &.dismissed { + color: #999; + } + } + + .text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .code { + color: #999; + font-family: monospace; + font-size: 0.7rem; + flex-shrink: 0; + } + + .count { + color: #7a7a7a; + background-color: #f4f4f4; + flex-shrink: 0; + border-radius: 50%; + padding: 0.4rem; + font-size: 0.6rem; + width: 1.3rem; + height: 1.3rem; + display: flex; + justify-content: center; + align-items: center; + } +} + +.restore-icon { + color: $restoreActionColor; + font-size: 1.2rem; + cursor: pointer; + width: auto; + display: flex; + align-items: center; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts new file mode 100644 index 00000000000..c9f5afd838c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditorInsightsPanelComponent } from './editor-insights-panel.component'; + +describe('EditorInsightsPanelComponent', () => { + let component: EditorInsightsPanelComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditorInsightsPanelComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(EditorInsightsPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts new file mode 100644 index 00000000000..e814df852c9 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts @@ -0,0 +1,370 @@ +import { FlatTreeControl } from '@angular/cdk/tree'; +import { Component, DestroyRef, Inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; +import { Router } from '@angular/router'; +import { Canon, VerseRef } from '@sillsdev/scripture'; +import { groupBy } from 'lodash-es'; +import { Delta, Range } from 'quill'; +import { + LynxInsightSortOrder, + LynxInsightType, + LynxInsightTypes +} from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { combineLatest, map, switchMap, tap } from 'rxjs'; +import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { TextDoc, TextDocId } from '../../../../../core/models/text-doc'; +import { SFProjectService } from '../../../../../core/sf-project.service'; +import { getText, rangeComparer } from '../../../../../shared/text/quill-util'; +import { combineVerseRefStrs, getVerseRefFromSegmentRef } from '../../../../../shared/utils'; +import { EditorSegmentService } from '../base-services/editor-segment.service'; +import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightConfig, LynxInsightRange } from '../lynx-insight'; +import { LynxInsightStateService } from '../lynx-insight-state.service'; + +interface InsightPanelNode { + name: string; + type: LynxInsightType; + children?: InsightPanelNode[]; + insight?: LynxInsight; + range: Range; + count?: number; + isDismissed?: boolean; +} + +interface InsightPanelFlatNode { + expandable: boolean; + name: string; + type: string; + level: number; + insight?: LynxInsight; + count?: number; + isDismissed?: boolean; +} + +interface LynxInsightWithText extends LynxInsight { + rangeText: string; +} + +@Component({ + selector: 'app-lynx-insights-panel', + templateUrl: './lynx-insights-panel.component.html', + styleUrl: './lynx-insights-panel.component.scss' +}) +export class LynxInsightsPanelComponent implements OnInit { + // @Output() insightSelect = new EventEmitter(); + + treeControl = new FlatTreeControl( + node => node.level, + node => node.expandable + ); + + dataSource = new MatTreeFlatDataSource( + this.treeControl, + new MatTreeFlattener( + this.flattenTransformer, + node => node.level, + node => node.expandable, + node => node.children + ) + ); + + // Preserve expand/collapse state when tree reloads due to insights$ update: code -> expanded + expandCollapseState = new Map(); + + orderBy?: LynxInsightSortOrder; + activeBookChapter?: RouteBookChapter; + + /** Map of TextDocId string -> (Map of segment ref -> segment range) */ + private textDocSegments = new Map>(); + + constructor( + private readonly destroyRef: DestroyRef, + private readonly editorInsightState: LynxInsightStateService, + private readonly activatedProject: ActivatedProjectService, + private readonly activatedBookChapterService: ActivatedBookChapterService, + private readonly editorSegmentService: EditorSegmentService, + private readonly projectService: SFProjectService, + private readonly router: Router, + readonly i18n: I18nService, + @Inject(EDITOR_INSIGHT_DEFAULTS) private readonly lynxInsightConfig: LynxInsightConfig + ) {} + + ngOnInit(): void { + combineLatest([ + this.editorInsightState.filteredInsights$.pipe(switchMap(insights => this.addRangeText(insights))), + this.editorInsightState.orderBy$.pipe(tap(val => (this.orderBy = val))), + this.editorInsightState.dismissedInsightIds$ + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + map(([insights, orderBy, dismissedIds]) => this.flattenGrouping(insights, orderBy, dismissedIds)) + ) + .subscribe(flattenedInsightNodes => { + this.dataSource.data = flattenedInsightNodes; + this.restoreExpandCollapseState(); + }); + + this.activatedBookChapterService.activatedBookChapter$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(bookChapter => { + this.activeBookChapter = bookChapter; + }); + } + + hasChild(index: number, node: InsightPanelFlatNode): boolean { + return node.expandable; + } + + onNodeClick(node: InsightPanelFlatNode, event: MouseEvent): void { + if (node.expandable) { + // Store expand/collapse state + this.expandCollapseState.set(node.name, this.treeControl.isExpanded(node)); + } else if (node.insight != null) { + // Stop bubble to user event service, which will clear display state + event.stopPropagation(); + + const insight: LynxInsight = node.insight; + + // Show action menu overlay in editor + this.navInsight(insight).then(() => { + this.editorInsightState.updateDisplayState({ + activeInsightIds: [insight.id], + promptActive: false, + actionOverlayActive: true + }); + }); + } + } + + restoreDismissedInsight(insight: LynxInsight): void { + console.log('Restore', insight.id); + this.editorInsightState.restoreDismissedInsights([insight.id]); + } + + /** + * Transforms InsightPanelNode to InsightPanelFlatNode. + */ + private flattenTransformer(node: InsightPanelNode, level: number): InsightPanelFlatNode { + return { + expandable: !!node.children && node.children.length > 0, + name: node.name, + type: node.type, + level: level, + insight: node.insight, + count: node.count, + isDismissed: node.isDismissed + }; + } + + // TODO: move to service? + /** + * Groups insights by code and flattens them into InsightPanelNode. + */ + private flattenGrouping( + insights: LynxInsightWithText[], + orderBy: LynxInsightSortOrder, + dismissedIds: string[] + ): InsightPanelNode[] { + const flattenedInsightNodes: InsightPanelNode[] = []; + const dismissedIdSet: Set = new Set(dismissedIds); + + for (const [code, byCode] of Object.entries(groupBy(insights, 'code'))) { + let codeNodeContainsAllDismissed = true; + + const children: InsightPanelNode[] = byCode.map(insight => { + const isDismissed: boolean = dismissedIdSet.has(insight.id); + if (!isDismissed) { + codeNodeContainsAllDismissed = false; + } + + return { + name: this.getLinkText(insight), + type: insight.type, + insight, + range: insight.range, + isDismissed + }; + }); + + const codeNode: InsightPanelNode = { + name: code, + type: byCode[0].type, + children, + count: byCode.length, + range: byCode[0].range, + isDismissed: codeNodeContainsAllDismissed + }; + + flattenedInsightNodes.push(codeNode); + } + + this.sortNodes(flattenedInsightNodes, orderBy); + + return flattenedInsightNodes; + } + + private restoreExpandCollapseState(): void { + if (this.expandCollapseState.size === 0) { + return; + } + + for (const node of this.treeControl.dataNodes) { + if (node.level === 0 && this.expandCollapseState.has(node.name)) { + this.treeControl.expand(node); + } else { + this.treeControl.collapse(node); + } + } + } + + private sortNodes(nodes: InsightPanelNode[], orderBy: LynxInsightSortOrder): InsightPanelNode[] { + switch (orderBy) { + case 'severity': + // Assume types are ordered from least to most severe + return nodes.sort((a, b) => LynxInsightTypes.indexOf(b.type) - LynxInsightTypes.indexOf(a.type)); + case 'appearance': + return nodes.sort(rangeComparer); + default: + return nodes; + } + } + + private async navInsight(insight: LynxInsight): Promise { + if (this.activeBookChapter?.bookId == null || this.activeBookChapter?.chapter == null) { + return false; + } + + const activeBookNum: number = Canon.bookIdToNumber(this.activeBookChapter.bookId); + + if (insight.book !== activeBookNum || insight.chapter !== this.activeBookChapter.chapter) { + const insightBookId: string = Canon.bookNumberToId(insight.book); + + // Navigate to book/chapter with insight id as query params + await this.router.navigate( + ['/projects', this.activatedProject.projectId, 'translate', insightBookId, insight.chapter], + { + queryParams: { [this.lynxInsightConfig.queryParamName]: insight.id } + } + ); + + return true; + } + + return false; + } + + /** + * Adds text to insights according to their ranges. + */ + private addRangeText(insights: LynxInsight[]): Promise { + const textDocMap = new Map>(); + const insightWithSamples: Promise[] = []; + + if (this.activatedProject.projectId != null) { + for (const insight of insights) { + const textDocId = new TextDocId(this.activatedProject.projectId!, insight.book, insight.chapter); + const textDocIdStr: string = textDocId.toString(); + + if (!textDocMap.has(textDocIdStr)) { + textDocMap.set( + textDocIdStr, + this.projectService.getText(textDocId).then(textDoc => { + // Update segment map for text doc + this.textDocSegments.set( + textDocIdStr, + textDoc.data?.ops != null ? this.editorSegmentService.parseSegments(textDoc.data.ops) : new Map() + ); + + return textDoc; + }) + ); + } + + insightWithSamples.push( + textDocMap.get(textDocIdStr)!.then(textDoc => { + return { + ...insight, + rangeText: getText(new Delta(textDoc.data?.ops), insight.range) + }; + }) + ); + } + } + + return Promise.all(insightWithSamples); + } + + /** + * Get the link text for an insight. The format is as follows: + * - If the range is within a single verse, the link text is the verse reference followed by a text sample. + * - If the range spans multiple verses, the link text is a list of verse references followed by a text sample. + * - Non-verse segments are included as [segment_ref] in the place of verse references. + */ + private getLinkText(insight: LynxInsightWithText): string { + let textDocIdStr: string = ''; + + if (this.activatedProject.projectId != null) { + const textDocId = new TextDocId(this.activatedProject.projectId, insight.book, insight.chapter); + textDocIdStr = textDocId.toString(); + } + + const editorSegments = this.textDocSegments.get(textDocIdStr); + + if (editorSegments == null) { + return '...'; // TODO: better default text + } + + const linkItems: string[] = []; + const segmentRefs: string[] = this.editorSegmentService.getSegmentRefs(insight.range, editorSegments); + let combinedVerseRef: VerseRef | undefined; + + for (const segmentRef of segmentRefs) { + const verseRef: VerseRef | undefined = getVerseRefFromSegmentRef(insight.book, segmentRef); + + if (verseRef != null) { + if (combinedVerseRef != null) { + combinedVerseRef = combineVerseRefStrs(combinedVerseRef.toString(), verseRef.toString()); + } else { + combinedVerseRef = verseRef; + } + } else { + if (combinedVerseRef != null) { + linkItems.push( + `${this.i18n.localizeReference(combinedVerseRef)} ${this.getTextSample(insight, combinedVerseRef.toString(), false)}` + ); + combinedVerseRef = undefined; + } + + const bookChapter: string = this.i18n.localizeBookChapter(insight.book, insight.chapter); + + linkItems.push(`${bookChapter}:${this.getTextSample(insight, segmentRef, true)}`); + } + } + + if (combinedVerseRef != null) { + linkItems.push( + `${this.i18n.localizeReference(combinedVerseRef)} ${this.getTextSample(insight, combinedVerseRef.toString(), false)}` + ); + } + + return linkItems.join(', '); + } + + /** + * Get a window of text from the segmentRef that contains insight range. + */ + private getTextSample(insight: LynxInsightWithText, segmentRef: string, includeRef: boolean): string { + if (insight.rangeText == null) { + return segmentRef; + } + + const maxLength: number = this.lynxInsightConfig.panelLinkTextMaxLength; + const optionalRefStr: string = includeRef ? `[${segmentRef}] ` : ''; + const text: string = insight.rangeText.substring(0, maxLength); + + // TODO: should '...' be dependent on verse boundaries? + return `${optionalRefStr}— "...${text}..."`; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts new file mode 100644 index 00000000000..7beb06bc17c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts @@ -0,0 +1,86 @@ +import { BidiModule } from '@angular/cdk/bidi'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatTreeModule } from '@angular/material/tree'; +import { TranslocoModule } from '@ngneat/transloco'; +import { IncludesPipe } from 'xforge-common/includes.pipe'; +import { QuillFormatRegistryService } from '../../../../shared/text/quill-editor-registration/quill-format-registry.service'; +import { EditorReadyService } from './base-services/editor-ready.service'; +import { EditorSegmentService } from './base-services/editor-segment.service'; +import { InsightRenderService } from './base-services/insight-render.service'; +import { InsightCodePipe } from './insight-code.pipe'; +import { LynxInsightActionPromptComponent } from './lynx-insight-action-prompt/lynx-insight-action-prompt.component'; +import { LynxInsightEditorObjectsComponent } from './lynx-insight-editor-objects/lynx-insight-editor-objects.component'; +import { LynxInsightOverlayComponent } from './lynx-insight-overlay/lynx-insight-overlay.component'; +import { LynxInsightScrollPositionIndicatorComponent } from './lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component'; +import { LynxInsightStatusIndicatorComponent } from './lynx-insight-status-indicator/lynx-insight-status-indicator.component'; +import { LynxInsightUserEventService } from './lynx-insight-user-event.service'; +import { LynxInsightsPanelHeaderComponent } from './lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component'; +import { LynxInsightsPanelComponent } from './lynx-insights-panel/lynx-insights-panel.component'; +import { lynxInsightBlots } from './quill-services/blots/lynx-insight-blot'; +import { QuillEditorReadyService } from './quill-services/quill-editor-ready.service'; +import { QuillEditorSegmentService } from './quill-services/quill-editor-segment.service'; +import { QuillInsightRenderService } from './quill-services/quill-insight-render.service'; + +@NgModule({ + declarations: [ + LynxInsightActionPromptComponent, + LynxInsightEditorObjectsComponent, + LynxInsightsPanelComponent, + LynxInsightsPanelHeaderComponent, + LynxInsightOverlayComponent, + LynxInsightScrollPositionIndicatorComponent, + LynxInsightStatusIndicatorComponent, + InsightCodePipe + ], + imports: [ + CommonModule, + BidiModule, + TranslocoModule, + MatButtonModule, + MatDividerModule, + MatIconModule, + MatMenuModule, + MatRippleModule, + MatTabsModule, + MatTooltipModule, + MatTreeModule, + OverlayModule, + IncludesPipe + ], + exports: [LynxInsightEditorObjectsComponent, LynxInsightsPanelComponent, InsightCodePipe] +}) +export class LynxInsightsModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: LynxInsightsModule, + providers: [ + { + provide: APP_INITIALIZER, + useFactory: () => () => {}, + deps: [LynxInsightUserEventService], + multi: true + }, + { + provide: APP_INITIALIZER, + useFactory: (formatRegistry: QuillFormatRegistryService) => () => { + formatRegistry.registerFormats(lynxInsightBlots); + }, + deps: [QuillFormatRegistryService], + multi: true + }, + { provide: EditorReadyService, useClass: QuillEditorReadyService }, + { provide: InsightRenderService, useClass: QuillInsightRenderService }, + { provide: EditorSegmentService, useClass: QuillEditorSegmentService } + ] + }; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/blots/lynx-insight-blot.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/blots/lynx-insight-blot.ts new file mode 100644 index 00000000000..7d1f4e4ac56 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/blots/lynx-insight-blot.ts @@ -0,0 +1,63 @@ +import { kebabCase } from 'lodash-es'; +import QuillInlineBlot from 'quill/blots/inline'; +import { LynxInsight } from '../../lynx-insight'; + +export class LynxInsightBlot extends QuillInlineBlot { + static tagName = 'span'; + static idDatasetPropName = 'insightId'; + static idAttributeName = kebabCase(LynxInsightBlot.idDatasetPropName); + + /** + * This custom prop is used on the parent class instead of using 'className' + * so that it isn't registered to the wrong child blot type. + * This way 'lynx-insight' class can be added to all child insight blots in addition to the 'className' + * specified in the child blot classes. + */ + static superClassName = 'lynx-insight'; + + static create(value: LynxInsight): HTMLElement { + const node = super.create(value) as HTMLElement; + LynxInsightBlot.formatNode(node, value); + return node; + } + + static formats(node: HTMLElement): any { + return LynxInsightBlot.value(node); + } + + static value(node: HTMLElement): string | undefined { + return node.dataset[LynxInsightBlot.idDatasetPropName]; + } + + format(name: string, value?: LynxInsight): void { + if (value && name === this.statics.blotName) { + LynxInsightBlot.formatNode(this.domNode, value); + } else { + super.format(name, value); + } + } + + private static formatNode(node: HTMLElement, value: LynxInsight): void { + node.classList.add(LynxInsightBlot.superClassName); + node.dataset[LynxInsightBlot.idDatasetPropName] = value.id; + } +} + +export class LynxInsightInfoBlot extends LynxInsightBlot { + static blotName = 'lynx-insight-info'; + static className = 'info'; +} +export class LynxInsightWarningBlot extends LynxInsightBlot { + static blotName = 'lynx-insight-warning'; + static className = 'warning'; +} +export class LynxInsightErrorBlot extends LynxInsightBlot { + static blotName = 'lynx-insight-error'; + static className = 'error'; +} + +export const lynxInsightBlots: (typeof LynxInsightBlot)[] = [ + LynxInsightInfoBlot, + LynxInsightWarningBlot, + LynxInsightErrorBlot +]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.spec.ts new file mode 100644 index 00000000000..822e50ce422 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { LynxInsightBlotService } from './lynx-insight-blot.service'; + +describe('LynxInsightBlotService', () => { + let service: LynxInsightBlotService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(LynxInsightBlotService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.ts new file mode 100644 index 00000000000..ffb4c1cdb6b --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/lynx-insight-blot.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import Quill from 'quill'; +import { RegisteredFormatNames } from '../../../../../shared/text/quill-scripture'; + +@Injectable({ + providedIn: 'root' +}) +export class LynxInsightBlotService { + constructor() {} + + registerBlots(formats: { blotName: string }[]): RegisteredFormatNames { + const formatNames: string[] = []; + + for (const format of formats) { + Quill.register(`blots/${format.blotName}`, format); + formatNames.push(format.blotName); + } + + return { + formatNames, + excludeFromDataModelFormatNames: [...formatNames] + }; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.spec.ts new file mode 100644 index 00000000000..5accc0598bd --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { QuillEditorReadyService } from './quill-editor-ready.service'; + +describe('QuillEditorReadyService', () => { + let service: QuillEditorReadyService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(QuillEditorReadyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.ts new file mode 100644 index 00000000000..d91501a57dc --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-ready.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import Quill from 'quill'; +import { Observable, distinctUntilChanged, filter, fromEvent, of, shareReplay, startWith, switchMap } from 'rxjs'; +import { EditorReadyService } from '../base-services/editor-ready.service'; + +@Injectable({ + providedIn: 'root' +}) +export class QuillEditorReadyService implements EditorReadyService { + // Arbitrary event to ensure 'ready' is checked in case editor changes have already fired + private readonly initialEvent = 'initial'; + + listenEditorReadyState(editor: Quill): Observable { + return fromEvent(editor, 'editor-change').pipe( + startWith([this.initialEvent]), + filter(([event]: any) => event === 'text-change' || event === this.initialEvent), + switchMap(() => of(editor.getLength() > 1)), + distinctUntilChanged(), + shareReplay(1) + ); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.spec.ts new file mode 100644 index 00000000000..b6fd62f6b1e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { QuillEditorSegmentService } from './quill-editor-segment.service'; + +describe('QuillEditorSegmentService', () => { + let service: QuillEditorSegmentService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(QuillEditorSegmentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts new file mode 100644 index 00000000000..d36449dfc52 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { DeltaOperation } from 'rich-text'; +import { isString } from '../../../../../../type-utils'; +import { EditorSegmentService } from '../base-services/editor-segment.service'; +import { LynxInsightRange } from '../lynx-insight'; + +@Injectable({ + providedIn: 'root' +}) +export class QuillEditorSegmentService extends EditorSegmentService { + /** + * Parses ops to get a map of segment name -> segment range. + */ + parseSegments(ops: DeltaOperation[]): Map { + const segmentMap = new Map(); + let currentIndex = 0; + + for (const op of ops) { + if (isString(op.insert)) { + const length: number = op.insert.length; + const segment: string | undefined = op.attributes?.segment as string | undefined; + + if (isString(segment)) { + if (segmentMap.has(segment)) { + const existingRange = segmentMap.get(segment)!; + existingRange.length += length; + } else { + segmentMap.set(segment, { index: currentIndex, length }); + } + } + + currentIndex += length; + } + } + + return segmentMap; + } + + /** + * Get all segment references that intersect the given range. + * @param range The range to check. + * @param segments A map of segment name -> segment range. + * @returns An array of the intersecting segment refs. + */ + getSegmentRefs(range: LynxInsightRange, segments: Map): string[] { + const segmentRefs: string[] = []; + + if (range != null) { + const rangeEnd: number = range.index + range.length; + + for (const [ref, segmentRange] of segments) { + const segEnd: number = segmentRange.index + segmentRange.length; + + if (range.index < segEnd) { + if (rangeEnd > segmentRange.index) { + segmentRefs.push(ref); + } + + if (rangeEnd <= segEnd) { + break; + } + } + } + } + + return segmentRefs; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.spec.ts new file mode 100644 index 00000000000..b2576667afb --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed } from '@angular/core/testing'; +import { QuillInsightRenderService } from './quill-insight-render.service'; + +describe('QuillInsightRenderService', () => { + let service: QuillInsightRenderService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(QuillInsightRenderService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts new file mode 100644 index 00000000000..70f58fd8053 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts @@ -0,0 +1,166 @@ +import { Injectable } from '@angular/core'; +import Quill, { Delta } from 'quill'; +import { LynxInsightTypes } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { StringMap } from 'rich-text'; +import { take, takeUntil } from 'rxjs'; +import { InsightRenderService } from '../base-services/insight-render.service'; +import { LynxEditor } from '../lynx-editor'; +import { LynxInsight } from '../lynx-insight'; +import { LynxInsightOverlayRef, LynxInsightOverlayService } from '../lynx-insight-overlay.service'; +import { getLeadingInsight, getMostNestedInsight } from '../lynx-insight-util'; +import { LynxInsightBlot } from './blots/lynx-insight-blot'; + +@Injectable({ + providedIn: 'root' +}) +export class QuillInsightRenderService extends InsightRenderService { + readonly prefix = 'lynx-insight'; + readonly editorAttentionClass = `${this.prefix}-attention`; + readonly activeInsightClass = `action-overlay-active`; + readonly cursorActiveClass = `cursor-active`; + + constructor(private readonly overlayService: LynxInsightOverlayService) { + super(); + } + + /** + * Renders the insights in the editor, applying formatting, action menus, and attention (opacity overlay). + */ + render(insights: LynxInsight[], editor: Quill | undefined): void { + console.log('*** Render insights', insights); + + // Ensure text is more than just '\n' + if (editor == null || editor.getLength() <= 1) { + return; + } + + this.refreshInsightFormatting(insights, editor); + } + + /** + * Removes all lynx insight formatting from the editor. + */ + removeAllInsightFormatting(editor: Quill): void { + const formats: StringMap = {}; + + for (const type of LynxInsightTypes) { + formats[`${this.prefix}-${type}`] = false; + } + + editor.formatText(0, editor.getLength(), formats); + } + + /** + * Creates a delta with all the insights' formatting applied, and sets the editor contents to that delta. + * This avoids multiple calls to quill `formatText`, which will re-render the DOM after each call. + */ + private refreshInsightFormatting(insights: LynxInsight[], editor: Quill): void { + let delta: Delta = editor.getContents(); + const formatsToRemove: StringMap = {}; + + // Prepare formats to remove + for (const type of LynxInsightTypes) { + formatsToRemove[`${this.prefix}-${type}`] = null; + } + + // Apply removal of formats + delta = delta.compose(new Delta().retain(delta.length(), formatsToRemove)); + + // Apply formats, merging each format op with the result of the prev (let quill handle overlapping formats) + for (const insight of insights) { + const deltaToApply = new Delta().retain(insight.range.index).retain(insight.range.length, { + [`${this.prefix}-${insight.type}`]: insight + }); + + delta = delta.compose(deltaToApply); + } + + // Set contents with the combined delta + editor.setContents(delta, 'api'); + } + + renderActionOverlay(insights: LynxInsight[], editor: Quill, actionOverlayActive: boolean): void { + this.overlayService.close(); + let editorAttention = false; + + if (actionOverlayActive) { + const leadingInsight: LynxInsight | undefined = getLeadingInsight(insights); + const overlayAnchorInsight: LynxInsight | undefined = getMostNestedInsight(insights); + + if (leadingInsight != null && overlayAnchorInsight != null) { + // Scroll to the first occurring active insight in the editor + editor.setSelection(leadingInsight.range.index, 'api'); + + const overlayAnchor: HTMLElement | null = this.getInsightElements(editor, overlayAnchorInsight.id)[0]; + + if (overlayAnchor != null) { + const ref: LynxInsightOverlayRef | undefined = this.overlayService.open( + overlayAnchor, + insights, + new LynxEditor(editor) + ); + + // Clear editor attention when overlay is closed + if (ref != null) { + ref.closed$.pipe(take(1)).subscribe(() => this.setEditorAttention(false, editor)); + ref.hoverMultiInsight$ + .pipe(takeUntil(ref.closed$)) + .subscribe(insight => this.setEditorAttention(true, editor, insight != null ? [insight] : insights)); + } + + editorAttention = true; + } + } + } + + this.setEditorAttention(editorAttention, editor, insights); + } + + renderCursorActiveState(activeCursorInsightIds: string[], editor: Quill): void { + // Clear previously set classes + editor.root.querySelectorAll(`.${this.cursorActiveClass}`).forEach(element => { + element.classList.remove(this.cursorActiveClass); + }); + + for (const insightId of activeCursorInsightIds) { + for (const insightEl of this.getInsightElements(editor, insightId)) { + insightEl.classList.add(this.cursorActiveClass); + } + } + } + + private setEditorAttention(editorAttention: boolean, editor: Quill, insights?: LynxInsight[]): void { + // Set attention class on editor (dims the editor) + if (editorAttention) { + editor.root.classList.add(this.editorAttentionClass); + } else { + editor.root.classList.remove(this.editorAttentionClass); + } + + // Clear any previously set active insight classes + editor.root.querySelectorAll(`.${this.activeInsightClass}`).forEach(element => { + element.classList.remove(this.activeInsightClass); + }); + + // Set class on active insights to pull them above the editor dim overlay + if (editorAttention && insights != null) { + for (const insight of insights) { + this.addActiveInsightClass(editor, insight.id); + } + } + } + + private addActiveInsightClass(editor: Quill, insightId: string): void { + // An insight may be split across multiple elements, so apply the class to all elements with the insight id + for (const element of this.getInsightElements(editor, insightId)) { + element.classList.add(this.activeInsightClass); + } + } + + /** + * Get all elements in the editor that contain the `[data-insight-id]` of the specified insight. + */ + private getInsightElements(editor: Quill, insightId: string): NodeListOf { + return editor.root.querySelectorAll(`[data-${LynxInsightBlot.idAttributeName}="${insightId}"]`); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate.module.ts index 38a7db69d4f..f2f13f89fd9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/translate.module.ts @@ -19,6 +19,7 @@ import { HistoryChooserComponent } from './editor/editor-history/history-chooser import { HistoryRevisionFormatPipe } from './editor/editor-history/history-chooser/history-revision-format.pipe'; import { EditorResourceComponent } from './editor/editor-resource/editor-resource.component'; import { EditorComponent } from './editor/editor.component'; +import { LynxInsightsModule } from './editor/lynx/insights/lynx-insights.module'; import { MultiViewerComponent } from './editor/multi-viewer/multi-viewer.component'; import { NoteDialogComponent } from './editor/note-dialog/note-dialog.component'; import { SuggestionsSettingsDialogComponent } from './editor/suggestions-settings-dialog.component'; @@ -61,7 +62,8 @@ import { TranslateRoutingModule } from './translate-routing.module'; CopyrightBannerComponent, DraftPreviewBooksComponent, DraftApplyProgressDialogComponent, - FontUnsupportedMessageComponent + FontUnsupportedMessageComponent, + LynxInsightsModule ] }) export class TranslateModule {} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index ad2ecbe7077..a41f960300d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -464,6 +464,23 @@ "sources_in_following_languages": "Currently the source projects are in the following languages:", "sources_must_be_same_language": "The training source must be the same language as the drafting source. You can update your source texts on the [link:projectSettingsUrl]settings page[/link]" }, + "lynx_insights_panel": { + "restore": "Restore" + }, + "lynx_insights_panel_header": { + "insights": "Insights", + "filter_menu_filter_header": "Show", + "filter_menu_filter_info": "Notices", + "filter_menu_filter_warning": "Warnings", + "filter_menu_filter_error": "Errors", + "filter_menu_filter_dismissed": "Dismissed", + "filter_menu_sort_header": "Order by", + "filter_menu_sort_severity": "Severity", + "filter_menu_sort_appearance": "Appearance in text", + "scope_chapter": "Chapter", + "scope_book": "Book", + "scope_project": "Project" + }, "multi_viewer": { "other_viewers": "{{ count }} other viewers" }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/styles.scss b/src/SIL.XForge.Scripture/ClientApp/src/styles.scss index 3e10358823f..5930f2a9376 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/styles.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/styles.scss @@ -5,6 +5,8 @@ @use 'src/xforge-common/media-breakpoints/css-vars'; @use 'src/breakpoints'; +@use 'app/translate/editor/lynx/insights/lynx-insights'; + @import 'fonts'; @import 'text'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.spec.ts new file mode 100644 index 00000000000..f00fba207bf --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ActivatedBookChapterService } from './activated-book-chapter.service'; + +describe('ActivatedBookChapterService', () => { + let service: ActivatedBookChapterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ActivatedBookChapterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.ts new file mode 100644 index 00000000000..6795833c2c3 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-book-chapter.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivationEnd, Params, Router } from '@angular/router'; +import { Canon } from '@sillsdev/scripture'; +import { isEqual } from 'lodash-es'; +import { + BehaviorSubject, + Observable, + combineLatest, + distinctUntilChanged, + filter, + map, + of, + shareReplay, + switchMap, + tap +} from 'rxjs'; +import { ActivatedProjectUserConfigService } from './activated-project-user-config.service'; +import { ActivatedProjectService } from './activated-project.service'; +import { filterNullish } from './util/rxjs-util'; + +export interface RouteBookChapter { + bookId?: string; + chapter?: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class ActivatedBookChapterService { + private routeBookChapterSource$ = new BehaviorSubject(undefined); + readonly routeBookChapter$: Observable = this.routeBookChapterSource$.pipe( + distinctUntilChanged(isEqual) + ); + + readonly activatedBookChapter$: Observable = combineLatest([ + this.routeBookChapter$.pipe(filterNullish()), + this.activatedProject.changes$.pipe( + map(doc => doc?.data), + filterNullish() + ), + this.activatedProjectUserConfig.projectUserConfig$.pipe(filterNullish()) + ]).pipe( + switchMap(([{ bookId, chapter }, projectProfile, projectUserConfig]) => { + if (bookId == null) { + return of(undefined); + } + + if (chapter == null) { + chapter = projectUserConfig.selectedChapterNum; + + if (chapter == null) { + let bookNum: number = Canon.bookIdToNumber(bookId); + chapter = projectProfile.texts.find(t => t.bookNum === bookNum)?.chapters[0]?.number; + } + } + + return of({ bookId, chapter }); + }), + distinctUntilChanged(isEqual), + tap(activatedBookChapter => console.log('activatedBookChapter$', activatedBookChapter)), + shareReplay(1) + ); + + constructor( + private readonly router: Router, + private readonly activatedProject: ActivatedProjectService, + private readonly activatedProjectUserConfig: ActivatedProjectUserConfigService + ) { + this.router.events + .pipe( + takeUntilDestroyed(), + filter((event): event is ActivationEnd => event instanceof ActivationEnd) + ) + .subscribe(event => { + this.routeBookChapterSource$.next(this.getBookChapterFromParams(event.snapshot.params)); + }); + } + + private getBookChapterFromParams(params: Params): RouteBookChapter | undefined { + const bookId = params.bookId; + const chapter = params.chapter ? Number(params.chapter) : undefined; + + return bookId ? { bookId, chapter } : undefined; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts new file mode 100644 index 00000000000..09e598dc56e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ActivatedProjectUserConfigService } from './activated-project-user-config.service'; + +describe('ActivatedProjectUserConfigService', () => { + let service: ActivatedProjectUserConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ActivatedProjectUserConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts new file mode 100644 index 00000000000..368da533a2b --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/activated-project-user-config.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { SFProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config'; +import { Observable, map, of, shareReplay, startWith, switchMap } from 'rxjs'; +import { SFProjectUserConfigDoc } from '../app/core/models/sf-project-user-config-doc'; +import { SFProjectService } from '../app/core/sf-project.service'; +import { ActivatedProjectService } from './activated-project.service'; +import { UserService } from './user.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ActivatedProjectUserConfigService { + readonly projectUserConfigDoc$: Observable = + this.activatedProject.projectId$.pipe( + switchMap(projectId => + projectId != null ? this.projectService.getUserConfig(projectId, this.userService.currentUserId) : of(undefined) + ), + switchMap( + projectUserConfigDoc => + projectUserConfigDoc?.changes$.pipe( + map(() => projectUserConfigDoc), + + startWith(projectUserConfigDoc) + ) ?? of(undefined) + ), + shareReplay(1) + ); + + readonly projectUserConfig$: Observable = this.projectUserConfigDoc$.pipe( + map(doc => doc?.data) + ); + + constructor( + private readonly activatedProject: ActivatedProjectService, + private readonly projectService: SFProjectService, + private readonly userService: UserService + ) {} +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts index 5a4366c012b..9e32fada11b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts @@ -319,6 +319,13 @@ export class FeatureFlagService { this.featureFlagStore ); + readonly enableLynxInsights: ObservableFeatureFlag = new FeatureFlagFromStorage( + 'EnableLynxInsights', + 'Enable Lynx insights', + 16, + this.featureFlagStore + ); + get featureFlags(): FeatureFlag[] { return Object.values(this).filter(value => value instanceof FeatureFlagFromStorage); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index 3c74468205c..7a6f05b9cfc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -234,13 +234,18 @@ export class I18nService { return this.transloco.translate(`canon.book_names.${book}`); } + // TODO: write unit test + localizeBookChapter(book: number | string, chapter: number): string { + return `${this.localizeBook(book)} ${this.getDirectionMark()}${chapter}`; + } + localizeReference(verse: VerseRef): string { // Add RTL mark before colon and hyphen characters, if in a RTL script. // See https://software.sil.org/arabicfonts/support/faq/ for description of this solution, under the section // "How do I get correct display for “Chapter:Verse” references using a regular “Roman” colon?" // In addition to suggested solution, direction mark is added before chapter number for the case // where non-localized book names are in a rtl environment so the chapter number displays with the verse. - const directionMark = this.locale.direction === 'ltr' ? '' : '\u200F'; + const directionMark = this.getDirectionMark(); // TODO Some ranges use a comma (and possibly other characters?) as a separator const range = verse.verse.split('-').join(directionMark + '-'); return `${this.localizeBook(verse.bookNum)} ${directionMark}${verse.chapterNum}${directionMark}:${range}`; @@ -425,6 +430,10 @@ export class I18nService { return new Intl.PluralRules(this.locale.canonicalTag).select(number); } + private getDirectionMark(): string { + return this.locale.direction === 'rtl' ? '\u200F' : ''; + } + private getTranslation(key: I18nKey): string { return ( this.transloco.getTranslation(this.transloco.getActiveLang())[key] ?? diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.spec.ts new file mode 100644 index 00000000000..fa6604c46db --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.spec.ts @@ -0,0 +1,8 @@ +import { IncludesPipe } from './includes.pipe'; + +describe('IncludesPipe', () => { + it('create an instance', () => { + const pipe = new IncludesPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.ts new file mode 100644 index 00000000000..16c4a3a8875 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/includes.pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'includes', + standalone: true +}) +export class IncludesPipe implements PipeTransform { + transform(items: T[] | undefined, item: T): boolean { + return items != null && items.includes(item); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/tsconfig.json b/src/SIL.XForge.Scripture/ClientApp/tsconfig.json index 828f7df2265..26b3974b91e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/tsconfig.json +++ b/src/SIL.XForge.Scripture/ClientApp/tsconfig.json @@ -16,7 +16,7 @@ "importHelpers": true, "target": "ES2022", "module": "es2020", - "lib": ["es2019", "dom"], + "lib": ["es2019", "dom", "dom.iterable"], "suppressImplicitAnyIndexErrors": true, "ignoreDeprecations": "5.0", "esModuleInterop": true, From e020b7c5b4d4850a6840659c629df7a6ea69a8b1 Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Wed, 12 Feb 2025 18:20:23 -0500 Subject: [PATCH 02/41] Integrate Lynx library --- .../ClientApp/package-lock.json | 46 ++- .../ClientApp/package.json | 2 + .../lynx/insights/insight-code.pipe.spec.ts | 8 - .../editor/lynx/insights/insight-code.pipe.ts | 14 - .../editor/lynx/insights/lynx-editor.ts | 12 +- .../insights/lynx-insight-action.service.ts | 99 +++-- .../lynx-insight-code.service.spec.ts | 16 - .../insights/lynx-insight-code.service.ts | 14 - .../lynx/insights/lynx-insight-codes.ts | 41 -- .../insights/lynx-insight-filter.service.ts | 11 +- .../lynx-insight-overlay.component.html | 4 +- .../lynx-insight-overlay.component.ts | 36 +- .../insights/lynx-insight-state.service.ts | 369 +++--------------- .../editor/lynx/insights/lynx-insight.ts | 9 +- .../lynx-insights-panel.component.html | 2 +- .../lynx-insights-panel.component.ts | 31 +- .../lynx/insights/lynx-insights.module.ts | 17 +- .../lynx/insights/lynx-workspace.service.ts | 272 +++++++++++++ 18 files changed, 494 insertions(+), 509 deletions(-) delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/package-lock.json b/src/SIL.XForge.Scripture/ClientApp/package-lock.json index 516830f7444..7dc87bd2120 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package-lock.json +++ b/src/SIL.XForge.Scripture/ClientApp/package-lock.json @@ -26,6 +26,8 @@ "@microsoft/signalr": "^7.0.0", "@ngneat/transloco": "^4.3.0", "@ngneat/transloco-locale": "^4.1.0", + "@sillsdev/lynx": "^0.3.0", + "@sillsdev/lynx-delta": "^0.2.0", "@sillsdev/machine": "^2.4.2", "@sillsdev/scripture": "1.4.1", "angular-file": "^4.0.2", @@ -6584,6 +6586,40 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@sillsdev/lynx": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@sillsdev/lynx/-/lynx-0.3.0.tgz", + "integrity": "sha512-X+KPwKCll1R3wIvvSXD/KzD54Mr0ZCGP8Huskt19DKuC1Fh3gHwSJ+mTFXQJ4/roaVli6ets+M6ZQpBb+4PKng==", + "license": "MIT", + "dependencies": { + "i18next": "^23.16.5", + "rxjs": "^7.8.1" + } + }, + "node_modules/@sillsdev/lynx-delta": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sillsdev/lynx-delta/-/lynx-delta-0.2.0.tgz", + "integrity": "sha512-KXyvOuc0GdBR93uvS580eCX5RJqTz6PcT2H7Jc8PYsZSq0T8trvM2DVde7n5LgFTCIe8Ih+csruss1OwbELjPA==", + "license": "MIT", + "dependencies": { + "@sillsdev/lynx": "*", + "quill-delta": "^5.1.0", + "uuid": "^11.0.3" + } + }, + "node_modules/@sillsdev/lynx-delta/node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@sillsdev/machine": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@sillsdev/machine/-/machine-2.4.2.tgz", @@ -15170,10 +15206,9 @@ } }, "node_modules/i18next": { - "version": "23.16.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.0.tgz", - "integrity": "sha512-Ni3CG6c14teOogY19YNRl+kYaE/Rb59khy0VyHVn4uOZ97E2E/Yziyi6r3C3s9+wacjdLZiq/LLYyx+Cgd+FCw==", - "dev": true, + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", "funding": [ { "type": "individual", @@ -25643,8 +25678,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regenerator-transform": { "version": "0.15.2", diff --git a/src/SIL.XForge.Scripture/ClientApp/package.json b/src/SIL.XForge.Scripture/ClientApp/package.json index 4f7865a6bbb..71d9eb42b66 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package.json +++ b/src/SIL.XForge.Scripture/ClientApp/package.json @@ -50,6 +50,8 @@ "@microsoft/signalr": "^7.0.0", "@ngneat/transloco": "^4.3.0", "@ngneat/transloco-locale": "^4.1.0", + "@sillsdev/lynx": "^0.3.0", + "@sillsdev/lynx-delta": "^0.2.0", "@sillsdev/machine": "^2.4.2", "@sillsdev/scripture": "1.4.1", "angular-file": "^4.0.2", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts deleted file mode 100644 index 192603faae4..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { InsightCodePipe } from './insight-code.pipe'; - -describe('InsightCodePipe', () => { - it('create an instance', () => { - const pipe = new InsightCodePipe(); - expect(pipe).toBeTruthy(); - }); -}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts deleted file mode 100644 index 0b59ebfec23..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/insight-code.pipe.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { LynxInsightCodeService } from './lynx-insight-code.service'; -import { LynxInsightCode } from './lynx-insight-codes'; - -@Pipe({ - name: 'insightCode' -}) -export class InsightCodePipe implements PipeTransform { - constructor(private codeService: LynxInsightCodeService) {} - - transform(code: string, locale: string, prop: keyof LynxInsightCode = 'description'): string { - return this.codeService.lookupCode(code, locale)?.[prop] || code; - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts index 5532a4f9463..5ce28f8875f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts @@ -1,4 +1,4 @@ -import Quill, { EmitterSource } from 'quill'; +import Quill, { Delta, EmitterSource, Op } from 'quill'; export type LynxableEditor = Quill; // Add future editor as union type @@ -86,6 +86,16 @@ export class LynxEditor { } } + updateContents(delta: Delta | Op[]): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.updateContents(delta, 'user'); + break; + default: + throw new Error('Unsupported editor type'); + } + } + get root(): HTMLElement { switch (true) { case this.isQuill(this.editor): diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts index ca148ebff04..87da9db86ff 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@angular/core'; -import { Observable, of, take } from 'rxjs'; +import { Diagnostic, DiagnosticSeverity } from '@sillsdev/lynx'; +import { Op } from 'quill'; +import { from, Observable } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { LynxEditor } from './lynx-editor'; -import { LynxInsight, LynxInsightRange } from './lynx-insight'; +import { LynxInsight } from './lynx-insight'; +import { LynxWorkspaceService } from './lynx-workspace.service'; export interface LynxInsightAction { id: string; @@ -9,68 +13,59 @@ export interface LynxInsightAction { label: string; description?: string; isPrimary?: boolean; -} - -// TODO: this type will be in Lynx lib -export interface TextEdit { - range: LynxInsightRange; - newText: string; + ops: Op[]; } @Injectable({ providedIn: 'root' }) export class LynxInsightActionService { - constructor() {} + constructor(private readonly lynxService: LynxWorkspaceService) {} - // TODO: send locale to server along with insightId - getActions(insight: LynxInsight, localeCode: string): Observable { - return of([ - // TODO: confirm that primary action should be an additional action (to have more flexible text) - { - id: '0', - insight, - label: 'Update quotation mark', - isPrimary: true - }, - { - id: '1', - insight, - label: 'Update', - description: 'Quotation mark to " style' - }, - { - id: '2', - insight, - label: 'Update all 48', - description: 'Quotation mark inconsistencies to " style' - }, - { - id: '3', - insight, - label: 'Reject', - description: 'Suggestion not relevant' - } - ]); + getActions(insight: LynxInsight): Observable { + return from(this.getActionsFromWorkspace(insight)); } performAction(action: LynxInsightAction, editor: LynxEditor): void { console.log('Performing action', action); - - this.getFix(action.insight) - .pipe(take(1)) - .subscribe(fix => { - console.log('Fix', fix); - - editor.deleteText(fix.range.index, fix.range.length); - editor.insertText(fix.range.index, fix.newText); - }); + editor.updateContents(action.ops); } - getFix(insight: LynxInsight): Observable { - return of({ - range: insight.range, - newText: 'New text' + insight.id - }); + private async getActionsFromWorkspace(insight: LynxInsight): Promise { + const doc = await this.lynxService.documentManager.get(insight.textDocId.toString()); + if (doc == null) { + return []; + } + let severity = DiagnosticSeverity.Information; + switch (insight.type) { + case 'info': + severity = DiagnosticSeverity.Information; + break; + case 'warning': + severity = DiagnosticSeverity.Warning; + break; + case 'error': + severity = DiagnosticSeverity.Error; + break; + } + const diagnostic: Diagnostic = { + code: insight.code, + source: insight.source, + range: { + start: doc.positionAt(insight.range.index), + end: doc.positionAt(insight.range.index + insight.range.length) + }, + message: insight.description, + severity, + data: insight.data + }; + const fixes = await this.lynxService.workspace.getDiagnosticFixes(insight.textDocId.toString(), diagnostic); + return fixes.map(fix => ({ + id: uuidv4(), + insight, + label: fix.title, + isPrimary: fix.isPreferred, + ops: fix.edits + })); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts deleted file mode 100644 index e7aff158026..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { EditorInsightCodeService } from './editor-insight-code.service'; - -describe('EditorInsightCodeService', () => { - let service: EditorInsightCodeService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(EditorInsightCodeService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts deleted file mode 100644 index 5d121f4ad06..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-code.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Inject, Injectable } from '@angular/core'; -import { EDITOR_INSIGHT_CODES, LynxInsightCode } from './lynx-insight-codes'; - -@Injectable({ - providedIn: 'root' -}) -export class LynxInsightCodeService { - constructor(@Inject(EDITOR_INSIGHT_CODES) private codes: Map) {} - - // TODO: send locale to server along with code - lookupCode(code: string, localeCode: string): LynxInsightCode | undefined { - return this.codes.get(code); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts deleted file mode 100644 index 406b66d8b55..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-codes.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -export interface LynxInsightCode { - code: string; - description: string; - moreInfo?: string; // Verbose information about the insight (markdown?) -} - -const codes = new Map([ - [ - '1000', - { - code: '1000', - description: 'Inconsistently used quotation mark', - moreInfo: ` - Adhering to typographical standards and guidelines often requires using consistent quotation marks, particularly in publishing and academic writing. - Using the same style throughout maintains the author's intended tone and style, reinforcing the voice and credibility of the writing. - - [Further reading on using quotation marks](https://en.wikipedia.org/wiki/Quotation_mark) - ` - } - ], - ['1002', { code: '1002', description: 'Closing quotation mark not found' }], - ['1001', { code: '1001', description: 'Five-digit numbers are whack.' }], - ['1012', { code: '1012', description: '"Must" is a strong word' }], - ['2001', { code: '2001', description: 'Crazy parens!' }], - ['2011', { code: '2011', description: 'I warned you!' }], - ['2002', { code: '2002', description: 'No such thing as "Information".' }], - ['3001', { code: '3001', description: 'Better to ask forgiveness.' }], - ['3002', { code: '3002', description: 'Some error text.' }], - ['2005', { code: '2005', description: 'Some warning text.' }], - ['1005', { code: '1005', description: 'Some notice text.' }], - ['1006', { code: '1006', description: 'Some notice text.' }], - ['3006', { code: '3006', description: 'Some error text.' }], - ['1011', { code: '1011', description: 'Some notice text.' }] -]); - -export const EDITOR_INSIGHT_CODES = new InjectionToken>('EDITOR_INSIGHT_CODES', { - providedIn: 'root', - factory: () => codes -}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts index d0d2bd8f43d..2bd83b8426c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts @@ -32,11 +32,14 @@ export class LynxInsightFilterService { return true; } - if (filter.scope === 'book' && routeBookNum !== insight.book) { + if (filter.scope === 'book' && routeBookNum !== insight.textDocId.bookNum) { return false; } - if (filter.scope === 'chapter' && (routeBookNum !== insight.book || routeChapter !== insight.chapter)) { + if ( + filter.scope === 'chapter' && + (routeBookNum !== insight.textDocId.bookNum || routeChapter !== insight.textDocId.chapterNum) + ) { return false; } @@ -47,9 +50,9 @@ export class LynxInsightFilterService { const routeBookNum: number | undefined = bookChapter.bookId ? Canon.bookIdToNumber(bookChapter.bookId) : undefined; const routeChapter = bookChapter.chapter; - if (insight.book === routeBookNum && insight.chapter === routeChapter) { + if (insight.textDocId.bookNum === routeBookNum && insight.textDocId.chapterNum === routeChapter) { return 'chapter'; - } else if (insight.book === routeBookNum) { + } else if (insight.textDocId.bookNum === routeBookNum) { return 'book'; } else { return 'project'; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html index 130cfb6f3d4..d392e05b870 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html @@ -39,10 +39,10 @@

{{ action.label }}

} -} @else if (insightsFlattened.length > 1) { +} @else if (insights.length > 1) {

Select for details

- @for (insight of insightsFlattened; track insight) { + @for (insight of insights; track insight) {
i.id).join(', ')})`); - this.insightsFlattened = value.map(insight => this.flattenInsight(insight)); + this._insights = value; // Focus if single insight - if (value.length === 1) { - this.focusInsight(this.insightsFlattened[0]); + if (this._insights.length === 1) { + this.focusInsight(this._insights[0]); } } @@ -48,7 +45,7 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { /** Emits hovered insight when overlay displays multi-insight selection list. Emits `null` when hover ceases. */ @Output() insightHover = new EventEmitter(); - focusedInsight?: LynxInsightFlattened; + focusedInsight?: LynxInsight; menuActions: LynxInsightAction[] = []; primaryAction?: LynxInsightAction; @@ -57,10 +54,8 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { constructor( private readonly insightState: LynxInsightStateService, - private readonly codeService: LynxInsightCodeService, private readonly actionService: LynxInsightActionService, private readonly overlayService: LynxInsightOverlayService, - private readonly i18n: I18nService, @Inject(DOCUMENT) private readonly document: Document, @Inject(EDITOR_INSIGHT_DEFAULTS) private readonly config: LynxInsightConfig ) {} @@ -88,7 +83,7 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { this.showMoreInfo = !this.showMoreInfo; } - focusInsight(insight: LynxInsightFlattened): void { + focusInsight(insight: LynxInsight): void { this.focusedInsight = insight; this.fetchInsightActions(insight); this.insightFocus.emit(insight); @@ -121,22 +116,13 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { this.insightState.dismissInsights([insight.id]); } - private flattenInsight(insight: LynxInsight): LynxInsightFlattened { - const insightCode = this.codeService.lookupCode(insight.code, this.i18n.localeCode); - return { - ...insight, - description: insightCode?.description ?? '', - moreInfo: insightCode?.moreInfo - }; - } - private fetchInsightActions(insight: LynxInsight | undefined): void { if (insight == null) { return; } this.actionService - .getActions(insight, this.i18n.localeCode) + .getActions(insight) .pipe(take(1)) .subscribe(actions => { const menuActions: LynxInsightAction[] = []; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts index f2b4f7e5b72..419dfc3c04e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts @@ -1,4 +1,6 @@ import { Inject, Injectable } from '@angular/core'; +import { DiagnosticSeverity } from '@sillsdev/lynx'; +import { Canon } from '@sillsdev/scripture'; import { isEqual } from 'lodash-es'; import { LynxInsightFilter, @@ -9,21 +11,25 @@ import { import { LynxInsightUserData } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight-user-data'; import { BehaviorSubject, - Observable, combineLatest, distinctUntilChanged, filter, map, + Observable, shareReplay, + switchMap, take, tap, withLatestFrom } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { ActivatedBookChapterService } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectUserConfigService } from 'xforge-common/activated-project-user-config.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; +import { TextDocId } from '../../../../core/models/text-doc'; import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightConfig, LynxInsightDisplayState } from './lynx-insight'; import { LynxInsightFilterService } from './lynx-insight-filter.service'; +import { LynxWorkspaceService } from './lynx-workspace.service'; type BooleanProp = { [K in keyof T]: T[K] extends boolean | undefined ? K : never }[keyof T]; @@ -31,298 +37,54 @@ type BooleanProp = { [K in keyof T]: T[K] extends boolean | undefined ? K : n providedIn: 'root' }) export class LynxInsightStateService { - private rawInsightSource$ = new BehaviorSubject([ - // Mark 1 - { - id: '0a', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 300, - length: 5 - }, - code: '1011' - }, - { - id: '0b', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 314, - length: 3 - }, - code: '1011' - }, - { - id: '0c', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 318, - length: 10 - }, - code: '1011' - }, - { - id: '1', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 0, - length: 5 - }, - code: '1001' - }, - // { - // id: '1b', - // type: 'info', - // chapter: 1, - // book: 41, - // range: { - // index: 1, - // length: 6 - // }, - // code: '1001' - // }, - { - id: '2', - type: 'warning', - chapter: 1, - book: 41, - range: { - index: 16, - length: 1 - }, - code: '2001' - }, - { - id: '2a', - type: 'warning', - chapter: 1, - book: 41, - range: { - index: 22, - length: 1 - }, - code: '2001' - }, - { - id: '2b', - type: 'error', - chapter: 1, - book: 41, - range: { - index: 40, - length: 10 - }, - code: '3002' - }, - { - id: '3', - type: 'error', - chapter: 1, - book: 41, - range: { - index: 86, - length: 10 - }, - code: '3001' - }, - { - id: '3a', - type: 'warning', - chapter: 1, - book: 41, - range: { - index: 76, - length: 30 - }, - code: '2011' - }, - { - id: '3b', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 88, - length: 13 - }, - code: '1012' - }, - { - id: '4', - type: 'warning', - chapter: 1, - book: 41, - range: { - index: 34, - length: 11 - }, - code: '1000' - // code: '2002' - }, - { - id: '5', - type: 'warning', - chapter: 1, - book: 41, - range: { - index: 110, - length: 11 - }, - code: '2005' - }, - { - id: '5a', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 112, - length: 5 - }, - code: '1005' - }, - { - id: '6', - type: 'info', - chapter: 1, - book: 41, - range: { - index: 125, - length: 10 - }, - code: '1006' - }, - { - id: '6a', - type: 'error', - chapter: 1, - book: 41, - range: { - index: 127, - length: 5 - }, - code: '3006' - }, - // Mark 2 - { - id: '11', - type: 'info', - chapter: 2, - book: 41, - range: { - index: 2, - length: 5 - }, - code: '1001' - }, - { - id: '22', - type: 'warning', - chapter: 2, - book: 41, - range: { - index: 16, - length: 1 - }, - code: '2001' - }, - { - id: '33', - type: 'warning', - chapter: 2, - book: 41, - range: { - index: 22, - length: 1 - }, - code: '2001' - }, - { - id: '44', - type: 'error', - chapter: 2, - book: 41, - range: { - index: 86, - length: 10 - }, - code: '3001' - }, - { - id: '55', - type: 'warning', - chapter: 2, - book: 41, - range: { - index: 34, - length: 11 - }, - code: '2002' - }, - // Luke 2 - { - id: '111', - type: 'info', - chapter: 2, - book: 42, - range: { - index: 0, - length: 5 - }, - code: '1001' - }, - { - id: '222', - type: 'warning', - chapter: 2, - book: 42, - range: { - index: 16, - length: 1 - }, - code: '2001' - }, - { - id: '333', - type: 'warning', - chapter: 2, - book: 42, - range: { - index: 22, - length: 1 - }, - code: '2001' - }, - { - id: '444', - type: 'error', - chapter: 2, - book: 42, - range: { - index: 86, - length: 10 - }, - code: '3001' - }, - { - id: '555', - type: 'warning', - chapter: 2, - book: 42, - range: { - index: 34, - length: 11 - }, - code: '2002' - } - ]); + private readonly curInsights = new Map(); + private rawInsightSource$: Observable = this.lynxWorkspaceService.workspace.diagnosticsChanged$.pipe( + switchMap(async e => { + if (e.diagnostics.length === 0) { + this.curInsights.delete(e.uri); + } else { + const doc = await this.lynxWorkspaceService.documentManager.get(e.uri); + const insights: LynxInsight[] = []; + if (doc != null) { + const textDocIdParts = e.uri.split(':', 3); + const textDocId = new TextDocId( + textDocIdParts[0], + Canon.bookIdToNumber(textDocIdParts[1]), + parseInt(textDocIdParts[2]) + ); + for (const diagnostic of e.diagnostics) { + let type: LynxInsightType = 'info'; + switch (diagnostic.severity) { + case DiagnosticSeverity.Information: + case DiagnosticSeverity.Hint: + type = 'info'; + break; + case DiagnosticSeverity.Warning: + type = 'warning'; + break; + case DiagnosticSeverity.Error: + type = 'error'; + break; + } + const start = doc.offsetAt(diagnostic.range.start); + const end = doc.offsetAt(diagnostic.range.end); + insights.push({ + id: uuidv4(), + type, + textDocId, + range: { index: start, length: end - start }, + code: diagnostic.code.toString(), + source: diagnostic.source, + description: diagnostic.message, + data: diagnostic.data + }); + } + } + this.curInsights.set(e.uri, insights); + } + return Array.from(this.curInsights.values()).flat(); + }) + ); private rawInsights$: Observable = this.rawInsightSource$.pipe( distinctUntilChanged(isEqual), @@ -454,23 +216,20 @@ export class LynxInsightStateService { @Inject(EDITOR_INSIGHT_DEFAULTS) private defaults: LynxInsightConfig, private readonly insightFilterService: LynxInsightFilterService, private readonly activatedBookChapter: ActivatedBookChapterService, - private readonly activatedProjectUserConfig: ActivatedProjectUserConfigService + private readonly activatedProjectUserConfig: ActivatedProjectUserConfigService, + private readonly lynxWorkspaceService: LynxWorkspaceService ) { this.init(); } getInsight(id: string): LynxInsight | undefined { - return this.rawInsightSource$.value.find(i => i.id === id); - } - - addInsight(insight: LynxInsight): void { - this.rawInsightSource$.next([...this.rawInsightSource$.value, insight]); - } - - updateInsight(newValue: LynxInsight): void { - this.rawInsightSource$.next( - this.rawInsightSource$.value.map(i => (i.id === newValue.id ? { ...i, ...newValue } : i)) - ); + for (const insights of this.curInsights.values()) { + const insight = insights.find(i => i.id === id); + if (insight != null) { + return insight; + } + } + return undefined; } setActiveInsights(ids: string[]): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts index 7f6f1e11e5f..db48e87457b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts @@ -4,6 +4,7 @@ import { LynxInsightSortOrder, LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; +import { TextDocId } from '../../../../core/models/text-doc'; export interface LynxInsightRange { index: number; @@ -20,14 +21,16 @@ export interface LynxInsightDisplayState { cursorActiveInsightIds: string[]; } -// TODO: include something like TextDocId? export interface LynxInsight { id: string; type: LynxInsightType; - chapter: number; - book: number; + textDocId: TextDocId; range: LynxInsightRange; code: string; + source: string; + data?: unknown; + description: string; + moreInfo?: string; // Verbose information about the insight (markdown?) } export interface LynxInsightNode { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html index dde12d626b8..9491ae3151b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html @@ -33,7 +33,7 @@ @case (0) {
- {{ node.name | insightCode: i18n.localeCode }} + {{ node.description }} ({{ node.name }}) {{ node.count }}
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts index e814df852c9..b4840fd2b47 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts @@ -15,7 +15,7 @@ import { combineLatest, map, switchMap, tap } from 'rxjs'; import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; -import { TextDoc, TextDocId } from '../../../../../core/models/text-doc'; +import { TextDoc } from '../../../../../core/models/text-doc'; import { SFProjectService } from '../../../../../core/sf-project.service'; import { getText, rangeComparer } from '../../../../../shared/text/quill-util'; import { combineVerseRefStrs, getVerseRefFromSegmentRef } from '../../../../../shared/utils'; @@ -25,6 +25,7 @@ import { LynxInsightStateService } from '../lynx-insight-state.service'; interface InsightPanelNode { name: string; + description: string; type: LynxInsightType; children?: InsightPanelNode[]; insight?: LynxInsight; @@ -36,6 +37,7 @@ interface InsightPanelNode { interface InsightPanelFlatNode { expandable: boolean; name: string; + description: string; type: string; level: number; insight?: LynxInsight; @@ -150,6 +152,7 @@ export class LynxInsightsPanelComponent implements OnInit { return { expandable: !!node.children && node.children.length > 0, name: node.name, + description: node.description, type: node.type, level: level, insight: node.insight, @@ -181,6 +184,7 @@ export class LynxInsightsPanelComponent implements OnInit { return { name: this.getLinkText(insight), + description: insight.description, type: insight.type, insight, range: insight.range, @@ -190,6 +194,7 @@ export class LynxInsightsPanelComponent implements OnInit { const codeNode: InsightPanelNode = { name: code, + description: byCode[0].description, type: byCode[0].type, children, count: byCode.length, @@ -238,12 +243,15 @@ export class LynxInsightsPanelComponent implements OnInit { const activeBookNum: number = Canon.bookIdToNumber(this.activeBookChapter.bookId); - if (insight.book !== activeBookNum || insight.chapter !== this.activeBookChapter.chapter) { - const insightBookId: string = Canon.bookNumberToId(insight.book); + if ( + insight.textDocId.bookNum !== activeBookNum || + insight.textDocId.chapterNum !== this.activeBookChapter.chapter + ) { + const insightBookId: string = Canon.bookNumberToId(insight.textDocId.bookNum); // Navigate to book/chapter with insight id as query params await this.router.navigate( - ['/projects', this.activatedProject.projectId, 'translate', insightBookId, insight.chapter], + ['/projects', this.activatedProject.projectId, 'translate', insightBookId, insight.textDocId.chapterNum], { queryParams: { [this.lynxInsightConfig.queryParamName]: insight.id } } @@ -264,13 +272,12 @@ export class LynxInsightsPanelComponent implements OnInit { if (this.activatedProject.projectId != null) { for (const insight of insights) { - const textDocId = new TextDocId(this.activatedProject.projectId!, insight.book, insight.chapter); - const textDocIdStr: string = textDocId.toString(); + const textDocIdStr: string = insight.textDocId.toString(); if (!textDocMap.has(textDocIdStr)) { textDocMap.set( textDocIdStr, - this.projectService.getText(textDocId).then(textDoc => { + this.projectService.getText(insight.textDocId).then(textDoc => { // Update segment map for text doc this.textDocSegments.set( textDocIdStr, @@ -306,8 +313,7 @@ export class LynxInsightsPanelComponent implements OnInit { let textDocIdStr: string = ''; if (this.activatedProject.projectId != null) { - const textDocId = new TextDocId(this.activatedProject.projectId, insight.book, insight.chapter); - textDocIdStr = textDocId.toString(); + textDocIdStr = insight.textDocId.toString(); } const editorSegments = this.textDocSegments.get(textDocIdStr); @@ -321,7 +327,7 @@ export class LynxInsightsPanelComponent implements OnInit { let combinedVerseRef: VerseRef | undefined; for (const segmentRef of segmentRefs) { - const verseRef: VerseRef | undefined = getVerseRefFromSegmentRef(insight.book, segmentRef); + const verseRef: VerseRef | undefined = getVerseRefFromSegmentRef(insight.textDocId.bookNum, segmentRef); if (verseRef != null) { if (combinedVerseRef != null) { @@ -337,7 +343,10 @@ export class LynxInsightsPanelComponent implements OnInit { combinedVerseRef = undefined; } - const bookChapter: string = this.i18n.localizeBookChapter(insight.book, insight.chapter); + const bookChapter: string = this.i18n.localizeBookChapter( + insight.textDocId.bookNum, + insight.textDocId.chapterNum + ); linkItems.push(`${bookChapter}:${this.getTextSample(insight, segmentRef, true)}`); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts index 7beb06bc17c..c4a5a27c723 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts @@ -16,7 +16,6 @@ import { QuillFormatRegistryService } from '../../../../shared/text/quill-editor import { EditorReadyService } from './base-services/editor-ready.service'; import { EditorSegmentService } from './base-services/editor-segment.service'; import { InsightRenderService } from './base-services/insight-render.service'; -import { InsightCodePipe } from './insight-code.pipe'; import { LynxInsightActionPromptComponent } from './lynx-insight-action-prompt/lynx-insight-action-prompt.component'; import { LynxInsightEditorObjectsComponent } from './lynx-insight-editor-objects/lynx-insight-editor-objects.component'; import { LynxInsightOverlayComponent } from './lynx-insight-overlay/lynx-insight-overlay.component'; @@ -25,6 +24,7 @@ import { LynxInsightStatusIndicatorComponent } from './lynx-insight-status-indic import { LynxInsightUserEventService } from './lynx-insight-user-event.service'; import { LynxInsightsPanelHeaderComponent } from './lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component'; import { LynxInsightsPanelComponent } from './lynx-insights-panel/lynx-insights-panel.component'; +import { LynxWorkspaceService } from './lynx-workspace.service'; import { lynxInsightBlots } from './quill-services/blots/lynx-insight-blot'; import { QuillEditorReadyService } from './quill-services/quill-editor-ready.service'; import { QuillEditorSegmentService } from './quill-services/quill-editor-segment.service'; @@ -38,8 +38,7 @@ import { QuillInsightRenderService } from './quill-services/quill-insight-render LynxInsightsPanelHeaderComponent, LynxInsightOverlayComponent, LynxInsightScrollPositionIndicatorComponent, - LynxInsightStatusIndicatorComponent, - InsightCodePipe + LynxInsightStatusIndicatorComponent ], imports: [ CommonModule, @@ -56,7 +55,7 @@ import { QuillInsightRenderService } from './quill-services/quill-insight-render OverlayModule, IncludesPipe ], - exports: [LynxInsightEditorObjectsComponent, LynxInsightsPanelComponent, InsightCodePipe] + exports: [LynxInsightEditorObjectsComponent, LynxInsightsPanelComponent] }) export class LynxInsightsModule { static forRoot(): ModuleWithProviders { @@ -65,8 +64,8 @@ export class LynxInsightsModule { providers: [ { provide: APP_INITIALIZER, - useFactory: () => () => {}, - deps: [LynxInsightUserEventService], + useFactory: moduleInit, + deps: [LynxWorkspaceService, LynxInsightUserEventService], multi: true }, { @@ -84,3 +83,9 @@ export class LynxInsightsModule { }; } } + +function moduleInit(lynxService: LynxWorkspaceService): () => Promise { + return () => { + return lynxService.init(); + }; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts new file mode 100644 index 00000000000..026f17f34b2 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts @@ -0,0 +1,272 @@ +import { DestroyRef, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + Diagnostic, + DiagnosticFix, + DiagnosticProvider, + DiagnosticsChanged, + DiagnosticSeverity, + DocumentAccessor, + DocumentData, + DocumentManager, + DocumentReader, + Localizer, + ScriptureDocument, + ScriptureNodeType, + ScriptureText, + ScriptureVerse, + Workspace +} from '@sillsdev/lynx'; +import { ScriptureDeltaDocument, ScriptureDeltaDocumentFactory, ScriptureDeltaEditFactory } from '@sillsdev/lynx-delta'; +import { Canon } from '@sillsdev/scripture'; +import Delta, { Op } from 'quill-delta'; +import { obj } from 'realtime-server/lib/esm/common/utils/obj-path'; +import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { getTextDocId } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; +import { map, merge, Observable, Subscription, switchMap } from 'rxjs'; +import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; +import { TextDocId } from '../../../../core/models/text-doc'; +import { SFProjectService } from '../../../../core/sf-project.service'; + +const TEXTS_PATH_TEMPLATE = obj().pathTemplate(p => p.texts); + +@Injectable({ + providedIn: 'root' +}) +export class LynxWorkspaceService { + private readonly documentReader: TextDocReader; + public readonly documentManager: DocumentManager; + public readonly workspace: Workspace; + private textDocId?: TextDocId; + private textDocChangeSubscription?: Subscription; + private projectDocChangeSubscription?: Subscription; + + constructor( + private readonly projectService: SFProjectService, + private readonly i18n: I18nService, + private readonly activatedProjectService: ActivatedProjectService, + private readonly activatedBookChapterService: ActivatedBookChapterService, + private readonly destroyRef: DestroyRef + ) { + const documentFactory = new ScriptureDeltaDocumentFactory(); + const editFactory = new ScriptureDeltaEditFactory(); + this.documentReader = new TextDocReader(this.projectService); + this.documentManager = new DocumentManager(documentFactory, this.documentReader); + + const localizer = new Localizer(); + this.workspace = new Workspace({ + localizer, + diagnosticProviders: [new TestDiagnosticProvider(localizer, this.documentManager, editFactory)] + }); + + this.activatedProjectService.projectDoc$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(projectDoc => this.onProjectActivated(projectDoc)); + this.activatedBookChapterService.activatedBookChapter$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(bookChapter => this.onBookChapterActivated(bookChapter)); + } + + async init(): Promise { + await this.workspace.init(); + await this.workspace.changeLanguage(this.i18n.localeCode); + this.i18n.locale$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async locale => { + await this.workspace.changeLanguage(locale.canonicalTag); + }); + } + + private async onProjectActivated(projectDoc: SFProjectProfileDoc | undefined): Promise { + if (this.projectDocChangeSubscription != null) { + this.projectDocChangeSubscription.unsubscribe(); + this.projectDocChangeSubscription = undefined; + } + + this.documentReader.textDocIds = getTextDocIds(projectDoc); + await this.documentManager.reset(); + if (projectDoc != null) { + this.projectDocChangeSubscription = projectDoc.changes$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(async ops => { + if (ops.some(op => TEXTS_PATH_TEMPLATE.matches(op.p))) { + const oldTextDocIds = this.documentReader.textDocIds; + this.documentReader.textDocIds = getTextDocIds(projectDoc); + // created texts + for (const textDocId of setDifference(this.documentReader.textDocIds, oldTextDocIds)) { + await this.documentManager.fireCreated(textDocId); + } + // deleted texts + for (const textDocId of setDifference(oldTextDocIds, this.documentReader.textDocIds)) { + await this.documentManager.fireDeleted(textDocId); + } + } + }); + } + } + + private async onBookChapterActivated(bookChapter: RouteBookChapter | undefined): Promise { + const textDocId = + this.activatedProjectService.projectId == null || bookChapter?.bookId == null || bookChapter.chapter == null + ? undefined + : new TextDocId( + this.activatedProjectService.projectId, + Canon.bookIdToNumber(bookChapter.bookId), + bookChapter?.chapter + ); + if (textDocId === this.textDocId) { + return; + } + + if (this.textDocId != null) { + await this.documentManager.fireClosed(this.textDocId.toString()); + this.textDocId = undefined; + if (this.textDocChangeSubscription != null) { + this.textDocChangeSubscription.unsubscribe(); + this.textDocChangeSubscription = undefined; + } + } + + this.textDocId = textDocId; + if (this.textDocId != null) { + const textDoc = await this.projectService.getText(this.textDocId); + await this.documentManager.fireOpened(this.textDocId.toString(), { + format: 'scripture-delta', + version: textDoc.adapter.version, + content: textDoc.data as Delta + }); + const uri = this.textDocId.toString(); + this.textDocChangeSubscription = textDoc.changes$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(async changes => { + await this.documentManager.fireChanged(uri, { + contentChanges: changes.ops ?? [], + version: textDoc.adapter.version + }); + }); + } + } +} + +class TextDocReader implements DocumentReader { + public textDocIds: Set = new Set(); + + constructor(private readonly projectService: SFProjectService) {} + + keys(): string[] { + return Array.from(this.textDocIds); + } + + async read(uri: string): Promise> { + const textDoc = await this.projectService.getText(uri); + return { + format: 'scripture-delta', + content: textDoc.data as Delta, + version: textDoc.adapter.version + }; + } +} + +function getTextDocIds(projectDoc: SFProjectProfileDoc | undefined): Set { + if (projectDoc == null || projectDoc.data == null) { + return new Set(); + } + return new Set( + projectDoc.data.texts.flatMap(text => + text.chapters.map(chapter => getTextDocId(projectDoc.id, text.bookNum, chapter.number)) + ) + ); +} + +function* setDifference(x: Set, y: Set): Iterable { + for (const value of x) { + if (!y.has(value)) { + yield value; + } + } +} + +export class TestDiagnosticProvider implements DiagnosticProvider { + public readonly id = 'test'; + public readonly diagnosticsChanged$: Observable; + + constructor( + private readonly localizer: Localizer, + private readonly documents: DocumentAccessor, + private readonly editFactory: ScriptureDeltaEditFactory + ) { + this.diagnosticsChanged$ = merge( + documents.opened$.pipe( + map(e => ({ + uri: e.document.uri, + version: e.document.version, + diagnostics: this.validateDocument(e.document) + })) + ), + documents.changed$.pipe( + map(e => ({ + uri: e.document.uri, + version: e.document.version, + diagnostics: this.validateDocument(e.document) + })) + ), + documents.closed$.pipe( + switchMap(async e => { + const doc = await this.documents.get(e.uri); + return { uri: e.uri, version: doc?.version, diagnostics: [] }; + }) + ) + ); + } + + init(): Promise { + return Promise.resolve(); + } + + async getDiagnostics(uri: string): Promise { + const doc = await this.documents.get(uri); + if (doc == null) { + return []; + } + return this.validateDocument(doc); + } + + async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]> { + const doc = await this.documents.get(uri); + if (doc == null) { + return []; + } + const fixes: DiagnosticFix[] = []; + if (diagnostic.code === 'tst0001') { + fixes.push({ + title: 'Fix the problem', + isPreferred: true, + diagnostic, + edits: this.editFactory.createScriptureEdit( + doc, + { start: diagnostic.range.start, end: diagnostic.range.start }, + new ScriptureText('Problem fixed! ') + ) + }); + } + return fixes; + } + + private validateDocument(doc: ScriptureDocument): Diagnostic[] { + const firstVerses = doc.findNodes(n => n.type === ScriptureNodeType.Verse && (n as ScriptureVerse).number === '1'); + const diagnostics: Diagnostic[] = []; + for (const verseNode of firstVerses) { + if (verseNode.next?.type === ScriptureNodeType.Text && !verseNode.next.getText().includes('Problem fixed!')) { + diagnostics.push({ + code: 'tst0001', + source: this.id, + severity: DiagnosticSeverity.Error, + message: 'Test error', + range: verseNode.next.range + }); + } + } + return diagnostics; + } +} From d94b0879f40b25008694c17b1be36b0ecbe6c7a0 Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Wed, 12 Feb 2025 18:28:12 -0500 Subject: [PATCH 03/41] Fix issues when updating insights --- .../lynx-insight-editor-objects.component.ts | 77 ++++++++++++- .../lynx-insight-overlay.component.html | 2 +- ...t-scroll-position-indicator.component.html | 2 +- .../lynx-insight-user-event.service.spec.ts | 16 --- .../lynx-insight-user-event.service.ts | 106 ------------------ .../lynx/insights/lynx-insights.module.ts | 3 +- .../quill-insight-render.service.ts | 7 +- 7 files changed, 81 insertions(+), 132 deletions(-) delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts index 354fbcfdbae..af4f6f9cce4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts @@ -1,13 +1,15 @@ import { Component, DestroyRef, Input, OnDestroy, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isEqual } from 'lodash-es'; -import { filter, merge, switchMap, tap } from 'rxjs'; -import { pairwise } from 'rxjs/operators'; +import { combineLatest, filter, fromEvent, merge, switchMap, tap } from 'rxjs'; +import { map, pairwise } from 'rxjs/operators'; import { EditorReadyService } from '../base-services/editor-ready.service'; import { InsightRenderService } from '../base-services/insight-render.service'; import { LynxableEditor } from '../lynx-editor'; +import { LynxInsight, LynxInsightDisplayState, LynxInsightRange } from '../lynx-insight'; import { LynxInsightOverlayService } from '../lynx-insight-overlay.service'; import { LynxInsightStateService } from '../lynx-insight-state.service'; +import { LynxInsightBlot } from '../quill-services/blots/lynx-insight-blot'; @Component({ selector: 'app-lynx-insight-editor-objects', @@ -15,6 +17,10 @@ import { LynxInsightStateService } from '../lynx-insight-state.service'; styleUrl: './lynx-insight-editor-objects.component.scss' }) export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { + readonly insightSelector = `.${LynxInsightBlot.superClassName}`; + + private readonly dataIdProp = LynxInsightBlot.idDatasetPropName; + @Input() editor?: LynxableEditor; constructor( @@ -30,6 +36,17 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { return; } + combineLatest([ + fromEvent(this.editor, 'selection-change').pipe(map(([range]) => range)), + this.insightState.filteredChapterInsights$ + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([range, insights]) => this.handleSelectionChange(range, insights)); + + combineLatest([fromEvent(this.editor.root, 'mouseover'), this.insightState.filteredChapterInsights$]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([event]) => this.handleMouseOver(event.target as HTMLElement)); + this.editorReadyService .listenEditorReadyState(this.editor) .pipe( @@ -86,4 +103,60 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { this.insightRenderService.removeAllInsightFormatting(this.editor); } } + + private handleSelectionChange(selection: LynxInsightRange | undefined, insights: LynxInsight[]): void { + console.log('SelectionChange', selection, insights); + const ids = insights + .filter(insight => selection != null && overlaps(insight.range, selection)) + .map(insight => insight.id); + + let displayStateChanges: Partial = { + activeInsightIds: ids, + promptActive: ids.length > 0, + actionOverlayActive: false + }; + + this.insightState.updateDisplayState(displayStateChanges); + } + + private handleMouseOver(target: HTMLElement): void { + // Clear any 'hover-insight' classes if the target is not an insight element + if (!target.matches('.' + LynxInsightBlot.superClassName)) { + this.insightState.updateDisplayState({ cursorActiveInsightIds: [] }); + return; + } + + console.log('MouseOver', target); + const ids: string[] = this.getInsightIds(target); + + // Set 'hover-insight' class on the affected insight elements (clear others) + this.insightState.updateDisplayState({ cursorActiveInsightIds: ids }); + } + + /** + * Get all insight ids from the element and its parents that match the lynx insight selector. + */ + private getInsightIds(el: HTMLElement): string[] { + const ids: string[] = []; + + if (el.matches(this.insightSelector)) { + let currentEl: HTMLElement | null | undefined = el; + + while (currentEl != null) { + const id = currentEl.dataset[this.dataIdProp]; + + if (id != null) { + ids.push(id); + } + + currentEl = currentEl.parentElement?.closest(this.insightSelector); + } + } + + return ids; + } +} + +function overlaps(x: LynxInsightRange, y: LynxInsightRange): boolean { + return x.index <= y.index + y.length && y.index <= x.index + x.length; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html index d392e05b870..a90ac48dae9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html @@ -42,7 +42,7 @@

{{ action.label }}

} @else if (insights.length > 1) {

Select for details

- @for (insight of insights; track insight) { + @for (insight of insights; track insight.id) {
} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts deleted file mode 100644 index 03d3f219ce4..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { EditorInsightUserEventService } from './editor-insight-user-event.service'; - -describe('EditorInsightUserEventService', () => { - let service: EditorInsightUserEventService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(EditorInsightUserEventService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts deleted file mode 100644 index 6e7fd0b0650..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-user-event.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; -import { LynxInsightDisplayState } from './lynx-insight'; -import { LynxInsightStateService } from './lynx-insight-state.service'; -import { LynxInsightBlot } from './quill-services/blots/lynx-insight-blot'; - -type EventType = 'click' | 'mouseover'; - -@Injectable({ - providedIn: 'root' -}) -export class LynxInsightUserEventService { - readonly insightSelector = `.${LynxInsightBlot.superClassName}`; - readonly overlaySelector = '.lynx-insight-overlay-panel'; - - private readonly dataIdProp = LynxInsightBlot.idDatasetPropName; - - constructor( - private readonly insightState: LynxInsightStateService, - @Inject(DOCUMENT) private readonly document: Document - ) { - console.log('LynxInsightUserEventService initialized'); - this.addEventListeners(); - } - - private addEventListeners(): void { - this.addEventListener('click'); - this.addEventListener('mouseover'); - } - - private addEventListener(eventType: EventType): void { - this.document.addEventListener(eventType, this.handleEvent.bind(this, eventType)); - } - - private handleEvent(eventType: EventType, event: MouseEvent): void { - const target = event.target as HTMLElement; - - switch (eventType) { - case 'click': - this.handleClick(target); - break; - case 'mouseover': - this.handleMouseOver(target); - break; - } - } - - private handleClick(target: HTMLElement): void { - console.log('Click', target); - const ids: string[] = this.getInsightIds(target); - - if (ids.length === 0) { - // Non- 'insights panel' clicks that are not in action overlay should clear display state - // unless action 'fixes' menu is open (indicated by '.cdk-overlay-backdrop'). - if (target?.closest(`${this.overlaySelector}, .cdk-overlay-backdrop`) == null) { - this.insightState.clearDisplayState(); - } - return; - } - - let displayStateChanges: Partial = { - activeInsightIds: ids, - promptActive: true, - actionOverlayActive: false - }; - - this.insightState.updateDisplayState(displayStateChanges); - } - - private handleMouseOver(target: HTMLElement): void { - // Clear any 'hover-insight' classes if the target is not an insight element - if (!target.matches('.' + LynxInsightBlot.superClassName)) { - this.insightState.updateDisplayState({ cursorActiveInsightIds: [] }); - return; - } - - console.log('MouseOver', target); - const ids: string[] = this.getInsightIds(target); - - // Set 'hover-insight' class on the affected insight elements (clear others) - this.insightState.updateDisplayState({ cursorActiveInsightIds: ids }); - } - - /** - * Get all insight ids from the element and its parents that match the lynx insight selector. - */ - private getInsightIds(el: HTMLElement): string[] { - const ids: string[] = []; - - if (el.matches(this.insightSelector)) { - let currentEl: HTMLElement | null | undefined = el; - - while (currentEl != null) { - const id = currentEl.dataset[this.dataIdProp]; - - if (id != null) { - ids.push(id); - } - - currentEl = currentEl.parentElement?.closest(this.insightSelector); - } - } - - return ids; - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts index c4a5a27c723..d6bdfb7a961 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts @@ -21,7 +21,6 @@ import { LynxInsightEditorObjectsComponent } from './lynx-insight-editor-objects import { LynxInsightOverlayComponent } from './lynx-insight-overlay/lynx-insight-overlay.component'; import { LynxInsightScrollPositionIndicatorComponent } from './lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component'; import { LynxInsightStatusIndicatorComponent } from './lynx-insight-status-indicator/lynx-insight-status-indicator.component'; -import { LynxInsightUserEventService } from './lynx-insight-user-event.service'; import { LynxInsightsPanelHeaderComponent } from './lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component'; import { LynxInsightsPanelComponent } from './lynx-insights-panel/lynx-insights-panel.component'; import { LynxWorkspaceService } from './lynx-workspace.service'; @@ -65,7 +64,7 @@ export class LynxInsightsModule { { provide: APP_INITIALIZER, useFactory: moduleInit, - deps: [LynxWorkspaceService, LynxInsightUserEventService], + deps: [LynxWorkspaceService], multi: true }, { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts index 70f58fd8053..5be16c91392 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts @@ -55,7 +55,6 @@ export class QuillInsightRenderService extends InsightRenderService { * This avoids multiple calls to quill `formatText`, which will re-render the DOM after each call. */ private refreshInsightFormatting(insights: LynxInsight[], editor: Quill): void { - let delta: Delta = editor.getContents(); const formatsToRemove: StringMap = {}; // Prepare formats to remove @@ -64,7 +63,7 @@ export class QuillInsightRenderService extends InsightRenderService { } // Apply removal of formats - delta = delta.compose(new Delta().retain(delta.length(), formatsToRemove)); + let delta = new Delta().retain(editor.getLength(), formatsToRemove); // Apply formats, merging each format op with the result of the prev (let quill handle overlapping formats) for (const insight of insights) { @@ -75,8 +74,8 @@ export class QuillInsightRenderService extends InsightRenderService { delta = delta.compose(deltaToApply); } - // Set contents with the combined delta - editor.setContents(delta, 'api'); + // Update contents with the combined delta + editor.updateContents(delta, 'api'); } renderActionOverlay(insights: LynxInsight[], editor: Quill, actionOverlayActive: boolean): void { From bd57fe335c0739bebffc0185d0358647d2013d54 Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Wed, 19 Mar 2025 10:59:33 -0500 Subject: [PATCH 04/41] Add Lynx Punctuation Checker - update Lynx dependencies - abstract all Lynx implementation in LynxWorkspaceService - fix bundling errors - add support for on-type edits - fix issue when clicking on insight action - hide primary action when there are no actions - hide secondary actions menu when there are no secondary actions - remove capitalization from insight panel nodes - fix bug in insight panel expand/collapse state - add SPA routes for Lynx localization files --- .../ClientApp/angular.json | 6 +- .../ClientApp/package-lock.json | 29 +- .../ClientApp/package.json | 5 +- .../lynx-insight-action-prompt.component.ts | 4 +- .../lynx-insight-action.service.spec.ts | 16 -- .../insights/lynx-insight-action.service.ts | 71 ----- .../lynx-insight-editor-objects.component.ts | 29 +- .../insights/lynx-insight-overlay.service.ts | 8 +- .../lynx-insight-overlay.component.html | 24 +- .../lynx-insight-overlay.component.ts | 35 ++- ...ght-scroll-position-indicator.component.ts | 4 +- .../insights/lynx-insight-state.service.ts | 58 +--- .../editor/lynx/insights/lynx-insight.ts | 10 + .../lynx-insights-panel.component.scss | 1 - .../lynx-insights-panel.component.ts | 2 +- .../lynx/insights/lynx-insights.module.ts | 4 +- .../lynx/insights/lynx-workspace.service.ts | 254 +++++++++++------- src/SIL.XForge.Scripture/Startup.cs | 3 + 18 files changed, 268 insertions(+), 295 deletions(-) delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/angular.json b/src/SIL.XForge.Scripture/ClientApp/angular.json index 543185e0887..a35c7f6508e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/angular.json +++ b/src/SIL.XForge.Scripture/ClientApp/angular.json @@ -81,7 +81,7 @@ ], "optimization": true, "outputHashing": "all", - "namedChunks": false, + "namedChunks": true, "aot": true, "vendorChunk": false, "buildOptimizer": true, @@ -102,7 +102,7 @@ ], "optimization": true, "outputHashing": "all", - "namedChunks": false, + "namedChunks": true, "aot": true, "vendorChunk": false, "buildOptimizer": true, @@ -116,7 +116,7 @@ } ], "outputHashing": "all", - "namedChunks": false, + "namedChunks": true, "vendorChunk": false, "serviceWorker": true }, diff --git a/src/SIL.XForge.Scripture/ClientApp/package-lock.json b/src/SIL.XForge.Scripture/ClientApp/package-lock.json index 7dc87bd2120..5dd1fb74f12 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package-lock.json +++ b/src/SIL.XForge.Scripture/ClientApp/package-lock.json @@ -26,8 +26,9 @@ "@microsoft/signalr": "^7.0.0", "@ngneat/transloco": "^4.3.0", "@ngneat/transloco-locale": "^4.1.0", - "@sillsdev/lynx": "^0.3.0", - "@sillsdev/lynx-delta": "^0.2.0", + "@sillsdev/lynx": "^0.3.4", + "@sillsdev/lynx-delta": "^0.2.1", + "@sillsdev/lynx-punctuation-checker": "^0.1.3", "@sillsdev/machine": "^2.4.2", "@sillsdev/scripture": "1.4.1", "angular-file": "^4.0.2", @@ -6587,9 +6588,9 @@ } }, "node_modules/@sillsdev/lynx": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@sillsdev/lynx/-/lynx-0.3.0.tgz", - "integrity": "sha512-X+KPwKCll1R3wIvvSXD/KzD54Mr0ZCGP8Huskt19DKuC1Fh3gHwSJ+mTFXQJ4/roaVli6ets+M6ZQpBb+4PKng==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@sillsdev/lynx/-/lynx-0.3.4.tgz", + "integrity": "sha512-s5EyP4C/penStoMV5cflTIbdfpPjdDNBPzZ9nd607cGlhLST7aMZkB+UcipXl4FuVbAYfrEYQcIyQ4vMiM9s1A==", "license": "MIT", "dependencies": { "i18next": "^23.16.5", @@ -6597,12 +6598,12 @@ } }, "node_modules/@sillsdev/lynx-delta": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sillsdev/lynx-delta/-/lynx-delta-0.2.0.tgz", - "integrity": "sha512-KXyvOuc0GdBR93uvS580eCX5RJqTz6PcT2H7Jc8PYsZSq0T8trvM2DVde7n5LgFTCIe8Ih+csruss1OwbELjPA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sillsdev/lynx-delta/-/lynx-delta-0.2.1.tgz", + "integrity": "sha512-OP+uN8or9un59C+WNtkCx0Ppg5sQuSH+/IW/xjAsGOhJvXyLIJjt0SnF/EQnWSkO8w8bspPhXOs0uZJaL/sjrQ==", "license": "MIT", "dependencies": { - "@sillsdev/lynx": "*", + "@sillsdev/lynx": "^0.3.0", "quill-delta": "^5.1.0", "uuid": "^11.0.3" } @@ -6620,6 +6621,16 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/@sillsdev/lynx-punctuation-checker": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@sillsdev/lynx-punctuation-checker/-/lynx-punctuation-checker-0.1.3.tgz", + "integrity": "sha512-myTLBG53Ve3dQaIBHddLGotQTQLznlRDiD+d/wTZiEk+qRtHtdtqzVvZrYIiE9vIA6v1OQxdHegjTvxUWNkc/Q==", + "license": "MIT", + "dependencies": { + "@sillsdev/lynx": "^0.3.3", + "rxjs": "^7.8.1" + } + }, "node_modules/@sillsdev/machine": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@sillsdev/machine/-/machine-2.4.2.tgz", diff --git a/src/SIL.XForge.Scripture/ClientApp/package.json b/src/SIL.XForge.Scripture/ClientApp/package.json index 71d9eb42b66..1e9d61c1a6d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package.json +++ b/src/SIL.XForge.Scripture/ClientApp/package.json @@ -50,8 +50,9 @@ "@microsoft/signalr": "^7.0.0", "@ngneat/transloco": "^4.3.0", "@ngneat/transloco-locale": "^4.1.0", - "@sillsdev/lynx": "^0.3.0", - "@sillsdev/lynx-delta": "^0.2.0", + "@sillsdev/lynx": "^0.3.4", + "@sillsdev/lynx-delta": "^0.2.1", + "@sillsdev/lynx-punctuation-checker": "^0.1.3", "@sillsdev/machine": "^2.4.2", "@sillsdev/scripture": "1.4.1", "angular-file": "^4.0.2", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts index e8a09d7e15d..dc4d2bb9cc8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts @@ -15,8 +15,8 @@ import { getMostNestedInsight } from '../lynx-insight-util'; styleUrl: './lynx-insight-action-prompt.component.scss' }) export class LynxInsightActionPromptComponent implements OnInit { - @Input() set editor(value: LynxableEditor) { - this.lynxEditor = new LynxEditor(value); + @Input() set editor(value: LynxableEditor | undefined) { + this.lynxEditor = value == null ? undefined : new LynxEditor(value); } activeInsights: LynxInsight[] = []; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts deleted file mode 100644 index d26c0e9da1a..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { EditorInsightActionService } from './editor-insight-action.service'; - -describe('EditorInsightActionService', () => { - let service: EditorInsightActionService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(EditorInsightActionService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts deleted file mode 100644 index 87da9db86ff..00000000000 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Diagnostic, DiagnosticSeverity } from '@sillsdev/lynx'; -import { Op } from 'quill'; -import { from, Observable } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; -import { LynxEditor } from './lynx-editor'; -import { LynxInsight } from './lynx-insight'; -import { LynxWorkspaceService } from './lynx-workspace.service'; - -export interface LynxInsightAction { - id: string; - insight: LynxInsight; - label: string; - description?: string; - isPrimary?: boolean; - ops: Op[]; -} - -@Injectable({ - providedIn: 'root' -}) -export class LynxInsightActionService { - constructor(private readonly lynxService: LynxWorkspaceService) {} - - getActions(insight: LynxInsight): Observable { - return from(this.getActionsFromWorkspace(insight)); - } - - performAction(action: LynxInsightAction, editor: LynxEditor): void { - console.log('Performing action', action); - editor.updateContents(action.ops); - } - - private async getActionsFromWorkspace(insight: LynxInsight): Promise { - const doc = await this.lynxService.documentManager.get(insight.textDocId.toString()); - if (doc == null) { - return []; - } - let severity = DiagnosticSeverity.Information; - switch (insight.type) { - case 'info': - severity = DiagnosticSeverity.Information; - break; - case 'warning': - severity = DiagnosticSeverity.Warning; - break; - case 'error': - severity = DiagnosticSeverity.Error; - break; - } - const diagnostic: Diagnostic = { - code: insight.code, - source: insight.source, - range: { - start: doc.positionAt(insight.range.index), - end: doc.positionAt(insight.range.index + insight.range.length) - }, - message: insight.description, - severity, - data: insight.data - }; - const fixes = await this.lynxService.workspace.getDiagnosticFixes(insight.textDocId.toString(), diagnostic); - return fixes.map(fix => ({ - id: uuidv4(), - insight, - label: fix.title, - isPrimary: fix.isPreferred, - ops: fix.edits - })); - } -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts index af4f6f9cce4..5f6313323d0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts @@ -1,6 +1,7 @@ import { Component, DestroyRef, Input, OnDestroy, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isEqual } from 'lodash-es'; +import { Delta } from 'quill'; import { combineLatest, filter, fromEvent, merge, switchMap, tap } from 'rxjs'; import { map, pairwise } from 'rxjs/operators'; import { EditorReadyService } from '../base-services/editor-ready.service'; @@ -9,6 +10,7 @@ import { LynxableEditor } from '../lynx-editor'; import { LynxInsight, LynxInsightDisplayState, LynxInsightRange } from '../lynx-insight'; import { LynxInsightOverlayService } from '../lynx-insight-overlay.service'; import { LynxInsightStateService } from '../lynx-insight-state.service'; +import { LynxWorkspaceService } from '../lynx-workspace.service'; import { LynxInsightBlot } from '../quill-services/blots/lynx-insight-blot'; @Component({ @@ -28,7 +30,8 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { private readonly insightState: LynxInsightStateService, private readonly insightRenderService: InsightRenderService, private readonly editorReadyService: EditorReadyService, - private readonly overlayService: LynxInsightOverlayService + private readonly overlayService: LynxInsightOverlayService, + private readonly lynxWorkspaceService: LynxWorkspaceService ) {} ngOnInit(): void { @@ -36,6 +39,13 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { return; } + fromEvent(this.editor, 'text-change') + .pipe( + filter(([_delta, _oldContents, source]) => source === 'user'), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(([delta]) => this.handleTextChange(delta)); + combineLatest([ fromEvent(this.editor, 'selection-change').pipe(map(([range]) => range)), this.insightState.filteredChapterInsights$ @@ -105,12 +115,14 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { } private handleSelectionChange(selection: LynxInsightRange | undefined, insights: LynxInsight[]): void { - console.log('SelectionChange', selection, insights); + if (this.overlayService.isOpen) { + return; + } const ids = insights .filter(insight => selection != null && overlaps(insight.range, selection)) .map(insight => insight.id); - let displayStateChanges: Partial = { + const displayStateChanges: Partial = { activeInsightIds: ids, promptActive: ids.length > 0, actionOverlayActive: false @@ -126,7 +138,6 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { return; } - console.log('MouseOver', target); const ids: string[] = this.getInsightIds(target); // Set 'hover-insight' class on the affected insight elements (clear others) @@ -155,6 +166,16 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { return ids; } + + private async handleTextChange(delta: Delta): Promise { + if (this.editor == null) { + return; + } + const edits = await this.lynxWorkspaceService.getOnTypeEdits(delta); + for (const edit of edits) { + this.editor.updateContents(edit, 'user'); + } + } } function overlaps(x: LynxInsightRange, y: LynxInsightRange): boolean { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts index 2c09348c009..71b8b90970e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay.service.ts @@ -32,6 +32,10 @@ export class LynxInsightOverlayService { private ngZone: NgZone ) {} + get isOpen(): boolean { + return this.openRef != null; + } + open(origin: HTMLElement, insights: LynxInsight[], editor: LynxEditor): LynxInsightOverlayRef | undefined { if (insights.length === 0) { return undefined; @@ -44,6 +48,7 @@ export class LynxInsightOverlayService { const overlayRef: LynxInsightOverlayRef = this.createOverlayRef(origin); const componentRef = overlayRef.ref.attach(new ComponentPortal(LynxInsightOverlayComponent)); + overlayRef.ref.backdropClick().subscribe(() => this.close()); componentRef.instance.insights = insights; componentRef.instance.editor = editor; @@ -88,7 +93,8 @@ export class LynxInsightOverlayService { private getConfig(origin: HTMLElement): OverlayConfig { return { positionStrategy: this.getPositionStrategy(origin), - hasBackdrop: false, + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', panelClass: 'lynx-insight-overlay-panel', scrollStrategy: this.overlay.scrollStrategies.reposition() }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html index a90ac48dae9..8296f462d62 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html @@ -8,15 +8,23 @@

help_outline }

- + @if (primaryAction != null) { + + }
- more_horiz + more_horiz visibility_off diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts index 78fffc7ceea..6a31efc5c04 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts @@ -1,12 +1,11 @@ import { DOCUMENT } from '@angular/common'; import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { MAT_TOOLTIP_DEFAULT_OPTIONS } from '@angular/material/tooltip'; -import { take } from 'rxjs'; import { LynxEditor } from '../lynx-editor'; -import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightConfig } from '../lynx-insight'; -import { LynxInsightAction, LynxInsightActionService } from '../lynx-insight-action.service'; +import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightAction, LynxInsightConfig } from '../lynx-insight'; import { LynxInsightOverlayService } from '../lynx-insight-overlay.service'; import { LynxInsightStateService } from '../lynx-insight-state.service'; +import { LynxWorkspaceService } from '../lynx-workspace.service'; @Component({ selector: 'app-lynx-insight-overlay', @@ -54,8 +53,8 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { constructor( private readonly insightState: LynxInsightStateService, - private readonly actionService: LynxInsightActionService, private readonly overlayService: LynxInsightOverlayService, + private readonly lynxWorkspaceService: LynxWorkspaceService, @Inject(DOCUMENT) private readonly document: Document, @Inject(EDITOR_INSIGHT_DEFAULTS) private readonly config: LynxInsightConfig ) {} @@ -101,7 +100,8 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { return; } - this.actionService.performAction(action, this.editor); + console.log('Performing action', action); + this.editor.updateContents(action.ops); if (this.focusedInsight == null) { throw new Error('No focused insight'); @@ -121,22 +121,19 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { return; } - this.actionService - .getActions(insight) - .pipe(take(1)) - .subscribe(actions => { - const menuActions: LynxInsightAction[] = []; - - for (const action of actions) { - if (action.isPrimary) { - this.primaryAction = action; - } else { - menuActions.push(action); - } + this.lynxWorkspaceService.getActions(insight).then(actions => { + const menuActions: LynxInsightAction[] = []; + + for (const action of actions) { + if (action.isPrimary) { + this.primaryAction = action; + } else { + menuActions.push(action); } + } - this.menuActions = menuActions; - }); + this.menuActions = menuActions; + }); } /** diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts index aa1306ef4b6..5c829fbeb8e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.ts @@ -19,8 +19,8 @@ interface LynxInsightScrollPosition { styleUrl: './lynx-insight-scroll-position-indicator.component.scss' }) export class LynxInsightScrollPositionIndicatorComponent implements OnInit { - @Input() set editor(value: LynxableEditor) { - this.lynxEditor = new LynxEditor(value); + @Input() set editor(value: LynxableEditor | undefined) { + this.lynxEditor = value == null ? undefined : new LynxEditor(value); } scrollPositions: LynxInsightScrollPosition[] = []; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts index 419dfc3c04e..88be0e0617f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts @@ -1,6 +1,4 @@ import { Inject, Injectable } from '@angular/core'; -import { DiagnosticSeverity } from '@sillsdev/lynx'; -import { Canon } from '@sillsdev/scripture'; import { isEqual } from 'lodash-es'; import { LynxInsightFilter, @@ -17,16 +15,13 @@ import { map, Observable, shareReplay, - switchMap, take, tap, withLatestFrom } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; import { ActivatedBookChapterService } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectUserConfigService } from 'xforge-common/activated-project-user-config.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; -import { TextDocId } from '../../../../core/models/text-doc'; import { EDITOR_INSIGHT_DEFAULTS, LynxInsight, LynxInsightConfig, LynxInsightDisplayState } from './lynx-insight'; import { LynxInsightFilterService } from './lynx-insight-filter.service'; import { LynxWorkspaceService } from './lynx-workspace.service'; @@ -37,56 +32,7 @@ type BooleanProp = { [K in keyof T]: T[K] extends boolean | undefined ? K : n providedIn: 'root' }) export class LynxInsightStateService { - private readonly curInsights = new Map(); - private rawInsightSource$: Observable = this.lynxWorkspaceService.workspace.diagnosticsChanged$.pipe( - switchMap(async e => { - if (e.diagnostics.length === 0) { - this.curInsights.delete(e.uri); - } else { - const doc = await this.lynxWorkspaceService.documentManager.get(e.uri); - const insights: LynxInsight[] = []; - if (doc != null) { - const textDocIdParts = e.uri.split(':', 3); - const textDocId = new TextDocId( - textDocIdParts[0], - Canon.bookIdToNumber(textDocIdParts[1]), - parseInt(textDocIdParts[2]) - ); - for (const diagnostic of e.diagnostics) { - let type: LynxInsightType = 'info'; - switch (diagnostic.severity) { - case DiagnosticSeverity.Information: - case DiagnosticSeverity.Hint: - type = 'info'; - break; - case DiagnosticSeverity.Warning: - type = 'warning'; - break; - case DiagnosticSeverity.Error: - type = 'error'; - break; - } - const start = doc.offsetAt(diagnostic.range.start); - const end = doc.offsetAt(diagnostic.range.end); - insights.push({ - id: uuidv4(), - type, - textDocId, - range: { index: start, length: end - start }, - code: diagnostic.code.toString(), - source: diagnostic.source, - description: diagnostic.message, - data: diagnostic.data - }); - } - } - this.curInsights.set(e.uri, insights); - } - return Array.from(this.curInsights.values()).flat(); - }) - ); - - private rawInsights$: Observable = this.rawInsightSource$.pipe( + private rawInsights$: Observable = this.lynxWorkspaceService.rawInsightSource$.pipe( distinctUntilChanged(isEqual), tap(insights => console.log('rawInsights$ changed (LynxInsightStateService)', insights)), shareReplay(1) @@ -223,7 +169,7 @@ export class LynxInsightStateService { } getInsight(id: string): LynxInsight | undefined { - for (const insights of this.curInsights.values()) { + for (const insights of this.lynxWorkspaceService.currentInsights.values()) { const insight = insights.find(i => i.id === id); if (insight != null) { return insight; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts index db48e87457b..e0b9c787304 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight.ts @@ -1,4 +1,5 @@ import { InjectionToken } from '@angular/core'; +import { Op } from 'quill'; import { LynxInsightFilter, LynxInsightSortOrder, @@ -46,6 +47,15 @@ export interface LynxInsightConfig { actionOverlayApplyPrimaryActionChord: Partial; } +export interface LynxInsightAction { + id: string; + insight: LynxInsight; + label: string; + description?: string; + isPrimary?: boolean; + ops: Op[]; +} + export const EDITOR_INSIGHT_DEFAULTS = new InjectionToken('EDITOR_INSIGHT_DEFAULTS', { providedIn: 'root', factory: () => ({ diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss index 116f14c23b7..266f7b1780c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss @@ -77,7 +77,6 @@ $restoreActionColor: #5485e7; display: flex; align-items: center; gap: 0.5rem; - text-transform: capitalize; // TODO: use i18n instead overflow: hidden; padding-inline-end: 0.5rem; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts index b4840fd2b47..3a0f103d1a8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts @@ -216,7 +216,7 @@ export class LynxInsightsPanelComponent implements OnInit { } for (const node of this.treeControl.dataNodes) { - if (node.level === 0 && this.expandCollapseState.has(node.name)) { + if (node.level === 0 && this.expandCollapseState.get(node.name)) { this.treeControl.expand(node); } else { this.treeControl.collapse(node); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts index d6bdfb7a961..c1dec76d325 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights.module.ts @@ -83,8 +83,8 @@ export class LynxInsightsModule { } } -function moduleInit(lynxService: LynxWorkspaceService): () => Promise { +function moduleInit(lynxWorkspaceService: LynxWorkspaceService): () => Promise { return () => { - return lynxService.init(); + return lynxWorkspaceService.init(); }; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts index 026f17f34b2..eebcdd35ee0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts @@ -2,34 +2,30 @@ import { DestroyRef, Injectable } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Diagnostic, - DiagnosticFix, - DiagnosticProvider, - DiagnosticsChanged, DiagnosticSeverity, - DocumentAccessor, DocumentData, DocumentManager, DocumentReader, Localizer, - ScriptureDocument, - ScriptureNodeType, - ScriptureText, - ScriptureVerse, Workspace } from '@sillsdev/lynx'; import { ScriptureDeltaDocument, ScriptureDeltaDocumentFactory, ScriptureDeltaEditFactory } from '@sillsdev/lynx-delta'; +import { StandardRuleSets } from '@sillsdev/lynx-punctuation-checker'; import { Canon } from '@sillsdev/scripture'; import Delta, { Op } from 'quill-delta'; import { obj } from 'realtime-server/lib/esm/common/utils/obj-path'; +import { LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { getTextDocId } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; -import { map, merge, Observable, Subscription, switchMap } from 'rxjs'; +import { Observable, Subscription, switchMap } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { I18nService } from 'xforge-common/i18n.service'; import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile-doc'; import { TextDocId } from '../../../../core/models/text-doc'; import { SFProjectService } from '../../../../core/sf-project.service'; +import { LynxInsight, LynxInsightAction } from './lynx-insight'; const TEXTS_PATH_TEMPLATE = obj().pathTemplate(p => p.texts); @@ -38,11 +34,14 @@ const TEXTS_PATH_TEMPLATE = obj().pathTemplate(p => p.texts); }) export class LynxWorkspaceService { private readonly documentReader: TextDocReader; - public readonly documentManager: DocumentManager; - public readonly workspace: Workspace; + private readonly documentManager: DocumentManager; + private readonly workspace: Workspace; + private projectId?: string; private textDocId?: TextDocId; private textDocChangeSubscription?: Subscription; private projectDocChangeSubscription?: Subscription; + private readonly curInsights = new Map(); + public readonly rawInsightSource$: Observable; constructor( private readonly projectService: SFProjectService, @@ -59,7 +58,12 @@ export class LynxWorkspaceService { const localizer = new Localizer(); this.workspace = new Workspace({ localizer, - diagnosticProviders: [new TestDiagnosticProvider(localizer, this.documentManager, editFactory)] + diagnosticProviders: [ + ...StandardRuleSets.English.createDiagnosticProviders(localizer, this.documentManager, editFactory, true) + ], + onTypeFormattingProviders: [ + ...StandardRuleSets.English.createOnTypeFormattingProviders(this.documentManager, editFactory) + ] }); this.activatedProjectService.projectDoc$ @@ -68,6 +72,59 @@ export class LynxWorkspaceService { this.activatedBookChapterService.activatedBookChapter$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(bookChapter => this.onBookChapterActivated(bookChapter)); + + this.rawInsightSource$ = this.workspace.diagnosticsChanged$.pipe( + switchMap(async e => { + if (e.diagnostics.length === 0) { + this.curInsights.delete(e.uri); + } else { + const doc = await this.documentManager.get(e.uri); + const insights: LynxInsight[] = []; + if (doc != null) { + const textDocIdParts = e.uri.split(':', 3); + const textDocId = new TextDocId( + textDocIdParts[0], + Canon.bookIdToNumber(textDocIdParts[1]), + parseInt(textDocIdParts[2]) + ); + for (const diagnostic of e.diagnostics) { + let type: LynxInsightType = 'info'; + switch (diagnostic.severity) { + case DiagnosticSeverity.Information: + case DiagnosticSeverity.Hint: + type = 'info'; + break; + case DiagnosticSeverity.Warning: + type = 'warning'; + break; + case DiagnosticSeverity.Error: + type = 'error'; + break; + } + const start = doc.offsetAt(diagnostic.range.start); + const end = doc.offsetAt(diagnostic.range.end); + insights.push({ + id: uuidv4(), + type, + textDocId, + range: { index: start, length: end - start }, + code: diagnostic.code.toString(), + source: diagnostic.source, + description: diagnostic.message, + moreInfo: diagnostic.moreInfo, + data: diagnostic.data + }); + } + } + this.curInsights.set(e.uri, insights); + } + return Array.from(this.curInsights.values()).flat(); + }) + ); + } + + get currentInsights(): ReadonlyMap { + return this.curInsights; } async init(): Promise { @@ -78,12 +135,97 @@ export class LynxWorkspaceService { }); } + async getActions(insight: LynxInsight): Promise { + const doc = await this.documentManager.get(insight.textDocId.toString()); + if (doc == null) { + return []; + } + let severity = DiagnosticSeverity.Information; + switch (insight.type) { + case 'info': + severity = DiagnosticSeverity.Information; + break; + case 'warning': + severity = DiagnosticSeverity.Warning; + break; + case 'error': + severity = DiagnosticSeverity.Error; + break; + } + const diagnostic: Diagnostic = { + code: insight.code, + source: insight.source, + range: { + start: doc.positionAt(insight.range.index), + end: doc.positionAt(insight.range.index + insight.range.length) + }, + message: insight.description, + severity, + data: insight.data + }; + const fixes = await this.workspace.getDiagnosticFixes(insight.textDocId.toString(), diagnostic); + return fixes.map(fix => ({ + id: uuidv4(), + insight, + label: fix.title, + isPrimary: fix.isPreferred, + ops: fix.edits + })); + } + + async getOnTypeEdits(delta: Delta): Promise { + const curDocUri = this.textDocId?.toString(); + if (curDocUri == null) { + return []; + } + const ops = delta.ops; + let offset: number; + let text: string; + if (ops.length === 1 && typeof ops[0].insert === 'string') { + offset = 0; + text = ops[0].insert; + } else if (ops.length === 2 && typeof ops[0].retain === 'number' && typeof ops[1].insert === 'string') { + offset = ops[0].retain; + text = ops[1].insert; + } else { + return []; + } + + const doc = await this.documentManager.get(curDocUri); + if (doc == null) { + return []; + } + const edits: Delta[] = []; + for (const ch of this.workspace.getOnTypeTriggerCharacters()) { + let startIndex = 0; + while (startIndex < text.length) { + const chIndex = text.indexOf(ch, startIndex); + if (chIndex >= 0) { + const chEdits = await this.workspace.getOnTypeEdits(curDocUri, doc.positionAt(offset + chIndex), ch); + + if (chEdits != null && chEdits.length > 0) { + edits.push(new Delta(chEdits)); + } + startIndex = chIndex + ch.length; + } else { + break; + } + } + } + return edits; + } + private async onProjectActivated(projectDoc: SFProjectProfileDoc | undefined): Promise { + if (projectDoc?.id === this.projectId) { + return; + } + if (this.projectDocChangeSubscription != null) { this.projectDocChangeSubscription.unsubscribe(); this.projectDocChangeSubscription = undefined; } + this.projectId = projectDoc?.id; this.documentReader.textDocIds = getTextDocIds(projectDoc); await this.documentManager.reset(); if (projectDoc != null) { @@ -154,8 +296,8 @@ class TextDocReader implements DocumentReader { constructor(private readonly projectService: SFProjectService) {} - keys(): string[] { - return Array.from(this.textDocIds); + keys(): Promise { + return Promise.resolve(Array.from(this.textDocIds)); } async read(uri: string): Promise> { @@ -186,87 +328,3 @@ function* setDifference(x: Set, y: Set): Iterable { } } } - -export class TestDiagnosticProvider implements DiagnosticProvider { - public readonly id = 'test'; - public readonly diagnosticsChanged$: Observable; - - constructor( - private readonly localizer: Localizer, - private readonly documents: DocumentAccessor, - private readonly editFactory: ScriptureDeltaEditFactory - ) { - this.diagnosticsChanged$ = merge( - documents.opened$.pipe( - map(e => ({ - uri: e.document.uri, - version: e.document.version, - diagnostics: this.validateDocument(e.document) - })) - ), - documents.changed$.pipe( - map(e => ({ - uri: e.document.uri, - version: e.document.version, - diagnostics: this.validateDocument(e.document) - })) - ), - documents.closed$.pipe( - switchMap(async e => { - const doc = await this.documents.get(e.uri); - return { uri: e.uri, version: doc?.version, diagnostics: [] }; - }) - ) - ); - } - - init(): Promise { - return Promise.resolve(); - } - - async getDiagnostics(uri: string): Promise { - const doc = await this.documents.get(uri); - if (doc == null) { - return []; - } - return this.validateDocument(doc); - } - - async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]> { - const doc = await this.documents.get(uri); - if (doc == null) { - return []; - } - const fixes: DiagnosticFix[] = []; - if (diagnostic.code === 'tst0001') { - fixes.push({ - title: 'Fix the problem', - isPreferred: true, - diagnostic, - edits: this.editFactory.createScriptureEdit( - doc, - { start: diagnostic.range.start, end: diagnostic.range.start }, - new ScriptureText('Problem fixed! ') - ) - }); - } - return fixes; - } - - private validateDocument(doc: ScriptureDocument): Diagnostic[] { - const firstVerses = doc.findNodes(n => n.type === ScriptureNodeType.Verse && (n as ScriptureVerse).number === '1'); - const diagnostics: Diagnostic[] = []; - for (const verseNode of firstVerses) { - if (verseNode.next?.type === ScriptureNodeType.Text && !verseNode.next.getText().includes('Problem fixed!')) { - diagnostics.push({ - code: 'tst0001', - source: this.id, - severity: DiagnosticSeverity.Error, - message: 'Test error', - range: verseNode.next.range - }); - } - } - return diagnostics; - } -} diff --git a/src/SIL.XForge.Scripture/Startup.cs b/src/SIL.XForge.Scripture/Startup.cs index 5ae5425e0e2..5630eb70070 100644 --- a/src/SIL.XForge.Scripture/Startup.cs +++ b/src/SIL.XForge.Scripture/Startup.cs @@ -80,6 +80,7 @@ public class Startup private static readonly HashSet DevelopmentSpaPostRoutes = ["sockjs-node"]; private static readonly HashSet ProductionSpaPostRoutes = []; private static readonly HashSet SpaPostRoutes = []; + private const string SpaGetRoutesLynxPrefix = "node_modules_sillsdev_lynx"; public Startup(IConfiguration configuration, IWebHostEnvironment env, ILoggerFactory loggerFactory) { @@ -339,6 +340,8 @@ internal bool IsSpaRoute(HttpContext context) int periodIndex = path.IndexOf("."); prefix = prefix[..(periodIndex - 1)]; } + if (context.Request.Method == HttpMethods.Get && prefix.StartsWith(SpaGetRoutesLynxPrefix)) + return true; return (context.Request.Method == HttpMethods.Get && SpaGetRoutes.Contains(prefix)) || (context.Request.Method == HttpMethods.Post && SpaPostRoutes.Contains(prefix)); } From a00d4290a8a49428d67f340b54cf81c9ff55ba75 Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Mon, 24 Mar 2025 15:20:58 -0500 Subject: [PATCH 05/41] Update lynx-punctuation-checker dependency - focus editor after quick fix - on type edits need position after typing - use concatMap instead of switchMap to ensure correct order of diagnostic changed events --- .../ClientApp/package-lock.json | 8 +- .../ClientApp/package.json | 2 +- .../editor/lynx/insights/lynx-editor.ts | 10 ++ .../lynx-insight-overlay.component.ts | 1 + .../lynx/insights/lynx-workspace.service.ts | 101 +++++++++--------- 5 files changed, 67 insertions(+), 55 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/package-lock.json b/src/SIL.XForge.Scripture/ClientApp/package-lock.json index 5dd1fb74f12..0386e3820c0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package-lock.json +++ b/src/SIL.XForge.Scripture/ClientApp/package-lock.json @@ -28,7 +28,7 @@ "@ngneat/transloco-locale": "^4.1.0", "@sillsdev/lynx": "^0.3.4", "@sillsdev/lynx-delta": "^0.2.1", - "@sillsdev/lynx-punctuation-checker": "^0.1.3", + "@sillsdev/lynx-punctuation-checker": "^0.1.4", "@sillsdev/machine": "^2.4.2", "@sillsdev/scripture": "1.4.1", "angular-file": "^4.0.2", @@ -6622,9 +6622,9 @@ } }, "node_modules/@sillsdev/lynx-punctuation-checker": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@sillsdev/lynx-punctuation-checker/-/lynx-punctuation-checker-0.1.3.tgz", - "integrity": "sha512-myTLBG53Ve3dQaIBHddLGotQTQLznlRDiD+d/wTZiEk+qRtHtdtqzVvZrYIiE9vIA6v1OQxdHegjTvxUWNkc/Q==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@sillsdev/lynx-punctuation-checker/-/lynx-punctuation-checker-0.1.4.tgz", + "integrity": "sha512-G1xH2cWLPlvZLZmYdcGY4QEq/m0u5KYZ4vQlphA1R9J13uwJCCVSKItjaYFHIKiQMhEjYKUh36ew/EkVc4uhgg==", "license": "MIT", "dependencies": { "@sillsdev/lynx": "^0.3.3", diff --git a/src/SIL.XForge.Scripture/ClientApp/package.json b/src/SIL.XForge.Scripture/ClientApp/package.json index 1e9d61c1a6d..ac4e928484b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package.json +++ b/src/SIL.XForge.Scripture/ClientApp/package.json @@ -52,7 +52,7 @@ "@ngneat/transloco-locale": "^4.1.0", "@sillsdev/lynx": "^0.3.4", "@sillsdev/lynx-delta": "^0.2.1", - "@sillsdev/lynx-punctuation-checker": "^0.1.3", + "@sillsdev/lynx-punctuation-checker": "^0.1.4", "@sillsdev/machine": "^2.4.2", "@sillsdev/scripture": "1.4.1", "angular-file": "^4.0.2", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts index 5ce28f8875f..f5657a079ab 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts @@ -96,6 +96,16 @@ export class LynxEditor { } } + focus(): void { + switch (true) { + case this.isQuill(this.editor): + this.editor.focus(); + break; + default: + throw new Error('Unsupported editor type'); + } + } + get root(): HTMLElement { switch (true) { case this.isQuill(this.editor): diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts index 6a31efc5c04..4582bbc3260 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.ts @@ -108,6 +108,7 @@ export class LynxInsightOverlayComponent implements OnInit, OnDestroy { } this.overlayService.close(); + this.editor.focus(); } dismissInsight(insight: LynxInsight): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts index eebcdd35ee0..95032bec384 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts @@ -2,6 +2,7 @@ import { DestroyRef, Injectable } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Diagnostic, + DiagnosticsChanged, DiagnosticSeverity, DocumentData, DocumentManager, @@ -17,7 +18,7 @@ import { obj } from 'realtime-server/lib/esm/common/utils/obj-path'; import { LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { getTextDocId } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; -import { Observable, Subscription, switchMap } from 'rxjs'; +import { concatMap, Observable, Subscription } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; @@ -73,54 +74,7 @@ export class LynxWorkspaceService { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(bookChapter => this.onBookChapterActivated(bookChapter)); - this.rawInsightSource$ = this.workspace.diagnosticsChanged$.pipe( - switchMap(async e => { - if (e.diagnostics.length === 0) { - this.curInsights.delete(e.uri); - } else { - const doc = await this.documentManager.get(e.uri); - const insights: LynxInsight[] = []; - if (doc != null) { - const textDocIdParts = e.uri.split(':', 3); - const textDocId = new TextDocId( - textDocIdParts[0], - Canon.bookIdToNumber(textDocIdParts[1]), - parseInt(textDocIdParts[2]) - ); - for (const diagnostic of e.diagnostics) { - let type: LynxInsightType = 'info'; - switch (diagnostic.severity) { - case DiagnosticSeverity.Information: - case DiagnosticSeverity.Hint: - type = 'info'; - break; - case DiagnosticSeverity.Warning: - type = 'warning'; - break; - case DiagnosticSeverity.Error: - type = 'error'; - break; - } - const start = doc.offsetAt(diagnostic.range.start); - const end = doc.offsetAt(diagnostic.range.end); - insights.push({ - id: uuidv4(), - type, - textDocId, - range: { index: start, length: end - start }, - code: diagnostic.code.toString(), - source: diagnostic.source, - description: diagnostic.message, - moreInfo: diagnostic.moreInfo, - data: diagnostic.data - }); - } - } - this.curInsights.set(e.uri, insights); - } - return Array.from(this.curInsights.values()).flat(); - }) - ); + this.rawInsightSource$ = this.workspace.diagnosticsChanged$.pipe(concatMap(e => this.onDiagnosticsChanged(e))); } get currentInsights(): ReadonlyMap { @@ -201,7 +155,7 @@ export class LynxWorkspaceService { while (startIndex < text.length) { const chIndex = text.indexOf(ch, startIndex); if (chIndex >= 0) { - const chEdits = await this.workspace.getOnTypeEdits(curDocUri, doc.positionAt(offset + chIndex), ch); + const chEdits = await this.workspace.getOnTypeEdits(curDocUri, doc.positionAt(offset + chIndex + 1), ch); if (chEdits != null && chEdits.length > 0) { edits.push(new Delta(chEdits)); @@ -215,6 +169,53 @@ export class LynxWorkspaceService { return edits; } + private async onDiagnosticsChanged(event: DiagnosticsChanged): Promise { + if (event.diagnostics.length === 0) { + this.curInsights.delete(event.uri); + } else { + const doc = await this.documentManager.get(event.uri); + const insights: LynxInsight[] = []; + if (doc != null) { + const textDocIdParts = event.uri.split(':', 3); + const textDocId = new TextDocId( + textDocIdParts[0], + Canon.bookIdToNumber(textDocIdParts[1]), + parseInt(textDocIdParts[2]) + ); + for (const diagnostic of event.diagnostics) { + let type: LynxInsightType = 'info'; + switch (diagnostic.severity) { + case DiagnosticSeverity.Information: + case DiagnosticSeverity.Hint: + type = 'info'; + break; + case DiagnosticSeverity.Warning: + type = 'warning'; + break; + case DiagnosticSeverity.Error: + type = 'error'; + break; + } + const start = doc.offsetAt(diagnostic.range.start); + const end = doc.offsetAt(diagnostic.range.end); + insights.push({ + id: uuidv4(), + type, + textDocId, + range: { index: start, length: end - start }, + code: diagnostic.code.toString(), + source: diagnostic.source, + description: diagnostic.message, + moreInfo: diagnostic.moreInfo, + data: diagnostic.data + }); + } + } + this.curInsights.set(event.uri, insights); + } + return Array.from(this.curInsights.values()).flat(); + } + private async onProjectActivated(projectDoc: SFProjectProfileDoc | undefined): Promise { if (projectDoc?.id === this.projectId) { return; From 51eae1ad4e58769a3a09d838b757a7babe686a7f Mon Sep 17 00:00:00 2001 From: siltomato Date: Fri, 14 Mar 2025 17:20:49 -0400 Subject: [PATCH 06/41] remove 'dismiss' option and filter for MVP --- .../lynx-insight-overlay/lynx-insight-overlay.component.html | 5 +++-- .../lynx-insights-panel-header.component.html | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html index 8296f462d62..ddae5a9ca11 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html @@ -25,9 +25,10 @@

[style.visibility]="menuActions.length === 0 ? 'hidden' : 'visible'" >more_horiz - + +

diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html index 3e793319197..b3333e2af41 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel-header/lynx-insights-panel-header.component.html @@ -38,10 +38,11 @@

{{ t("filter_menu_filter_header") }}

{{ t("filter_menu_filter_" + insightType) }} } -
}
- more_horiz + @if (menuActions.length > 0) { + + more_horiz + + } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss index f466537ac88..29e18d95812 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.scss @@ -73,6 +73,17 @@ $restoreActionColor: #5485e7; display: flex; align-items: center; gap: 1rem; + + > span:first-child { + display: flex; + align-items: center; + + &::before { + content: '•'; + margin-right: 0.6em; + font-size: 1.5em; + } + } } .level-code { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts index b7d508f90e5..ecc003069dd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.ts @@ -304,7 +304,7 @@ export class LynxInsightsPanelComponent implements OnInit { * Processes a single insight to extract appropriate text. */ private processInsightText(insight: LynxInsight, textDoc: TextDoc): LynxInsightWithText { - const maxLength = this.lynxInsightConfig.panelLinkTextMaxLength; + const textGoalLength = this.lynxInsightConfig.panelLinkTextGoalLength; if (!textDoc.data?.ops?.length) { return { ...insight, rangeText: '' }; @@ -317,23 +317,27 @@ export class LynxInsightsPanelComponent implements OnInit { const originalText: string = getText(delta, originalRange); // If original text is long enough, use it directly - if (originalText.length >= maxLength * 0.7) { + if (originalText.length >= textGoalLength * 0.7) { return { ...insight, rangeText: originalText }; } // Get expanded text with padding - const padding: number = Math.floor((maxLength - originalText.length) / 2); + const padding: number = Math.floor((textGoalLength - originalText.length) / 2); const expandedStart: number = Math.max(0, originalRange.index - padding); const expandedEnd: number = Math.min(delta.length(), originalRange.index + originalRange.length + padding); const expandedRange: LynxInsightRange = { index: expandedStart, length: expandedEnd - expandedStart }; const expandedText: string = getText(delta, expandedRange); + const firstSpace = expandedText.indexOf(' '); + const lastSpace = expandedText.lastIndexOf(' '); + // Trim back toward original text stopping at first space (on both ends) - const adjustedStart: number = Math.min(originalRange.index, expandedStart + expandedText.indexOf(' ')); + const adjustedStart: number = + firstSpace >= 0 ? Math.min(originalRange.index, expandedStart + firstSpace) : expandedStart; const adjustedEnd: number = Math.max( originalRange.index + originalRange.length, - expandedStart + expandedText.lastIndexOf(' ') + lastSpace >= 0 ? expandedStart + lastSpace : expandedEnd ); const adjustedExpandedText: string = getText(delta, { index: adjustedStart, length: adjustedEnd - adjustedStart }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts index a482502842b..860ffa22b11 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts @@ -20,7 +20,7 @@ import { SFProjectProfileDoc } from '../../../../core/models/sf-project-profile- import { SF_TYPE_REGISTRY } from '../../../../core/models/sf-type-registry'; import { TextDoc, TextDocId } from '../../../../core/models/text-doc'; import { SFProjectService } from '../../../../core/sf-project.service'; -import { LynxInsight } from './lynx-insight'; +import { LynxInsight, LynxInsightAction } from './lynx-insight'; import { LynxWorkspaceService, TextDocReader } from './lynx-workspace.service'; describe('LynxWorkspaceService', () => { @@ -460,57 +460,64 @@ describe('LynxWorkspaceService', () => { }); describe('getOnTypeEdits', () => { - it('should return edits for trigger characters', fakeAsync(async () => { + it('should return edits for trigger characters', fakeAsync(() => { const env = new TestEnvironment(); env.service['textDocId'] = new TextDocId(PROJECT_ID, BOOK_NUM, CHAPTER_NUM); const delta = new Delta().insert('Hello,'); + let result: Delta[] = []; when(mockWorkspace.getOnTypeEdits(anything(), anything(), anything())).thenResolve([ { retain: 5 }, { insert: ' ' } ]); - const result = await env.service.getOnTypeEdits(delta); + env.service.getOnTypeEdits(delta).then(res => (result = res)); + tick(); expect(result.length).toBe(1); expect(result[0] instanceof Delta).toBe(true); expect(result[0].ops).toEqual([{ retain: 5 }, { insert: ' ' }]); })); - it('should handle multiple trigger characters', fakeAsync(async () => { + it('should handle multiple trigger characters', fakeAsync(() => { const env = new TestEnvironment(); env.service['textDocId'] = new TextDocId(PROJECT_ID, BOOK_NUM, CHAPTER_NUM); const delta = new Delta().insert('Hello, world.'); + let result: Delta[] = []; when(mockWorkspace.getOnTypeEdits(anything(), anything(), ',')).thenResolve([{ retain: 6 }, { insert: ' ' }]); when(mockWorkspace.getOnTypeEdits(anything(), anything(), '.')).thenResolve([{ retain: 13 }, { insert: ' ' }]); - const result = await env.service.getOnTypeEdits(delta); + env.service.getOnTypeEdits(delta).then(res => (result = res)); + tick(); expect(result.length).toBe(2); expect(result[0].ops).toEqual([{ retain: 13 }, { insert: ' ' }]); expect(result[1].ops).toEqual([{ retain: 6 }, { insert: ' ' }]); })); - it('should handle null document when getting on-type edits', fakeAsync(async () => { + it('should handle null document when getting on-type edits', fakeAsync(() => { const env = new TestEnvironment(); env.service['textDocId'] = new TextDocId(PROJECT_ID, BOOK_NUM, CHAPTER_NUM); when(mockDocumentManager.get(anything())).thenReturn(Promise.resolve(undefined)); const delta = new Delta().insert('Hello,'); + let result: Delta[] = []; - const result = await env.service.getOnTypeEdits(delta); + env.service.getOnTypeEdits(delta).then(res => (result = res)); + tick(); expect(result).toEqual([]); })); }); describe('getActions', () => { - it('should get actions for an insight', fakeAsync(async () => { + it('should get actions for an insight', fakeAsync(() => { const env = new TestEnvironment(); - env.init(); const insight = env.createTestInsight(); + let actions: LynxInsightAction[] = []; - const actions = await env.service.getActions(insight); + env.service.getActions(insight).then(res => (actions = res)); + tick(); expect(actions.length).toBe(1); expect(actions[0].label).toBe('Fix issue'); @@ -518,12 +525,14 @@ describe('LynxWorkspaceService', () => { expect(actions[0].ops).toEqual([{ retain: 0 }, { insert: 'corrected' }, { delete: 10 }]); })); - it('should handle null document when getting actions', fakeAsync(async () => { + it('should handle null document when getting actions', fakeAsync(() => { const env = new TestEnvironment(); when(mockDocumentManager.get(anything())).thenReturn(Promise.resolve(undefined)); const insight = env.createTestInsight(); + let actions: LynxInsightAction[] = []; - const actions = await env.service.getActions(insight); + env.service.getActions(insight).then(res => (actions = res)); + tick(); expect(actions).toEqual([]); })); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts index 0d0c5b9cff0..6210fba4940 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-editor-segment.service.ts @@ -59,10 +59,6 @@ export class QuillEditorSegmentService extends EditorSegmentService { if (rangeEnd > segmentRange.index) { segmentRefs.push(ref); } - - if (rangeEnd <= segEnd) { - break; - } } } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts index 79cf8052381..32d644a5c3e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts @@ -46,7 +46,7 @@ export class QuillInsightRenderService extends InsightRenderService { const formats: StringMap = {}; for (const type of LynxInsightTypes) { - formats[`${this.prefix}-${type}`] = false; + formats[`${this.prefix}-${type}`] = null; } editor.formatText(0, editor.getLength(), formats); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-lynx-editor-adapter.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-lynx-editor-adapter.ts index e78cbe4b115..7c2e5b0956e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-lynx-editor-adapter.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-lynx-editor-adapter.ts @@ -13,20 +13,20 @@ export class QuillLynxEditorAdapter implements LynxEditor { return this.editor; } - insertText(index: number, text: string, formats?: any): void { - this.editor.insertText(index, text, formats); + insertText(index: number, text: string, formats: Record, source: EmitterSource): void { + this.editor.insertText(index, text, formats, source); } - deleteText(index: number, length: number): void { - this.editor.deleteText(index, length); + deleteText(index: number, length: number, source: EmitterSource): void { + this.editor.deleteText(index, length, source); } getLength(): number { return this.editor.getLength(); } - formatText(index: number, length: number, formats: any): void { - this.editor.formatText(index, length, formats); + formatText(index: number, length: number, formats: Record, source: EmitterSource): void { + this.editor.formatText(index, length, formats, source); } setContents(delta: any, source: EmitterSource): void { @@ -45,8 +45,8 @@ export class QuillLynxEditorAdapter implements LynxEditor { return this.editor.getBounds(index, length); } - updateContents(delta: Delta | DeltaOperation[]): void { - this.editor.updateContents(delta, 'user'); + updateContents(delta: Delta | DeltaOperation[], source: EmitterSource): void { + this.editor.updateContents(delta, source); } focus(): void { From 649b46e7dd01e35badeb85fba7cb82c73e623547 Mon Sep 17 00:00:00 2001 From: siltomato Date: Mon, 5 May 2025 16:15:46 -0400 Subject: [PATCH 35/41] fix incorrect insight range in editor after note embed --- .../app/shared/text/text-view-model.spec.ts | 148 ++++++++++++++++++ .../src/app/shared/text/text-view-model.ts | 101 ++++++++++++ .../src/app/shared/text/text.component.html | 2 +- .../base-services/insight-render.service.ts | 3 +- .../lynx-insight-editor-objects.component.ts | 6 +- .../quill-insight-render.service.ts | 12 +- 6 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.spec.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.spec.ts new file mode 100644 index 00000000000..fcbde662f32 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.spec.ts @@ -0,0 +1,148 @@ +import { TestBed } from '@angular/core/testing'; +import Quill, { Delta, Range } from 'quill'; +import { instance, mock, when } from 'ts-mockito'; +import { configureTestingModule } from 'xforge-common/test-utils'; +import { TextViewModel } from './text-view-model'; + +describe('TextViewModel', () => { + const mockQuill = mock(); + let testDelta: Delta; + + configureTestingModule(() => ({ + providers: [TextViewModel, { provide: Quill, useMock: mockQuill }] + })); + + describe('dataRangeToEditorRange', () => { + it('should return same range when there are no note embeds', () => { + const env = new TestEnvironment(); + env.setupBasicContent(); + + const dataRange: Range = { index: 1, length: 4 }; + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + expect(result).toEqual(dataRange); + }); + + it('should adjust range when note embeds exist before the range', () => { + const env = new TestEnvironment(); + env.setupContentWithEmbedBefore(); + + const dataRange: Range = { index: 5, length: 6 }; // ' world' + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Index should be increased by 1 to account for note embed + expect(result).toEqual({ index: 6, length: 6 }); + }); + + it('should handle note embeds within the range', () => { + const env = new TestEnvironment(); + env.setupContentWithEmbedInMiddle(); + + const dataRange: Range = { index: 0, length: 11 }; // 'Hello world' + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Length should be increased by 1 to account for note embed + expect(result).toEqual({ index: 0, length: 12 }); + }); + + it('should handle multiple note embeds', () => { + const env = new TestEnvironment(); + env.setupContentWithMultipleEmbeds(); + + const dataRange: Range = { index: 0, length: 11 }; // 'Hello world' + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Length should be increased by 2 to account for both note embeds + expect(result).toEqual({ index: 0, length: 13 }); + }); + + it('should handle a zero-length range before embed', () => { + const env = new TestEnvironment(); + env.setupContentWithEmbedInMiddle(); + + const dataRange: Range = { index: 4, length: 0 }; // Start of 'o' in 'Hello' + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Index should remain the same since embed is after this position + expect(result).toEqual({ index: 4, length: 0 }); + }); + + it('should handle a zero-length range after an embed', () => { + const env = new TestEnvironment(); + env.setupContentWithEmbedBefore(); + + const dataRange: Range = { index: 5, length: 0 }; // Start of ' world' + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Index should be increased by 1 because of embed + expect(result).toEqual({ index: 6, length: 0 }); + }); + + it('should handle range at the end of document', () => { + const env = new TestEnvironment(); + env.setupContentWithEmbedBefore(); + + const dataRange: Range = { index: 11, length: 0 }; // End of text + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Index should be 12 due to the embed + expect(result).toEqual({ index: 12, length: 0 }); + }); + + it('should handle non-string inserts (other embeds)', () => { + const env = new TestEnvironment(); + env.setupContentWithOtherEmbed(); + + const dataRange: Range = { index: 0, length: 12 }; // 'Hello {image} world' + const result: Range = env.textViewModel.dataRangeToEditorRange(dataRange); + + // Length should be increased by 1 to account for note embed + expect(result).toEqual({ index: 0, length: 13 }); + }); + }); + + class TestEnvironment { + readonly textViewModel: TextViewModel; + + constructor() { + this.textViewModel = TestBed.inject(TextViewModel); + this.textViewModel.editor = instance(mockQuill); + } + + setupBasicContent(): void { + testDelta = new Delta([{ insert: 'Hello world' }]); + when(mockQuill.getContents()).thenReturn(testDelta); + } + + setupContentWithEmbedBefore(): void { + testDelta = new Delta([{ insert: { 'note-thread-embed': true } }, { insert: 'Hello' }, { insert: ' world' }]); + when(mockQuill.getContents()).thenReturn(testDelta); + } + + setupContentWithEmbedInMiddle(): void { + testDelta = new Delta([{ insert: 'Hello ' }, { insert: { 'note-thread-embed': true } }, { insert: 'world' }]); + when(mockQuill.getContents()).thenReturn(testDelta); + } + + setupContentWithMultipleEmbeds(): void { + testDelta = new Delta([ + { insert: 'Hello' }, + { insert: { 'note-thread-embed': true } }, + { insert: ' w' }, + { insert: { 'note-thread-embed': true } }, + { insert: 'orld' } + ]); + when(mockQuill.getContents()).thenReturn(testDelta); + } + + setupContentWithOtherEmbed(): void { + testDelta = new Delta([ + { insert: 'Hello ' }, + { insert: { image: 'url' } }, + { insert: { 'note-thread-embed': true } }, + { insert: ' world' } + ]); + when(mockQuill.getContents()).thenReturn(testDelta); + } + } +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts index 91aabbd60db..057d3ac1d51 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text-view-model.ts @@ -485,6 +485,107 @@ export class TextViewModel implements OnDestroy { }; } + /** + * Translates a range from the data model (without note embeds) to the editor (with note embeds). + * @param dataRange The range (index, length) in the data model. + * @returns The corresponding range in the editor model, or the original range as a fallback. + */ + dataRangeToEditorRange(dataRange: Range): Range { + const editor: Quill = this.checkEditor(); + const editorDelta: Delta = editor.getContents(); + + if (editorDelta.ops == null || dataRange.length < 0) { + return dataRange; // Return original as fallback + } + + const targetStartIndex: number = dataRange.index; + const targetEndIndex: number = dataRange.index + dataRange.length; + const isZeroLengthRange: boolean = dataRange.length === 0; + + let editorPos: number = 0; + let dataPos: number = 0; + let startEditorPos: number = -1; + let endEditorPos: number = -1; + + // Iterate ops, tracking parallel positions with/without note embeds. + // Note embeds advance only editor position. + // String inserts and other embeds advance both data and editor positions equally. + for (const op of editorDelta.ops) { + // Early exit if we've found both positions + if (startEditorPos !== -1 && endEditorPos !== -1) { + break; + } + + if (op.insert == null) { + continue; + } + + // Note embeds only advance editor position + if (op.insert?.['note-thread-embed'] != null) { + editorPos++; + continue; + } + + const isStringInsert: boolean = isString(op.insert); + const contentLength: number = isStringInsert ? (op.insert.length as number) : 1; + + // Skip content before target start + if (startEditorPos === -1 && dataPos + contentLength <= targetStartIndex) { + dataPos += contentLength; + editorPos += contentLength; + continue; + } + + // Skip further processing if this content is after end position + if (!isZeroLengthRange && startEditorPos !== -1 && dataPos >= targetEndIndex) { + break; + } + + // Check for start position + if (startEditorPos === -1) { + if (!isStringInsert) { + // For embeds, only exact position matches + if (dataPos === targetStartIndex) { + startEditorPos = editorPos; + } + } else { + // For strings, check if position is within string + if (dataPos <= targetStartIndex && dataPos + contentLength > targetStartIndex) { + startEditorPos = editorPos + (targetStartIndex - dataPos); + } + } + + if (isZeroLengthRange && startEditorPos !== -1) { + return { index: startEditorPos, length: 0 }; + } + } + + // Check for end position + if (!isZeroLengthRange && endEditorPos === -1) { + if (!isStringInsert) { + // For embeds, only exact position matches + if (dataPos === targetEndIndex) { + endEditorPos = editorPos; + } + } else { + // For strings, check if position is within string + if (dataPos < targetEndIndex && dataPos + contentLength >= targetEndIndex) { + endEditorPos = editorPos + (targetEndIndex - dataPos); + } + } + } + + // Update positions + dataPos += contentLength; + editorPos += contentLength; + } + + startEditorPos = startEditorPos === -1 ? editorPos : startEditorPos; + endEditorPos = endEditorPos === -1 ? editorPos : endEditorPos; + + return { index: startEditorPos, length: endEditorPos - startEditorPos }; + } + private countSequentialEmbedsStartingAt(startEditorPosition: number): number { const embedEditorPositions = this.embedPositions; // add up the leading embeds diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html index 5717f7a9447..3f7234a45e6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.html @@ -1,5 +1,5 @@ @if (showInsights && editor != null) { - + } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts index 4ec0c61f3f6..a28677c8b2c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; +import { TextViewModel } from '../../../../../shared/text/text-view-model'; import { LynxableEditor } from '../lynx-editor'; import { LynxInsight } from '../lynx-insight'; @Injectable() export abstract class InsightRenderService { - abstract render(insights: LynxInsight[], editor: LynxableEditor): void; + abstract render(insights: LynxInsight[], editor: LynxableEditor, editorViewModel: TextViewModel): void; abstract removeAllInsightFormatting(editor: LynxableEditor): void; abstract renderActionOverlay(insights: LynxInsight[], editor: LynxableEditor, actionOverlayActive: boolean): void; abstract renderCursorActiveState(insightIds: string[], editor: LynxableEditor): void; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts index ab7f40a353d..77cab8a65ff 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts @@ -4,6 +4,7 @@ import { Delta } from 'quill'; import { asapScheduler, combineLatest, EMPTY, filter, fromEvent, merge, switchMap, tap } from 'rxjs'; import { map, observeOn, scan } from 'rxjs/operators'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { TextViewModel } from '../../../../../shared/text/text-view-model'; import { EditorReadyService } from '../base-services/editor-ready.service'; import { InsightRenderService } from '../base-services/insight-render.service'; import { LynxableEditor } from '../lynx-editor'; @@ -24,6 +25,7 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { private readonly dataIdProp = LynxInsightBlot.idDatasetPropName; @Input() editor?: LynxableEditor; + @Input() editorViewModel?: TextViewModel; constructor( private readonly destroyRef: DestroyRef, @@ -35,7 +37,7 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - if (this.editor == null) { + if (this.editor == null || this.editorViewModel == null) { return; } @@ -72,7 +74,7 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { return merge( // Render blots when insights change this.insightState.filteredChapterInsights$.pipe( - tap(insights => this.insightRenderService.render(insights, this.editor!)) + tap(insights => this.insightRenderService.render(insights, this.editor!, this.editorViewModel!)) ), // Check display state to render action overlay or cursor active state this.insightState.displayState$.pipe( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts index 32d644a5c3e..a752ecd79c0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts @@ -3,6 +3,7 @@ import Quill, { Delta } from 'quill'; import { LynxInsightTypes } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; import { StringMap } from 'rich-text'; import { take, takeUntil } from 'rxjs'; +import { TextViewModel } from '../../../../../shared/text/text-view-model'; import { InsightRenderService } from '../base-services/insight-render.service'; import { LynxInsight } from '../lynx-insight'; import { LynxInsightOverlayRef, LynxInsightOverlayService } from '../lynx-insight-overlay.service'; @@ -30,13 +31,13 @@ export class QuillInsightRenderService extends InsightRenderService { /** * Renders the insights in the editor, applying formatting, action menus, and attention (opacity overlay). */ - render(insights: LynxInsight[], editor: Quill | undefined): void { + render(insights: LynxInsight[], editor: Quill | undefined, editorViewModel: TextViewModel): void { // Ensure text is more than just '\n' if (editor == null || editor.getLength() <= 1) { return; } - this.refreshInsightFormatting(insights, editor); + this.refreshInsightFormatting(insights, editor, editorViewModel); } /** @@ -56,13 +57,16 @@ export class QuillInsightRenderService extends InsightRenderService { * Creates a delta with all the insights' formatting applied, and sets the editor contents to that delta. * This avoids multiple calls to quill `formatText`, which will re-render the DOM after each call. */ - private refreshInsightFormatting(insights: LynxInsight[], editor: Quill): void { + private refreshInsightFormatting(insights: LynxInsight[], editor: Quill, editorViewModel: TextViewModel): void { // Group insights by type and range const insightsByTypeAndRange = new Map>(); for (const insight of insights) { + // Translate dataRange to editorRange (adjust for note embeds) + const editorRange = editorViewModel.dataRangeToEditorRange(insight.range); + const typeKey = `${this.prefix}-${insight.type}`; - const rangeKey = `${insight.range.index}:${insight.range.length}`; + const rangeKey = `${editorRange.index}:${editorRange.length}`; if (!insightsByTypeAndRange.has(typeKey)) { insightsByTypeAndRange.set(typeKey, new Map()); From 32f04f7ee1a7a74ca45b84aa8fdd11b3cdc0cd0d Mon Sep 17 00:00:00 2001 From: siltomato Date: Mon, 5 May 2025 18:42:35 -0400 Subject: [PATCH 36/41] add backend lynx user data model --- .../Models/LynxInsightUserData.cs | 61 +++++++++++++++++++ .../Models/SFProjectUserConfig.cs | 1 + 2 files changed, 62 insertions(+) create mode 100644 src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs diff --git a/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs b/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs new file mode 100644 index 00000000000..2f292c2e50b --- /dev/null +++ b/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Newtonsoft.Json.Converters; + +namespace SIL.XForge.Scripture.Models; + +public class LynxInsightUserData +{ + public LynxInsightPanelUserData? PanelData { get; set; } +} + +public class LynxInsightPanelUserData +{ + public bool IsOpen { get; set; } + public LynxInsightFilter Filter { get; set; } + public LynxInsightSortOrder SortOrder { get; set; } +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum LynxInsightType +{ + [EnumMember(Value = "info")] + Info, + + [EnumMember(Value = "warning")] + Warning, + + [EnumMember(Value = "error")] + Error, +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum LynxInsightFilterScope +{ + [EnumMember(Value = "project")] + Project, + + [EnumMember(Value = "book")] + Book, + + [EnumMember(Value = "chapter")] + Chapter, +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum LynxInsightSortOrder +{ + [EnumMember(Value = "severity")] + Severity, + + [EnumMember(Value = "appearance")] + Appearance, +} + +public class LynxInsightFilter +{ + public List Types { get; set; } = []; + public LynxInsightFilterScope Scope { get; set; } + public bool? IncludeDismissed { get; set; } +} diff --git a/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs b/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs index 4b18b4a07d7..095297499f3 100644 --- a/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs +++ b/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs @@ -28,6 +28,7 @@ public class SFProjectUserConfig : ProjectData public List AnswerRefsRead { get; set; } = []; public List CommentRefsRead { get; set; } = []; public List EditorTabsOpen { get; set; } = []; + public LynxInsightUserData? LynxInsightUserData { get; set; } public string? SelectedQuestionRef { get; set; } [Obsolete("For backwards compatibility with older frontend clients. Deprecated September 2024.")] From f547d795f7b39f92cd8bfa785973258fc3420676 Mon Sep 17 00:00:00 2001 From: siltomato Date: Mon, 5 May 2025 18:43:23 -0400 Subject: [PATCH 37/41] use spread syntax --- .../quill-format-registry.service.ts | 2 +- .../editor/lynx/insights/lynx-insight-state.service.ts | 4 ++-- .../editor/lynx/insights/lynx-workspace.service.spec.ts | 4 ++-- .../translate/editor/lynx/insights/lynx-workspace.service.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts index 35001d5aaba..1967dc63604 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.ts @@ -37,6 +37,6 @@ export class QuillFormatRegistryService { } getRegisteredFormats(): string[] { - return Array.from(this.registeredFormats); + return [...this.registeredFormats]; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts index 1ef5496741a..f1edc6b3ae6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.ts @@ -213,13 +213,13 @@ export class LynxInsightStateService { // Ensure no duplicates const dismissedIds = new Set(this.dismissedInsightIdsSource$.value); ids.forEach(id => dismissedIds.add(id)); - this.dismissedInsightIdsSource$.next(Array.from(dismissedIds)); + this.dismissedInsightIdsSource$.next([...dismissedIds]); } restoreDismissedInsights(ids: string[]): void { const dismissedIds = new Set(this.dismissedInsightIdsSource$.value); ids.forEach(id => dismissedIds.delete(id)); - this.dismissedInsightIdsSource$.next(Array.from(dismissedIds)); + this.dismissedInsightIdsSource$.next([...dismissedIds]); } updateFilter(filter: Partial): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts index 860ffa22b11..28b1a4e186b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.spec.ts @@ -325,13 +325,13 @@ describe('LynxWorkspaceService', () => { const insight = env.createTestInsight(); env.addInsightToService(insight); - expect(Array.from(env.service.currentInsights.values()).flat().length).toBeGreaterThan(0); + expect([...env.service.currentInsights.values()].flat().length).toBeGreaterThan(0); env.service['projectId'] = 'different-id'; env.triggerProjectChange('new-project'); tick(); - expect(Array.from(env.service.currentInsights.values()).flat().length).toBe(0); + expect([...env.service.currentInsights.values()].flat().length).toBe(0); })); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts index ee40e7e278d..ff1821699d5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-workspace.service.ts @@ -207,7 +207,7 @@ export class LynxWorkspaceService { } this.curInsights.set(event.uri, insights); } - return Array.from(this.curInsights.values()).flat(); + return [...this.curInsights.values()].flat(); } private async onProjectActivated(projectDoc: SFProjectProfileDoc | undefined): Promise { @@ -297,7 +297,7 @@ export class TextDocReader implements DocumentReader { constructor(private readonly projectService: SFProjectService) {} keys(): Promise { - return Promise.resolve(Array.from(this.textDocIds)); + return Promise.resolve([...this.textDocIds]); } async read(uri: string): Promise> { From ac9f0d3a140898df7620ea6fe902bc52cced3e1e Mon Sep 17 00:00:00 2001 From: siltomato Date: Tue, 6 May 2025 19:35:35 -0400 Subject: [PATCH 38/41] misc code review changes --- .../src/app/shared/svg-icons/lynx-icons.ts | 48 +++---- .../quill-format-registry.service.spec.ts | 0 .../translate/editor/editor.component.html | 19 +-- .../lynx-insight-action-prompt.component.ts | 4 +- ...lynx-insight-editor-objects.component.scss | 0 .../lynx-insight-editor-objects.component.ts | 3 +- .../lynx-insight-filter.service.spec.ts | 6 +- .../lynx-insight-overlay.component.html | 128 +++++++++--------- .../lynx-insight-overlay.component.scss | 6 - ...t-scroll-position-indicator.component.html | 2 +- ...nx-insight-status-indicator.component.html | 2 +- .../lynx-insights-panel.component.html | 7 +- .../lynx-insights-panel.component.scss | 10 +- .../lynx-insights-panel.component.ts | 27 ++-- .../src/assets/i18n/non_checking_en.json | 6 + .../src/xforge-common/i18n.service.spec.ts | 17 +++ .../src/xforge-common/i18n.service.ts | 1 - .../src/xforge-common/includes.pipe.spec.ts | 48 ++++++- 18 files changed, 183 insertions(+), 151 deletions(-) delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.spec.ts delete mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.scss diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts index c8cd07b267c..9b81cc45f85 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/svg-icons/lynx-icons.ts @@ -1,48 +1,34 @@ export const lynxIcons = { - lynx_info: ` - - - - - - - - + lynx_info: ` + `, - lynx_warning: ` - + + 16c-44.2 0-80 35.8-80 80z" fill="currentColor"/> `, - lynx_error: ` + lynx_error: ` + fill="currentColor"/> `, - lynx_checkmark: ` + lynx_checkmark: ` + .29-.71l-.04.05z" fill="currentColor"/> ` }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-format-registry.service.spec.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html index 8befffdb1f2..eff94cb37d3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.html @@ -244,15 +244,16 @@
- - - + @if (showInsights) { + + + + } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts index 5e47d3715c7..54396778352 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-action-prompt/lynx-insight-action-prompt.component.ts @@ -32,7 +32,7 @@ export class LynxInsightActionPromptComponent implements OnInit { } activeInsights: LynxInsight[] = []; - isLtr: boolean = this.dir.value === 'ltr'; + isRtl: boolean = this.dir.value === 'rtl'; // Adjust to move prompt up so less text is hidden private readonly defaultLineHeight = 9; @@ -106,7 +106,7 @@ export class LynxInsightActionPromptComponent implements OnInit { const offsetBounds: Bounds | undefined = this.getPromptOffset(); if (offsetBounds != null) { - const boundsEnd: number = this.isLtr ? offsetBounds.right : offsetBounds.left; + const boundsEnd: number = this.isRtl ? offsetBounds.left : offsetBounds.right; this.setHostStyle('top', `${offsetBounds.top + this.yOffsetAdjustment}px`); this.setHostStyle('left', `${boundsEnd + this.xOffsetAdjustment}px`); this.setHostStyle('display', 'flex'); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.scss deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts index 77cab8a65ff..9468164786c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts @@ -16,8 +16,7 @@ import { LynxInsightBlot } from '../quill-services/blots/lynx-insight-blot'; @Component({ selector: 'app-lynx-insight-editor-objects', - templateUrl: './lynx-insight-editor-objects.component.html', - styleUrl: './lynx-insight-editor-objects.component.scss' + templateUrl: './lynx-insight-editor-objects.component.html' }) export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { readonly insightSelector = `.${LynxInsightBlot.superClassName}`; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts index 8073fbb4d3c..61d81a4317a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts @@ -80,7 +80,7 @@ describe('LynxInsightFilterService', () => { }); it('should return true for project scope regardless of book/chapter', () => { - const insight = createMockInsight('warning', 42, 3); // Mark 3 + const insight = createMockInsight('warning', 42, 3); // Luke 3 const filter: LynxInsightFilter = { types: ['warning'], scope: 'project', @@ -95,7 +95,7 @@ describe('LynxInsightFilterService', () => { }); it('should return false for book scope when insight is in different book', () => { - const insight = createMockInsight('warning', 42, 3); // Mark 3 + const insight = createMockInsight('warning', 42, 3); // Luke 3 const filter: LynxInsightFilter = { types: ['warning'], scope: 'book', @@ -175,7 +175,7 @@ describe('LynxInsightFilterService', () => { }); it('should return "project" when insight is in different book', () => { - const insight = createMockInsight('warning', 42, 1); // Mark 1 + const insight = createMockInsight('warning', 42, 1); // Luke 1 const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 }; const result = service.getScope(insight, bookChapter); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html index 836fd90ab3d..68ddd3ecbed 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.html @@ -1,73 +1,75 @@ -@if (focusedInsight != null) { -
-

- {{ focusedInsight.description }} - ({{ focusedInsight.code }}) + + @if (focusedInsight != null) { +
+

+ {{ focusedInsight.description }} - @if (focusedInsight.moreInfo != null) { - help_outline - } -

+ @if (focusedInsight.moreInfo != null) { + help_outline + } +

- @if (primaryAction != null) { -
- - content_paste_go - {{ primaryAction.label }} - + @if (primaryAction != null) { +
+ + content_paste_go + {{ primaryAction.label }} + - @if (applyActionShortcut) { - {{ applyActionShortcut }} - } -
- } -
- @if (menuActions.length > 0) { - - more_horiz - + @if (applyActionShortcut) { + {{ applyActionShortcut }} + } +
} - - + +
- - - @if (focusedInsight.moreInfo != null && showMoreInfo) { -
- {{ focusedInsight.moreInfo }} -
- } - - @for (action of menuActions; track action) { -
- -
- } -
-} @else if (insights.length > 1) { -
-

Select for details

- @for (insight of insights; track insight.id) { -
- -

- {{ insight.description }} - ({{ insight.code }}) -

- more_horiz + @if (focusedInsight.moreInfo != null && showMoreInfo) { +
+ {{ focusedInsight.moreInfo }}
} -
-} + + + @for (action of menuActions; track action) { +
+ +
+ } +
+ } @else if (insights.length > 1) { +
+

{{ t("multi_insight_header_select_for_details") }}

+ @for (insight of insights; track insight.id) { +
+ +

+ {{ insight.description }} +

+ more_horiz +
+ } +
+ } + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss index bf81bd73531..83abab7e06a 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-overlay/lynx-insight-overlay.component.scss @@ -129,12 +129,6 @@ h1 { font-weight: 400; line-height: normal; - .code { - color: $secondaryTextColor; - font-size: 0.9em; - display: none; // TODO: is code needed for display, and is there a good way to display it? - } - .more-info-icon { margin-inline-start: auto; color: $secondaryTextColor; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html index 2edaead45f8..c292c58e88d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-scroll-position-indicator/lynx-insight-scroll-position-indicator.component.html @@ -1,3 +1,3 @@ @for (pos of scrollPositions; track pos.id) { -
+
} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html index 3dc268a6541..39d4f6a6668 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-status-indicator/lynx-insight-status-indicator.component.html @@ -1,7 +1,7 @@ @for (insight of insightCountsByType$ | async; track insight.type) {
- {{ insight.count }} + {{ insight.count }}
} @empty { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html index 156c5d7356e..83184fd85ff 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insights-panel/lynx-insights-panel.component.html @@ -13,7 +13,7 @@ >
- {{ node.name }} + {{ node.description }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts index a28677c8b2c..dc2fd9b4fd4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/base-services/insight-render.service.ts @@ -1,11 +1,10 @@ import { Injectable } from '@angular/core'; -import { TextViewModel } from '../../../../../shared/text/text-view-model'; -import { LynxableEditor } from '../lynx-editor'; +import { LynxableEditor, LynxRangeConverter } from '../lynx-editor'; import { LynxInsight } from '../lynx-insight'; @Injectable() export abstract class InsightRenderService { - abstract render(insights: LynxInsight[], editor: LynxableEditor, editorViewModel: TextViewModel): void; + abstract render(insights: LynxInsight[], editor: LynxableEditor, rangeConverter: LynxRangeConverter): void; abstract removeAllInsightFormatting(editor: LynxableEditor): void; abstract renderActionOverlay(insights: LynxInsight[], editor: LynxableEditor, actionOverlayActive: boolean): void; abstract renderCursorActiveState(insightIds: string[], editor: LynxableEditor): void; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts index ccc68fa6238..7a00454757f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-editor.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import Quill, { Delta, Op } from 'quill'; +import Quill, { Delta, Op, Range } from 'quill'; import { QuillLynxEditorAdapter } from './quill-services/quill-lynx-editor-adapter'; export type LynxableEditor = Quill; // Add future editor as union type @@ -19,6 +19,17 @@ export interface LynxEditor { getRoot(): HTMLElement; } +export interface LynxRangeConverter { + /** + * Translates a range from the data model to the editor. + * Useful when embeds that are present only in the editor model may affect + * the insight ranges determined from the data model. + * @param dataRange The range (index, length) in the data model. + * @returns The corresponding range in the editor model, or the original range as a fallback. + */ + dataRangeToEditorRange(dataRange: Range): Range; +} + @Injectable({ providedIn: 'root' }) export class LynxEditorAdapterFactory { getAdapter(editor: LynxableEditor): LynxEditor { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts index 9468164786c..89a7e7f7fa1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-editor-objects/lynx-insight-editor-objects.component.ts @@ -4,10 +4,9 @@ import { Delta } from 'quill'; import { asapScheduler, combineLatest, EMPTY, filter, fromEvent, merge, switchMap, tap } from 'rxjs'; import { map, observeOn, scan } from 'rxjs/operators'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; -import { TextViewModel } from '../../../../../shared/text/text-view-model'; import { EditorReadyService } from '../base-services/editor-ready.service'; import { InsightRenderService } from '../base-services/insight-render.service'; -import { LynxableEditor } from '../lynx-editor'; +import { LynxableEditor, LynxRangeConverter } from '../lynx-editor'; import { LynxInsight, LynxInsightDisplayState, LynxInsightRange } from '../lynx-insight'; import { LynxInsightOverlayService } from '../lynx-insight-overlay.service'; import { LynxInsightStateService } from '../lynx-insight-state.service'; @@ -24,7 +23,7 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { private readonly dataIdProp = LynxInsightBlot.idDatasetPropName; @Input() editor?: LynxableEditor; - @Input() editorViewModel?: TextViewModel; + @Input() lynxRangeConverter?: LynxRangeConverter; constructor( private readonly destroyRef: DestroyRef, @@ -36,7 +35,7 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - if (this.editor == null || this.editorViewModel == null) { + if (this.editor == null || this.lynxRangeConverter == null) { return; } @@ -73,7 +72,7 @@ export class LynxInsightEditorObjectsComponent implements OnInit, OnDestroy { return merge( // Render blots when insights change this.insightState.filteredChapterInsights$.pipe( - tap(insights => this.insightRenderService.render(insights, this.editor!, this.editorViewModel!)) + tap(insights => this.insightRenderService.render(insights, this.editor!, this.lynxRangeConverter!)) ), // Check display state to render action overlay or cursor active state this.insightState.displayState$.pipe( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts index a752ecd79c0..537668e577b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/quill-services/quill-insight-render.service.ts @@ -3,8 +3,8 @@ import Quill, { Delta } from 'quill'; import { LynxInsightTypes } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight'; import { StringMap } from 'rich-text'; import { take, takeUntil } from 'rxjs'; -import { TextViewModel } from '../../../../../shared/text/text-view-model'; import { InsightRenderService } from '../base-services/insight-render.service'; +import { LynxRangeConverter } from '../lynx-editor'; import { LynxInsight } from '../lynx-insight'; import { LynxInsightOverlayRef, LynxInsightOverlayService } from '../lynx-insight-overlay.service'; import { LynxInsightStateService } from '../lynx-insight-state.service'; @@ -31,13 +31,13 @@ export class QuillInsightRenderService extends InsightRenderService { /** * Renders the insights in the editor, applying formatting, action menus, and attention (opacity overlay). */ - render(insights: LynxInsight[], editor: Quill | undefined, editorViewModel: TextViewModel): void { + render(insights: LynxInsight[], editor: Quill | undefined, rangeConverter: LynxRangeConverter): void { // Ensure text is more than just '\n' if (editor == null || editor.getLength() <= 1) { return; } - this.refreshInsightFormatting(insights, editor, editorViewModel); + this.refreshInsightFormatting(insights, editor, rangeConverter); } /** @@ -57,13 +57,13 @@ export class QuillInsightRenderService extends InsightRenderService { * Creates a delta with all the insights' formatting applied, and sets the editor contents to that delta. * This avoids multiple calls to quill `formatText`, which will re-render the DOM after each call. */ - private refreshInsightFormatting(insights: LynxInsight[], editor: Quill, editorViewModel: TextViewModel): void { + private refreshInsightFormatting(insights: LynxInsight[], editor: Quill, rangeConverter: LynxRangeConverter): void { // Group insights by type and range const insightsByTypeAndRange = new Map>(); for (const insight of insights) { // Translate dataRange to editorRange (adjust for note embeds) - const editorRange = editorViewModel.dataRangeToEditorRange(insight.range); + const editorRange = rangeConverter.dataRangeToEditorRange(insight.range); const typeKey = `${this.prefix}-${insight.type}`; const rangeKey = `${editorRange.index}:${editorRange.length}`; From 78a3e3732411f1caa3a2ad16c9a1b5f744ea4d33 Mon Sep 17 00:00:00 2001 From: siltomato Date: Thu, 8 May 2025 15:22:19 -0400 Subject: [PATCH 41/41] correct backend types --- src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs | 2 +- src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs b/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs index 2f292c2e50b..a1d75a70a03 100644 --- a/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs +++ b/src/SIL.XForge.Scripture/Models/LynxInsightUserData.cs @@ -13,7 +13,7 @@ public class LynxInsightUserData public class LynxInsightPanelUserData { public bool IsOpen { get; set; } - public LynxInsightFilter Filter { get; set; } + public LynxInsightFilter Filter { get; set; } = new(); public LynxInsightSortOrder SortOrder { get; set; } } diff --git a/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs b/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs index 095297499f3..46c546bb66f 100644 --- a/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs +++ b/src/SIL.XForge.Scripture/Models/SFProjectUserConfig.cs @@ -28,7 +28,7 @@ public class SFProjectUserConfig : ProjectData public List AnswerRefsRead { get; set; } = []; public List CommentRefsRead { get; set; } = []; public List EditorTabsOpen { get; set; } = []; - public LynxInsightUserData? LynxInsightUserData { get; set; } + public LynxInsightUserData? LynxInsightState { get; set; } public string? SelectedQuestionRef { get; set; } [Obsolete("For backwards compatibility with older frontend clients. Deprecated September 2024.")]