From c98cda81ca1f58bb50a31cd92076c69d632e28bb Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 09:55:07 +0100 Subject: [PATCH 01/11] responsive header + sorting for calculated columns --- .../character-inventory.component.tsx | 7 +- .../src/checks/inventories/inventory-utils.ts | 9 +++ .../marker-inventory.component.tsx | 4 +- .../punctuation-inventory.component.tsx | 7 +- .../data-table/data-table.stories.tsx | 1 + .../advanced/inventory/inventory-columns.tsx | 77 +++++++++++-------- lib/platform-bible-react/src/index.ts | 1 + 7 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts diff --git a/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx index 283c6be5a89..906647c553a 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx @@ -7,12 +7,14 @@ import { InventorySummaryItem, InventoryTableData, Scope, + getInventoryHeader, inventoryCountColumn, inventoryItemColumn, inventoryStatusColumn, } from 'platform-bible-react'; import { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; import { useMemo } from 'react'; +import { getUnicodeValue } from './inventory-utils'; const CHARACTER_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_table_header_character%', @@ -47,10 +49,11 @@ const createColumns = ( inventoryItemColumn(itemLabel), { accessorKey: 'unicodeValue', - header: () => , + accessorFn: (row) => getUnicodeValue(row.items[0]), + header: ({ column }) => getInventoryHeader(column, unicodeValueLabel), cell: ({ row }) => { const item: string = row.getValue('item'); - return item.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); + return getUnicodeValue(item); }, }, inventoryCountColumn(countLabel), diff --git a/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts b/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts new file mode 100644 index 00000000000..652ac017492 --- /dev/null +++ b/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts @@ -0,0 +1,9 @@ +/** + * Converts a character to its Unicode hexadecimal representation + * + * @param char The character to convert + * @returns The Unicode value as a 4-digit uppercase hexadecimal string (e.g., "0041" for 'A') + */ +export function getUnicodeValue(char: string): string { + return char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); +} diff --git a/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx index d174021806b..a44e5b2650d 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx @@ -4,6 +4,7 @@ import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; import { Button, ColumnDef, + getInventoryHeader, Inventory, inventoryCountColumn, InventorySummaryItem, @@ -69,7 +70,8 @@ const createColumns = ( inventoryCountColumn(countLabel), { accessorKey: 'styleName', - header: () => , + accessorFn: (row) => getDescription(markerNames, row.items[0]) || unknownMarkerLabel, + header: ({ column }) => getInventoryHeader(column, styleNameLabel), cell: ({ row }) => { const marker: string = row.getValue('item'); return getDescription(markerNames, marker) || unknownMarkerLabel; diff --git a/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx index f2bed00d3d4..b17fb1b9adc 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx @@ -7,12 +7,14 @@ import { InventorySummaryItem, InventoryTableData, Scope, + getInventoryHeader, inventoryCountColumn, inventoryItemColumn, inventoryStatusColumn, } from 'platform-bible-react'; import { LanguageStrings, LocalizeKey } from 'platform-bible-utils'; import { useMemo } from 'react'; +import { getUnicodeValue } from './inventory-utils'; const PUNCTUATION_INVENTORY_STRING_KEYS: LocalizeKey[] = [ '%webView_inventory_table_header_count%', @@ -55,11 +57,12 @@ const createColumns = ( }, { accessorKey: 'unicodeValue', - header: () => , + accessorFn: (row) => getUnicodeValue(row.items[0]), + header: ({ column }) => getInventoryHeader(column, unicodeValueLabel), // Q: How to style the and directly? cell: ({ row }) => (
- {String(row.getValue('item')).charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')} + {getUnicodeValue(row.getValue('item'))}
), }, diff --git a/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx b/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx index a36a8dee330..06cc3b07618 100644 --- a/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx +++ b/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx @@ -250,6 +250,7 @@ const advancedUserColumns: ColumnDef[] = [ }, { accessorKey: 'name', + // header: ({ column }) => getInventoryHeader(column, "Name"), header: ({ column }) => { return ( + + + {label} + + + ); +}; + /** * Function that creates the item column for inventories * @@ -39,12 +80,7 @@ export const inventoryItemColumn = (itemLabel: string): ColumnDef row.items[0], - header: ({ column }) => ( - - ), + header: ({ column }) => getInventoryHeader(column, itemLabel), }; }; @@ -65,12 +101,7 @@ export const inventoryAdditionalItemColumn = ( return { accessorKey: `item${additionalItemIndex}`, accessorFn: (row: InventoryTableData) => row.items[additionalItemIndex], - header: ({ column }) => ( - - ), + header: ({ column }) => getInventoryHeader(column, additionalItemLabel), }; }; @@ -84,15 +115,10 @@ export const inventoryAdditionalItemColumn = ( export const inventoryCountColumn = (countLabel: string): ColumnDef => { return { accessorKey: 'count', - header: ({ column }) => ( -
- -
+ header: ({ column }) => getInventoryHeader(column, countLabel), + cell: ({ row }) => ( +
{row.getValue('count')}
), - cell: ({ row }) =>
{row.getValue('count')}
, }; }; @@ -160,16 +186,7 @@ export const inventoryStatusColumn = ( ): ColumnDef => { return { accessorKey: 'status', - header: ({ column }) => { - return ( -
- -
- ); - }, + header: ({ column }) => getInventoryHeader(column, statusLabel), cell: ({ row }) => { const status: Status = row.getValue('status'); const item: string = row.getValue('item'); diff --git a/lib/platform-bible-react/src/index.ts b/lib/platform-bible-react/src/index.ts index 3d60194a8ad..ba75bc5660b 100644 --- a/lib/platform-bible-react/src/index.ts +++ b/lib/platform-bible-react/src/index.ts @@ -92,6 +92,7 @@ export { inventoryItemColumn, inventoryCountColumn, inventoryStatusColumn, + getInventoryHeader, } from './components/advanced/inventory/inventory-columns'; export { MarkerMenu, MARKER_MENU_STRING_KEYS } from './components/advanced/marker-menu.component'; export type { From ed6ce7aa2edac0999797dd238167713e181ca5a4 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 10:16:18 +0100 Subject: [PATCH 02/11] remove gap between status toggles --- .../src/components/advanced/inventory/inventory-columns.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx b/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx index eb5f11f6be2..aa435afa772 100644 --- a/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx +++ b/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx @@ -191,7 +191,7 @@ export const inventoryStatusColumn = ( const status: Status = row.getValue('status'); const item: string = row.getValue('item'); return ( - + { event.stopPropagation(); @@ -205,6 +205,7 @@ export const inventoryStatusColumn = ( ); }} value="approved" + className="tw-rounded-e-none tw-border-e-0" > @@ -221,6 +222,7 @@ export const inventoryStatusColumn = ( ); }} value="unapproved" + className="tw-rounded-none" > @@ -237,6 +239,7 @@ export const inventoryStatusColumn = ( ); }} value="unknown" + className="tw-rounded-s-none tw-border-s-0" > From bb1a56917f9a62e4017e686f2c2fa23377251273 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 12:07:25 +0100 Subject: [PATCH 03/11] data table: p-0 for THs, no hover on skeletons --- .../components/advanced/data-table/data-table.component.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/platform-bible-react/src/components/advanced/data-table/data-table.component.tsx b/lib/platform-bible-react/src/components/advanced/data-table/data-table.component.tsx index 0e36cc0e331..398cfc557d6 100644 --- a/lib/platform-bible-react/src/components/advanced/data-table/data-table.component.tsx +++ b/lib/platform-bible-react/src/components/advanced/data-table/data-table.component.tsx @@ -98,7 +98,7 @@ export function DataTable({ const rowCount = 10; const skeletonRowIds = Array.from({ length: rowCount }).map((_, idx) => `skeleton-row-${idx}`); bodyContent = skeletonRowIds.map((rowId) => ( - +
@@ -139,7 +139,8 @@ export function DataTable({ {headerGroup.headers.map((header) => { return ( - + /* CUSTOM: tw-p-0, let consumers define padding on their own */ + {header.isPlaceholder ? undefined : flexRender(header.column.columnDef.header, header.getContext())} From ed333688536d22813a695691855deaa6602f4c96 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 12:08:21 +0100 Subject: [PATCH 04/11] expose button interface on tooltip trigger --- .../src/components/shadcn-ui/tooltip.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx b/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx index 72bf99d2067..9c6c7dce2a0 100644 --- a/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx +++ b/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx @@ -2,6 +2,7 @@ import React from 'react'; import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import { cn } from '@/utils/shadcn-ui.util'; +import { ButtonProps, buttonVariants } from './button'; /** @inheritdoc Tooltip */ const TooltipProvider = TooltipPrimitive.Provider; @@ -14,8 +15,18 @@ const TooltipProvider = TooltipPrimitive.Provider; */ const Tooltip = TooltipPrimitive.Root; +// CUSTOM: TooltipTrigger is a button, so allow to use the Button interface /** @inheritdoc Tooltip */ -const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & ButtonProps +>(({ className, variant, size, ...props }, ref) => ( + +)); /** @inheritdoc Tooltip */ const TooltipContent = React.forwardRef< From 1bd17d1d994d039a3bb66feb0fb5e5d5f1d6755b Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 12:09:47 +0100 Subject: [PATCH 05/11] simplify inventory header --- .../advanced/inventory/inventory-columns.tsx | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx b/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx index aa435afa772..5efe3cb9670 100644 --- a/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx +++ b/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx @@ -1,22 +1,20 @@ import { ColumnDef, SortDirection } from '@/components/advanced/data-table/data-table.component'; -import { Button } from '@/components/shadcn-ui/button'; import { ToggleGroup, ToggleGroupItem } from '@/components/shadcn-ui/toggle-group'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/shadcn-ui/tooltip'; +import { Column } from '@tanstack/react-table'; import { ArrowDownIcon, - ArrowUpDownIcon, ArrowUpIcon, CircleCheckIcon, CircleHelpIcon, CircleXIcon, } from 'lucide-react'; import { ReactNode } from 'react'; -import { Column } from '@tanstack/react-table'; -import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, -} from '@/components/shadcn-ui/tooltip'; import { InventoryTableData, Status } from './inventory-utils'; /** @@ -28,12 +26,12 @@ import { InventoryTableData, Status } from './inventory-utils'; */ const getSortingIcon = (sortDirection: false | SortDirection): ReactNode => { if (sortDirection === 'asc') { - return ; + return ; } if (sortDirection === 'desc') { - return ; + return ; } - return ; + return <>; }; /** @@ -50,19 +48,15 @@ export const getInventoryHeader = ( return ( - -
- -
+ column.toggleSorting(undefined)} + > + + {label} + + {getSortingIcon(column.getIsSorted())} {label}
From 1ddd1bbe4ace60016f3d78b2b40b3b3fa0550619 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 12:46:48 +0100 Subject: [PATCH 06/11] align column headers like the cells --- .../inventories/character-inventory.component.tsx | 1 - .../advanced/inventory/inventory-columns.tsx | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx index 906647c553a..e8953f178ea 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/character-inventory.component.tsx @@ -1,7 +1,6 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { - Button, ColumnDef, Inventory, InventorySummaryItem, diff --git a/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx b/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx index 5efe3cb9670..24c66cb22ab 100644 --- a/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx +++ b/lib/platform-bible-react/src/components/advanced/inventory/inventory-columns.tsx @@ -15,6 +15,7 @@ import { CircleXIcon, } from 'lucide-react'; import { ReactNode } from 'react'; +import { cn } from '@/utils/shadcn-ui.util'; import { InventoryTableData, Status } from './inventory-utils'; /** @@ -31,7 +32,7 @@ const getSortingIcon = (sortDirection: false | SortDirection): ReactNode => { if (sortDirection === 'desc') { return ; } - return <>; + return undefined; }; /** @@ -44,12 +45,13 @@ const getSortingIcon = (sortDirection: false | SortDirection): ReactNode => { export const getInventoryHeader = ( column: Column, label: string, + buttonClassName?: string, ): ReactNode => { return ( column.toggleSorting(undefined)} > @@ -109,7 +111,7 @@ export const inventoryAdditionalItemColumn = ( export const inventoryCountColumn = (countLabel: string): ColumnDef => { return { accessorKey: 'count', - header: ({ column }) => getInventoryHeader(column, countLabel), + header: ({ column }) => getInventoryHeader(column, countLabel, 'tw-justify-end'), cell: ({ row }) => (
{row.getValue('count')}
), @@ -180,7 +182,7 @@ export const inventoryStatusColumn = ( ): ColumnDef => { return { accessorKey: 'status', - header: ({ column }) => getInventoryHeader(column, statusLabel), + header: ({ column }) => getInventoryHeader(column, statusLabel, 'tw-justify-center'), cell: ({ row }) => { const status: Status = row.getValue('status'); const item: string = row.getValue('item'); From cd9a1a7d3e52ca3b84834ef49aef9af11fd2e568 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Thu, 29 Jan 2026 16:54:06 +0100 Subject: [PATCH 07/11] fix tooltip --- .../src/components/shadcn-ui/tooltip.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx b/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx index 9c6c7dce2a0..6a71fb57e9e 100644 --- a/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx +++ b/lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx @@ -15,15 +15,15 @@ const TooltipProvider = TooltipPrimitive.Provider; */ const Tooltip = TooltipPrimitive.Root; -// CUSTOM: TooltipTrigger is a button, so allow to use the Button interface +// CUSTOM: TooltipTrigger is a button, so allow to use the button variants (avoids the need for a nested button) /** @inheritdoc Tooltip */ const TooltipTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & ButtonProps ->(({ className, variant, size, ...props }, ref) => ( +>(({ className, variant, ...props }, ref) => ( )); From 74d3d51b37db79ad9ea1472cbe05bfcde9cfc8f9 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Tue, 3 Feb 2026 12:08:01 +0100 Subject: [PATCH 08/11] add markers inventory story --- .../stories/advanced/inventory.stories.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/lib/platform-bible-react/src/stories/advanced/inventory.stories.tsx b/lib/platform-bible-react/src/stories/advanced/inventory.stories.tsx index 25084b23c05..245eac311fc 100644 --- a/lib/platform-bible-react/src/stories/advanced/inventory.stories.tsx +++ b/lib/platform-bible-react/src/stories/advanced/inventory.stories.tsx @@ -4,6 +4,7 @@ import { InventorySummaryItem, } from '@/components/advanced/inventory/inventory.component'; import { + getInventoryHeader, inventoryCountColumn, inventoryItemColumn, inventoryStatusColumn, @@ -217,6 +218,109 @@ export const RepeatedWords: Story = { }, }; +function getDescription(markerDescriptions: string[], marker: string): string | undefined { + // Search for whole marker surrounded by whitespace or periods or at string boundaries (^ and $) + const findMarker = new RegExp(`(^|[\\s.])${marker}([\\s.]|$)`); + return markerDescriptions.find((markerDescription) => findMarker.test(markerDescription)); +} + +const markerNames: string[] = [ + 'xt - Cross Reference - Target References', + 'toc2 - File - Short Table of Contents Text', + 'fig - Auxiliary - Figure/Illustration/Map', + 'f - Footnote', + 'fq - Footnote - Footnote Translation Quotation', +]; + +const createMarkerColumns = ( + approvedItems: string[], + onApprovedItemsChange: (items: string[]) => void, + unapprovedItems: string[], + onUnapprovedItemsChange: (items: string[]) => void, +): ColumnDef[] => [ + inventoryItemColumn('Marker'), + inventoryCountColumn('Count'), + { + accessorKey: 'styleName', + accessorFn: (row) => getDescription(markerNames, row.items[0]) || 'unknownMarkerLabel', + header: ({ column }) => getInventoryHeader(column, 'Style Name'), + cell: ({ row }) => { + const marker: string = row.getValue('item'); + return getDescription(markerNames, marker) || 'unknownMarkerLabel'; + }, + }, + inventoryStatusColumn( + 'Status', + approvedItems, + onApprovedItemsChange, + unapprovedItems, + onUnapprovedItemsChange, + ), +]; + +export const MarkersInventory: Story = { + render: () => { + const markersItems: InventoryItem[] = [ + { + inventoryText: ['xt', 'p'], + verse: 'In the beginning God created the heavens and the earth.', + verseRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + offset: 7, + }, + { + inventoryText: ['f', 'v'], + verse: 'And God said, "Let there be light," and there was light.', + verseRef: { book: 'GEN', chapterNum: 1, verseNum: 3 }, + offset: 4, + }, + { + inventoryText: ['toc2', 'c'], + verse: 'The LORD is good and upright; therefore he instructs sinners in his ways.', + verseRef: { book: 'PSA', chapterNum: 25, verseNum: 8 }, + offset: 4, + }, + { + inventoryText: ['fig', 'p'], + verse: 'God blessed them and said to them, "Be fruitful and increase in number."', + verseRef: { book: 'GEN', chapterNum: 1, verseNum: 28 }, + offset: 4, + }, + ]; + + const [approvedItems, setApprovedItems] = useState(['xt']); + const [unapprovedItems, setUnapprovedItems] = useState(['f']); + + return ( + console.log('Set verse ref:', ref)} + localizedStrings={localizedStrings} + approvedItems={approvedItems} + unapprovedItems={unapprovedItems} + additionalItemsLabels={{ + checkboxText: 'Show Preceding Markers', + tableHeaders: ['Preceding Markers'], + }} + scope="chapter" + onScopeChange={(scope: Scope) => console.log('Scope changed:', scope)} + columns={createMarkerColumns( + approvedItems, + setApprovedItems, + unapprovedItems, + setUnapprovedItems, + )} + /> + ); + }, + parameters: { + docs: { + description: { + story: 'Inventory component for checking markers.', + }, + }, + }, +}; + export const EmptyInventory: Story = { render: () => { const [approvedItems, setApprovedItems] = useState([]); From 58ee0a788fa8b949d1db9cb6391ea55c6048c393 Mon Sep 17 00:00:00 2001 From: Sebastian-ubs Date: Tue, 3 Feb 2026 12:37:23 +0100 Subject: [PATCH 09/11] review comments --- .../src/checks/inventories/inventory-utils.ts | 1 + .../src/checks/inventories/marker-inventory.component.tsx | 1 - .../src/checks/inventories/punctuation-inventory.component.tsx | 1 - .../src/components/advanced/data-table/data-table.stories.tsx | 1 - lib/platform-bible-react/src/components/shadcn-ui/tooltip.tsx | 3 ++- 5 files changed, 3 insertions(+), 4 deletions(-) diff --git a/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts b/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts index 652ac017492..ab31505a40e 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts +++ b/extensions/src/platform-scripture/src/checks/inventories/inventory-utils.ts @@ -5,5 +5,6 @@ * @returns The Unicode value as a 4-digit uppercase hexadecimal string (e.g., "0041" for 'A') */ export function getUnicodeValue(char: string): string { + if (!char || char.length === 0) return '0000'; return char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); } diff --git a/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx index a44e5b2650d..c0110b061c0 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/marker-inventory.component.tsx @@ -2,7 +2,6 @@ import { logger } from '@papi/frontend'; import { useLocalizedStrings, useProjectData } from '@papi/frontend/react'; import { Canon, SerializedVerseRef } from '@sillsdev/scripture'; import { - Button, ColumnDef, getInventoryHeader, Inventory, diff --git a/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx b/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx index b17fb1b9adc..8cdf0d6bf78 100644 --- a/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx +++ b/extensions/src/platform-scripture/src/checks/inventories/punctuation-inventory.component.tsx @@ -1,7 +1,6 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { SerializedVerseRef } from '@sillsdev/scripture'; import { - Button, ColumnDef, Inventory, InventorySummaryItem, diff --git a/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx b/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx index 06cc3b07618..a36a8dee330 100644 --- a/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx +++ b/lib/platform-bible-react/src/components/advanced/data-table/data-table.stories.tsx @@ -250,7 +250,6 @@ const advancedUserColumns: ColumnDef[] = [ }, { accessorKey: 'name', - // header: ({ column }) => getInventoryHeader(column, "Name"), header: ({ column }) => { return (
\n );\n },\n);\n","import React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Style variants for the Button component.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/button}\n */\nexport const buttonVariants = cva(\n 'pr-twp tw-inline-flex tw-items-center tw-justify-center tw-gap-2 tw-whitespace-nowrap tw-rounded-md tw-text-sm tw-font-medium tw-ring-offset-background tw-transition-colors focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-ring focus-visible:tw-ring-offset-2 disabled:tw-pointer-events-none disabled:tw-opacity-50 [&_svg]:tw-pointer-events-none [&_svg]:tw-size-4 [&_svg]:tw-shrink-0',\n {\n variants: {\n variant: {\n default: 'tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/90',\n destructive: 'tw-bg-destructive tw-text-destructive-foreground hover:tw-bg-destructive/90',\n outline:\n 'tw-border tw-border-input tw-bg-background hover:tw-bg-accent hover:tw-text-accent-foreground',\n secondary: 'tw-bg-secondary tw-text-secondary-foreground hover:tw-bg-secondary/80',\n ghost: 'hover:tw-bg-accent hover:tw-text-accent-foreground',\n link: 'tw-text-primary tw-underline-offset-4 hover:tw-underline',\n },\n size: {\n default: 'tw-h-10 tw-px-4 tw-py-2',\n sm: 'tw-h-9 tw-rounded-md tw-px-3',\n lg: 'tw-h-11 tw-rounded-md tw-px-8',\n icon: 'tw-h-10 tw-w-10',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n },\n);\n\n/**\n * Props for Button component\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/button}\n */\nexport interface ButtonProps\n extends React.ButtonHTMLAttributes,\n VariantProps {\n asChild?: boolean;\n}\n\n/**\n * The Button component displays a button or a component that looks like a button. The component is\n * built and styled by Shadcn UI.\n *\n * @param ButtonProps\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/button}\n */\nexport const Button = React.forwardRef(\n ({ className, variant, size, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : 'button';\n return (\n \n );\n },\n);\nButton.displayName = 'Button';\n","import React from 'react';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * The Popover component displays rich content in a portal, triggered by a button. This popover is\n * built on Radix UI's Popover component and styled by Shadcn UI.\n *\n * See Shadcn UI Documentation https://ui.shadcn.com/docs/components/popover See Radix UI\n * Documentation https://www.radix-ui.com/docs/primitives/components/popover\n */\nconst Popover = PopoverPrimitive.Root;\n\n/** @inheritdoc Popover */\nconst PopoverTrigger = PopoverPrimitive.Trigger;\n\n/** @inheritdoc Popover */\nconst PopoverAnchor = PopoverPrimitive.Anchor;\n\n/** @inheritdoc Popover */\nconst PopoverContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n \n \n );\n});\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };\n","import {\n ALL_ENGLISH_BOOK_NAMES,\n getLocalizedBookId,\n getLocalizedBookName,\n} from '@/components/shared/book.utils';\n\nexport function generateCommandValue(\n bookId: string,\n localizedBookNames?: Map,\n chapter?: number,\n): string {\n return `${bookId} ${ALL_ENGLISH_BOOK_NAMES[bookId]}${localizedBookNames ? ` ${getLocalizedBookId(bookId, localizedBookNames)} ${getLocalizedBookName(bookId, localizedBookNames)}` : ''}${chapter ? ` ${chapter}` : ''}`;\n}\n","import { Clock } from 'lucide-react';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { Command, CommandGroup, CommandItem, CommandList } from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { useState } from 'react';\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/** Interface defining the properties for the RecentSearches component */\nexport interface RecentSearchesProps {\n /** Array of recent search items */\n recentSearches: T[];\n /** Callback when a recent search item is selected */\n onSearchItemSelect: (item: T) => void;\n /** Function to render each search item as a string for display */\n renderItem?: (item: T) => string;\n /** Function to create a unique key for each item */\n getItemKey?: (item: T) => string;\n /** Aria label for the recent searches button */\n ariaLabel?: string;\n /** Heading text for the recent searches group */\n groupHeading?: string;\n /** Optional ID for the popover content for accessibility */\n id?: string;\n /** Class name for styling the `CommandItem` for each recent search result */\n classNameForItems?: string;\n}\n\n/**\n * Generic component that displays a button to show recent searches in a popover. Only renders if\n * there are recent searches available. Works with any data type T.\n */\nexport default function RecentSearches({\n recentSearches,\n onSearchItemSelect,\n renderItem = (item) => String(item),\n getItemKey = (item) => String(item),\n ariaLabel = 'Show recent searches',\n groupHeading = 'Recent',\n id,\n classNameForItems,\n}: RecentSearchesProps) {\n const [isOpen, setIsOpen] = useState(false);\n\n if (recentSearches.length === 0) {\n return undefined;\n }\n\n const handleSearchItemSelect = (item: T) => {\n onSearchItemSelect(item);\n setIsOpen(false);\n };\n\n return (\n \n \n \n \n \n \n \n \n \n \n {recentSearches.map((item) => (\n handleSearchItemSelect(item)}\n className={cn('tw-flex tw-items-center', classNameForItems)}\n >\n \n {renderItem(item)}\n \n ))}\n \n \n \n \n \n );\n}\n\n/** Generic hook for managing recent searches state and operations. */\nexport function useRecentSearches(\n recentSearches: T[],\n setRecentSearches: (items: T[]) => void,\n areItemsEqual: (a: T, b: T) => boolean = (a, b) => a === b,\n maxItems: number = 15,\n) {\n return (item: T) => {\n // Add the current item to recent searches, moving it to the top if it already exists\n const recentSearchesWithoutCurrent = recentSearches.filter(\n (existingItem) => !areItemsEqual(existingItem, item),\n );\n const updatedRecentSearches = [item, ...recentSearchesWithoutCurrent.slice(0, maxItems - 1)];\n setRecentSearches(updatedRecentSearches);\n };\n}\n","import { Canon } from '@sillsdev/scripture';\nimport { getChaptersForBook } from 'platform-bible-utils';\nimport { ALL_ENGLISH_BOOK_NAMES, doesBookMatchQuery } from '@/components/shared/book.utils';\nimport { BookWithOptionalChapterAndVerse } from './book-chapter-control.types';\n\n// Smart parsing regex patterns\nexport const SCRIPTURE_REGEX_PATTERNS = {\n // Matches start of string (`^`), one or more non-colon/space words, optionally followed by space and more words (`([^:\\s]+(?:\\s+[^:\\s]+)*)`), end of string (`$`), case-insensitive (`i`)\n BOOK_ONLY: /^([^:\\s]+(?:\\s+[^:\\s]+)*)$/i,\n // Same as above, but followed by a space and a chapter number (`\\s+(\\d+)`)\n BOOK_CHAPTER: /^([^:\\s]+(?:\\s+[^:\\s]+)*)\\s+(\\d+)$/i,\n // Same as above, but followed by a colon and optionally a verse number (`:(\\d*)`)\n BOOK_CHAPTER_VERSE: /^([^:\\s]+(?:\\s+[^:\\s]+)*)\\s+(\\d+):(\\d*)$/i,\n} as const;\n\nexport const SEARCH_QUERY_FORMATS = [\n SCRIPTURE_REGEX_PATTERNS.BOOK_ONLY,\n SCRIPTURE_REGEX_PATTERNS.BOOK_CHAPTER,\n SCRIPTURE_REGEX_PATTERNS.BOOK_CHAPTER_VERSE,\n];\n\nexport function getKeyCharacterType(key: string) {\n const isLetter = /^[a-zA-Z]$/.test(key);\n const isDigit = /^[0-9]$/.test(key);\n return { isLetter, isDigit };\n}\n\nexport function fetchEndChapter(bookId: string) {\n // getChaptersForBook returns -1 if not found in scrBookData\n // scrBookData only includes OT and NT, so all DC will return -1\n return getChaptersForBook(Canon.bookIdToNumber(bookId));\n}\n\nexport function calculateTopMatch(\n query: string,\n availableBooks: string[],\n localizedBookNames?: Map,\n): BookWithOptionalChapterAndVerse | undefined {\n if (!query.trim() || availableBooks.length === 0) return undefined;\n\n // First try smart parsing with regex patterns\n const topMatch = SEARCH_QUERY_FORMATS.reduce(\n (result: BookWithOptionalChapterAndVerse | undefined, format) => {\n if (result) return result;\n\n const matches = format.exec(query.trim());\n if (matches) {\n const [book, chapter = undefined, verse = undefined] = matches.slice(1);\n\n let validBookId: string | undefined;\n\n // Match for partial book name or id\n\n const allPotentialMatches = availableBooks.filter((bookId) => {\n return doesBookMatchQuery(bookId, book, localizedBookNames);\n });\n\n // Only create a topMatch if exactly one book could match\n if (allPotentialMatches.length === 1) {\n [validBookId] = allPotentialMatches;\n }\n\n // Match for exact book id (English or localized)\n // This is only performed when a chapter number is provided, to prevent edge cases where\n // a search for e.g. `jud` would generate a top match for 'Jude', even though 'Judges' would\n // also be a valid match\n if (!validBookId && chapter) {\n // Check exact English book ID\n if (Canon.isBookIdValid(book)) {\n const bookIdUpperCase = book.toUpperCase();\n if (availableBooks.includes(bookIdUpperCase)) {\n validBookId = bookIdUpperCase;\n }\n }\n\n // Check exact localized book ID\n if (!validBookId && localizedBookNames) {\n const matchingLocalizedBookId = Array.from(localizedBookNames.entries()).find(\n ([, localizedBook]) => localizedBook.localizedId.toLowerCase() === book.toLowerCase(),\n );\n if (matchingLocalizedBookId && availableBooks.includes(matchingLocalizedBookId[0])) {\n [validBookId] = matchingLocalizedBookId;\n }\n }\n }\n\n // Match for exact full book name (English or localized)\n // This is only performed when a chapter number is provided, to prevent edge cases where\n // a search for e.g. `john` only matches `John` but not `1 John`, `2 John` and `3 John`\n if (!validBookId && chapter) {\n // Check exact English book name\n const getBookIdFromEnglishName = (bookName: string): string | undefined => {\n return Object.keys(ALL_ENGLISH_BOOK_NAMES).find(\n (bookId) => ALL_ENGLISH_BOOK_NAMES[bookId].toLowerCase() === bookName.toLowerCase(),\n );\n };\n\n const matchingBookIdForFullName = getBookIdFromEnglishName(book);\n if (matchingBookIdForFullName && availableBooks.includes(matchingBookIdForFullName)) {\n validBookId = matchingBookIdForFullName;\n }\n\n // Check exact localized book name\n if (!validBookId && localizedBookNames) {\n const matchingLocalizedBookName = Array.from(localizedBookNames.entries()).find(\n ([, localizedBook]) =>\n localizedBook.localizedName.toLowerCase() === book.toLowerCase(),\n );\n if (\n matchingLocalizedBookName &&\n availableBooks.includes(matchingLocalizedBookName[0])\n ) {\n [validBookId] = matchingLocalizedBookName;\n }\n }\n }\n\n if (validBookId) {\n let chapterNum = chapter ? parseInt(chapter, 10) : undefined;\n if (chapterNum && chapterNum > fetchEndChapter(validBookId))\n chapterNum = Math.max(fetchEndChapter(validBookId), 1);\n const verseNum = verse ? parseInt(verse, 10) : undefined;\n\n return {\n book: validBookId,\n chapterNum,\n verseNum,\n };\n }\n }\n\n return undefined;\n },\n undefined,\n );\n\n if (topMatch) return topMatch;\n\n return undefined;\n}\n","import { Direction } from '@/utils/dir-helper.util';\nimport { SerializedVerseRef } from '@sillsdev/scripture';\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\nimport { ComponentType, useCallback, useMemo } from 'react';\nimport { fetchEndChapter } from './book-chapter-control.utils';\n\nexport interface QuickNavButton {\n onClick: () => void;\n disabled?: boolean;\n title: string;\n icon: ComponentType<{ className?: string }>;\n}\n\nexport function useQuickNavButtons(\n scrRef: SerializedVerseRef,\n availableBooks: string[],\n direction: Direction,\n handleSubmit: (scrRef: SerializedVerseRef) => void,\n): QuickNavButton[] {\n const handlePreviousChapter = useCallback(() => {\n if (scrRef.chapterNum > 1) {\n handleSubmit({\n book: scrRef.book,\n chapterNum: scrRef.chapterNum - 1,\n verseNum: 1,\n });\n } else {\n // Go to previous book's last chapter\n const currentBookIndex = availableBooks.indexOf(scrRef.book);\n if (currentBookIndex > 0) {\n const previousBook = availableBooks[currentBookIndex - 1];\n const lastChapter = Math.max(fetchEndChapter(previousBook), 1);\n handleSubmit({\n book: previousBook,\n chapterNum: lastChapter,\n verseNum: 1,\n });\n }\n }\n }, [scrRef, availableBooks, handleSubmit]);\n\n const handleNextChapter = useCallback(() => {\n const maxChapter = fetchEndChapter(scrRef.book);\n if (scrRef.chapterNum < maxChapter) {\n handleSubmit({\n book: scrRef.book,\n chapterNum: scrRef.chapterNum + 1,\n verseNum: 1,\n });\n } else {\n // Go to next book's first chapter\n const currentBookIndex = availableBooks.indexOf(scrRef.book);\n if (currentBookIndex < availableBooks.length - 1) {\n const nextBook = availableBooks[currentBookIndex + 1];\n handleSubmit({\n book: nextBook,\n chapterNum: 1,\n verseNum: 1,\n });\n }\n }\n }, [scrRef, availableBooks, handleSubmit]);\n\n const handlePreviousVerse = useCallback(() => {\n handleSubmit({\n book: scrRef.book,\n chapterNum: scrRef.chapterNum,\n verseNum: scrRef.verseNum > 1 ? scrRef.verseNum - 1 : 0,\n });\n }, [scrRef, handleSubmit]);\n\n const handleNextVerse = useCallback(() => {\n handleSubmit({\n book: scrRef.book,\n chapterNum: scrRef.chapterNum,\n verseNum: scrRef.verseNum + 1,\n });\n }, [scrRef, handleSubmit]);\n\n return useMemo(() => {\n return [\n {\n onClick: handlePreviousChapter,\n disabled:\n availableBooks.length === 0 ||\n (scrRef.chapterNum === 1 && availableBooks.indexOf(scrRef.book) === 0),\n title: 'Previous chapter',\n icon: direction === 'ltr' ? ChevronsLeft : ChevronsRight,\n },\n {\n onClick: handlePreviousVerse,\n disabled: availableBooks.length === 0 || scrRef.verseNum === 0,\n title: 'Previous verse',\n icon: direction === 'ltr' ? ChevronLeft : ChevronRight,\n },\n {\n onClick: handleNextVerse,\n disabled: availableBooks.length === 0,\n title: 'Next verse',\n icon: direction === 'ltr' ? ChevronRight : ChevronLeft,\n },\n {\n onClick: handleNextChapter,\n disabled:\n availableBooks.length === 0 ||\n ((scrRef.chapterNum === fetchEndChapter(scrRef.book) ||\n fetchEndChapter(scrRef.book) <= 0) &&\n availableBooks.indexOf(scrRef.book) === availableBooks.length - 1),\n title: 'Next chapter',\n icon: direction === 'ltr' ? ChevronsRight : ChevronsLeft,\n },\n ];\n }, [\n scrRef,\n availableBooks,\n direction,\n handlePreviousChapter,\n handlePreviousVerse,\n handleNextVerse,\n handleNextChapter,\n ]);\n}\n","import { CommandGroup, CommandItem } from '@/components/shadcn-ui/command';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { ALL_ENGLISH_BOOK_NAMES } from '@/components/shared/book.utils';\nimport { fetchEndChapter } from './book-chapter-control.utils';\n\nexport interface ChapterGridProps {\n /** The book ID to render chapters for */\n bookId: string;\n /** Current scripture reference for highlighting */\n scrRef: { book: string; chapterNum: number };\n /** Callback when a chapter is selected */\n onChapterSelect: (chapter: number) => void;\n /** Function to set chapter refs for keyboard navigation */\n setChapterRef: (chapter: number) => (element: HTMLDivElement | null) => void;\n /** Optional function to determine if a chapter should be dimmed */\n isChapterDimmed?: (chapter: number) => boolean;\n /** Optional additional class name for styling */\n className?: string;\n}\n\n/**\n * Renders a grid of chapter numbers for a given book, with highlighting for the current chapter and\n * optional dimmed chapters based on state logic.\n */\nexport function ChapterGrid({\n bookId,\n scrRef,\n onChapterSelect,\n setChapterRef,\n isChapterDimmed,\n className,\n}: ChapterGridProps) {\n if (!bookId) return undefined;\n\n return (\n \n
\n {Array.from({ length: fetchEndChapter(bookId) }, (_, i) => i + 1).map((chapter) => (\n onChapterSelect(chapter)}\n ref={setChapterRef(chapter)}\n className={cn(\n 'tw-h-8 tw-w-8 tw-cursor-pointer tw-justify-center tw-rounded-md tw-text-center tw-text-sm',\n {\n 'tw-bg-primary tw-text-primary-foreground':\n bookId === scrRef.book && chapter === scrRef.chapterNum,\n },\n {\n 'tw-bg-muted/50 tw-text-muted-foreground/50': isChapterDimmed?.(chapter) ?? false,\n },\n )}\n >\n {chapter}\n \n ))}\n
\n
\n );\n}\n","import { BookItem } from '@/components/shared/book-item.component';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Command,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Canon, SerializedVerseRef } from '@sillsdev/scripture';\nimport { ArrowLeft, ArrowRight } from 'lucide-react';\nimport { formatScrRef, getSectionForBook, Section } from 'platform-bible-utils';\nimport {\n getSectionLongName,\n getLocalizedBookName,\n getLocalizedBookId,\n ALL_BOOK_IDS,\n ALL_ENGLISH_BOOK_NAMES,\n doesBookMatchQuery,\n} from '@/components/shared/book.utils';\nimport { KeyboardEvent, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { generateCommandValue } from '@/components/shared/book-item.utils';\nimport RecentSearches from '../recent-searches.component';\nimport { useQuickNavButtons } from './book-chapter-control.navigation';\nimport { BookChapterControlProps, ViewMode } from './book-chapter-control.types';\nimport {\n calculateTopMatch,\n fetchEndChapter,\n getKeyCharacterType,\n} from './book-chapter-control.utils';\nimport { ChapterGrid } from './chapter-grid.component';\n\n/**\n * `BookChapterControl` is a component that provides an interactive UI for selecting book chapters.\n * It allows users to input a search query to find specific books and chapters, navigate through\n * options with keyboard interactions, and submit selections. The component handles various\n * interactions such as opening and closing the dropdown menu, filtering book lists based on search\n * input, and managing highlighted selections. It also integrates with external handlers for\n * submitting selected references and retrieving active book IDs.\n */\nexport function BookChapterControl({\n scrRef,\n handleSubmit,\n className,\n getActiveBookIds,\n localizedBookNames,\n localizedStrings,\n recentSearches,\n onAddRecentSearch,\n id,\n}: BookChapterControlProps) {\n const direction: Direction = readDirection();\n\n // Indicates if the Command popover is open or not\n const [isCommandOpen, setIsCommandOpen] = useState(false);\n // The value of the Command, mainly needed for reliable keyboard navigation\n const [commandValue, setCommandValue] = useState('');\n // The value of the Input inside the Command\n const [inputValue, setInputValue] = useState('');\n // The current view mode (books or chapters)\n const [viewMode, setViewMode] = useState('books');\n // The book currently selected for chapter view, if any\n const [selectedBookForChaptersView, setSelectedBookForChaptersView] = useState<\n string | undefined\n >(undefined);\n const [isCommandListHidden, setIsCommandListHidden] = useState(false);\n\n // Reference to the Command component\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const commandRef = useRef(undefined!);\n // Reference to the Input component inside the Command\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const commandInputRef = useRef(undefined!);\n // Reference to the CommandList inside the Command\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const commandListRef = useRef(undefined!);\n // Reference to the selected book item in the CommandList\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const selectedBookItemRef = useRef(undefined!);\n // References to the chapters that are shown as CommandItems\n const chapterRefs = useRef>({});\n\n // Wrapper function to handle submit and add to recent searches\n const handleSubmitAndAddToRecent = useCallback(\n (newScrRef: SerializedVerseRef) => {\n handleSubmit(newScrRef);\n if (onAddRecentSearch) {\n onAddRecentSearch(newScrRef);\n }\n },\n [handleSubmit, onAddRecentSearch],\n );\n\n // #region Available books, filtering and top match logic\n\n const activeBookIds = useMemo(() => {\n return getActiveBookIds ? getActiveBookIds() : ALL_BOOK_IDS;\n }, [getActiveBookIds]);\n\n const availableBooksByType = useMemo(() => {\n const grouped: Record = {\n [Section.OT]: activeBookIds.filter((bookId) => Canon.isBookOT(bookId)),\n [Section.NT]: activeBookIds.filter((bookId) => Canon.isBookNT(bookId)),\n [Section.DC]: activeBookIds.filter((bookId) => Canon.isBookDC(bookId)),\n [Section.Extra]: activeBookIds.filter((bookId) => Canon.extraBooks().includes(bookId)),\n };\n return grouped;\n }, [activeBookIds]);\n\n const availableBooks = useMemo(() => {\n return Object.values(availableBooksByType).flat();\n }, [availableBooksByType]);\n\n // Filter books based on search input\n const filteredBooksByType = useMemo(() => {\n if (!inputValue.trim()) return availableBooksByType;\n\n const filteredBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n const bookTypes: Section[] = [Section.OT, Section.NT, Section.DC, Section.Extra];\n bookTypes.forEach((type) => {\n filteredBooks[type] = availableBooksByType[type].filter((bookId) => {\n return doesBookMatchQuery(bookId, inputValue, localizedBookNames);\n });\n });\n\n return filteredBooks;\n }, [availableBooksByType, inputValue, localizedBookNames]);\n\n // Get the current top match\n const topMatch = useMemo(\n () => calculateTopMatch(inputValue, availableBooks, localizedBookNames),\n [inputValue, availableBooks, localizedBookNames],\n );\n\n // #endregion\n\n // #region Submitting references\n\n const handleTopMatchSelect = useCallback(() => {\n // If we have a top match (smart parsed or single book filter), use its specific chapter/verse\n if (topMatch) {\n handleSubmitAndAddToRecent({\n book: topMatch.book,\n chapterNum: topMatch.chapterNum ?? 1,\n verseNum: topMatch.verseNum ?? 1,\n });\n setIsCommandOpen(false);\n setInputValue('');\n setCommandValue(''); // Reset command value\n }\n }, [handleSubmitAndAddToRecent, topMatch]);\n\n const handleBookSelect = useCallback(\n (bookId: string) => {\n // Check if book has chapters - if not, submit immediately\n const endChapter = fetchEndChapter(bookId);\n if (endChapter <= 1) {\n handleSubmitAndAddToRecent({\n book: bookId,\n chapterNum: 1,\n verseNum: 1,\n });\n setIsCommandOpen(false);\n setInputValue('');\n return;\n }\n\n // Book has multiple chapters - transition to chapter view\n setSelectedBookForChaptersView(bookId);\n setViewMode('chapters');\n },\n [handleSubmitAndAddToRecent],\n );\n\n const handleChapterSelect = useCallback(\n (chapterNumber: number) => {\n // Determine which book we're selecting a chapter for\n const bookId = viewMode === 'chapters' ? selectedBookForChaptersView : topMatch?.book;\n if (!bookId) return;\n\n handleSubmitAndAddToRecent({\n book: bookId,\n chapterNum: chapterNumber,\n verseNum: 1,\n });\n setIsCommandOpen(false);\n setViewMode('books');\n setSelectedBookForChaptersView(undefined);\n setInputValue('');\n },\n [handleSubmitAndAddToRecent, viewMode, selectedBookForChaptersView, topMatch],\n );\n\n const handleRecentItemSelect = useCallback(\n (item: SerializedVerseRef) => {\n handleSubmitAndAddToRecent(item);\n setIsCommandOpen(false);\n setInputValue('');\n },\n [handleSubmitAndAddToRecent],\n );\n\n // #endregion\n\n // #region Navigation and view changes\n\n // Hook that provides navigation buttons for quick chapter/verse navigation\n const quickNavButtons = useQuickNavButtons(scrRef, availableBooks, direction, handleSubmit);\n\n const handleBackToBooks = useCallback(() => {\n setViewMode('books');\n setSelectedBookForChaptersView(undefined);\n\n // Focus the search input when returning to book view\n setTimeout(() => {\n if (commandInputRef.current) {\n commandInputRef.current.focus();\n }\n }, 0);\n }, []);\n\n // Reset view state when popover opens\n const handleOpenChange = useCallback(\n (shouldCommandBeOpen: boolean) => {\n // If we're closing from chapter view, don't close popover but go back to books view instead\n if (!shouldCommandBeOpen && viewMode === 'chapters') {\n handleBackToBooks();\n return;\n }\n\n setIsCommandOpen(shouldCommandBeOpen);\n\n if (shouldCommandBeOpen) {\n // Reset Command state when opening\n setViewMode('books');\n setSelectedBookForChaptersView(undefined);\n setInputValue('');\n }\n },\n [viewMode, handleBackToBooks],\n );\n\n // #endregion\n\n // #region Helper functions and variables\n\n const { otLong, ntLong, dcLong, extraLong } = {\n otLong: localizedStrings?.['%scripture_section_ot_long%'],\n ntLong: localizedStrings?.['%scripture_section_nt_long%'],\n dcLong: localizedStrings?.['%scripture_section_dc_long%'],\n extraLong: localizedStrings?.['%scripture_section_extra_long%'],\n };\n\n const getSectionLabel = useCallback(\n (section: Section): string => {\n return getSectionLongName(section, otLong, ntLong, dcLong, extraLong);\n },\n [otLong, ntLong, dcLong, extraLong],\n );\n\n const doesChapterMatch = useCallback(\n (chapter: number) => {\n if (!topMatch) return false;\n return !!topMatch.chapterNum && !chapter.toString().includes(topMatch.chapterNum.toString());\n },\n [topMatch],\n );\n\n const currentDisplayValue = useMemo(\n () =>\n formatScrRef(\n scrRef,\n localizedBookNames ? getLocalizedBookName(scrRef.book, localizedBookNames) : 'English',\n ),\n [scrRef, localizedBookNames],\n );\n\n const setChapterRef = useCallback((chapter: number) => {\n return (element: HTMLDivElement | null) => {\n chapterRefs.current[chapter] = element;\n };\n }, []);\n\n // #endregion\n\n // #region Keyboard handling\n\n // Handle keyboard navigation for CommandInput\n const handleInputKeyDown = useCallback((event: KeyboardEvent) => {\n // Override default Home and End key behavior to work normally for cursor movement.\n // Default behavior was to jump to the start/end of the list of items in the Command\n if (event.key === 'Home' || event.key === 'End') {\n event.stopPropagation(); // Prevent Command component from handling these\n }\n }, []);\n\n // Grid-aware keyboard navigation using Command's controlled value\n const handleCommandKeyDown = useCallback(\n (event: KeyboardEvent) => {\n if (event.ctrlKey) return;\n\n const { isLetter, isDigit } = getKeyCharacterType(event.key);\n\n // Handle keypresses in chapter viewmode\n if (viewMode === 'chapters') {\n // Handle backspace for going back to books\n if (event.key === 'Backspace') {\n event.preventDefault();\n event.stopPropagation();\n handleBackToBooks();\n return;\n }\n\n if (isLetter || isDigit) {\n event.preventDefault();\n event.stopPropagation();\n setViewMode('books');\n setSelectedBookForChaptersView(undefined);\n\n if (isDigit && selectedBookForChaptersView) {\n // Digit pressed: go back to book list and start search with current book name + digit\n const currentBookName = ALL_ENGLISH_BOOK_NAMES[selectedBookForChaptersView];\n setInputValue(`${currentBookName} ${event.key}`);\n } else {\n setInputValue(event.key);\n }\n\n setTimeout(() => {\n if (commandInputRef.current) {\n commandInputRef.current.focus();\n }\n }, 0);\n return;\n }\n }\n\n // Handle grid navigation for arrow keys in chapter views\n if (\n (viewMode === 'chapters' || (viewMode === 'books' && topMatch)) &&\n ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)\n ) {\n // Extract current chapter from commandValue\n const currentBookId =\n viewMode === 'chapters' ? selectedBookForChaptersView : topMatch?.book;\n if (!currentBookId) return;\n\n // Parse chapter from current command value\n const currentChapter = (() => {\n if (!commandValue) return 1;\n const match = commandValue.match(/(\\d+)$/);\n return match ? parseInt(match[1], 10) : 0;\n })();\n\n const maxChapter = fetchEndChapter(currentBookId);\n\n if (!maxChapter) return;\n\n let targetChapter = currentChapter;\n const GRID_COLS = 6;\n\n switch (event.key) {\n case 'ArrowLeft':\n if (currentChapter !== 0)\n targetChapter = currentChapter > 1 ? currentChapter - 1 : maxChapter;\n break;\n case 'ArrowRight':\n if (currentChapter !== 0)\n targetChapter = currentChapter < maxChapter ? currentChapter + 1 : 1;\n break;\n case 'ArrowUp':\n targetChapter =\n currentChapter === 0 ? maxChapter : Math.max(1, currentChapter - GRID_COLS);\n break;\n case 'ArrowDown':\n targetChapter =\n currentChapter === 0 ? 1 : Math.min(maxChapter, currentChapter + GRID_COLS);\n break;\n default:\n return;\n }\n\n if (targetChapter !== currentChapter) {\n event.preventDefault();\n event.stopPropagation();\n\n // Update the command value to the target chapter\n setCommandValue(generateCommandValue(currentBookId, localizedBookNames, targetChapter));\n\n // Scroll the target chapter into view using refs\n setTimeout(() => {\n const targetElement = chapterRefs.current[targetChapter];\n if (targetElement) {\n targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n }, 0);\n }\n }\n },\n [\n viewMode,\n topMatch,\n handleBackToBooks,\n selectedBookForChaptersView,\n commandValue,\n localizedBookNames,\n ],\n );\n\n const handleQuickNavButtonKeyDown = useCallback((event: KeyboardEvent) => {\n if (event.shiftKey || event.key === 'Tab' || event.key === ' ') return;\n\n const { isLetter, isDigit } = getKeyCharacterType(event.key);\n\n if (isLetter || isDigit) {\n event.preventDefault();\n\n setInputValue((prevValue) => prevValue + event.key);\n commandInputRef.current.focus();\n\n setIsCommandListHidden(false);\n }\n }, []);\n\n // #endregion\n\n // #region Auto-scroll\n\n // Auto-scroll to currently selected book when dropdown opens in book view\n useLayoutEffect(() => {\n const scrollTimeout = setTimeout(() => {\n if (\n isCommandOpen &&\n viewMode === 'books' &&\n commandListRef.current &&\n selectedBookItemRef.current\n ) {\n const listElement = commandListRef.current;\n const itemElement = selectedBookItemRef.current;\n\n // Calculate scroll position to center the selected item\n const itemOffsetTop = itemElement.offsetTop;\n const listHeight = listElement.clientHeight;\n const itemHeight = itemElement.clientHeight;\n const scrollPosition = itemOffsetTop - listHeight / 2 + itemHeight / 2;\n\n listElement.scrollTo({\n top: Math.max(0, scrollPosition),\n behavior: 'smooth',\n });\n\n // Set the selected book as the active item for keyboard navigation\n setCommandValue(generateCommandValue(scrRef.book));\n }\n }, 0);\n\n return () => {\n clearTimeout(scrollTimeout);\n };\n }, [isCommandOpen, viewMode, inputValue, topMatch, scrRef.book]);\n\n // Auto-scroll to appropriate chapter\n useLayoutEffect(() => {\n if (viewMode === 'chapters' && selectedBookForChaptersView) {\n // Check if we're entering chapter view for the currently selected book\n const isCurrentlySelectedBook = selectedBookForChaptersView === scrRef.book;\n\n // Reset scroll position to top, except when viewing the currently selected book\n setTimeout(() => {\n if (commandListRef.current) {\n if (isCurrentlySelectedBook) {\n // Scroll to the currently selected chapter\n const targetElement = chapterRefs.current[scrRef.chapterNum];\n if (targetElement) {\n targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' });\n }\n } else {\n // Reset to top for other books\n commandListRef.current.scrollTo({ top: 0 });\n }\n }\n\n // Ensure Command component has focus for keyboard navigation\n if (commandRef.current) {\n commandRef.current.focus();\n }\n }, 0);\n }\n }, [viewMode, selectedBookForChaptersView, topMatch, scrRef.book, scrRef.chapterNum]);\n\n // #endregion\n\n return (\n \n \n \n {currentDisplayValue}\n \n \n \n \n {/* Header: Input (with quick nav buttons) for book view, fixed header for chapter view */}\n {viewMode === 'books' ? (\n
\n
\n setIsCommandListHidden(false)}\n className={recentSearches && recentSearches.length > 0 ? '!tw-pr-10' : ''}\n />\n {recentSearches && recentSearches.length > 0 && (\n formatScrRef(verseRef, 'English')}\n getItemKey={(verseRef) =>\n `${verseRef.book}-${verseRef.chapterNum}-${verseRef.verseNum}`\n }\n ariaLabel={localizedStrings?.['%history_recentSearches_ariaLabel%']}\n groupHeading={localizedStrings?.['%history_recent%']}\n />\n )}\n
\n {/* Navigation buttons for previous/next chapter/book */}\n
\n {quickNavButtons.map(({ onClick, disabled, title, icon: Icon }) => (\n {\n setIsCommandListHidden(true);\n onClick();\n }}\n disabled={disabled}\n className=\"tw-h-10 tw-w-4 tw-p-0\"\n title={title}\n onKeyDown={handleQuickNavButtonKeyDown}\n >\n \n \n ))}\n
\n
\n ) : (\n
\n \n {direction === 'ltr' ? (\n \n ) : (\n \n )}\n \n {selectedBookForChaptersView && (\n \n {getLocalizedBookName(selectedBookForChaptersView, localizedBookNames)}\n \n )}\n
\n )}\n\n {/** Body */}\n {!isCommandListHidden && (\n \n {/** Book list mode (also used in case of top matches) */}\n {viewMode === 'books' && (\n <>\n {/* Book List - Show when we don't have a top match */}\n {!topMatch &&\n Object.entries(filteredBooksByType).map(([type, books]) => {\n if (books.length === 0) return undefined;\n\n return (\n // We are mapping over filteredBooksByType, which uses Section as key type\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n \n {books.map((bookId) => (\n \n handleBookSelect(selectedBookId)\n }\n section={getSectionForBook(bookId)}\n commandValue={`${bookId} ${ALL_ENGLISH_BOOK_NAMES[bookId]}`}\n ref={bookId === scrRef.book ? selectedBookItemRef : undefined}\n localizedBookNames={localizedBookNames}\n />\n ))}\n \n );\n })}\n\n {/* Top match scripture reference */}\n {topMatch && (\n \n \n {formatScrRef(\n {\n book: topMatch.book,\n chapterNum: topMatch.chapterNum ?? 1,\n verseNum: topMatch.verseNum ?? 1,\n },\n localizedBookNames\n ? getLocalizedBookId(topMatch.book, localizedBookNames)\n : undefined,\n )}\n \n \n )}\n\n {/* Chapter Selector - Show when we have a top match */}\n {topMatch && fetchEndChapter(topMatch.book) > 1 && (\n <>\n
\n {getLocalizedBookName(topMatch.book, localizedBookNames)}\n
\n \n \n )}\n \n )}\n\n {/* Basic chapter view mode */}\n {viewMode === 'chapters' && selectedBookForChaptersView && (\n \n )}\n
\n )}\n \n
\n
\n );\n}\n\nexport default BookChapterControl;\n","import { SerializedVerseRef } from '@sillsdev/scripture';\nimport { LanguageStrings } from 'platform-bible-utils';\n\n/**\n * Object containing all keys used for localization in the BookChapterControl component. If you're\n * using this component in an extension, you can pass it into the useLocalizedStrings hook to easily\n * obtain the localized strings and pass them into the localizedStrings prop of this component\n */\nexport const BOOK_CHAPTER_CONTROL_STRING_KEYS = Object.freeze([\n '%scripture_section_ot_long%',\n '%scripture_section_nt_long%',\n '%scripture_section_dc_long%',\n '%scripture_section_extra_long%',\n '%history_recent%',\n '%history_recentSearches_ariaLabel%',\n] as const);\n\n/** Type definition for the localized strings used in the BookChapterControl component */\nexport type BookChapterControlLocalizedStrings = {\n [localizedKey in (typeof BOOK_CHAPTER_CONTROL_STRING_KEYS)[number]]?: string;\n};\n\nexport type BookWithOptionalChapterAndVerse = Omit &\n Partial>;\n\nexport type ViewMode = 'books' | 'chapters';\n\nexport type BookChapterControlProps = {\n /** The current scripture reference */\n scrRef: SerializedVerseRef;\n /** Callback to handle the submission of a selected reference */\n handleSubmit: (scrRef: SerializedVerseRef) => void;\n /** Optional additional class name for styling */\n className?: string;\n /** Callback to retrieve book IDs that are available in the current context */\n getActiveBookIds?: () => string[];\n /**\n * Optional map of localized book IDs/short names and full names. The key is the standard book ID\n * (e.g., \"2CH\"), the value contains a localized version of the ID and related book name (e.g. {\n * localizedId: '2CR', localizedName: '2 Crรณnicas' })\n */\n localizedBookNames?: Map;\n /** Optional localized strings for the component */\n localizedStrings?: LanguageStrings;\n /** Array of recent scripture references for quick access */\n recentSearches?: SerializedVerseRef[];\n /** Callback to add a new recent scripture reference */\n onAddRecentSearch?: (scrRef: SerializedVerseRef) => void;\n /** Optional ID for the popover content for accessibility */\n id?: string;\n};\n","import React from 'react';\nimport * as LabelPrimitive from '@radix-ui/react-label';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Style variants for the Label component.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/label}\n * @see Radix UI Documentation: {@link https://www.radix-ui.com/primitives/docs/components/label}\n */\nconst labelVariants = cva(\n 'tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70',\n);\n\n/**\n * The Label component renders an accessible label associated with controls. This components is\n * built on Radix UI primitives and styled with Shadcn UI.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/label}\n * @see Radix UI Documentation: {@link https://www.radix-ui.com/primitives/docs/components/label}\n */\nexport const Label = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & VariantProps\n>(({ className, ...props }, ref) => (\n \n));\nLabel.displayName = LabelPrimitive.Root.displayName;\n","import React from 'react';\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group';\nimport { Circle } from 'lucide-react';\n\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/**\n * Radio Group components providing a set of checkable buttonsโ€”known as radio buttonsโ€”where no more\n * than one of the buttons can be checked at a time. These components are built on Radix UI\n * primitives and styled with Shadcn UI.\n *\n * See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/radio-group See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/radio-group\n */\nconst RadioGroup = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n );\n});\nRadioGroup.displayName = RadioGroupPrimitive.Root.displayName;\n\n/** @inheritdoc RadioGroup */\nconst RadioGroupItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n return (\n \n \n \n \n \n );\n});\nRadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;\n\nexport { RadioGroup, RadioGroupItem };\n","import { ReactNode, useState } from 'react';\nimport { Check, ChevronDown } from 'lucide-react';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Button, ButtonProps } from '@/components/shadcn-ui/button';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from '@/components/shadcn-ui/command';\nimport { PopoverProps } from '@radix-ui/react-popover';\n\nexport type ComboBoxLabelOption = { label: string; secondaryLabel?: string };\nexport type ComboBoxOption = string | number | ComboBoxLabelOption;\n\n/** Represents a group of options with an optional heading */\nexport type ComboBoxGroup = {\n /** The heading text for this group of options */\n groupHeading: string;\n /** The options within this group */\n options: readonly T[];\n};\n\nexport type ComboBoxProps = {\n /** Optional unique identifier */\n id?: string;\n /**\n * List of available options for the dropdown menu. Can be either:\n *\n * - A flat array of options (single group, no heading)\n * - An array of group objects. Each group has a heading and an array of options\n */\n options?: readonly T[] | readonly ComboBoxGroup[];\n /** @deprecated 3 December 2024. Renamed to `buttonClassName` */\n className?: string;\n /** Additional css classes to help with unique styling of the combo box button */\n buttonClassName?: string;\n /** Additional css classes to help with unique styling of the combo box popover */\n popoverContentClassName?: string;\n /**\n * The selected value that the combo box currently holds. Must be shallow equal to one of the\n * options entries.\n */\n value?: T;\n /** Triggers when content of textfield is changed */\n onChange?: (newValue: T) => void;\n /** Used to determine the string value for a given option. */\n getOptionLabel?: (option: T) => string;\n /**\n * Used to determine the string value to display on the button for the selected value. If not\n * provided, falls back to `getOptionLabel`.\n */\n getButtonLabel?: (option: T) => string;\n /** Icon to be displayed on the trigger */\n icon?: ReactNode;\n /** Text displayed on button if `value` is undefined */\n buttonPlaceholder?: string;\n /** Placeholder text for text field */\n textPlaceholder?: string;\n /** Text to display when no options match input */\n commandEmptyMessage?: string;\n /** Variant of button */\n buttonVariant?: ButtonProps['variant'];\n /** Control how the popover menu should be aligned. Defaults to start */\n alignDropDown?: 'start' | 'center' | 'end';\n /** Optional boolean to set if trigger should be disabled */\n isDisabled?: boolean;\n /** Optional aria-label for the trigger button for accessibility */\n ariaLabel?: string;\n} & PopoverProps;\n\nfunction getOptionLabelDefault(option: ComboBoxOption): string {\n if (typeof option === 'string') {\n return option;\n }\n if (typeof option === 'number') {\n return option.toString();\n }\n return option.label;\n}\n\n/**\n * Autocomplete input and command palette with a list of suggestions.\n *\n * Thanks to Shadcn for heavy inspiration and documentation\n * https://ui.shadcn.com/docs/components/combobox\n */\nexport function ComboBox({\n id,\n options = [],\n className,\n buttonClassName,\n popoverContentClassName,\n value,\n onChange = () => {},\n getOptionLabel = getOptionLabelDefault,\n getButtonLabel,\n icon = undefined,\n buttonPlaceholder = '',\n textPlaceholder = '',\n commandEmptyMessage = 'No option found',\n buttonVariant = 'outline',\n alignDropDown = 'start',\n isDisabled = false,\n ariaLabel,\n ...props\n}: ComboBoxProps) {\n const [open, setOpen] = useState(false);\n\n const buttonLabel = getButtonLabel ?? getOptionLabel;\n\n const isGroupedOptions = (\n groupOptions: readonly T[] | readonly ComboBoxGroup[],\n ): groupOptions is readonly ComboBoxGroup[] => {\n return Boolean(\n groupOptions.length > 0 &&\n typeof groupOptions[0] === 'object' &&\n 'options' in groupOptions[0],\n );\n };\n\n const renderCommandItem = (option: T, groupHeading?: string) => {\n const optionLabel = getOptionLabel(option);\n const secondaryLabel =\n typeof option === 'object' && 'secondaryLabel' in option ? option.secondaryLabel : undefined;\n\n const key = `${groupHeading ?? ''}${optionLabel}${secondaryLabel ?? ''}`;\n\n return (\n {\n onChange(option);\n setOpen(false);\n }}\n className=\"tw-flex tw-items-center\"\n >\n \n \n {optionLabel}\n {secondaryLabel && ยท {secondaryLabel}}\n \n \n );\n };\n\n return (\n \n \n \n
\n {icon &&
{icon}
}\n \n {value ? buttonLabel(value) : buttonPlaceholder}\n \n
\n\n \n \n
\n \n \n \n {commandEmptyMessage}\n \n {isGroupedOptions(options)\n ? options.map((group) => (\n \n {group.options.map((option) => renderCommandItem(option, group.groupHeading))}\n \n ))\n : options.map((option) => renderCommandItem(option))}\n \n \n \n
\n );\n}\n\nexport default ComboBox;\n","import { ComboBox } from '@/components/basics/combo-box.component';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { useMemo } from 'react';\n\nexport type ChapterRangeSelectorProps = {\n /** The selected start chapter */\n startChapter: number;\n /** The selected end chapter */\n endChapter: number;\n /** Callback function to handle the selection of the start chapter */\n handleSelectStartChapter: (chapter: number) => void;\n /** Callback function to handle the selection of the end chapter */\n handleSelectEndChapter: (chapter: number) => void;\n /** Flag to disable the component */\n isDisabled?: boolean;\n /** The total number of chapters available */\n chapterCount: number;\n};\n\n/**\n * ChapterRangeSelector is a component that provides a UI for selecting a range of chapters. It\n * consists of two combo boxes for selecting the start and end chapters. The component ensures that\n * the selected start chapter is always less than or equal to the end chapter, and vice versa.\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param {ChapterRangeSelectorProps} props - The props for the component.\n */\n\nexport function ChapterRangeSelector({\n startChapter,\n endChapter,\n handleSelectStartChapter,\n handleSelectEndChapter,\n isDisabled = false,\n chapterCount,\n}: ChapterRangeSelectorProps) {\n const chapterOptions = useMemo(\n () => Array.from({ length: chapterCount }, (_, index) => index + 1),\n [chapterCount],\n );\n\n const onChangeStartChapter = (value: number) => {\n handleSelectStartChapter(value);\n if (value > endChapter) {\n handleSelectEndChapter(value);\n }\n };\n\n const onChangeEndChapter = (value: number) => {\n handleSelectEndChapter(value);\n if (value < startChapter) {\n handleSelectStartChapter(value);\n }\n };\n\n return (\n <>\n \n option.toString()}\n value={startChapter}\n />\n\n \n option.toString()}\n value={endChapter}\n />\n \n );\n}\n\nexport default ChapterRangeSelector;\n","import { Button } from '@/components/shadcn-ui/button';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { RadioGroup, RadioGroupItem } from '@/components/shadcn-ui/radio-group';\nimport { Canon } from '@sillsdev/scripture';\nimport { LocalizedStringValue } from 'platform-bible-utils';\nimport { useState } from 'react';\nimport {\n ChapterRangeSelector,\n ChapterRangeSelectorProps,\n} from '../basics/chapter-range-selector.component';\n\n/** Enumeration of possible book selection modes */\nexport enum BookSelectionMode {\n CURRENT_BOOK = 'current book',\n CHOOSE_BOOKS = 'choose books',\n}\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const BOOK_SELECTOR_STRING_KEYS = Object.freeze([\n '%webView_bookSelector_currentBook%',\n '%webView_bookSelector_choose%',\n '%webView_bookSelector_chooseBooks%',\n] as const);\n\nexport type BookSelectorLocalizedStrings = {\n [localizedBookSelectorKey in (typeof BOOK_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: BookSelectorLocalizedStrings,\n key: keyof BookSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\ntype BookSelectorProps = ChapterRangeSelectorProps & {\n handleBookSelectionModeChange: (newMode: BookSelectionMode) => void;\n currentBookName: string;\n onSelectBooks: () => void;\n selectedBookIds: string[];\n localizedStrings: BookSelectorLocalizedStrings;\n};\n\n/**\n * BookSelector is a component that provides an interactive UI for selecting books. It can be set to\n * either allow the user to select a single book or to choose multiple books. In the former case, it\n * will display the range of chapters in the selected book, and in the latter case it will display a\n * list of the selected books.\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param {BookSelectorProps} props\n * @param {function} props.handleBookSelectionModeChange - Callback function to handle changes in\n * book selection mode.\n * @param {string} props.currentBookName - The name of the currently selected book.\n * @param {function} props.onSelectBooks - Callback function to handle book selection.\n * @param {string[]} props.selectedBookIds - An array of book IDs that have been selected.\n * @param {BookSelectorLocalizedStrings} props.localizedStrings - Object containing localized\n * strings for the component.\n */\nexport function BookSelector({\n handleBookSelectionModeChange,\n currentBookName,\n onSelectBooks,\n selectedBookIds,\n chapterCount,\n endChapter,\n handleSelectEndChapter,\n startChapter,\n handleSelectStartChapter,\n localizedStrings,\n}: BookSelectorProps) {\n const currentBookText = localizeString(localizedStrings, '%webView_bookSelector_currentBook%');\n const chooseText = localizeString(localizedStrings, '%webView_bookSelector_choose%');\n const chooseBooksText = localizeString(localizedStrings, '%webView_bookSelector_chooseBooks%');\n\n const [bookSelectionMode, setBookSelectionMode] = useState(\n BookSelectionMode.CURRENT_BOOK,\n );\n\n const onSelectionModeChange = (newMode: BookSelectionMode) => {\n setBookSelectionMode(newMode);\n handleBookSelectionModeChange(newMode);\n };\n\n return (\n onSelectionModeChange(value as BookSelectionMode)}\n >\n
\n
\n
\n \n \n
\n \n
\n \n
\n
\n
\n
\n \n \n
\n \n onSelectBooks()}\n >\n {chooseText}\n \n
\n
\n \n );\n}\n\nexport default BookSelector;\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{createContext as n,useContext as e}from\"react\";const r=n(null);function t(n,e){let r=null;return null!=n&&(r=n[1]),{getTheme:function(){return null!=e?e:null!=r?r.getTheme():null}}}function o(){const n=e(r);return null==n&&function(n,...e){const r=new URL(\"https://lexical.dev/docs/error\"),t=new URLSearchParams;t.append(\"code\",n);for(const n of e)t.append(\"v\",n);throw r.search=t.toString(),Error(`Minified Lexical error #${n}; visit ${r.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}(8),n}export{r as LexicalComposerContext,t as createLexicalComposerContext,o as useLexicalComposerContext};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{createLexicalComposerContext as e,LexicalComposerContext as t}from\"@lexical/react/LexicalComposerContext\";import{createEditor as o,$getRoot as n,$createParagraphNode as i,$getSelection as r,HISTORY_MERGE_TAG as a}from\"lexical\";import{useLayoutEffect as c,useEffect as l,useMemo as d}from\"react\";import{jsx as s}from\"react/jsx-runtime\";const m=\"undefined\"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,u=m?c:l,p={tag:a};function f({initialConfig:a,children:c}){const l=d(()=>{const{theme:t,namespace:c,nodes:l,onError:d,editorState:s,html:u}=a,f=e(null,t),E=o({editable:a.editable,html:u,namespace:c,nodes:l,onError:e=>d(e,E),theme:t});return function(e,t){if(null===t)return;if(void 0===t)e.update(()=>{const t=n();if(t.isEmpty()){const o=i();t.append(o);const n=m?document.activeElement:null;(null!==r()||null!==n&&n===e.getRootElement())&&o.select()}},p);else if(null!==t)switch(typeof t){case\"string\":{const o=e.parseEditorState(t);e.setEditorState(o,p);break}case\"object\":e.setEditorState(t,p);break;case\"function\":e.update(()=>{n().isEmpty()&&t(e)},p)}}(E,s),[E,f]},[]);return u(()=>{const e=a.editable,[t]=l;t.setEditable(void 0===e||e)},[]),s(t.Provider,{value:l,children:c})}export{f as LexicalComposer};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{useLexicalComposerContext as e}from\"@lexical/react/LexicalComposerContext\";import{HISTORY_MERGE_TAG as t}from\"lexical\";import{useLayoutEffect as o,useEffect as i}from\"react\";const r=\"undefined\"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?o:i;function n({ignoreHistoryMergeTagChange:o=!0,ignoreSelectionChange:i=!1,onChange:n}){const[a]=e();return r(()=>{if(n)return a.registerUpdateListener(({editorState:e,dirtyElements:r,dirtyLeaves:d,prevEditorState:s,tags:c})=>{i&&0===r.size&&0===d.size||o&&c.has(t)||s.isEmpty()||n(e,a,c)})},[a,o,i,n]),null}export{n as OnChangePlugin};\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure. Also,\n * modifications have been made to integrate with our codebase.\n *\n * Original file location: src/components/editor/themes/editor-theme.ts\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { EditorThemeClasses } from 'lexical';\n\nimport './editor-theme.css';\n\nexport const editorTheme: EditorThemeClasses = {\n ltr: 'tw-text-left',\n rtl: 'tw-text-right',\n heading: {\n h1: 'tw-scroll-m-20 tw-text-4xl tw-font-extrabold tw-tracking-tight lg:tw-text-5xl',\n h2: 'tw-scroll-m-20 tw-border-b tw-pb-2 tw-text-3xl tw-font-semibold tw-tracking-tight first:tw-mt-0',\n h3: 'tw-scroll-m-20 tw-text-2xl tw-font-semibold tw-tracking-tight',\n h4: 'tw-scroll-m-20 tw-text-xl tw-font-semibold tw-tracking-tight',\n h5: 'tw-scroll-m-20 tw-text-lg tw-font-semibold tw-tracking-tight',\n h6: 'tw-scroll-m-20 tw-text-base tw-font-semibold tw-tracking-tight',\n },\n paragraph: 'tw-outline-none',\n quote: 'tw-mt-6 tw-border-l-2 tw-pl-6 tw-italic',\n link: 'tw-text-blue-600 hover:tw-underline hover:tw-cursor-pointer',\n list: {\n checklist: 'tw-relative',\n listitem: 'tw-mx-8',\n listitemChecked:\n 'tw-relative tw-mx-2 tw-px-6 tw-list-none tw-outline-none tw-line-through before:tw-content-[\"\"] before:tw-w-4 before:tw-h-4 before:tw-top-0.5 before:tw-left-0 before:tw-cursor-pointer before:tw-block before:tw-bg-cover before:tw-absolute before:tw-border before:tw-border-primary before:tw-rounded before:tw-bg-primary before:tw-bg-no-repeat after:tw-content-[\"\"] after:tw-cursor-pointer after:tw-border-white after:tw-border-solid after:tw-absolute after:tw-block after:tw-top-[6px] after:tw-w-[3px] after:tw-left-[7px] after:tw-right-[7px] after:tw-h-[6px] after:tw-rotate-45 after:tw-border-r-2 after:tw-border-b-2 after:tw-border-l-0 after:tw-border-t-0',\n listitemUnchecked:\n 'tw-relative tw-mx-2 tw-px-6 tw-list-none tw-outline-none before:tw-content-[\"\"] before:tw-w-4 before:tw-h-4 before:tw-top-0.5 before:tw-left-0 before:tw-cursor-pointer before:tw-block before:tw-bg-cover before:tw-absolute before:tw-border before:tw-border-primary before:tw-rounded',\n nested: {\n listitem: 'tw-list-none before:tw-hidden after:tw-hidden',\n },\n ol: 'tw-m-0 tw-p-0 tw-list-decimal [&>li]:tw-mt-2',\n olDepth: [\n 'tw-list-outside !tw-list-decimal',\n 'tw-list-outside !tw-list-[upper-roman]',\n 'tw-list-outside !tw-list-[lower-roman]',\n 'tw-list-outside !tw-list-[upper-alpha]',\n 'tw-list-outside !tw-list-[lower-alpha]',\n ],\n ul: 'tw-m-0 tw-p-0 tw-list-outside [&>li]:tw-mt-2',\n ulDepth: [\n 'tw-list-outside !tw-list-disc',\n 'tw-list-outside !tw-list-disc',\n 'tw-list-outside !tw-list-disc',\n 'tw-list-outside !tw-list-disc',\n 'tw-list-outside !tw-list-disc',\n ],\n },\n hashtag: 'tw-text-blue-600 tw-bg-blue-100 tw-rounded-md tw-px-1',\n text: {\n bold: 'tw-font-bold',\n code: 'tw-bg-gray-100 tw-p-1 tw-rounded-md',\n italic: 'tw-italic',\n strikethrough: 'tw-line-through',\n subscript: 'tw-sub',\n superscript: 'tw-sup',\n underline: 'tw-underline',\n underlineStrikethrough: 'tw-underline tw-line-through',\n },\n image: 'tw-relative tw-inline-block tw-user-select-none tw-cursor-default editor-image',\n inlineImage:\n 'tw-relative tw-inline-block tw-user-select-none tw-cursor-default inline-editor-image',\n keyword: 'tw-text-purple-900 tw-font-bold',\n code: 'EditorTheme__code',\n codeHighlight: {\n atrule: 'EditorTheme__tokenAttr',\n attr: 'EditorTheme__tokenAttr',\n boolean: 'EditorTheme__tokenProperty',\n builtin: 'EditorTheme__tokenSelector',\n cdata: 'EditorTheme__tokenComment',\n char: 'EditorTheme__tokenSelector',\n class: 'EditorTheme__tokenFunction',\n 'class-name': 'EditorTheme__tokenFunction',\n comment: 'EditorTheme__tokenComment',\n constant: 'EditorTheme__tokenProperty',\n deleted: 'EditorTheme__tokenProperty',\n doctype: 'EditorTheme__tokenComment',\n entity: 'EditorTheme__tokenOperator',\n function: 'EditorTheme__tokenFunction',\n important: 'EditorTheme__tokenVariable',\n inserted: 'EditorTheme__tokenSelector',\n keyword: 'EditorTheme__tokenAttr',\n namespace: 'EditorTheme__tokenVariable',\n number: 'EditorTheme__tokenProperty',\n operator: 'EditorTheme__tokenOperator',\n prolog: 'EditorTheme__tokenComment',\n property: 'EditorTheme__tokenProperty',\n punctuation: 'EditorTheme__tokenPunctuation',\n regex: 'EditorTheme__tokenVariable',\n selector: 'EditorTheme__tokenSelector',\n string: 'EditorTheme__tokenSelector',\n symbol: 'EditorTheme__tokenProperty',\n tag: 'EditorTheme__tokenProperty',\n url: 'EditorTheme__tokenOperator',\n variable: 'EditorTheme__tokenVariable',\n },\n characterLimit: '!tw-bg-destructive/50',\n table: 'EditorTheme__table tw-w-fit tw-overflow-scroll tw-border-collapse',\n tableCell:\n 'EditorTheme__tableCell tw-w-24 tw-relative tw-border tw-px-4 tw-py-2 tw-text-left [&[align=center]]:tw-text-center [&[align=right]]:tw-text-right',\n tableCellActionButton:\n 'EditorTheme__tableCellActionButton tw-bg-background tw-block tw-border-0 tw-rounded-2xl tw-w-5 tw-h-5 tw-text-foreground tw-cursor-pointer',\n tableCellActionButtonContainer:\n 'EditorTheme__tableCellActionButtonContainer tw-block tw-right-1 tw-top-1.5 tw-absolute tw-z-10 tw-w-5 tw-h-5',\n tableCellEditing: 'EditorTheme__tableCellEditing tw-rounded-sm tw-shadow-sm',\n tableCellHeader:\n 'EditorTheme__tableCellHeader tw-bg-muted tw-border tw-px-4 tw-py-2 tw-text-left tw-font-bold [&[align=center]]:tw-text-center [&[align=right]]:tw-text-right',\n tableCellPrimarySelected:\n 'EditorTheme__tableCellPrimarySelected tw-border tw-border-primary tw-border-solid tw-block tw-h-[calc(100%-2px)] tw-w-[calc(100%-2px)] tw-absolute tw--left-[1px] tw--top-[1px] tw-z-10 ',\n tableCellResizer:\n 'EditorTheme__tableCellResizer tw-absolute tw--right-1 tw-h-full tw-w-2 tw-cursor-ew-resize tw-z-10 tw-top-0',\n tableCellSelected: 'EditorTheme__tableCellSelected tw-bg-muted',\n tableCellSortedIndicator:\n 'EditorTheme__tableCellSortedIndicator tw-block tw-opacity-50 tw-absolute tw-bottom-0 tw-left-0 tw-w-full tw-h-1 tw-bg-muted',\n tableResizeRuler:\n 'EditorTheme__tableCellResizeRuler tw-block tw-absolute tw-w-[1px] tw-h-full tw-bg-primary tw-top-0',\n tableRowStriping: 'EditorTheme__tableRowStriping tw-m-0 tw-border-t tw-p-0 even:tw-bg-muted',\n tableSelected: 'EditorTheme__tableSelected tw-ring-2 tw-ring-primary tw-ring-offset-2',\n tableSelection: 'EditorTheme__tableSelection tw-bg-transparent',\n layoutItem: 'tw-border tw-border-dashed tw-px-4 tw-py-2',\n layoutContainer: 'tw-grid tw-gap-2.5 tw-my-2.5 tw-mx-0',\n autocomplete: 'tw-text-muted-foreground',\n blockCursor: '',\n embedBlock: {\n base: 'tw-user-select-none',\n focus: 'tw-ring-2 tw-ring-primary tw-ring-offset-2',\n },\n hr: 'tw-p-0.5 tw-border-none tw-my-1 tw-mx-0 tw-cursor-pointer after:tw-content-[\"\"] after:tw-block after:tw-h-0.5 after:tw-bg-muted selected:tw-ring-2 selected:tw-ring-primary selected:tw-ring-offset-2 selected:tw-user-select-none',\n indent: '[--lexical-indent-base-value:40px]',\n mark: '',\n markOverlap: '',\n};\n","import React from 'react';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/** @inheritdoc Tooltip */\nconst TooltipProvider = TooltipPrimitive.Provider;\n\n/**\n * Tooltip components provide a popover that displays information related to an element when hovered\n * or focused. These components are built on Radix UI primitives and styled with Shadcn UI. See\n * Shadcn UI Documentation: https://ui.shadcn.com/docs/components/tooltip See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/tooltip\n */\nconst Tooltip = TooltipPrimitive.Root;\n\n/** @inheritdoc Tooltip */\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\n/** @inheritdoc Tooltip */\nconst TooltipContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, sideOffset = 4, ...props }, ref) => (\n \n));\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure.\n *\n * Original file location: src/components/blocks/editor-00/nodes.ts\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { HeadingNode, QuoteNode } from '@lexical/rich-text';\nimport { Klass, LexicalNode, LexicalNodeReplacement, ParagraphNode, TextNode } from 'lexical';\n\nexport const nodes: ReadonlyArray | LexicalNodeReplacement> = [\n HeadingNode,\n ParagraphNode,\n TextNode,\n QuoteNode,\n];\n","'use client';\nimport { createContext, Component, createElement, useContext, useState, useMemo, forwardRef } from 'react';\n\nconst ErrorBoundaryContext = createContext(null);\n\nconst initialState = {\n didCatch: false,\n error: null\n};\nclass ErrorBoundary extends Component {\n constructor(props) {\n super(props);\n this.resetErrorBoundary = this.resetErrorBoundary.bind(this);\n this.state = initialState;\n }\n static getDerivedStateFromError(error) {\n return {\n didCatch: true,\n error\n };\n }\n resetErrorBoundary() {\n const {\n error\n } = this.state;\n if (error !== null) {\n var _this$props$onReset, _this$props;\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n (_this$props$onReset = (_this$props = this.props).onReset) === null || _this$props$onReset === void 0 ? void 0 : _this$props$onReset.call(_this$props, {\n args,\n reason: \"imperative-api\"\n });\n this.setState(initialState);\n }\n }\n componentDidCatch(error, info) {\n var _this$props$onError, _this$props2;\n (_this$props$onError = (_this$props2 = this.props).onError) === null || _this$props$onError === void 0 ? void 0 : _this$props$onError.call(_this$props2, error, info);\n }\n componentDidUpdate(prevProps, prevState) {\n const {\n didCatch\n } = this.state;\n const {\n resetKeys\n } = this.props;\n\n // There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array,\n // we'd end up resetting the error boundary immediately.\n // This would likely trigger a second error to be thrown.\n // So we make sure that we don't check the resetKeys on the first call of cDU after the error is set.\n\n if (didCatch && prevState.error !== null && hasArrayChanged(prevProps.resetKeys, resetKeys)) {\n var _this$props$onReset2, _this$props3;\n (_this$props$onReset2 = (_this$props3 = this.props).onReset) === null || _this$props$onReset2 === void 0 ? void 0 : _this$props$onReset2.call(_this$props3, {\n next: resetKeys,\n prev: prevProps.resetKeys,\n reason: \"keys\"\n });\n this.setState(initialState);\n }\n }\n render() {\n const {\n children,\n fallbackRender,\n FallbackComponent,\n fallback\n } = this.props;\n const {\n didCatch,\n error\n } = this.state;\n let childToRender = children;\n if (didCatch) {\n const props = {\n error,\n resetErrorBoundary: this.resetErrorBoundary\n };\n if (typeof fallbackRender === \"function\") {\n childToRender = fallbackRender(props);\n } else if (FallbackComponent) {\n childToRender = createElement(FallbackComponent, props);\n } else if (fallback !== undefined) {\n childToRender = fallback;\n } else {\n throw error;\n }\n }\n return createElement(ErrorBoundaryContext.Provider, {\n value: {\n didCatch,\n error,\n resetErrorBoundary: this.resetErrorBoundary\n }\n }, childToRender);\n }\n}\nfunction hasArrayChanged() {\n let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];\n let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];\n return a.length !== b.length || a.some((item, index) => !Object.is(item, b[index]));\n}\n\nfunction assertErrorBoundaryContext(value) {\n if (value == null || typeof value.didCatch !== \"boolean\" || typeof value.resetErrorBoundary !== \"function\") {\n throw new Error(\"ErrorBoundaryContext not found\");\n }\n}\n\nfunction useErrorBoundary() {\n const context = useContext(ErrorBoundaryContext);\n assertErrorBoundaryContext(context);\n const [state, setState] = useState({\n error: null,\n hasError: false\n });\n const memoized = useMemo(() => ({\n resetBoundary: () => {\n context.resetErrorBoundary();\n setState({\n error: null,\n hasError: false\n });\n },\n showBoundary: error => setState({\n error,\n hasError: true\n })\n }), [context.resetErrorBoundary]);\n if (state.hasError) {\n throw state.error;\n }\n return memoized;\n}\n\nfunction withErrorBoundary(component, errorBoundaryProps) {\n const Wrapped = forwardRef((props, ref) => createElement(ErrorBoundary, errorBoundaryProps, createElement(component, {\n ...props,\n ref\n })));\n\n // Format for display in DevTools\n const name = component.displayName || component.name || \"Unknown\";\n Wrapped.displayName = \"withErrorBoundary(\".concat(name, \")\");\n return Wrapped;\n}\n\nexport { ErrorBoundary, ErrorBoundaryContext, useErrorBoundary, withErrorBoundary };\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{ErrorBoundary as r}from\"react-error-boundary\";import{jsx as o}from\"react/jsx-runtime\";function n({children:n,onError:e}){return o(r,{fallback:o(\"div\",{style:{border:\"1px solid #f00\",color:\"#f00\",padding:\"8px\"},children:\"An error was thrown.\"}),onError:e,children:n})}export{n as LexicalErrorBoundary};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{useLexicalComposerContext as e}from\"@lexical/react/LexicalComposerContext\";import{useLayoutEffect as n,useEffect as t,useMemo as i,useState as r,useRef as o}from\"react\";const c=\"undefined\"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?n:t;function u(e){return{initialValueFn:()=>e.isEditable(),subscribe:n=>e.registerEditableListener(n)}}function a(){return function(n){const[t]=e(),u=i(()=>n(t),[t,n]),[a,l]=r(()=>u.initialValueFn()),d=o(a);return c(()=>{const{initialValueFn:e,subscribe:n}=u,t=e();return d.current!==t&&(d.current=t,l(t)),n(e=>{d.current=e,l(e)})},[u,n]),a}(u)}export{a as useLexicalEditable};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{$isTextNode as e,$getEditor as t,$isRootNode as n,$isElementNode as o,$getNodeByKey as l,$getPreviousSelection as r,$createTextNode as s,$isRangeSelection as i,$getSelection as c,$caretRangeFromSelection as f,$isTokenOrSegmented as u,$getCharacterOffsets as g,$cloneWithPropertiesEphemeral as a,$createRangeSelection as d,$findMatchingParent as p,INTERNAL_$isBlock as h,$setSelection as y,$caretFromPoint as m,$isExtendableTextPointCaret as S,$extendCaretToRange as x,$isChildCaret as T,$isDecoratorNode as w,$isRootOrShadowRoot as N,$hasAncestor as v,$isLeafNode as C}from\"lexical\";export{$cloneWithProperties,$selectAll}from\"lexical\";function K(e,...t){const n=new URL(\"https://lexical.dev/docs/error\"),o=new URLSearchParams;o.append(\"code\",e);for(const e of t)o.append(\"v\",e);throw n.search=o.toString(),Error(`Minified Lexical error #${e}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}const E=new Map;function P(e){let t=e;for(;null!=t;){if(t.nodeType===Node.TEXT_NODE)return t;t=t.firstChild}return null}function k(e){const t=e.parentNode;if(null==t)throw new Error(\"Should never happen\");return[t,Array.from(t.childNodes).indexOf(e)]}function I(t,n,o,l,r){const s=n.getKey(),i=l.getKey(),c=document.createRange();let f=t.getElementByKey(s),u=t.getElementByKey(i),g=o,a=r;if(e(n)&&(f=P(f)),e(l)&&(u=P(u)),void 0===n||void 0===l||null===f||null===u)return null;\"BR\"===f.nodeName&&([f,g]=k(f)),\"BR\"===u.nodeName&&([u,a]=k(u));const d=f.firstChild;f===u&&null!=d&&\"BR\"===d.nodeName&&0===g&&0===a&&(a=1);try{c.setStart(f,g),c.setEnd(u,a)}catch(e){return null}return!c.collapsed||g===a&&s===i||(c.setStart(u,a),c.setEnd(f,g)),c}function B(e,t){const n=e.getRootElement();if(null===n)return[];const o=n.getBoundingClientRect(),l=getComputedStyle(n),r=parseFloat(l.paddingLeft)+parseFloat(l.paddingRight),s=Array.from(t.getClientRects());let i,c=s.length;s.sort((e,t)=>{const n=e.top-t.top;return Math.abs(n)<=3?e.left-t.left:n});for(let e=0;et.top&&i.left+i.width>t.left,l=t.width+r===o.width;n||l?(s.splice(e--,1),c--):i=t}return s}function F(e){const t={};if(!e)return t;const n=e.split(\";\");for(const e of n)if(\"\"!==e){const[n,o]=e.split(/:([^]+)/);n&&o&&(t[n.trim()]=o.trim())}return t}function b(e){let t=E.get(e);return void 0===t&&(t=F(e),E.set(e,t)),t}function R(e){let t=\"\";for(const n in e)n&&(t+=`${n}: ${e[n]};`);return t}function z(e){const n=t().getElementByKey(e.getKey());if(null===n)return null;const o=n.ownerDocument.defaultView;return null===o?null:o.getComputedStyle(n)}function O(e){return z(n(e)?e:e.getParentOrThrow())}function A(e){const t=O(e);return null!==t&&\"rtl\"===t.direction}function M(e,t,n=\"self\"){const o=e.getStartEndPoints();if(t.isSelected(e)&&!u(t)&&null!==o){const[l,r]=o,s=e.isBackward(),i=l.getNode(),c=r.getNode(),f=t.is(i),u=t.is(c);if(f||u){const[o,l]=g(e),r=i.is(c),f=t.is(s?c:i),u=t.is(s?i:c);let d,p=0;if(r)p=o>l?l:o,d=o>l?o:l;else if(f){p=s?l:o,d=void 0}else if(u){p=0,d=s?o:l}const h=t.__text.slice(p,d);h!==t.__text&&(\"clone\"===n&&(t=a(t)),t.__text=h)}}return t}function _(e){if(\"text\"===e.type)return e.offset===e.getNode().getTextContentSize();const t=e.getNode();return o(t)||K(177),e.offset===t.getChildrenSize()}function L(t,c,f){let u=c.getNode(),g=f;if(o(u)){const e=u.getDescendantByIndex(c.offset);null!==e&&(u=e)}for(;g>0&&null!==u;){if(o(u)){const e=u.getLastDescendant();null!==e&&(u=e)}let f=u.getPreviousSibling(),a=0;if(null===f){let e=u.getParentOrThrow(),t=e.getPreviousSibling();for(;null===t;){if(e=e.getParent(),null===e){f=null;break}t=e.getPreviousSibling()}null!==e&&(a=e.isInline()?0:2,f=t)}let d=u.getTextContent();\"\"===d&&o(u)&&!u.isInline()&&(d=\"\\n\\n\");const p=d.length;if(!e(u)||g>=p){const e=u.getParent();u.remove(),null==e||0!==e.getChildrenSize()||n(e)||e.remove(),g-=p+a,u=f}else{const n=u.getKey(),o=t.getEditorState().read(()=>{const t=l(n);return e(t)&&t.isSimpleText()?t.getTextContent():null}),f=p-g,a=d.slice(0,f);if(null!==o&&o!==d){const e=r();let t=u;if(u.isSimpleText())u.setTextContent(o);else{const e=s(o);u.replace(e),t=e}if(i(e)&&e.isCollapsed()){const n=e.anchor.offset;t.select(n,n)}}else if(u.isSimpleText()){const e=c.key===n;let t=c.offset;t(\"function\"==typeof o?e[n]=o(l[n],t):null===o?delete e[n]:e[n]=o,e),{...l}),s=R(r);i(t)||e(t)?t.setStyle(s):t.setTextStyle(s),E.set(s,r)}function U(e,t){if(i(e)&&e.isCollapsed()){D(e,t);const n=e.anchor.getNode();o(n)&&n.isEmpty()&&D(n,t)}j(e=>{D(e,t)});const n=e.getNodes();if(n.length>0){const e=new Set;for(const l of n){if(!o(l)||!l.canBeEmpty()||0!==l.getChildrenSize())continue;const n=l.getKey();e.has(n)||(e.add(n),D(l,t))}}}function j(t){const n=c();if(!n)return;const o=new Map,l=e=>o.get(e.getKey())||[0,e.getTextContentSize()];if(i(n))for(const e of f(n).getTextSlices())e&&o.set(e.caret.origin.getKey(),e.getSliceIndices());const r=n.getNodes();for(const n of r){if(!e(n)||!n.canHaveFormat())continue;const[o,r]=l(n);if(r!==o)if(u(n)||0===o&&r===n.getTextContentSize())t(n);else{t(n.splitText(o,r)[0===o?0:1])}}i(n)&&\"text\"===n.anchor.type&&\"text\"===n.focus.type&&n.anchor.key===n.focus.key&&H(n)}function H(e){if(e.isBackward()){const{anchor:t,focus:n}=e,{key:o,offset:l,type:r}=t;t.set(n.key,n.offset,n.type),n.set(o,l,r)}}function V(e,t){const n=e.getFormatType(),o=e.getIndent();n!==t.getFormatType()&&t.setFormat(n),o!==t.getIndent()&&t.setIndent(o)}function W(e,t,n=V){if(null===e)return;const l=e.getStartEndPoints(),r=new Map;let s=null;if(l){const[e,t]=l;s=d(),s.anchor.set(e.key,e.offset,e.type),s.focus.set(t.key,t.offset,t.type);const n=p(e.getNode(),h),i=p(t.getNode(),h);o(n)&&r.set(n.getKey(),n),o(i)&&r.set(i.getKey(),i)}for(const t of e.getNodes())if(o(t)&&h(t))r.set(t.getKey(),t);else if(null===l){const e=p(t,h);o(e)&&r.set(e.getKey(),e)}for(const[e,o]of r){const l=t();n(o,l),o.replace(l,!0),s&&(e===s.anchor.key&&s.anchor.set(l.getKey(),s.anchor.offset,s.anchor.type),e===s.focus.key&&s.focus.set(l.getKey(),s.focus.offset,s.focus.type))}s&&e.is(c())&&y(s)}function X(e){return e.getNode().isAttached()}function q(e){let t=e;for(;null!==t&&!N(t);){const e=t.getLatest(),n=t.getParent();0===e.getChildrenSize()&&t.remove(!0),t=n}}function G(e,t,n=null){const o=e.getStartEndPoints(),l=o?o[0]:null,r=e.getNodes(),s=r.length;if(null!==l&&(0===s||1===s&&\"element\"===l.type&&0===l.getNode().getChildrenSize())){const e=\"text\"===l.type?l.getNode().getParentOrThrow():l.getNode(),o=e.getChildren();let r=t();return r.setFormat(e.getFormatType()),r.setIndent(e.getIndent()),o.forEach(e=>r.append(e)),n&&(r=n.append(r)),void e.replace(r)}let i=null,c=[];for(let o=0;o{t.append(e),p.add(e.getKey()),o(e)&&e.getChildrenKeys().forEach(e=>p.add(e))}),q(r)}}else if(d.has(n.getKey())){o(n)||K(179);const e=l();e.setFormat(n.getFormatType()),e.setIndent(n.getIndent()),u.push(e),n.remove(!0)}}if(null!==s)for(let e=0;e=0;e--){const t=u[e];g.insertAfter(t)}else{const e=g.getFirstChild();if(o(e)&&(g=e),null===e)if(s)g.append(s);else for(let e=0;e=0;e--){const t=u[e];g.insertAfter(t),h=t}const m=r();i(m)&&X(m.anchor)&&X(m.focus)?y(m.clone()):null!==h?h.selectEnd():e.dirty=!0}function Q(e){const t=Y(e);return null!==t&&\"vertical-rl\"===t.writingMode}function Y(e){const t=e.anchor.getNode();return o(t)?z(t):O(t)}function Z(e,t){let n=Q(e)?!t:t;te(e)&&(n=!n);const l=m(e.focus,n?\"previous\":\"next\");if(S(l))return!1;for(const e of x(l)){if(T(e))return!e.origin.isInline();if(!o(e.origin)){if(w(e.origin))return!0;break}}return!1}function ee(e,t,n,o){e.modify(t?\"extend\":\"move\",n,o)}function te(e){const t=Y(e);return null!==t&&\"rtl\"===t.direction}function ne(e,t,n){const o=te(e);let l;l=Q(e)||o?!n:n,ee(e,t,l,\"character\")}function oe(e,t,n){const o=b(e.getStyle());return null!==o&&o[t]||n}function le(t,n,o=\"\"){let l=null;const r=t.getNodes(),s=t.anchor,c=t.focus,f=t.isBackward(),u=f?c.offset:s.offset,g=f?c.getNode():s.getNode();if(i(t)&&t.isCollapsed()&&\"\"!==t.style){const e=b(t.style);if(null!==e&&n in e)return e[n]}for(let t=0;tc.length;)u.pop();f&&o(u)}function a(){i=null,r=null,null!==l&&l.disconnect(),l=null,s.remove();for(const t of u)t.remove();u=[]}s.style.position=\"relative\";const d=e.registerRootListener(function n(){const o=e.getRootElement();if(null===o)return a();const u=o.parentElement;if(!t(u))return a();a(),r=o,i=u,l=new MutationObserver(t=>{const o=e.getRootElement(),l=o&&o.parentElement;if(o!==r||l!==i)return n();for(const e of t)if(!s.contains(e.target))return c()}),l.observe(u,z),c()});return()=>{d(),a()}}function W(t,e,n){if(\"text\"!==t.type&&r(e)){const o=e.getDOMSlot(n);return[o.element,o.getFirstChildOffset()+t.offset]}return[i(n)||n,t.offset]}function G(t,r){let i=null,l=null,u=null,s=null,c=null,a=null,d=()=>{};function f(e){e.read(()=>{const e=n();if(!o(e))return i=null,u=null,s=null,a=null,d(),void(d=()=>{});const[f,g]=function(t){const e=t.getStartEndPoints();return t.isBackward()?[e[1],e[0]]:e}(e),p=f.getNode(),m=p.getKey(),h=f.offset,v=g.getNode(),y=v.getKey(),w=g.offset,E=t.getElementByKey(m),x=t.getElementByKey(y),S=null===i||E!==l||h!==u||m!==i.getKey(),C=null===s||x!==c||w!==a||y!==s.getKey();if((S||C)&&null!==E&&null!==x){const e=function(t,e,n,o,r,i,l){const u=(t._window?t._window.document:document).createRange();return u.setStart(...W(e,n,o)),u.setEnd(...W(r,i,l)),u}(t,f,p,E,g,v,x);d(),d=V(t,e,t=>{if(void 0===r)for(const e of t){const t=e.style;\"Highlight\"!==t.background&&(t.background=\"Highlight\"),\"HighlightText\"!==t.color&&(t.color=\"HighlightText\"),t.marginTop!==U(-1.5)&&(t.marginTop=U(-1.5)),t.paddingTop!==U(4)&&(t.paddingTop=U(4)),t.paddingBottom!==U(0)&&(t.paddingBottom=U(0))}else r(t)})}i=p,l=E,u=h,s=v,c=x,a=w})}return f(t.getEditorState()),e(t.registerUpdateListener(({editorState:t})=>f(t)),()=>{d()})}function q(t,e){let n=null;const o=()=>{const o=getSelection(),r=o&&o.anchorNode,i=t.getRootElement();null!==r&&null!==i&&i.contains(r)?null!==n&&(n(),n=null):null===n&&(n=G(t,e))};return document.addEventListener(\"selectionchange\",o),()=>{null!==n&&n(),document.removeEventListener(\"selectionchange\",o)}}const J=K,Q=B,X=j,Y=F,Z=k,tt=I,et=D,nt=$,ot=H,rt=O;function it(t,e){for(const n of e)if(t.type.startsWith(n))return!0;return!1}function lt(t,e){const n=t[Symbol.iterator]();return new Promise((t,o)=>{const r=[],i=()=>{const{done:l,value:u}=n.next();if(l)return t(r);const s=new FileReader;s.addEventListener(\"error\",o),s.addEventListener(\"load\",()=>{const t=s.result;\"string\"==typeof t&&r.push({file:u,result:t}),i()}),it(u,e)?s.readAsDataURL(u):i()};i()})}function ut(t,e){return Array.from(at(t,e))}function st(t){return t?t.getAdjacentCaret():null}function ct(t,e){return Array.from(ht(t,e))}function at(t,e){return ft(\"next\",t,e)}function dt(t,e){const n=l(u(t,e));return n&&n[0]}function ft(t,e,n){const o=m(),i=e||o,c=r(i)?p(i,t):u(i,t),a=pt(i),d=n?v(s(u(n,t)))||dt(n,t):dt(i,t);let f=a;return L({hasNext:t=>null!==t,initial:c,map:t=>({depth:f,node:t.origin}),step:t=>{if(t.isSameNodeCaret(d))return null;y(t)&&f++;const e=l(t);return!e||e[0].isSameNodeCaret(d)?null:(f+=e[1],e[0])}})}function gt(t){const e=l(u(t,\"next\"));return e&&[e[0].origin,e[1]]}function pt(t){let e=-1;for(let n=t;null!==n;n=n.getParent())e++;return e}function mt(t){const e=s(u(t,\"previous\")),n=l(e,\"root\");return n&&n[0].origin}function ht(t,e){return ft(\"previous\",t,e)}function vt(t,e){let n=t;for(;null!=n;){if(n instanceof e)return n;n=n.getParent()}return null}function yt(t){const e=c(t,t=>r(t)&&!t.isInline());return r(e)||T(4,t.__key),e}function wt(t,e,n,o){const r=t=>t instanceof e;return t.registerNodeTransform(e,t=>{const e=(t=>{const e=t.getChildren();for(let t=0;ti.insertAfter(t))),i.remove());return o}function Bt(t,e){const n=[],o=Array.from(t).reverse();for(let t=o.pop();void 0!==t;t=o.pop())if(e(t))n.push(t);else if(r(t))for(const e of kt(t))o.push(e);return n}function _t(t){return $t(p(t,\"next\"))}function kt(t){return $t(p(t,\"previous\"))}function $t(t){return L({hasNext:M,initial:t.getAdjacentCaret(),map:t=>t.origin.getLatest(),step:t=>t.getAdjacentCaret()})}function Kt(t){b(u(t,\"next\")).splice(1,t.getChildren())}function Ot(t){const e=e=>N(e,t),n=(e,n)=>P(e,t,n);return{$get:e,$set:n,accessors:[e,n],makeGetterMethod:()=>function(){return e(this)},makeSetterMethod:()=>function(t){return n(this,t)},stateConfig:t}}export{Bt as $descendantsMatching,ut as $dfs,at as $dfsIterator,bt as $filter,_t as $firstToLastIterator,st as $getAdjacentCaret,pt as $getDepth,yt as $getNearestBlockElementAncestorOrThrow,vt as $getNearestNodeOfType,mt as $getNextRightPreorderNode,gt as $getNextSiblingOrParentSibling,Nt as $insertFirst,xt as $insertNodeToNearestRoot,St as $insertNodeToNearestRootAtCaret,Mt as $isEditorIsNestedEditor,kt as $lastToFirstIterator,Et as $restoreEditorState,ct as $reverseDfs,ht as $reverseDfsIterator,Rt as $unwrapAndFilterDescendants,Kt as $unwrapNode,Ct as $wrapNodeInElement,J as CAN_USE_BEFORE_INPUT,Q as CAN_USE_DOM,X as IS_ANDROID,Y as IS_ANDROID_CHROME,Z as IS_APPLE,tt as IS_APPLE_WEBKIT,et as IS_CHROME,nt as IS_FIREFOX,ot as IS_IOS,rt as IS_SAFARI,Lt as calculateZoomLevel,it as isMimeType,Ot as makeStateWrapper,G as markSelection,lt as mediaFileReader,At as objectKlassEquals,V as positionNodeOnRange,wt as registerNestedElementResolver,q as selectionAlwaysOnDisplay};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{defineExtension as t,safeCast as e,CLEAR_EDITOR_COMMAND as n,COMMAND_PRIORITY_EDITOR as i,$getRoot as o,$getSelection as s,$createParagraphNode as r,$isRangeSelection as c,shallowMergeConfig as d,RootNode as a,TextNode as f,LineBreakNode as u,TabNode as h,ParagraphNode as l,$isEditorState as g,HISTORY_MERGE_TAG as p,createEditor as m,mergeRegister as v,$getNodeByKey as x,createCommand as y,$create as S,CLICK_COMMAND as E,isDOMNode as b,$getNodeFromDOMNode as w,COMMAND_PRIORITY_LOW as N,DecoratorNode as O,addClassNamesToElement as R,$isNodeSelection as M,$createNodeSelection as C,$setSelection as D,removeClassNamesFromElement as _,KEY_TAB_COMMAND as I,OUTDENT_CONTENT_COMMAND as j,INDENT_CONTENT_COMMAND as A,INSERT_TAB_COMMAND as P,COMMAND_PRIORITY_CRITICAL as K,$isBlockElementNode as k,$createRangeSelection as $,$normalizeSelection__EXPERIMENTAL as z}from\"lexical\";export{configExtension,declarePeerDependency,defineExtension,safeCast,shallowMergeConfig}from\"lexical\";import{$getNearestBlockElementAncestorOrThrow as U}from\"@lexical/utils\";const L=Symbol.for(\"preact-signals\");function T(){if(W>1)return void W--;let t,e=!1;for(;void 0!==G;){let n=G;for(G=void 0,Z++;void 0!==n;){const i=n.o;if(n.o=void 0,n.f&=-3,!(8&n.f)&&X(n))try{n.c()}catch(n){e||(t=n,e=!0)}n=i}}if(Z=0,W--,e)throw t}function B(t){if(W>0)return t();W++;try{return t()}finally{T()}}let F,G;function V(t){const e=F;F=void 0;try{return t()}finally{F=e}}let W=0,Z=0,J=0;function H(t){if(void 0===F)return;let e=t.n;return void 0===e||e.t!==F?(e={i:0,S:t,p:F.s,n:void 0,t:F,e:void 0,x:void 0,r:e},void 0!==F.s&&(F.s.n=e),F.s=e,t.n=e,32&F.f&&t.S(e),e):-1===e.i?(e.i=0,void 0!==e.n&&(e.n.p=e.p,void 0!==e.p&&(e.p.n=e.n),e.p=F.s,e.n=void 0,F.s.n=e,F.s=e),e):void 0}function q(t,e){this.v=t,this.i=0,this.n=void 0,this.t=void 0,this.W=null==e?void 0:e.watched,this.Z=null==e?void 0:e.unwatched,this.name=null==e?void 0:e.name}function Q(t,e){return new q(t,e)}function X(t){for(let e=t.s;void 0!==e;e=e.n)if(e.S.i!==e.i||!e.S.h()||e.S.i!==e.i)return!0;return!1}function Y(t){for(let e=t.s;void 0!==e;e=e.n){const n=e.S.n;if(void 0!==n&&(e.r=n),e.S.n=e,e.i=-1,void 0===e.n){t.s=e;break}}}function tt(t){let e,n=t.s;for(;void 0!==n;){const t=n.p;-1===n.i?(n.S.U(n),void 0!==t&&(t.n=n.n),void 0!==n.n&&(n.n.p=t)):e=n,n.S.n=n.r,void 0!==n.r&&(n.r=void 0),n=t}t.s=e}function et(t,e){q.call(this,void 0),this.x=t,this.s=void 0,this.g=J-1,this.f=4,this.W=null==e?void 0:e.watched,this.Z=null==e?void 0:e.unwatched,this.name=null==e?void 0:e.name}function nt(t,e){return new et(t,e)}function it(t){const e=t.u;if(t.u=void 0,\"function\"==typeof e){W++;const n=F;F=void 0;try{e()}catch(e){throw t.f&=-2,t.f|=8,ot(t),e}finally{F=n,T()}}}function ot(t){for(let e=t.s;void 0!==e;e=e.n)e.S.U(e);t.x=void 0,t.s=void 0,it(t)}function st(t){if(F!==this)throw new Error(\"Out-of-order effect\");tt(this),F=t,this.f&=-2,8&this.f&&ot(this),T()}function rt(t,e){this.x=t,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=null==e?void 0:e.name}function ct(t,e){const n=new rt(t,e);try{n.c()}catch(t){throw n.d(),t}const i=n.d.bind(n);return i[Symbol.dispose]=i,i}function dt(t,e={}){const n={};for(const i in t){const o=e[i],s=Q(void 0===o?t[i]:o);n[i]=s}return n}q.prototype.brand=L,q.prototype.h=function(){return!0},q.prototype.S=function(t){const e=this.t;e!==t&&void 0===t.e&&(t.x=e,this.t=t,void 0!==e?e.e=t:V(()=>{var t;null==(t=this.W)||t.call(this)}))},q.prototype.U=function(t){if(void 0!==this.t){const e=t.e,n=t.x;void 0!==e&&(e.x=n,t.e=void 0),void 0!==n&&(n.e=e,t.x=void 0),t===this.t&&(this.t=n,void 0===n&&V(()=>{var t;null==(t=this.Z)||t.call(this)}))}},q.prototype.subscribe=function(t){return ct(()=>{const e=this.value,n=F;F=void 0;try{t(e)}finally{F=n}},{name:\"sub\"})},q.prototype.valueOf=function(){return this.value},q.prototype.toString=function(){return this.value+\"\"},q.prototype.toJSON=function(){return this.value},q.prototype.peek=function(){const t=F;F=void 0;try{return this.value}finally{F=t}},Object.defineProperty(q.prototype,\"value\",{get(){const t=H(this);return void 0!==t&&(t.i=this.i),this.v},set(t){if(t!==this.v){if(Z>100)throw new Error(\"Cycle detected\");this.v=t,this.i++,J++,W++;try{for(let t=this.t;void 0!==t;t=t.x)t.t.N()}finally{T()}}}}),et.prototype=new q,et.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if(32==(36&this.f))return!0;if(this.f&=-5,this.g===J)return!0;if(this.g=J,this.f|=1,this.i>0&&!X(this))return this.f&=-2,!0;const t=F;try{Y(this),F=this;const t=this.x();(16&this.f||this.v!==t||0===this.i)&&(this.v=t,this.f&=-17,this.i++)}catch(t){this.v=t,this.f|=16,this.i++}return F=t,tt(this),this.f&=-2,!0},et.prototype.S=function(t){if(void 0===this.t){this.f|=36;for(let t=this.s;void 0!==t;t=t.n)t.S.S(t)}q.prototype.S.call(this,t)},et.prototype.U=function(t){if(void 0!==this.t&&(q.prototype.U.call(this,t),void 0===this.t)){this.f&=-33;for(let t=this.s;void 0!==t;t=t.n)t.S.U(t)}},et.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(let t=this.t;void 0!==t;t=t.x)t.t.N()}},Object.defineProperty(et.prototype,\"value\",{get(){if(1&this.f)throw new Error(\"Cycle detected\");const t=H(this);if(this.h(),void 0!==t&&(t.i=this.i),16&this.f)throw this.v;return this.v}}),rt.prototype.c=function(){const t=this.S();try{if(8&this.f)return;if(void 0===this.x)return;const t=this.x();\"function\"==typeof t&&(this.u=t)}finally{t()}},rt.prototype.S=function(){if(1&this.f)throw new Error(\"Cycle detected\");this.f|=1,this.f&=-9,it(this),Y(this),W++;const t=F;return F=this,st.bind(this,t)},rt.prototype.N=function(){2&this.f||(this.f|=2,this.o=G,G=this)},rt.prototype.d=function(){this.f|=8,1&this.f||ot(this)},rt.prototype.dispose=function(){this.d()};const at=t({build:(t,e,n)=>dt(e),config:e({defaultSelection:\"rootEnd\",disabled:!1}),name:\"@lexical/extension/AutoFocus\",register(t,e,n){const i=n.getOutput();return ct(()=>i.disabled.value?void 0:t.registerRootListener(e=>{t.focus(()=>{const t=document.activeElement;null===e||null!==t&&e.contains(t)||e.focus({preventScroll:!0})},{defaultSelection:i.defaultSelection.peek()})}))}});function ft(){const t=o(),e=s(),n=r();t.clear(),t.append(n),null!==e&&n.select(),c(e)&&(e.format=0)}function ut(t,e=ft){return t.registerCommand(n,n=>(t.update(e),!0),i)}const ht=t({build:(t,e,n)=>dt(e),config:e({$onClear:ft}),name:\"@lexical/extension/ClearEditor\",register(t,e,n){const{$onClear:i}=n.getOutput();return ct(()=>ut(t,i.value))}});function lt(t){const e=new Set,n=new Set;for(const i of gt(t)){const t=\"function\"==typeof i?i:i.replace;e.add(t.getType()),n.add(t)}return{nodes:n,types:e}}function gt(t){return(\"function\"==typeof t.nodes?t.nodes():t.nodes)||[]}function pt(t,e){let n;return Q(t(),{unwatched(){n&&(n(),n=void 0)},watched(){this.value=t(),n=e(this)}})}const mt=t({build:t=>pt(()=>t.getEditorState(),e=>t.registerUpdateListener(t=>{e.value=t.editorState})),name:\"@lexical/extension/EditorState\"});function vt(t,...e){const n=new URL(\"https://lexical.dev/docs/error\"),i=new URLSearchParams;i.append(\"code\",t);for(const t of e)i.append(\"v\",t);throw n.search=i.toString(),Error(`Minified Lexical error #${t}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}function xt(t,e){if(t&&e&&!Array.isArray(e)&&\"object\"==typeof t&&\"object\"==typeof e){const n=t,i=e;for(const t in i)n[t]=xt(n[t],i[t]);return t}return e}const yt=0,St=1,Et=2,bt=3,wt=4,Nt=5,Ot=6,Rt=7;function Mt(t){return t.id===yt}function Ct(t){return t.id===Et}function Dt(t){return function(t){return t.id===St}(t)||vt(305,String(t.id),String(St)),Object.assign(t,{id:Et})}const _t=new Set;class It{builder;configs;_dependency;_peerNameSet;extension;state;_signal;constructor(t,e){this.builder=t,this.extension=e,this.configs=new Set,this.state={id:yt}}mergeConfigs(){let t=this.extension.config||{};const e=this.extension.mergeConfig?this.extension.mergeConfig.bind(this.extension):d;for(const n of this.configs)t=e(t,n);return t}init(t){const e=this.state;Ct(e)||vt(306,String(e.id));const n={getDependency:this.getInitDependency.bind(this),getDirectDependentNames:this.getDirectDependentNames.bind(this),getPeer:this.getInitPeer.bind(this),getPeerNameSet:this.getPeerNameSet.bind(this)},i={...n,getDependency:this.getDependency.bind(this),getInitResult:this.getInitResult.bind(this),getPeer:this.getPeer.bind(this)},o=function(t,e,n){return Object.assign(t,{config:e,id:bt,registerState:n})}(e,this.mergeConfigs(),n);let s;this.state=o,this.extension.init&&(s=this.extension.init(t,o.config,n)),this.state=function(t,e,n){return Object.assign(t,{id:wt,initResult:e,registerState:n})}(o,s,i)}build(t){const e=this.state;let n;e.id!==wt&&vt(307,String(e.id),String(Nt)),this.extension.build&&(n=this.extension.build(t,e.config,e.registerState));const i={...e.registerState,getOutput:()=>n,getSignal:this.getSignal.bind(this)};this.state=function(t,e,n){return Object.assign(t,{id:Nt,output:e,registerState:n})}(e,n,i)}register(t,e){this._signal=e;const n=this.state;n.id!==Nt&&vt(308,String(n.id),String(Nt));const i=this.extension.register&&this.extension.register(t,n.config,n.registerState);return this.state=function(t){return Object.assign(t,{id:Ot})}(n),()=>{const t=this.state;t.id!==Rt&&vt(309,String(n.id),String(Rt)),this.state=function(t){return Object.assign(t,{id:Nt})}(t),i&&i()}}afterRegistration(t){const e=this.state;let n;return e.id!==Ot&&vt(310,String(e.id),String(Ot)),this.extension.afterRegistration&&(n=this.extension.afterRegistration(t,e.config,e.registerState)),this.state=function(t){return Object.assign(t,{id:Rt})}(e),n}getSignal(){return void 0===this._signal&&vt(311),this._signal}getInitResult(){void 0===this.extension.init&&vt(312,this.extension.name);const t=this.state;return function(t){return t.id>=wt}(t)||vt(313,String(t.id),String(wt)),t.initResult}getInitPeer(t){const e=this.builder.extensionNameMap.get(t);return e?e.getExtensionInitDependency():void 0}getExtensionInitDependency(){const t=this.state;return function(t){return t.id>=bt}(t)||vt(314,String(t.id),String(bt)),{config:t.config}}getPeer(t){const e=this.builder.extensionNameMap.get(t);return e?e.getExtensionDependency():void 0}getInitDependency(t){const e=this.builder.getExtensionRep(t);return void 0===e&&vt(315,this.extension.name,t.name),e.getExtensionInitDependency()}getDependency(t){const e=this.builder.getExtensionRep(t);return void 0===e&&vt(315,this.extension.name,t.name),e.getExtensionDependency()}getState(){const t=this.state;return function(t){return t.id>=Rt}(t)||vt(316,String(t.id),String(Rt)),t}getDirectDependentNames(){return this.builder.incomingEdges.get(this.extension.name)||_t}getPeerNameSet(){let t=this._peerNameSet;return t||(t=new Set((this.extension.peerDependencies||[]).map(([t])=>t)),this._peerNameSet=t),t}getExtensionDependency(){if(!this._dependency){const t=this.state;(function(t){return t.id>=Nt})(t)||vt(317,this.extension.name),this._dependency={config:t.config,init:t.initResult,output:t.output}}return this._dependency}}const jt={tag:p};function At(){const t=o();t.isEmpty()&&t.append(r())}const Pt=t({config:e({setOptions:jt,updateOptions:jt}),init:({$initialEditorState:t=At})=>({$initialEditorState:t,initialized:!1}),afterRegistration(t,{updateOptions:e,setOptions:n},i){const o=i.getInitResult();if(!o.initialized){o.initialized=!0;const{$initialEditorState:i}=o;if(g(i))t.setEditorState(i,n);else if(\"function\"==typeof i)t.update(()=>{i(t)},e);else if(i&&(\"string\"==typeof i||\"object\"==typeof i)){const e=t.parseEditorState(i);t.setEditorState(e,n)}}return()=>{}},name:\"@lexical/extension/InitialState\",nodes:[a,f,u,h,l]}),Kt=Symbol.for(\"@lexical/extension/LexicalBuilder\");function kt(...t){return Tt.fromExtensions(t).buildEditor()}function $t(){}function zt(t){throw t}function Ut(t){return Array.isArray(t)?t:[t]}const Lt=\"0.40.0+prod.esm\";class Tt{roots;extensionNameMap;outgoingConfigEdges;incomingEdges;conflicts;_sortedExtensionReps;PACKAGE_VERSION;constructor(t){this.outgoingConfigEdges=new Map,this.incomingEdges=new Map,this.extensionNameMap=new Map,this.conflicts=new Map,this.PACKAGE_VERSION=Lt,this.roots=t;for(const e of t)this.addExtension(e)}static fromExtensions(t){const e=[Ut(Pt)];for(const n of t)e.push(Ut(n));return new Tt(e)}static maybeFromEditor(t){const e=t[Kt];return e&&(e.PACKAGE_VERSION!==Lt&&vt(292,e.PACKAGE_VERSION,Lt),e instanceof Tt||vt(293)),e}static fromEditor(t){const e=Tt.maybeFromEditor(t);return void 0===e&&vt(294),e}constructEditor(){const{$initialEditorState:t,onError:e,...n}=this.buildCreateEditorArgs(),i=Object.assign(m({...n,...e?{onError:t=>{e(t,i)}}:{}}),{[Kt]:this});for(const t of this.sortedExtensionReps())t.build(i);return i}buildEditor(){let t=$t;function e(){try{t()}finally{t=$t}}const n=Object.assign(this.constructEditor(),{dispose:e,[Symbol.dispose]:e});return t=v(this.registerEditor(n),()=>n.setRootElement(null)),n}hasExtensionByName(t){return this.extensionNameMap.has(t)}getExtensionRep(t){const e=this.extensionNameMap.get(t.name);if(e)return e.extension!==t&&vt(295,t.name),e}addEdge(t,e,n){const i=this.outgoingConfigEdges.get(t);i?i.set(e,n):this.outgoingConfigEdges.set(t,new Map([[e,n]]));const o=this.incomingEdges.get(e);o?o.add(t):this.incomingEdges.set(e,new Set([t]))}addExtension(t){void 0!==this._sortedExtensionReps&&vt(296);const e=Ut(t),[n]=e;\"string\"!=typeof n.name&&vt(297,typeof n.name);let i=this.extensionNameMap.get(n.name);if(void 0!==i&&i.extension!==n&&vt(298,n.name),!i){i=new It(this,n),this.extensionNameMap.set(n.name,i);const t=this.conflicts.get(n.name);\"string\"==typeof t&&vt(299,n.name,t);for(const t of n.conflictsWith||[])this.extensionNameMap.has(t)&&vt(299,n.name,t),this.conflicts.set(t,n.name);for(const t of n.dependencies||[]){const e=Ut(t);this.addEdge(n.name,e[0].name,e.slice(1)),this.addExtension(e)}for(const[t,e]of n.peerDependencies||[])this.addEdge(n.name,t,e?[e]:[])}}sortedExtensionReps(){if(this._sortedExtensionReps)return this._sortedExtensionReps;const t=[],e=(n,i)=>{let o=n.state;if(Ct(o))return;const s=n.extension.name;var r;Mt(o)||vt(300,s,i||\"[unknown]\"),Mt(r=o)||vt(304,String(r.id),String(yt)),o=Object.assign(r,{id:St}),n.state=o;const c=this.outgoingConfigEdges.get(s);if(c)for(const t of c.keys()){const n=this.extensionNameMap.get(t);n&&e(n,s)}o=Dt(o),n.state=o,t.push(n)};for(const t of this.extensionNameMap.values())Mt(t.state)&&e(t);for(const e of t)for(const[t,n]of this.outgoingConfigEdges.get(e.extension.name)||[])if(n.length>0){const e=this.extensionNameMap.get(t);if(e)for(const t of n)e.configs.add(t)}for(const[t,...e]of this.roots)if(e.length>0){const n=this.extensionNameMap.get(t.name);void 0===n&&vt(301,t.name);for(const t of e)n.configs.add(t)}return this._sortedExtensionReps=t,this._sortedExtensionReps}registerEditor(t){const e=this.sortedExtensionReps(),n=new AbortController,i=[()=>n.abort()],o=n.signal;for(const n of e){const e=n.register(t,o);e&&i.push(e)}for(const n of e){const e=n.afterRegistration(t);e&&i.push(e)}return v(...i)}buildCreateEditorArgs(){const t={},e=new Set,n=new Map,i=new Map,o={},s={},r=this.sortedExtensionReps();for(const c of r){const{extension:r}=c;if(void 0!==r.onError&&(t.onError=r.onError),void 0!==r.disableEvents&&(t.disableEvents=r.disableEvents),void 0!==r.parentEditor&&(t.parentEditor=r.parentEditor),void 0!==r.editable&&(t.editable=r.editable),void 0!==r.namespace&&(t.namespace=r.namespace),void 0!==r.$initialEditorState&&(t.$initialEditorState=r.$initialEditorState),r.nodes)for(const t of gt(r)){if(\"function\"!=typeof t){const e=n.get(t.replace);e&&vt(302,r.name,t.replace.name,e.extension.name),n.set(t.replace,c)}e.add(t)}if(r.html){if(r.html.export)for(const[t,e]of r.html.export.entries())i.set(t,e);r.html.import&&Object.assign(o,r.html.import)}r.theme&&xt(s,r.theme)}Object.keys(s).length>0&&(t.theme=s),e.size&&(t.nodes=[...e]);const c=Object.keys(o).length>0,d=i.size>0;(c||d)&&(t.html={},c&&(t.html.import=o),d&&(t.html.export=i));for(const e of r)e.init(t);return t.onError||(t.onError=zt),t}}function Bt(t,e){const n=Tt.fromEditor(t).getExtensionRep(e);return void 0===n&&vt(303,e.name),n.getExtensionDependency()}function Ft(t,e){const n=Tt.fromEditor(t).extensionNameMap.get(e);return n?n.getExtensionDependency():void 0}function Gt(t,e){const n=Ft(t,e);return void 0===n&&vt(291,e),n}const Vt=new Set,Wt=t({build(t,e,n){const i=n.getDependency(mt).output,o=Q({watchedNodeKeys:new Map}),r=pt(()=>{},()=>ct(()=>{const t=r.peek(),{watchedNodeKeys:e}=o.value;let n,c=!1;i.value.read(()=>{if(s())for(const[i,o]of e.entries()){if(0===o.size){e.delete(i);continue}const s=x(i),r=s&&s.isSelected()||!1;c=c||r!==(!!t&&t.has(i)),r&&(n=n||new Set,n.add(i))}}),!c&&n&&t&&n.size===t.size||(r.value=n)}));return{watchNodeKey:function(t){const e=nt(()=>(r.value||Vt).has(t)),{watchedNodeKeys:n}=o.peek();let i=n.get(t);const s=void 0!==i;return i=i||new Set,i.add(e),s||(n.set(t,i),o.value={watchedNodeKeys:n}),e}}},dependencies:[mt],name:\"@lexical/extension/NodeSelection\"}),Zt=y(\"INSERT_HORIZONTAL_RULE_COMMAND\");class Jt extends O{static getType(){return\"horizontalrule\"}static clone(t){return new Jt(t.__key)}static importJSON(t){return qt().updateFromJSON(t)}static importDOM(){return{hr:()=>({conversion:Ht,priority:0})}}exportDOM(){return{element:document.createElement(\"hr\")}}createDOM(t){const e=document.createElement(\"hr\");return R(e,t.theme.hr),e}getTextContent(){return\"\\n\"}isInline(){return!1}updateDOM(){return!1}}function Ht(){return{node:qt()}}function qt(){return S(Jt)}function Qt(t){return t instanceof Jt}const Xt=t({dependencies:[mt,Wt],name:\"@lexical/extension/HorizontalRule\",nodes:()=>[Jt],register(t,e,n){const{watchNodeKey:i}=n.getDependency(Wt).output,o=Q({nodeSelections:new Map}),r=t._config.theme.hrSelected??\"selected\";return v(t.registerCommand(E,t=>{if(b(t.target)){const e=w(t.target);if(Qt(e))return function(t,e=!1){const n=s(),i=t.isSelected(),o=t.getKey();let r;e&&M(n)?r=n:(r=C(),D(r)),i?r.delete(o):r.add(o)}(e,t.shiftKey),!0}return!1},N),t.registerMutationListener(Jt,(e,n)=>{B(()=>{let n=!1;const{nodeSelections:s}=o.peek();for(const[o,r]of e.entries())if(\"destroyed\"===r)s.delete(o),n=!0;else{const e=s.get(o),r=t.getElementByKey(o);e?e.domNode.value=r:(n=!0,s.set(o,{domNode:Q(r),selectedSignal:i(o)}))}n&&(o.value={nodeSelections:s})})}),ct(()=>{const t=[];for(const{domNode:e,selectedSignal:n}of o.value.nodeSelections.values())t.push(ct(()=>{const t=e.value;if(t){n.value?R(t,r):_(t,r)}}));return v(...t)}))}});function Yt(t,e){return v(t.registerCommand(I,e=>{const n=s();if(!c(n))return!1;e.preventDefault();const i=function(t){if(t.getNodes().filter(t=>k(t)&&t.canIndent()).length>0)return!0;const e=t.anchor,n=t.focus,i=n.isBefore(e)?n:e,o=i.getNode(),s=U(o);if(s.canIndent()){const t=s.getKey();let e=$();if(e.anchor.set(t,0,\"element\"),e.focus.set(t,0,\"element\"),e=z(e),e.anchor.is(i))return!0}return!1}(n)?e.shiftKey?j:A:P;return t.dispatchCommand(i,void 0)},i),t.registerCommand(A,()=>{const t=\"number\"==typeof e?e:e?e.peek():null;if(null==t)return!1;const n=s();if(!c(n))return!1;const i=n.getNodes().map(t=>U(t).getIndent());return Math.max(...i)+1>=t},K))}const te=t({build:(t,e,n)=>dt(e),config:e({disabled:!1,maxIndent:null}),name:\"@lexical/extension/TabIndentation\",register(t,e,n){const{disabled:i,maxIndent:o}=n.getOutput();return ct(()=>{if(!i.value)return Yt(t,o)})}});export{qt as $createHorizontalRuleNode,Qt as $isHorizontalRuleNode,at as AutoFocusExtension,ht as ClearEditorExtension,mt as EditorStateExtension,Xt as HorizontalRuleExtension,Jt as HorizontalRuleNode,Zt as INSERT_HORIZONTAL_RULE_COMMAND,Pt as InitialStateExtension,Tt as LexicalBuilder,Wt as NodeSelectionExtension,te as TabIndentationExtension,B as batch,kt as buildEditorFromExtensions,nt as computed,ct as effect,Bt as getExtensionDependencyFromEditor,lt as getKnownTypesAndNodes,Ft as getPeerDependencyFromEditor,Gt as getPeerDependencyFromEditorOrThrow,dt as namedSignals,ut as registerClearEditor,Yt as registerTabIndentation,Q as signal,V as untracked,pt as watchedSignal};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{defineExtension as e}from\"lexical\";const r=e({name:\"@lexical/react/ReactProvider\"});export{r as ReactProviderExtension};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{$getRoot as t,$isDecoratorNode as e,$isElementNode as n,$isParagraphNode as r,$isTextNode as i,TextNode as o,$createTextNode as l}from\"lexical\";function s(){return t().getTextContent()}function f(t,e=!0){if(t)return!1;let n=s();return e&&(n=n.trim()),\"\"===n}function u(t,e){return()=>f(t,e)}function c(o){if(!f(o,!1))return!1;const l=t().getChildren(),s=l.length;if(s>1)return!1;for(let t=0;tc(t)}function a(t,e){let r=t.getFirstChild(),o=0;t:for(;null!==r;){if(n(r)){const t=r.getFirstChild();if(null!==t){r=t;continue}}else if(i(r)){const t=r.getTextContentSize();if(o+t>e)return{node:r,offset:e-o};o+=t}const t=r.getNextSibling();if(null!==t){r=t;continue}let l=r.getParent();for(;null!==l;){const t=l.getNextSibling();if(null!==t){r=t;continue t}l=l.getParent()}break}return null}function d(t,...e){const n=new URL(\"https://lexical.dev/docs/error\"),r=new URLSearchParams;r.append(\"code\",t);for(const t of e)r.append(\"v\",t);throw n.search=r.toString(),Error(`Minified Lexical error #${t}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}function x(t,e,n,r){const s=t=>t instanceof n,f=t=>{const e=l(t.getTextContent());e.setFormat(t.getFormat()),t.replace(e)};return[t.registerNodeTransform(o,t=>{if(!t.isSimpleText())return;let n,o=t.getPreviousSibling(),l=t.getTextContent(),u=t;if(i(o)){const n=o.getTextContent(),r=e(n+l);if(s(o)){if(null===r||0!==(t=>t.getLatest().__mode)(o))return void f(o);{const e=r.end-n.length;if(e>0){const r=n+l.slice(0,e);if(o.select(),o.setTextContent(r),e===l.length)t.remove();else{const n=l.slice(e);t.setTextContent(n)}return}}}else if(null===r||r.start{const n=t.getTextContent(),r=e(n);if(null===r||0!==r.start)return void f(t);if(n.length>r.end)return void t.splitText(r.end);const o=t.getPreviousSibling();i(o)&&o.isTextEntity()&&(f(o),f(t));const l=t.getNextSibling();i(l)&&l.isTextEntity()&&(f(l),s(t)&&f(t))})]}export{c as $canShowPlaceholder,g as $canShowPlaceholderCurry,a as $findTextIntersectionFromCharacters,f as $isRootTextContentEmpty,u as $isRootTextContentEmptyCurry,s as $rootTextContent,x as registerLexicalTextEntity};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{effect as e,namedSignals as t}from\"@lexical/extension\";import{defineExtension as n,safeCast as o,$getSelection as i,$isRangeSelection as a,$isTextNode as r}from\"lexical\";function s(e){const t=window.location.origin,n=n=>{if(n.origin!==t)return;const o=e.getRootElement();if(document.activeElement!==o)return;const s=n.data;if(\"string\"==typeof s){let t;try{t=JSON.parse(s)}catch(e){return}if(t&&\"nuanria_messaging\"===t.protocol&&\"request\"===t.type){const o=t.payload;if(o&&\"makeChanges\"===o.functionId){const t=o.args;if(t){const[o,s,d,c,g]=t;e.update(()=>{const e=i();if(a(e)){const t=e.anchor;let i=t.getNode(),a=0,l=0;if(r(i)&&o>=0&&s>=0&&(a=o,l=o+s,e.setTextNodeRange(i,a,i,l)),a===l&&\"\"===d||(e.insertRawText(d),i=t.getNode()),r(i)){a=c,l=c+g;const t=i.getTextContentSize();a=a>t?t:a,l=l>t?t:l,e.setTextNodeRange(i,a,i,l)}n.stopImmediatePropagation()}})}}}}};return window.addEventListener(\"message\",n,!0),()=>{window.removeEventListener(\"message\",n,!0)}}const d=n({build:(e,n,o)=>t(n),config:o({disabled:\"undefined\"==typeof window}),name:\"@lexical/dragon\",register:(t,n,o)=>e(()=>o.getOutput().disabled.value?void 0:s(t))});export{d as DragonExtension,s as registerDragonSupport};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{useLexicalComposerContext as r}from\"@lexical/react/LexicalComposerContext\";import{useLexicalEditable as e}from\"@lexical/react/useLexicalEditable\";import{LexicalBuilder as t}from\"@lexical/extension\";import{ReactProviderExtension as o}from\"@lexical/react/ReactProviderExtension\";import{useLayoutEffect as n,useEffect as i,useState as c,useMemo as a,Suspense as l}from\"react\";import{flushSync as s,createPortal as u}from\"react-dom\";import{jsx as d,jsxs as f,Fragment as m}from\"react/jsx-runtime\";import{$canShowPlaceholderCurry as p}from\"@lexical/text\";import{mergeRegister as x}from\"@lexical/utils\";import{registerDragonSupport as E}from\"@lexical/dragon\";import{registerRichText as h}from\"@lexical/rich-text\";function g(r,...e){const t=new URL(\"https://lexical.dev/docs/error\"),o=new URLSearchParams;o.append(\"code\",r);for(const r of e)o.append(\"v\",r);throw t.search=o.toString(),Error(`Minified Lexical error #${r}; visit ${t.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}const y=\"undefined\"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?n:i;function w({editor:r,ErrorBoundary:e}){return function(r,e){const[t,o]=c(()=>r.getDecorators());return y(()=>r.registerDecoratorListener(r=>{s(()=>{o(r)})}),[r]),i(()=>{o(r.getDecorators())},[r]),a(()=>{const o=[],n=Object.keys(t);for(let i=0;ir._onError(e),children:d(l,{fallback:null,children:t[c]})}),s=r.getElementByKey(c);null!==s&&o.push(u(a,s,c))}return o},[e,t,r])}(r,e)}function v({editor:r,ErrorBoundary:e}){return function(r){const e=t.maybeFromEditor(r);if(e&&e.hasExtensionByName(o.name)){for(const r of[\"@lexical/plain-text\",\"@lexical/rich-text\"])e.hasExtensionByName(r)&&g(320,r);return!0}return!1}(r)?null:d(w,{editor:r,ErrorBoundary:e})}function B(r){return r.getEditorState().read(p(r.isComposing()))}function L({contentEditable:e,placeholder:t=null,ErrorBoundary:o}){const[n]=r();return function(r){y(()=>x(h(r),E(r)),[r])}(n),f(m,{children:[e,d(b,{content:t}),d(v,{editor:n,ErrorBoundary:o})]})}function b({content:t}){const[o]=r(),n=function(r){const[e,t]=c(()=>B(r));return y(()=>{function e(){const e=B(r);t(e)}return e(),x(r.registerUpdateListener(()=>{e()}),r.registerEditableListener(()=>{e()}))},[r]),e}(o),i=e();return n?\"function\"==typeof t?t(i):t:null}export{L as RichTextPlugin};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{useLexicalComposerContext as e}from\"@lexical/react/LexicalComposerContext\";import{useEffect as t}from\"react\";function o({defaultSelection:o}){const[l]=e();return t(()=>{l.focus(()=>{const e=document.activeElement,t=l.getRootElement();null===t||null!==e&&t.contains(e)||t.focus({preventScroll:!0})},{defaultSelection:o})},[o,l]),null}export{o as AutoFocusPlugin};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{registerClearEditor as o}from\"@lexical/extension\";import{useLexicalComposerContext as e}from\"@lexical/react/LexicalComposerContext\";import{useLayoutEffect as n,useEffect as t}from\"react\";const i=\"undefined\"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?n:t;function r({onClear:n}){const[t]=e();return i(()=>o(t,n),[t,n]),null}export{r as ClearEditorPlugin};\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{useLexicalComposerContext as e}from\"@lexical/react/LexicalComposerContext\";import{useLayoutEffect as t,useEffect as i,forwardRef as a,useState as r,useCallback as n,useMemo as o}from\"react\";import{jsx as l,jsxs as d,Fragment as c}from\"react/jsx-runtime\";import{$canShowPlaceholderCurry as s}from\"@lexical/text\";import{mergeRegister as u}from\"@lexical/utils\";const m=\"undefined\"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?t:i;function f({editor:e,ariaActiveDescendant:t,ariaAutoComplete:i,ariaControls:a,ariaDescribedBy:d,ariaErrorMessage:c,ariaExpanded:s,ariaInvalid:u,ariaLabel:f,ariaLabelledBy:b,ariaMultiline:p,ariaOwns:x,ariaRequired:E,autoCapitalize:v,className:w,id:y,role:C=\"textbox\",spellCheck:g=!0,style:L,tabIndex:h,\"data-testid\":D,...I},R){const[k,q]=r(e.isEditable()),z=n(t=>{t&&t.ownerDocument&&t.ownerDocument.defaultView?e.setRootElement(t):e.setRootElement(null)},[e]),A=o(()=>function(...e){return t=>{for(const i of e)\"function\"==typeof i?i(t):null!=i&&(i.current=t)}}(R,z),[z,R]);return m(()=>(q(e.isEditable()),e.registerEditableListener(e=>{q(e)})),[e]),l(\"div\",{\"aria-activedescendant\":k?t:void 0,\"aria-autocomplete\":k?i:\"none\",\"aria-controls\":k?a:void 0,\"aria-describedby\":d,...null!=c?{\"aria-errormessage\":c}:{},\"aria-expanded\":k&&\"combobox\"===C?!!s:void 0,...null!=u?{\"aria-invalid\":u}:{},\"aria-label\":f,\"aria-labelledby\":b,\"aria-multiline\":p,\"aria-owns\":k?x:void 0,\"aria-readonly\":!k||void 0,\"aria-required\":E,autoCapitalize:v,className:w,contentEditable:k,\"data-testid\":D,id:y,ref:A,role:C,spellCheck:g,style:L,tabIndex:h,...I})}const b=a(f);function p(e){return e.getEditorState().read(s(e.isComposing()))}const x=a(E);function E(t,i){const{placeholder:a,...r}=t,[n]=e();return d(c,{children:[l(b,{editor:n,...r,ref:i}),null!=a&&l(v,{editor:n,content:a})]})}function v({content:e,editor:i}){const a=function(e){const[t,i]=r(()=>p(e));return m(()=>{function t(){const t=p(e);i(t)}return t(),u(e.registerUpdateListener(()=>{t()}),e.registerEditableListener(()=>{t()}))},[e]),t}(i),[n,o]=r(i.isEditable());if(t(()=>(o(i.isEditable()),i.registerEditableListener(e=>{o(e)})),[i]),!a)return null;let d=null;return\"function\"==typeof e?d=e(n):null!==e&&(d=e),null===d?null:l(\"div\",{\"aria-hidden\":!0,children:d})}export{x as ContentEditable,b as ContentEditableElement};\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure. Also,\n * modifications have been made to integrate with our codebase.\n *\n * Original file location: src/components/editor/editor-ui/content-editable.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { ReactNode } from 'react';\nimport { ContentEditable as LexicalContentEditable } from '@lexical/react/LexicalContentEditable';\n\ntype Props = {\n placeholder: string;\n className?: string;\n placeholderClassName?: string;\n};\n\nexport function ContentEditable({\n placeholder,\n className,\n placeholderClassName,\n}: Props): ReactNode {\n return (\n \n {placeholder}\n \n }\n />\n );\n}\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure.\n *\n * Original file location: src/components/editor/context/toolbar-context.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\n'use client';\n\nimport { createContext, ReactNode, useContext, useMemo } from 'react';\nimport { LexicalEditor } from 'lexical';\n\nconst Context = createContext<\n | {\n activeEditor: LexicalEditor;\n $updateToolbar: () => void;\n blockType: string;\n setBlockType: (blockType: string) => void;\n showModal: (title: string, showModal: (onClose: () => void) => ReactNode) => void;\n }\n | undefined\n>(undefined);\n\nexport function ToolbarContext({\n activeEditor,\n $updateToolbar,\n blockType,\n setBlockType,\n showModal,\n children,\n}: {\n activeEditor: LexicalEditor;\n $updateToolbar: () => void;\n blockType: string;\n setBlockType: (blockType: string) => void;\n showModal: (title: string, showModal: (onClose: () => void) => ReactNode) => void;\n children: ReactNode;\n}) {\n const contextValue = useMemo(\n () => ({\n activeEditor,\n $updateToolbar,\n blockType,\n setBlockType,\n showModal,\n }),\n [activeEditor, $updateToolbar, blockType, setBlockType, showModal],\n );\n\n return {children};\n}\n\nexport function useToolbarContext() {\n const context = useContext(Context);\n if (!context) {\n throw new Error('useToolbarContext must be used within a ToolbarContext provider');\n }\n return context;\n}\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure.\n *\n * Original file location: src/components/editor/editor-hooks/use-modal.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { ReactNode, useCallback, useMemo, useState } from 'react';\n\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/shadcn-ui/dialog';\n\nexport function useEditorModal(): [\n ReactNode | undefined,\n (title: string, showModal: (onClose: () => void) => ReactNode) => void,\n] {\n const [modalContent, setModalContent] = useState<\n | {\n closeOnClickOutside: boolean;\n content: ReactNode;\n title: string;\n }\n | undefined\n >(undefined);\n\n const onClose = useCallback(() => {\n setModalContent(undefined);\n }, []);\n\n const modal = useMemo(() => {\n if (modalContent === undefined) {\n return undefined;\n }\n const { title, content } = modalContent;\n return (\n \n \n \n {title}\n \n {content}\n \n \n );\n }, [modalContent, onClose]);\n\n const showModal = useCallback(\n (\n title: string,\n getContent: (onClose: () => void) => ReactNode,\n closeOnClickOutside = false,\n ) => {\n setModalContent({\n closeOnClickOutside,\n content: getContent(onClose),\n title,\n });\n },\n [onClose],\n );\n\n return [modal, showModal];\n}\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure.\n *\n * Original file location: src/components/editor/plugins/toolbar/toolbar-plugin.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n *\n * Documentation for the Toolbar Plugin-framework:\n * https://shadcn-editor.vercel.app/docs/plugins/toolbar\n */\n\nimport { ReactNode, useEffect, useState } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { COMMAND_PRIORITY_CRITICAL, SELECTION_CHANGE_COMMAND } from 'lexical';\n\nimport { ToolbarContext } from '@/components/advanced/editor/context/toolbar-context';\nimport { useEditorModal } from '@/components/advanced/editor/editor-hooks/use-modal';\n\nexport function ToolbarPlugin({\n children,\n}: {\n children: (props: { blockType: string }) => ReactNode;\n}) {\n const [editor] = useLexicalComposerContext();\n\n const [activeEditor, setActiveEditor] = useState(editor);\n const [blockType, setBlockType] = useState('paragraph');\n\n const [modal, showModal] = useEditorModal();\n\n const $updateToolbar = () => {};\n\n useEffect(() => {\n return activeEditor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n (_payload, newEditor) => {\n setActiveEditor(newEditor);\n return false;\n },\n COMMAND_PRIORITY_CRITICAL,\n );\n }, [activeEditor]);\n\n return (\n \n {modal}\n\n {children({ blockType })}\n \n );\n}\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure.\n *\n * Original file location: src/components/editor/editor-hooks/use-update-toolbar.ts\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { useEffect } from 'react';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport {\n $getSelection,\n BaseSelection,\n COMMAND_PRIORITY_CRITICAL,\n SELECTION_CHANGE_COMMAND,\n} from 'lexical';\n\nimport { useToolbarContext } from '@/components/advanced/editor/context/toolbar-context';\n\nexport function useUpdateToolbarHandler(callback: (selection: BaseSelection) => void) {\n const [editor] = useLexicalComposerContext();\n const { activeEditor } = useToolbarContext();\n\n useEffect(() => {\n return activeEditor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n const selection = $getSelection();\n if (selection) {\n callback(selection);\n }\n return false;\n },\n COMMAND_PRIORITY_CRITICAL,\n );\n /**\n * We use `editor` (not `activeEditor`) in the dependency array because `activeEditor` can\n * change frequently. Re-registering the command on every `activeEditor` change would be\n * unnecessary. We only need to re-register when the main editor instance or callback changes.\n */\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [editor, callback]);\n\n useEffect(() => {\n activeEditor.getEditorState().read(() => {\n const selection = $getSelection();\n if (selection) {\n callback(selection);\n }\n });\n }, [activeEditor, callback]);\n}\n","import React from 'react';\nimport * as TogglePrimitive from '@radix-ui/react-toggle';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\nconst toggleVariants = cva(\n 'pr-twp tw-inline-flex tw-items-center tw-justify-center tw-rounded-md tw-text-sm tw-font-medium tw-ring-offset-background tw-transition-colors hover:tw-bg-muted hover:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-ring focus-visible:tw-ring-offset-2 disabled:tw-pointer-events-none disabled:tw-opacity-50 data-[state=on]:tw-bg-accent data-[state=on]:tw-text-accent-foreground',\n {\n variants: {\n variant: {\n default: 'tw-bg-transparent',\n outline:\n 'tw-border tw-border-input tw-bg-transparent hover:tw-bg-accent hover:tw-text-accent-foreground',\n },\n size: {\n default: 'tw-h-10 tw-px-3',\n sm: 'tw-h-9 tw-px-2.5',\n lg: 'tw-h-11 tw-px-5',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n },\n);\n\nconst Toggle = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & VariantProps\n>(({ className, variant, size, ...props }, ref) => (\n \n));\n\nToggle.displayName = TogglePrimitive.Root.displayName;\n\nexport { Toggle, toggleVariants };\n","import React from 'react';\nimport * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';\nimport { type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { toggleVariants } from '@/components/shadcn-ui/toggle';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/** @inheritdoc ToggleGroup */\nconst ToggleGroupContext = React.createContext>({\n size: 'default',\n variant: 'default',\n});\n\n/**\n * ToggleGroup components provide a set of two-state buttons that can be toggled on or off. These\n * components are built on Radix UI primitives and styled with Shadcn UI. See Shadcn UI\n * Documentation: https://ui.shadcn.com/docs/components/toggle-group See Radix UI Documentation:\n * https://www.radix-ui.com/primitives/docs/components/toggle-group\n */\nconst ToggleGroup = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef &\n VariantProps\n>(({ className, variant, size, children, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n \n {children}\n \n \n );\n});\n\nToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;\n\n/** @inheritdoc ToggleGroup */\nconst ToggleGroupItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef &\n VariantProps\n>(({ className, children, variant, size, ...props }, ref) => {\n const context = React.useContext(ToggleGroupContext);\n\n return (\n \n {children}\n \n );\n});\n\nToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;\n\nexport { ToggleGroup, ToggleGroupItem };\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure.\n *\n * Original file location: src/components/editor/plugins/toolbar/font-format-toolbar-plugin.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n *\n * Documentation for this specific plugin:\n * https://shadcn-editor.vercel.app/docs/plugins/toolbar/font-format-toolbar\n */\n\nimport { useCallback, useState } from 'react';\nimport { $isTableSelection } from '@lexical/table';\nimport { $isRangeSelection, BaseSelection, FORMAT_TEXT_COMMAND } from 'lexical';\nimport { BoldIcon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from 'lucide-react';\n\nimport { useToolbarContext } from '@/components/advanced/editor/context/toolbar-context';\nimport { useUpdateToolbarHandler } from '@/components/advanced/editor/editor-hooks/use-update-toolbar';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/shadcn-ui/toggle-group';\n\nconst FORMATS = [\n { format: 'bold', icon: BoldIcon, label: 'Bold' },\n { format: 'italic', icon: ItalicIcon, label: 'Italic' },\n { format: 'underline', icon: UnderlineIcon, label: 'Underline' },\n { format: 'strikethrough', icon: StrikethroughIcon, label: 'Strikethrough' },\n] as const;\n\nexport function FontFormatToolbarPlugin() {\n const { activeEditor } = useToolbarContext();\n const [activeFormats, setActiveFormats] = useState([]);\n\n const $updateToolbar = useCallback((selection: BaseSelection) => {\n if ($isRangeSelection(selection) || $isTableSelection(selection)) {\n const formats: string[] = [];\n FORMATS.forEach(({ format }) => {\n if (selection.hasFormat(format)) {\n formats.push(format);\n }\n });\n setActiveFormats((prev) => {\n // Only update if formats have changed\n if (prev.length !== formats.length || !formats.every((f) => prev.includes(f))) {\n return formats;\n }\n return prev;\n });\n }\n }, []);\n\n useUpdateToolbarHandler($updateToolbar);\n\n return (\n \n {FORMATS.map(({ format, icon: Icon, label }) => (\n {\n activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, format);\n }}\n >\n \n \n ))}\n
\n );\n}\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure. Also,\n * modifications have been made to integrate with our codebase.\n *\n * Original file location: src/components/blocks/editor-00/plugins.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { useEffect, useState } from 'react';\nimport { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';\nimport { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';\nimport { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';\nimport { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin';\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';\nimport { CLEAR_EDITOR_COMMAND } from 'lexical';\n\nimport { ContentEditable } from '@/components/advanced/editor/editor-ui/content-editable';\nimport { ToolbarPlugin } from '@/components/advanced/editor/plugins/toolbar/toolbar-plugin';\nimport { FontFormatToolbarPlugin } from '@/components/advanced/editor/plugins/toolbar/font-format-toolbar-plugin';\n\nfunction ClearEditorBridge({ onClear }: { onClear?: (clearFn: () => void) => void }) {\n const [editor] = useLexicalComposerContext();\n\n useEffect(() => {\n if (onClear) {\n onClear(() => {\n editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);\n });\n }\n }, [editor, onClear]);\n\n return undefined;\n}\n\nexport function Plugins({\n placeholder = 'Start typing ...',\n autoFocus = false,\n onClear,\n}: {\n placeholder?: string;\n autoFocus?: boolean;\n onClear?: (clearFn: () => void) => void;\n}) {\n const [, setFloatingAnchorElem] = useState(undefined);\n\n const onRef = (_floatingAnchorElem: HTMLDivElement) => {\n if (_floatingAnchorElem !== undefined) {\n setFloatingAnchorElem(_floatingAnchorElem);\n }\n };\n\n return (\n
\n {/* toolbar plugins */}\n \n {() => (\n
\n \n
\n )}\n
\n\n
\n \n \n
\n }\n ErrorBoundary={LexicalErrorBoundary}\n />\n {autoFocus && }\n\n \n \n {/* editor plugins */}\n
\n {/* actions plugins */}\n \n );\n}\n","/**\n * This file was automatically generated on installation of the Shadcn/Lexical editor. The default\n * location of this file has been changed to integrate better with our project structure. Also,\n * modifications have been made to integrate with our codebase.\n *\n * Original file location: src/components/blocks/editor-00/editor.tsx\n *\n * Shadcn/Lexical Editor Documentation: https://shadcn-editor.vercel.app/docs/\n */\n\nimport { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer';\nimport { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';\nimport { EditorState, SerializedEditorState } from 'lexical';\n\nimport { editorTheme } from '@/components/advanced/editor/themes/editor-theme';\nimport { TooltipProvider } from '@/components/shadcn-ui/tooltip';\nimport { cn } from '@/utils/shadcn-ui.util';\n\nimport { nodes } from './nodes';\nimport { Plugins } from './plugins';\n\nconst editorConfig: InitialConfigType = {\n namespace: 'commentEditor',\n theme: editorTheme,\n nodes,\n onError: (error: Error) => {\n console.error(error);\n },\n};\n\n/**\n * Shadcn UI based Lexical Editor component\n *\n * Documentation: https://shadcn-editor.vercel.app/docs/\n */\nexport function Editor({\n editorState,\n editorSerializedState,\n onChange,\n onSerializedChange,\n placeholder = 'Start typingโ€ฆ',\n autoFocus = false,\n onClear,\n className,\n}: {\n editorState?: EditorState;\n editorSerializedState?: SerializedEditorState;\n onChange?: (editorState: EditorState) => void;\n onSerializedChange?: (editorSerializedState: SerializedEditorState) => void;\n placeholder?: string;\n autoFocus?: boolean;\n onClear?: (clearFn: () => void) => void;\n className?: string;\n}) {\n return (\n // CUSTOM: Added `className` prop\n \n \n \n \n\n {\n onChange?.(latestEditorState);\n onSerializedChange?.(latestEditorState.toJSON());\n }}\n />\n \n \n \n );\n}\n","/**\n * Copyright (c) Meta Platforms, Inc. and affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n *\n */\n\nimport{$sliceSelectedTextNodeContent as e}from\"@lexical/selection\";import{isHTMLElement as n,isBlockDomNode as t}from\"@lexical/utils\";import{isDOMDocumentNode as o,$getRoot as l,$isElementNode as r,$isTextNode as i,getRegisteredNode as s,isDocumentFragment as c,$isRootOrShadowRoot as u,$isBlockElementNode as f,$createLineBreakNode as a,ArtificialNode__DO_NOT_USE as d,isInlineDomNode as p,$createParagraphNode as h}from\"lexical\";function m(e,n){const t=o(n)?n.body.childNodes:n.childNodes;let l=[];const r=[];for(const n of t)if(!w.has(n.nodeName)){const t=y(n,e,r,!1);null!==t&&(l=l.concat(t))}return function(e){for(const n of e)n.getNextSibling()instanceof d&&n.insertAfter(a());for(const n of e){const e=n.getChildren();for(const t of e)n.insertBefore(t);n.remove()}}(r),l}function g(e,n){if(\"undefined\"==typeof document||\"undefined\"==typeof window&&void 0===global.window)throw new Error(\"To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.\");const t=document.createElement(\"div\"),o=l().getChildren();for(let l=0;l{const e=new d;return o.push(e),e}:h)),null==m?v.length>0?c=c.concat(v):t(e)&&function(e){if(null==e.nextSibling||null==e.previousSibling)return!1;return p(e.nextSibling)&&p(e.previousSibling)}(e)&&(c=c.concat(a())):r(m)&&m.append(...v),c}function C(e,n,t){const o=e.style.textAlign,l=[];let r=[];for(let e=0;e('[contenteditable=\"true\"]');\n if (!contentEditableField) return false;\n\n contentEditableField.focus();\n\n // Move cursor to the end\n const selection = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(contentEditableField);\n range.collapse(false); // false = collapse to end\n selection?.removeAllRanges();\n selection?.addRange(range);\n\n return true;\n}\n\n/**\n * Recursively check if any children have meaningful editor content\n *\n * @param children - Array of serialized lexical nodes to check\n * @returns True if any child has content, false otherwise\n */\nfunction doChildrenHaveEditorContent(\n children: (SerializedLexicalNode | SerializedElementNode | SerializedTextNode)[] | undefined,\n): boolean {\n if (!children) return false;\n\n return children.some(\n (child: SerializedLexicalNode | SerializedElementNode | SerializedTextNode) => {\n if (child && 'text' in child && child.text.trim().length > 0) return true;\n\n if (!child || !('children' in child)) return false;\n\n return doChildrenHaveEditorContent(child.children);\n },\n );\n}\n\n/**\n * Check if the editor state has any meaningful content\n *\n * @param editorState - SerializedEditorState to check\n * @returns True if the editor has content, false if it's empty\n */\nexport function hasEditorContent(editorState: SerializedEditorState | undefined): boolean {\n if (!editorState?.root?.children) return false;\n return doChildrenHaveEditorContent(editorState.root.children);\n}\n\n/**\n * Convert HTML string to Lexical SerializedEditorState\n *\n * @param html - HTML string to convert\n * @returns SerializedEditorState that can be used with the Editor component\n */\nexport function htmlToEditorState(html: string): SerializedEditorState {\n if (!html || html.trim() === '') {\n throw new Error('Input HTML is empty');\n }\n\n const editor = createHeadlessEditor({\n namespace: 'EditorUtils',\n theme: editorTheme,\n nodes,\n onError: (error: Error) => {\n console.error(error);\n },\n });\n\n let serializedState: SerializedEditorState | undefined;\n\n editor.update(\n () => {\n const parser = new DOMParser();\n const dom = parser.parseFromString(html, 'text/html');\n const generatedNodes = $generateNodesFromDOM(editor, dom);\n\n $getRoot().clear();\n $insertNodes(generatedNodes);\n },\n {\n discrete: true,\n },\n );\n\n editor.getEditorState().read(() => {\n serializedState = editor.getEditorState().toJSON();\n });\n\n if (!serializedState) {\n throw new Error('Failed to convert HTML to editor state');\n }\n\n return serializedState;\n}\n\n/**\n * Convert Lexical SerializedEditorState to HTML string\n *\n * @param editorState - SerializedEditorState to convert\n * @returns HTML string\n */\nexport function editorStateToHtml(editorState: SerializedEditorState): string {\n const editor = createHeadlessEditor({\n namespace: 'EditorUtils',\n theme: editorTheme,\n nodes,\n onError: (error: Error) => {\n console.error(error);\n },\n });\n\n const parsedEditorState = editor.parseEditorState(JSON.stringify(editorState));\n editor.setEditorState(parsedEditorState);\n\n let html = '';\n\n editor.getEditorState().read(() => {\n html = $generateHtmlFromNodes(editor);\n });\n\n // Clean up the HTML to remove Shadcn/Lexical-specific attributes and simplify structure\n html = html\n // Remove style attributes\n .replace(/\\s+style=\"[^\"]*\"/g, '')\n // Remove all class attributes (including Tailwind classes)\n .replace(/\\s+class=\"[^\"]*\"/g, '')\n // Remove empty spans\n .replace(/(.*?)<\\/span>/g, '$1')\n // Simplify nested bold tags (e.g., text -> text)\n .replace(/]*>(.*?)<\\/strong><\\/b>/g, '$1')\n .replace(/]*>(.*?)<\\/b><\\/strong>/g, '$1')\n // Simplify nested italic tags (e.g., text -> text)\n .replace(/]*>(.*?)<\\/em><\\/i>/g, '$1')\n .replace(/]*>(.*?)<\\/i><\\/em>/g, '$1')\n // Simplify nested underline tags (e.g., text -> text)\n .replace(/]*>(.*?)<\\/span><\\/u>/g, '$1')\n // Simplify nested strikethrough tags (e.g., text -> text)\n .replace(/]*>(.*?)<\\/span><\\/s>/g, '$1')\n // Convert all
variants to XML-compatible
for Paratext\n .replace(//gi, '
');\n\n return html;\n}\n\n/**\n * Handle keyboard events for editor navigation to prevent parent listbox from intercepting\n * navigation keys. This should be used on a container wrapping the Editor component.\n *\n * @param event - The keyboard event\n * @returns True if the event was handled (and should be stopped from propagating), false otherwise\n */\nexport function handleEditorKeyNavigation(event: React.KeyboardEvent): boolean {\n // Keys that should be kept within the editor for navigation\n const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'];\n\n if (navigationKeys.includes(event.key)) {\n event.stopPropagation();\n return true;\n }\n\n return false;\n}\n","import { Editor } from '@/components/advanced/editor/editor';\nimport {\n editorStateToHtml,\n focusContentEditable,\n handleEditorKeyNavigation,\n hasEditorContent,\n} from '@/components/advanced/editor/editor-utils';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { Command, CommandItem, CommandList } from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport {\n SerializedEditorState,\n SerializedElementNode,\n SerializedParagraphNode,\n SerializedTextNode,\n} from 'lexical';\nimport { AtSign, Check, X } from 'lucide-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { CommentEditorLocalizedStrings } from './comment-editor.types';\n\nconst initialValue: SerializedEditorState<\n SerializedParagraphNode & SerializedElementNode\n> = {\n root: {\n children: [\n {\n children: [\n {\n detail: 0,\n format: 0,\n mode: 'normal',\n style: '',\n text: '',\n type: 'text',\n version: 1,\n },\n ],\n direction: 'ltr',\n format: '',\n indent: 0,\n type: 'paragraph',\n version: 1,\n textFormat: 0,\n textStyle: '',\n },\n ],\n direction: 'ltr',\n format: '',\n indent: 0,\n type: 'root',\n version: 1,\n },\n};\n\n/** Interface containing the types of the properties that are passed to the `CommentEditor` */\nexport interface CommentEditorProps {\n /** List of users that can be assigned to the new comment thread */\n assignableUsers: string[];\n /**\n * External function to handle saving the new comment\n *\n * @param contents HTML content of the comment\n * @param assignedUser Optional user to assign the comment to\n */\n onSave: (contents: string, assignedUser?: string) => void;\n /**\n * External function to handle closing the comment editor. Gets called when the editor is closed\n * without saving changes\n */\n onClose: () => void;\n /** Localized strings to be passed to the comment editor component */\n localizedStrings: CommentEditorLocalizedStrings;\n}\n\n/**\n * Gets the display name for an assigned user\n *\n * @param user The user identifier (empty string for unassigned, \"Team\" for team, or username)\n * @param localizedStrings Localized strings for special values\n * @returns The display name for the user\n */\nfunction getAssignedUserDisplayName(\n user: string,\n localizedStrings: CommentEditorLocalizedStrings,\n): string {\n if (user === '') {\n return localizedStrings['%commentEditor_unassigned%'] ?? 'Unassigned';\n }\n if (user === 'Team') {\n return localizedStrings['%commentEditor_team%'] ?? 'Team';\n }\n return user;\n}\n\n/**\n * Component to create a new project comment from within the scripture editor\n *\n * @param CommentEditorProps - The properties for the comment editor component\n */\nexport default function CommentEditor({\n assignableUsers,\n onSave,\n onClose,\n localizedStrings,\n}: CommentEditorProps) {\n const [editorState, setEditorState] = useState(initialValue);\n const [selectedUser, setSelectedUser] = useState(undefined);\n const [isAssignPopoverOpen, setIsAssignPopoverOpen] = useState(false);\n const clearEditorRef = useRef<(() => void) | undefined>(undefined);\n\n // Using null for React ref compatibility\n // eslint-disable-next-line no-null/no-null\n const editorContainerRef = useRef(null);\n\n // Focus the editor after a delay to allow any closing popover/dropdown to finish\n useEffect(() => {\n let isMounted = true;\n const container = editorContainerRef.current;\n if (!container) return undefined;\n\n const timeoutId = setTimeout(() => {\n if (!isMounted) return;\n focusContentEditable(container);\n }, 300);\n\n return () => {\n isMounted = false;\n clearTimeout(timeoutId);\n };\n }, []);\n\n const handleSave = useCallback(() => {\n if (!hasEditorContent(editorState)) return;\n\n const contents = editorStateToHtml(editorState);\n onSave(contents, selectedUser);\n }, [editorState, onSave, selectedUser]);\n\n const placeholder =\n localizedStrings['%commentEditor_placeholder%'] ?? 'Type your comment here...';\n const saveTooltip = localizedStrings['%commentEditor_saveButton_tooltip%'] ?? 'Save comment';\n const cancelTooltip = localizedStrings['%commentEditor_cancelButton_tooltip%'] ?? 'Cancel';\n const assignToLabel = localizedStrings['%commentEditor_assignTo_label%'] ?? 'Assign to';\n\n return (\n
\n
\n {assignToLabel}\n
\n \n \n \n \n \n \n

{cancelTooltip}

\n
\n
\n
\n \n \n \n \n \n \n \n \n

{saveTooltip}

\n
\n
\n
\n
\n
\n\n
\n \n \n \n \n \n {selectedUser !== undefined\n ? getAssignedUserDisplayName(selectedUser, localizedStrings)\n : getAssignedUserDisplayName('', localizedStrings)}\n \n \n \n {\n if (e.key === 'Escape') {\n e.stopPropagation();\n setIsAssignPopoverOpen(false);\n }\n }}\n >\n \n \n {assignableUsers.map((user) => (\n {\n setSelectedUser(user === '' ? undefined : user);\n setIsAssignPopoverOpen(false);\n }}\n className=\"tw-flex tw-items-center\"\n >\n {getAssignedUserDisplayName(user, localizedStrings)}\n \n ))}\n \n \n \n \n
\n\n {\n if (e.key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n onClose();\n } else if (e.key === 'Enter') {\n const isMac = /Macintosh/i.test(navigator.userAgent);\n if ((isMac && e.metaKey) || (!isMac && e.ctrlKey)) {\n e.preventDefault();\n e.stopPropagation();\n if (hasEditorContent(editorState)) {\n handleSave();\n }\n }\n }\n }}\n onKeyDown={(e) => {\n handleEditorKeyNavigation(e);\n if (e.key === 'Enter' || e.key === ' ') {\n e.stopPropagation();\n }\n }}\n >\n setEditorState(value)}\n placeholder={placeholder}\n onClear={(clearFn) => {\n clearEditorRef.current = clearFn;\n }}\n />\n
\n \n );\n}\n","/**\n * Object containing all keys used for localization in the CommentEditor component. If you're using\n * this component in an extension, you can pass it into the useLocalizedStrings hook to easily\n * obtain the localized strings and pass them into the localizedStrings prop of this component\n */\nexport const COMMENT_EDITOR_STRING_KEYS = Object.freeze([\n '%commentEditor_placeholder%',\n '%commentEditor_saveButton_tooltip%',\n '%commentEditor_cancelButton_tooltip%',\n '%commentEditor_assignTo_label%',\n '%commentEditor_unassigned%',\n '%commentEditor_team%',\n] as const);\n\n/** Localized strings needed for the comment editor component */\nexport type CommentEditorLocalizedStrings = {\n [localizedKey in (typeof COMMENT_EDITOR_STRING_KEYS)[number]]?: string;\n};\n","import {\n CommentStatus,\n LanguageStrings,\n LegacyComment,\n LegacyCommentThread,\n LocalizeKey,\n} from 'platform-bible-utils';\n\n/** Options for adding a comment to a thread */\nexport type AddCommentToThreadOptions = {\n /** The ID of the thread to add the comment to */\n threadId: string;\n /** The content of the comment (optional - can be omitted when only changing status or assignment) */\n contents?: string;\n /** Status to set on the thread ('Resolved' or 'Todo') */\n status?: CommentStatus;\n /** User to assign to the thread. Use \"\" for unassigned, \"Team\" for team assignment. */\n assignedUser?: string;\n};\n\n/**\n * Object containing all keys used for localization in the CommentList component. If you're using\n * this component in an extension, you can pass it into the useLocalizedStrings hook to easily\n * obtain the localized strings and pass them into the localizedStrings prop of this component\n */\nexport const COMMENT_LIST_STRING_KEYS: LocalizeKey[] = [\n '%comment_assign_team%',\n '%comment_assign_unassigned%',\n '%comment_assigned_to%',\n '%comment_assigning_to%',\n '%comment_dateAtTime%',\n '%comment_date_today%',\n '%comment_date_yesterday%',\n '%comment_deleteComment%',\n '%comment_editComment%',\n '%comment_replyOrAssign%',\n '%comment_reopenResolved%',\n '%comment_status_resolved%',\n '%comment_status_todo%',\n '%comment_thread_multiple_replies%',\n '%comment_thread_single_reply%',\n];\n\n/** Type definition for the localized strings used in the CommentList component */\nexport type CommentListLocalizedStrings = {\n [localizedKey in (typeof COMMENT_LIST_STRING_KEYS)[number]]?: string;\n};\n\n/** Props for the CommentList component */\nexport interface CommentListProps {\n /** Additional class name for the component */\n className?: string;\n /** Class name to apply to the display of the verse text for the first comment in the thread */\n classNameForVerseText?: string;\n /** Comment threads to render */\n threads: LegacyCommentThread[];\n /** Name of the current user, retrieved from the current user's Paratext Registry user information */\n currentUser: string;\n /** Localized strings for the component */\n localizedStrings: LanguageStrings;\n /**\n * Externally controlled selected thread ID. When provided, this will be used as the selected\n * thread instead of internal state. The parent component is responsible for updating this value\n * when the selection changes.\n */\n selectedThreadId?: string;\n /**\n * Callback when the selected thread changes. Called when a thread is selected via click or\n * keyboard navigation. Parent components can use this to sync their state with the internal\n * selection.\n */\n onSelectedThreadChange?: (threadId: string | undefined) => void;\n /**\n * Handler for adding a comment to a thread. This unified handler supports:\n *\n * - Adding a comment (provide contents)\n * - Resolving/unresolving a thread (provide status: 'Resolved' or 'Todo')\n * - Assigning a user (provide assignedUser)\n * - Any combination of the above\n *\n * If successful, returns the auto-generated comment ID (format: \"threadId/userName/date\").\n * Otherwise, returns undefined.\n */\n handleAddCommentToThread: (options: AddCommentToThreadOptions) => Promise;\n /** Handler for updating a comment's content */\n handleUpdateComment: (commentId: string, contents: string) => Promise;\n /** Handler for deleting a comment */\n handleDeleteComment: (commentId: string) => Promise;\n /** Handler for updating a thread's read status */\n handleReadStatusChange: (threadId: string, markRead: boolean) => Promise;\n /**\n * Users that can be assigned to threads. Includes special values: \"Team\" for team assignment, \"\"\n * (empty string) for unassigned.\n */\n assignableUsers?: string[];\n /**\n * Whether the current user can add comments to existing threads in this project. When false, UI\n * elements for adding comments to threads should be hidden or disabled.\n */\n canUserAddCommentToThread?: boolean;\n /**\n * Callback to check if the current user can assign a specific thread. Returns a promise that\n * resolves to true if the user can assign the thread, false otherwise.\n */\n canUserAssignThreadCallback?: (threadId: string) => Promise;\n /**\n * Callback to check if the current user can resolve or re-open a specific thread. Returns a\n * promise that resolves to true if the user can resolve the thread, false otherwise.\n */\n canUserResolveThreadCallback?: (threadId: string) => Promise;\n /**\n * Callback to check if the current user can edit or delete a specific comment. Returns a promise\n * that resolves to true if the user can edit or delete the comment, false otherwise.\n */\n canUserEditOrDeleteCommentCallback?: (commentId: string) => Promise;\n /**\n * Callback when the user clicks a verse reference in a comment thread. The related project editor\n * web view can navigate and position the editor cursor at the start of the comment inside the\n * verse.\n */\n onVerseRefClick?: (thread: LegacyCommentThread) => void;\n}\n\n/** Props for the CommentThread component */\nexport interface CommentThreadProps {\n /** Class name to apply to the display of the verse text for the first comment in the thread */\n classNameForVerseText?: string;\n /** Comments in the thread */\n comments: LegacyComment[];\n /** Localized strings for the component */\n localizedStrings: LanguageStrings;\n /** Whether the thread is selected */\n isSelected?: boolean;\n /** Verse reference for the thread */\n verseRef?: string;\n /** Name of the current user, retrieved from the current user's Paratext Registry user information */\n currentUser: string;\n /** User assigned to the thread */\n assignedUser?: string;\n /** Handler for selecting the thread */\n handleSelectThread: (threadId: string) => void;\n /** ID of the thread */\n threadId: string;\n /** The full thread object, passed through so the onVerseRefClick callback can access all data */\n thread: LegacyCommentThread;\n /** Status of the thread */\n threadStatus?: CommentStatus;\n /**\n * Handler for adding a comment to a thread. This unified handler supports:\n *\n * - Adding a comment (provide contents)\n * - Resolving/unresolving a thread (provide status: 'Resolved' or 'Todo')\n * - Assigning a user (provide assignedUser)\n * - Any combination of the above\n *\n * If successful, returns the auto-generated comment ID (format: \"threadId/userName/date\").\n * Otherwise, returns undefined.\n */\n handleAddCommentToThread: (options: AddCommentToThreadOptions) => Promise;\n /** Handler for updating a comment's content */\n handleUpdateComment: (commentId: string, contents: string) => Promise;\n /** Handler for deleting a comment */\n handleDeleteComment: (commentId: string) => Promise;\n /** Handler for updating read status */\n handleReadStatusChange?: (threadId: string, markRead: boolean) => void;\n /**\n * Users that can be assigned to threads. Includes special values: \"Team\" for team assignment, \"\"\n * (empty string) for unassigned.\n */\n assignableUsers?: string[];\n /**\n * Whether the current user can add comments to existing threads in this project. When false, UI\n * elements for adding comments to threads should be hidden or disabled.\n */\n canUserAddCommentToThread?: boolean;\n /**\n * Callback to check if the current user can assign a specific thread. Returns a promise that\n * resolves to true if the user can assign the thread, false otherwise.\n */\n canUserAssignThreadCallback?: (threadId: string) => Promise;\n /**\n * Callback to check if the current user can resolve or re-open a specific thread. Returns a\n * promise that resolves to true if the user can resolve the thread, false otherwise.\n */\n canUserResolveThreadCallback?: (threadId: string) => Promise;\n /**\n * Callback to check if the current user can edit or delete a specific comment. Returns a promise\n * that resolves to true if the user can edit or delete the comment, false otherwise.\n */\n canUserEditOrDeleteCommentCallback?: (commentId: string) => Promise;\n /** Whether the thread has been read (by the current user) */\n isRead?: boolean;\n /** Delay in seconds before auto-marking as read when selected, default 5s */\n autoReadDelay?: number;\n /**\n * Callback when the user clicks a verse reference in a comment thread. The related project editor\n * web view can navigate and position the editor cursor at the start of the comment inside the\n * verse.\n */\n onVerseRefClick?: (thread: LegacyCommentThread) => void;\n}\n\n/** Props for the CommentItem component */\nexport interface CommentItemProps {\n /** Comment to render */\n comment: LegacyComment;\n /** Whether the comment is a reply or a top-level comment */\n isReply?: boolean;\n /** Localized strings for the component */\n localizedStrings: LanguageStrings;\n /** Whether the thread is expanded */\n isThreadExpanded?: boolean;\n /** Current status of the thread */\n threadStatus?: CommentStatus;\n /**\n * Handler for adding a comment to a thread (used for resolving). If successful, returns the\n * auto-generated comment ID. Otherwise, returns undefined.\n */\n handleAddCommentToThread?: (options: AddCommentToThreadOptions) => Promise;\n /** Handler for updating a comment's content */\n handleUpdateComment?: (commentId: string, contents: string) => Promise;\n /** Handler for deleting a comment */\n handleDeleteComment?: (commentId: string) => Promise;\n /** Callback when editing state changes */\n onEditingChange?: (isEditing: boolean) => void;\n /** Whether the current user can edit or delete this comment */\n canEditOrDelete?: boolean;\n /** Whether the current user can resolve or re-open this thread. */\n canUserResolveThread?: boolean;\n}\n","import React, { useCallback, useRef, useState } from 'react';\n\n/** Tags of interactive HTML elements to look for in the listbox */\nconst INTERACTIVE_ELEMENT_TAG_SELECTORS = ['input', 'select', 'textarea', 'button'];\n\n/** Roles of interactive HTML elements to look for in the listbox */\nconst INTERACTIVE_ELEMENT_ROLE_SELECTORS = ['button', 'textbox'];\n\n/** Properties of one option contained in a listbox */\nexport interface ListboxOption {\n /** Unique identifier for the option */\n id: string;\n}\n\n/** Props for the useListbox hook */\nexport interface UseListboxProps {\n /** Array of options for the listbox */\n options: ListboxOption[];\n /** Callback when the focus changes to a different option */\n onFocusChange?: (option: ListboxOption) => void;\n /** Callback to toggle the selection of an option */\n onOptionSelect?: (option: ListboxOption) => void;\n /** Callback when a character key is pressed */\n onCharacterPress?: (char: string) => void;\n}\n\n/**\n * Hook for handling keyboard navigation of a listbox.\n *\n * @param UseListboxProps - The properties for configuring the listbox behavior.\n * @returns An object containing:\n *\n * - `listboxRef`: A ref to be attached to the listbox container element (e.g., `
    `), used for\n * focus management.\n * - `activeId`: The id of the currently focused (active) option, or `undefined` if none is focused.\n * - `selectedId`: The id of the currently selected option, or `undefined` if none is selected.\n * - `handleKeyDown`: A keyboard event handler to be attached to the listbox container for handling\n * navigation and selection.\n * - `focusOption`: A function to programmatically focus a specific option by id.\n */\nexport const useListbox = ({\n options,\n onFocusChange,\n onOptionSelect,\n onCharacterPress,\n}: UseListboxProps) => {\n // ul/div ref property expects null and not undefined\n // eslint-disable-next-line no-null/no-null\n const listboxRef = useRef(null);\n const [activeId, setActiveId] = useState(undefined);\n const [selectedId, setSelectedId] = useState(undefined);\n\n const focusOption = useCallback(\n (id: string) => {\n setActiveId(id);\n const option = options.find((opt) => opt.id === id);\n if (option) {\n onFocusChange?.(option);\n }\n\n const element = document.getElementById(id);\n if (element) {\n element.scrollIntoView({ block: 'center' });\n element.focus();\n }\n\n // Ensure aria-activedescendant is set on the listbox container for internal focus tracking\n if (listboxRef.current) {\n listboxRef.current.setAttribute('aria-activedescendant', id);\n }\n },\n [onFocusChange, options],\n );\n\n const toggleSelectInternal = useCallback(\n (id: string) => {\n const option = options.find((opt) => opt.id === id);\n if (!option) return;\n\n setSelectedId((prev) => (prev === id ? undefined : id));\n onOptionSelect?.(option);\n },\n [onOptionSelect, options],\n );\n\n // Detect if the key event originated from an interactive element inside the currently selected option\n const isInteractiveElement = (element: HTMLElement | undefined) => {\n if (!element) return false;\n const tag = element.tagName.toLowerCase();\n if (element.isContentEditable) return true;\n if (INTERACTIVE_ELEMENT_TAG_SELECTORS.includes(tag)) return true;\n const role = element.getAttribute('role');\n if (role && INTERACTIVE_ELEMENT_ROLE_SELECTORS.includes(role)) return true;\n const tabIndex = element.getAttribute('tabindex');\n if (tabIndex !== undefined && tabIndex !== '-1') return true;\n return false;\n };\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n // Need to cast event.target to HTMLElement because the keyboard navigation can be used with multiple types of elements\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const targetElement = event.target as HTMLElement;\n const getElementById = (id?: string) => (id ? document.getElementById(id) : undefined);\n const selectedElement = getElementById(selectedId);\n const activeElement = getElementById(activeId);\n\n // Check if the event target is inside the selected option\n const isInsideSelected = !!(\n selectedElement &&\n targetElement &&\n selectedElement.contains(targetElement) &&\n targetElement !== selectedElement\n );\n const isInteractiveInsideSelected = isInsideSelected && isInteractiveElement(targetElement);\n\n // When focus is inside a selected option, don't hijack typical keys; allow an escape hatch back to the option\n if (isInteractiveInsideSelected) {\n if (\n event.key === 'Escape' ||\n (event.key === 'ArrowLeft' && !targetElement.isContentEditable)\n ) {\n if (selectedId) {\n // Return focus to the selected option root\n event.preventDefault();\n event.stopPropagation();\n const opt = options.find((o) => o.id === selectedId);\n if (opt) {\n focusOption(opt.id);\n }\n }\n return;\n }\n\n // Handle ArrowUp/ArrowDown to navigate between interactive elements within the selected option\n if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n if (!selectedElement) return;\n\n // Get all focusable elements within the selected option\n const focusableElements = Array.from(\n selectedElement.querySelectorAll(\n 'button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), textarea:not([disabled]), select:not([disabled]), [href], [tabindex]:not([tabindex=\"-1\"])',\n ),\n );\n\n if (focusableElements.length === 0) return;\n\n const currentIndex = focusableElements.findIndex((el) => el === targetElement);\n if (currentIndex === -1) return;\n\n let nextIndex: number;\n if (event.key === 'ArrowDown') {\n nextIndex = Math.min(currentIndex + 1, focusableElements.length - 1);\n } else {\n nextIndex = Math.max(currentIndex - 1, 0);\n }\n\n if (nextIndex !== currentIndex) {\n event.preventDefault();\n event.stopPropagation();\n focusableElements[nextIndex]?.focus();\n }\n return;\n }\n\n return; // Do not handle other keys while interacting within the selected option\n }\n\n const currentIndex = options.findIndex((opt) => opt.id === activeId);\n let nextIndex = currentIndex;\n\n switch (event.key) {\n case 'ArrowDown':\n nextIndex = Math.min(currentIndex + 1, options.length - 1);\n event.preventDefault();\n break;\n case 'ArrowUp':\n nextIndex = Math.max(currentIndex - 1, 0);\n event.preventDefault();\n break;\n case 'Home':\n nextIndex = 0;\n event.preventDefault();\n break;\n case 'End':\n nextIndex = options.length - 1;\n event.preventDefault();\n break;\n case ' ':\n case 'Enter':\n if (activeId) {\n toggleSelectInternal(activeId);\n }\n event.preventDefault();\n event.stopPropagation();\n return;\n case 'ArrowRight': {\n // If on an option, try to move focus into its first focusable control\n const container = activeElement;\n if (container) {\n const preferred = container.querySelector(\n 'input:not([disabled]):not([type=\"hidden\"]), textarea:not([disabled]), select:not([disabled])',\n );\n const fallback = container.querySelector(\n 'button:not([disabled]), [href], [tabindex]:not([tabindex=\"-1\"]), [contenteditable=\"true\"]',\n );\n const toFocus = preferred ?? fallback;\n if (toFocus) {\n event.preventDefault();\n toFocus.focus();\n return;\n }\n }\n break;\n }\n default:\n // Only handle character keys when not inside an interactive element\n if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {\n // Don't intercept typing in interactive elements\n const isInInteractiveElement = isInteractiveElement(targetElement);\n if (!isInInteractiveElement) {\n onCharacterPress?.(event.key);\n event.preventDefault();\n }\n }\n return;\n }\n\n const nextOption = options[nextIndex];\n if (nextOption) focusOption(nextOption.id);\n },\n [options, focusOption, activeId, selectedId, toggleSelectInternal, onCharacterPress],\n );\n\n return {\n listboxRef,\n activeId,\n selectedId,\n /** Keyboard event handler for listbox navigation and selection */\n handleKeyDown,\n /** Focus an option by its ID */\n focusOption,\n };\n};\n","import React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Style variants for the Badge component.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/badge}\n */\nconst badgeVariants = cva(\n 'pr-twp tw-inline-flex tw-items-center tw-rounded-full tw-px-2.5 tw-py-0.5 tw-text-xs tw-font-semibold tw-transition-colors focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2',\n {\n variants: {\n variant: {\n default:\n 'tw-border tw-border-transparent tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/80',\n secondary:\n 'tw-border tw-border-transparent tw-bg-secondary tw-text-secondary-foreground hover:tw-bg-secondary/80',\n muted:\n 'tw-border tw-border-transparent tw-bg-muted tw-text-muted-foreground hover:tw-bg-muted/80',\n destructive:\n 'tw-border tw-border-transparent tw-bg-destructive tw-text-destructive-foreground hover:tw-bg-destructive/80',\n outline: 'tw-border tw-text-foreground',\n blueIndicator: 'tw-w-[5px] tw-h-[5px] tw-bg-blue-400 tw-px-0',\n mutedIndicator: 'tw-w-[5px] tw-h-[5px] tw-bg-zinc-400 tw-px-0',\n ghost: 'hover:tw-bg-accent hover:tw-text-accent-foreground tw-text-mu',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n },\n);\n\n/**\n * Props for the Badge component.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/badge}\n */\nexport interface BadgeProps\n extends React.HTMLAttributes,\n VariantProps {}\n\n/**\n * The Badge component displays a badge or a component that looks like a badge. The component is\n * built and styled by Shadcn UI.\n *\n * @param BadgeProps\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/badge}\n */\nconst Badge = React.forwardRef(\n ({ className, variant, ...props }, ref) => {\n return (\n
    \n );\n },\n);\n\nBadge.displayName = 'Badge';\n\nexport { Badge, badgeVariants };\n","import React from 'react';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * The Card component displays a card with header, content, and footer. This component is built and\n * styled with Shadcn UI. See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/card\n */\nconst Card = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nCard.displayName = 'Card';\n\n/** @inheritdoc Card */\nconst CardHeader = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nCardHeader.displayName = 'CardHeader';\n\n/** @inheritdoc Card */\nconst CardTitle = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n {/* added because of https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/heading-has-content.md */}\n {props.children}\n \n ),\n);\nCardTitle.displayName = 'CardTitle';\n\n/** @inheritdoc Card */\nconst CardDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => (\n

    \n));\nCardDescription.displayName = 'CardDescription';\n\n/** @inheritdoc Card */\nconst CardContent = React.forwardRef>(\n ({ className, ...props }, ref) => (\n

    \n ),\n);\nCardContent.displayName = 'CardContent';\n\n/** @inheritdoc Card */\nconst CardFooter = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nCardFooter.displayName = 'CardFooter';\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };\n","import React from 'react';\nimport * as SeparatorPrimitive from '@radix-ui/react-separator';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * The Separator component visually or semantically separates content. This component is built on\n * Radix UI primitives and styled with Shadcn UI.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/separator}\n * @see Radix UI Documentation: {@link https://www.radix-ui.com/primitives/docs/components/separator}\n */\nconst Separator = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (\n \n));\nSeparator.displayName = SeparatorPrimitive.Root.displayName;\n\nexport { Separator };\n","import React from 'react';\nimport * as AvatarPrimitive from '@radix-ui/react-avatar';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * The Avatar component displays a user's profile picture or initials. The component is built and\n * styled by Shadcn UI. See Shadcn UI Documentation https://ui.shadcn.com/docs/components/avatar\n */\nconst Avatar = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nAvatar.displayName = AvatarPrimitive.Root.displayName;\n\n/** @inheritdoc Avatar */\nconst AvatarImage = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nAvatarImage.displayName = AvatarPrimitive.Image.displayName;\n\n/** @inheritdoc Avatar */\nconst AvatarFallback = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;\n\nexport { Avatar, AvatarImage, AvatarFallback };\n","import { cva } from 'class-variance-authority';\nimport { createContext, useContext } from 'react';\n\nexport type MenuContextProps = {\n variant?: 'default' | 'muted';\n};\n\nexport const MenuContext = createContext(undefined);\n\nexport function useMenuContext() {\n const context = useContext(MenuContext);\n if (!context) {\n throw new Error('useMenuContext must be used within a MenuContext.Provider.');\n }\n\n return context;\n}\n\nexport const menuVariants = cva('', {\n variants: {\n variant: {\n default: '',\n muted:\n 'hover:tw-bg-muted hover:tw-text-foreground focus:tw-bg-muted focus:tw-text-foreground data-[state=open]:tw-bg-muted data-[state=open]:tw-text-foreground',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n","import {\n MenuContext,\n MenuContextProps,\n menuVariants,\n useMenuContext,\n} from '@/context/menu.context';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\nimport React from 'react';\n\n/**\n * Dropdown Menu components providing accessible dropdown menus and submenus. These components are\n * built on Radix UI primitives and styled with Shadcn UI. See Shadcn UI Documentation:\n * https://ui.shadcn.com/docs/components/dropdown-menu See Radix UI Documentation:\n * https://www.radix-ui.com/primitives/docs/components/dropdown-menu\n */\n/* #region CUSTOM Add variant prop to support different styles */\nexport type DropdownMenuProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.Root\n> & {\n variant?: MenuContextProps['variant'];\n};\n/* #endregion CUSTOM */\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.SubTrigger\n> & {\n className?: string;\n inset?: boolean;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuSubContentProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.SubContent\n> & {\n className?: string;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuContentProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.Content\n> & {\n className?: string;\n sideOffset?: number;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuItemProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.Item\n> & {\n className?: string;\n inset?: boolean;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuCheckboxItemProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.CheckboxItem\n> & {\n className?: string;\n checked?: boolean;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuRadioItemProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.RadioItem\n> & {\n className?: string;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuLabelProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.Label\n> & {\n className?: string;\n inset?: boolean;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuSeparatorProps = React.ComponentPropsWithoutRef<\n typeof DropdownMenuPrimitive.Separator\n> & {\n className?: string;\n};\n\n/** @inheritdoc DropdownMenuProps */\nexport type DropdownMenuShortcutProps = React.HTMLAttributes & {\n className?: string;\n};\n\n/* #region CUSTOM Provide context to add variants */\n/** @inheritdoc DropdownMenuProps */\nexport function DropdownMenu({ variant = 'default', ...props }: DropdownMenuProps) {\n const contextValue = React.useMemo(\n () => ({\n variant,\n }),\n [variant],\n );\n return (\n \n \n \n );\n}\n/* #endregion CUSTOM */\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuSubTrigger = React.forwardRef<\n React.ElementRef,\n DropdownMenuSubTriggerProps\n>(({ className, inset, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n {children}\n \n \n );\n});\nDropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuSubContent = React.forwardRef<\n React.ElementRef,\n DropdownMenuSubContentProps\n>(({ className, ...props }, ref) => (\n \n));\nDropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;\n\n/* TODO: bug in shadcn component: DropdownMenuContent does not support a dir prop.\nFor the content we can work around this by adding a div with dir, but that would not cause\nthe scrollbar to appear left in an rtl layout (e.g. see book-chapter-control.component) */\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuContent = React.forwardRef<\n React.ElementRef,\n DropdownMenuContentProps\n>(({ className, sideOffset = 4, children, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n \n
    {children}
    \n \n
    \n );\n});\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuItem = React.forwardRef<\n React.ElementRef,\n DropdownMenuItemProps\n>(({ className, inset, ...props }, ref) => {\n const dir: Direction = readDirection();\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuCheckboxItem = React.forwardRef<\n React.ElementRef,\n DropdownMenuCheckboxItemProps\n>(({ className, children, checked, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nDropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuRadioItem = React.forwardRef<\n React.ElementRef,\n DropdownMenuRadioItemProps\n>(({ className, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuLabel = React.forwardRef<\n React.ElementRef,\n DropdownMenuLabelProps\n>(({ className, inset, ...props }, ref) => (\n \n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport const DropdownMenuSeparator = React.forwardRef<\n React.ElementRef,\n DropdownMenuSeparatorProps\n>(({ className, ...props }, ref) => (\n \n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\n/** @inheritdoc DropdownMenuProps */\nexport function DropdownMenuShortcut({ className, ...props }: DropdownMenuShortcutProps) {\n return (\n \n );\n}\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\n","import { LanguageStrings } from 'platform-bible-utils';\n\n/**\n * Gets the display name for an assigned user, with localized names for special values.\n *\n * @param user - The user identifier (empty string for unassigned, 'Team' for team)\n * @param localizedStrings - The localized strings to use for display names\n * @returns The display name for the user\n */\nexport function getAssignedUserDisplayName(\n user: string,\n localizedStrings: LanguageStrings,\n): string {\n if (user === '') {\n return localizedStrings['%comment_assign_unassigned%'] ?? 'Unassigned';\n }\n if (user === 'Team') {\n return localizedStrings['%comment_assign_team%'] ?? 'Team';\n }\n return user;\n}\n","import { Editor } from '@/components/advanced/editor/editor';\nimport {\n editorStateToHtml,\n focusContentEditable,\n handleEditorKeyNavigation,\n hasEditorContent,\n htmlToEditorState,\n} from '@/components/advanced/editor/editor-utils';\nimport { Avatar, AvatarFallback } from '@/components/shadcn-ui/avatar';\nimport { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { SerializedEditorState } from 'lexical';\nimport { ArrowUp, MoreHorizontal, Pencil, Trash2, X } from 'lucide-react';\nimport { formatRelativeDate, formatReplacementString, sanitizeHtml } from 'platform-bible-utils';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { CommentItemProps } from './comment-list.types';\nimport { getAssignedUserDisplayName } from './comment-list.utils';\n\n/**\n * A single comment item in the comment list.\n *\n * @param CommentItemProps The properties for the CommentItem component\n */\nexport function CommentItem({\n comment,\n isReply = false,\n localizedStrings,\n isThreadExpanded = false,\n handleUpdateComment,\n handleDeleteComment,\n onEditingChange,\n canEditOrDelete = false,\n}: CommentItemProps) {\n const [isEditing, setIsEditing] = useState(false);\n const [editorState, setEditorState] = useState();\n\n // eslint-disable-next-line no-null/no-null\n const editContainerRef = useRef(null);\n\n // Focus the editor when entering edit mode, after dropdown menu has fully closed\n useEffect(() => {\n if (!isEditing) return undefined;\n\n let isMounted = true;\n const container = editContainerRef.current;\n if (!container) return undefined;\n\n /**\n * The `Edit Comment` menu item is inside a dropdown that takes time to close. When the dropdown\n * closes, it brings focus back to the dropdown trigger button, which steals focus from the\n * editor. To work around this, we add a slight delay before focusing the editor. Unfortunately\n * there is no reliable way to detect when the dropdown has fully closed, which leaves us with\n * no other option than to use a timeout.\n */\n const timeoutId = setTimeout(() => {\n if (!isMounted) return;\n focusContentEditable(container);\n }, 300);\n\n return () => {\n isMounted = false;\n clearTimeout(timeoutId);\n };\n }, [isEditing]);\n\n const handleCancelEdit = useCallback(() => {\n setIsEditing(false);\n setEditorState(undefined);\n onEditingChange?.(false);\n }, [onEditingChange]);\n\n const handleSaveEdit = useCallback(async () => {\n if (!editorState || !handleUpdateComment) return;\n const isUpdateSuccessful = await handleUpdateComment(\n comment.id,\n editorStateToHtml(editorState),\n );\n if (isUpdateSuccessful) {\n setIsEditing(false);\n setEditorState(undefined);\n onEditingChange?.(false);\n }\n }, [editorState, handleUpdateComment, comment.id, onEditingChange]);\n\n const displayDate = useMemo(() => {\n const date = new Date(comment.date);\n const relativeDate = formatRelativeDate(\n date,\n localizedStrings['%comment_date_today%'],\n localizedStrings['%comment_date_yesterday%'],\n );\n const time = date.toLocaleTimeString(undefined, {\n hour: 'numeric',\n minute: '2-digit',\n });\n return formatReplacementString(localizedStrings['%comment_dateAtTime%'], {\n date: relativeDate,\n time,\n });\n }, [comment.date, localizedStrings]);\n\n const userLabel = useMemo(() => comment.user, [comment.user]);\n\n // Generate initials for avatar\n const initials = useMemo(\n () =>\n comment.user\n .split(' ')\n .map((name) => name[0])\n .join('')\n .toUpperCase()\n .slice(0, 2),\n [comment.user],\n );\n\n const sanitizedContent = useMemo(() => sanitizeHtml(comment.contents), [comment.contents]);\n\n const dropdownContent = useMemo(() => {\n if (!isThreadExpanded) return undefined;\n if (!canEditOrDelete) return undefined;\n\n return (\n <>\n {\n setIsEditing(true);\n setEditorState(htmlToEditorState(comment.contents));\n onEditingChange?.(true);\n }}\n >\n \n {localizedStrings['%comment_editComment%']}\n \n {\n if (handleDeleteComment) {\n await handleDeleteComment(comment.id);\n }\n }}\n >\n \n {localizedStrings['%comment_deleteComment%']}\n \n \n );\n }, [\n canEditOrDelete,\n isThreadExpanded,\n localizedStrings,\n comment.contents,\n comment.id,\n handleDeleteComment,\n onEditingChange,\n ]);\n\n return (\n \n \n {initials}\n \n
    \n
    \n

    {userLabel}

    \n

    {displayDate}

    \n
    \n {isReply && comment.assignedUser !== undefined && (\n \n โ†’ {getAssignedUserDisplayName(comment.assignedUser, localizedStrings)}\n \n )}\n
    \n {isEditing && (\n {\n if (e.key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n handleCancelEdit();\n } else if (e.key === 'Enter' && e.shiftKey) {\n e.preventDefault();\n e.stopPropagation();\n if (hasEditorContent(editorState)) {\n handleSaveEdit();\n }\n }\n }}\n onKeyDown={(e) => {\n handleEditorKeyNavigation(e);\n if (e.key === 'Enter' || e.key === ' ') {\n e.stopPropagation();\n }\n }}\n >\n blockquote]:tw-mt-0 [&_[data-lexical-editor=\"true\"]>blockquote]:tw-border-s-0 [&_[data-lexical-editor=\"true\"]>blockquote]:tw-ps-0 [&_[data-lexical-editor=\"true\"]>blockquote]:tw-font-normal [&_[data-lexical-editor=\"true\"]>blockquote]:tw-not-italic [&_[data-lexical-editor=\"true\"]>blockquote]:tw-text-foreground',\n )}\n editorSerializedState={editorState}\n onSerializedChange={(value) => setEditorState(value)}\n />\n
    \n \n \n \n \n \n \n
    \n
    \n )}\n {!isEditing && (\n <>\n {comment.status === 'Resolved' && (\n
    \n {localizedStrings['%comment_status_resolved%']}\n
    \n )}\n {comment.status === 'Todo' && isReply && (\n
    \n {localizedStrings['%comment_status_todo%']}\n
    \n )}\n blockquote]:tw-border-s-0 [&>blockquote]:tw-p-0 [&>blockquote]:tw-ps-0 [&>blockquote]:tw-font-normal [&>blockquote]:tw-not-italic [&>blockquote]:tw-text-foreground',\n // Don't render quotes on blockquotes\n 'tw-prose-quoteless',\n {\n 'tw-line-clamp-3': !isThreadExpanded,\n },\n )}\n // The comment content is stored in HTML so it needs to be set directly. To make sure\n // it is safe we have sanitized it first.\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{ __html: sanitizedContent }}\n />\n \n )}\n
    \n {dropdownContent && (\n \n \n \n \n {dropdownContent}\n \n )}\n
    \n );\n}\n","import { Editor } from '@/components/advanced/editor/editor';\nimport {\n editorStateToHtml,\n handleEditorKeyNavigation,\n hasEditorContent,\n} from '@/components/advanced/editor/editor-utils';\nimport { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { Card, CardContent } from '@/components/shadcn-ui/card';\nimport { Separator } from '@/components/shadcn-ui/separator';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport {\n SerializedEditorState,\n SerializedElementNode,\n SerializedParagraphNode,\n SerializedTextNode,\n} from 'lexical';\nimport { ArrowUp, AtSign, Check, ChevronDown, ChevronUp, Mail, MailOpen } from 'lucide-react';\nimport { formatReplacementString } from 'platform-bible-utils';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { Command, CommandItem, CommandList } from '@/components/shadcn-ui/command';\nimport { CommentItem } from './comment-item.component';\nimport { AddCommentToThreadOptions, CommentThreadProps } from './comment-list.types';\nimport { getAssignedUserDisplayName } from './comment-list.utils';\n\nconst initialValue: SerializedEditorState<\n SerializedParagraphNode & SerializedElementNode\n> = {\n root: {\n children: [\n {\n children: [\n {\n detail: 0,\n format: 0,\n mode: 'normal',\n style: '',\n text: '',\n type: 'text',\n version: 1,\n },\n ],\n direction: 'ltr',\n format: '',\n indent: 0,\n type: 'paragraph',\n version: 1,\n textFormat: 0,\n textStyle: '',\n },\n ],\n direction: 'ltr',\n format: '',\n indent: 0,\n type: 'root',\n version: 1,\n },\n};\n\n/**\n * Represents a thread of comments\n *\n * @props CommentThreadProps\n */\nexport function CommentThread({\n classNameForVerseText,\n comments,\n localizedStrings,\n isSelected = false,\n verseRef,\n assignedUser,\n currentUser,\n handleSelectThread,\n threadId,\n thread,\n threadStatus,\n handleAddCommentToThread,\n handleUpdateComment,\n handleDeleteComment,\n handleReadStatusChange,\n assignableUsers,\n canUserAddCommentToThread,\n canUserAssignThreadCallback,\n canUserResolveThreadCallback,\n canUserEditOrDeleteCommentCallback,\n isRead: isReadProp = false,\n autoReadDelay = 5,\n onVerseRefClick,\n}: CommentThreadProps) {\n const [editorState, setEditorState] = useState(initialValue);\n const isVerseExpanded = isSelected;\n const [showAllReplies, setShowAllReplies] = useState(false);\n const [isAnyCommentEditing, setIsAnyCommentEditing] = useState(false);\n const [isAssignPopoverOpen, setIsAssignPopoverOpen] = useState(false);\n const [pendingAssignedUser, setPendingAssignedUser] = useState(undefined);\n const [canAssign, setCanAssign] = useState(false);\n const [canResolve, setCanResolve] = useState(false);\n const [isRead, setIsRead] = useState(isReadProp);\n const [manuallyUnread, setManuallyUnread] = useState(false);\n const autoReadTimerRef = useRef | undefined>(undefined);\n const [commentEditDeletePermissions, setCommentEditDeletePermissions] = useState<\n Map\n >(new Map());\n\n // Check resolve permission on mount so the button can appear on hover\n useEffect(() => {\n let isPromiseCurrent = true;\n\n const checkResolvePermission = async () => {\n const resolveResult = canUserResolveThreadCallback\n ? await canUserResolveThreadCallback(threadId)\n : false;\n\n if (!isPromiseCurrent) return;\n setCanResolve(resolveResult);\n };\n\n checkResolvePermission();\n return () => {\n isPromiseCurrent = false;\n };\n }, [threadId, canUserResolveThreadCallback]);\n\n // Check remaining async permissions when thread is selected\n useEffect(() => {\n let isPromiseCurrent = true;\n\n if (!isSelected) {\n setCanAssign(false);\n setCommentEditDeletePermissions(new Map());\n return undefined;\n }\n\n const checkPermissions = async () => {\n const assignResult = canUserAssignThreadCallback\n ? await canUserAssignThreadCallback(threadId)\n : false;\n\n if (!isPromiseCurrent) return;\n setCanAssign(assignResult);\n };\n\n checkPermissions();\n return () => {\n isPromiseCurrent = false;\n };\n }, [isSelected, threadId, canUserAssignThreadCallback]);\n\n const activeComments = useMemo(() => comments.filter((comment) => !comment.deleted), [comments]);\n\n // Check edit/delete permissions for all comments when thread is selected or comments change\n useEffect(() => {\n let isPromiseCurrent = true;\n\n if (!isSelected || !canUserEditOrDeleteCommentCallback) {\n setCommentEditDeletePermissions(new Map());\n return undefined;\n }\n\n const checkCommentPermissions = async () => {\n const permissionsMap = new Map();\n\n await Promise.all(\n activeComments.map(async (comment) => {\n const canEdit = await canUserEditOrDeleteCommentCallback(comment.id);\n if (isPromiseCurrent) {\n permissionsMap.set(comment.id, canEdit);\n }\n }),\n );\n\n if (isPromiseCurrent) {\n setCommentEditDeletePermissions(permissionsMap);\n }\n };\n\n checkCommentPermissions();\n return () => {\n isPromiseCurrent = false;\n };\n }, [isSelected, activeComments, canUserEditOrDeleteCommentCallback]);\n\n const firstComment = useMemo(() => activeComments[0], [activeComments]);\n\n //

    expects null and not undefined\n // eslint-disable-next-line no-null/no-null\n const verseTextRef = useRef(null);\n const clearEditorRef = useRef<(() => void) | undefined>(undefined);\n\n const clearEditor = useCallback(() => {\n clearEditorRef.current?.();\n setEditorState(initialValue);\n }, []);\n\n const toggleRead = useCallback(() => {\n const newIsRead = !isRead;\n setIsRead(newIsRead);\n if (!newIsRead) {\n setManuallyUnread(true);\n } else {\n setManuallyUnread(false);\n }\n handleReadStatusChange?.(threadId, newIsRead);\n }, [isRead, handleReadStatusChange, threadId]);\n\n useEffect(() => {\n setShowAllReplies(false);\n }, [isSelected]);\n\n useEffect((): void | (() => void) => {\n if (isSelected && !isRead && !manuallyUnread) {\n const timer = setTimeout(() => {\n setIsRead(true);\n handleReadStatusChange?.(threadId, true);\n }, autoReadDelay * 1000);\n autoReadTimerRef.current = timer;\n return () => clearTimeout(timer);\n }\n if (autoReadTimerRef.current) {\n clearTimeout(autoReadTimerRef.current);\n autoReadTimerRef.current = undefined;\n }\n }, [isSelected, isRead, manuallyUnread, autoReadDelay, threadId, handleReadStatusChange]);\n\n const localizedReplies = useMemo(\n () => ({\n singleReply: localizedStrings['%comment_thread_single_reply%'],\n multipleReplies: localizedStrings['%comment_thread_multiple_replies%'],\n }),\n [localizedStrings],\n );\n\n const localizedAssignedToText = useMemo(() => {\n if (assignedUser === undefined) {\n return undefined;\n }\n if (assignedUser === '') {\n return localizedStrings['%comment_assign_unassigned%'] ?? 'Unassigned';\n }\n const displayName = getAssignedUserDisplayName(assignedUser, localizedStrings);\n return formatReplacementString(localizedStrings['%comment_assigned_to%'], {\n assignedUser: displayName,\n });\n }, [assignedUser, localizedStrings]);\n\n const replies = useMemo(() => activeComments.slice(1), [activeComments]);\n const replyCount = useMemo(() => replies.length ?? 0, [replies.length]);\n const hasReplies = useMemo(() => replyCount > 0, [replyCount]);\n\n // For expanded threads with more than 2 replies, show only the last 2 replies\n const visibleReplies = useMemo(() => {\n if (showAllReplies || replyCount <= 2) {\n return replies;\n }\n // Show only the last 2 replies\n return replies.slice(-2);\n }, [replies, replyCount, showAllReplies]);\n\n const hiddenReplyCount = useMemo(() => {\n if (showAllReplies || replyCount <= 2) {\n return 0;\n }\n return replyCount - 2;\n }, [replyCount, showAllReplies]);\n\n const replyText = useMemo(\n () =>\n replyCount === 1\n ? localizedReplies.singleReply\n : formatReplacementString(localizedReplies.multipleReplies, { count: replyCount }),\n [replyCount, localizedReplies],\n );\n\n const hiddenReplyText = useMemo(\n () =>\n hiddenReplyCount === 1\n ? localizedReplies.singleReply\n : formatReplacementString(localizedReplies.multipleReplies, { count: hiddenReplyCount }),\n [hiddenReplyCount, localizedReplies],\n );\n\n const handleSubmitComment = useCallback(async () => {\n const contents = hasEditorContent(editorState) ? editorStateToHtml(editorState) : undefined;\n\n // If there's a pending assignment, include it\n if (pendingAssignedUser !== undefined) {\n const success = await handleAddCommentToThread({\n threadId,\n contents,\n assignedUser: pendingAssignedUser,\n });\n if (success) {\n setPendingAssignedUser(undefined);\n if (contents) {\n clearEditor();\n }\n }\n return;\n }\n // Otherwise, just add a comment if there's content\n if (contents) {\n const newCommentId = await handleAddCommentToThread({ threadId, contents });\n if (newCommentId) {\n clearEditor();\n }\n }\n }, [clearEditor, editorState, handleAddCommentToThread, pendingAssignedUser, threadId]);\n\n const handleAddCommentToThreadWithContents = useCallback(\n async (options: AddCommentToThreadOptions) => {\n const contents = hasEditorContent(editorState) ? editorStateToHtml(editorState) : undefined;\n const success = await handleAddCommentToThread({\n ...options,\n contents,\n assignedUser: pendingAssignedUser ?? options.assignedUser,\n });\n if (success && contents) {\n clearEditor();\n }\n if (success && pendingAssignedUser !== undefined) {\n setPendingAssignedUser(undefined);\n }\n return success;\n },\n [clearEditor, editorState, handleAddCommentToThread, pendingAssignedUser],\n );\n\n return (\n {\n handleSelectThread(threadId);\n }}\n tabIndex={-1}\n >\n \n
    \n
    \n {localizedAssignedToText && (\n \n {localizedAssignedToText}\n \n )}\n {\n e.stopPropagation();\n toggleRead();\n }}\n className=\"tw-text-muted-foreground tw-transition hover:tw-text-foreground\"\n aria-label={isRead ? 'Mark as unread' : 'Mark as read'}\n >\n {isRead ? : }\n \n {canResolve && threadStatus !== 'Resolved' && (\n {\n e.stopPropagation();\n handleAddCommentToThreadWithContents({\n threadId,\n status: 'Resolved',\n });\n }}\n aria-label=\"Resolve thread\"\n >\n \n \n )}\n
    \n
    \n {/* Allow clicking to expand thread when collapsed, but allow text selection when expanded */}\n {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}\n \n {verseRef && onVerseRefClick ? (\n {\n e.stopPropagation();\n onVerseRefClick(thread);\n }}\n >\n {verseRef}\n \n ) : (\n verseRef\n )}\n \n {firstComment.contextBefore}\n {firstComment.selectedText}\n {firstComment.contextAfter}\n \n

    \n
    \n \n
    \n <>\n {hasReplies && !isSelected && (\n
    \n
    \n \n
    \n

    {replyText}

    \n
    \n )}\n {/* Show Editor on an unselected thread when it has drafted content */}\n {!isSelected && hasEditorContent(editorState) && (\n setEditorState(value)}\n placeholder={localizedStrings['%comment_replyOrAssign%']}\n />\n )}\n {isSelected && (\n <>\n {/* Show \"hidden replies\" separator before the visible replies if there are hidden replies */}\n {hiddenReplyCount > 0 && (\n {\n e.stopPropagation();\n setShowAllReplies(true);\n }}\n role=\"button\"\n tabIndex={0}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n e.stopPropagation();\n setShowAllReplies(true);\n }\n }}\n >\n
    \n \n
    \n
    \n

    {hiddenReplyText}

    \n {showAllReplies ? : }\n
    \n
    \n )}\n {visibleReplies.map((reply) => (\n
    \n \n
    \n ))}\n\n {/* Only show main Editor if user can add comments, no comment is being edited, or if it has draft content */}\n {canUserAddCommentToThread !== false &&\n (!isAnyCommentEditing || hasEditorContent(editorState)) && (\n e.stopPropagation()}\n onKeyDownCapture={(e) => {\n if (e.key === 'Enter' && e.shiftKey) {\n e.preventDefault();\n e.stopPropagation();\n if (hasEditorContent(editorState) || pendingAssignedUser !== undefined) {\n handleSubmitComment();\n }\n }\n }}\n onKeyDown={(e) => {\n handleEditorKeyNavigation(e);\n if (e.key === 'Enter' || e.key === ' ') {\n e.stopPropagation();\n }\n }}\n >\n setEditorState(value)}\n placeholder={\n threadStatus === 'Resolved'\n ? localizedStrings['%comment_reopenResolved%']\n : localizedStrings['%comment_replyOrAssign%']\n }\n autoFocus\n onClear={(clearFn) => {\n clearEditorRef.current = clearFn;\n }}\n />\n
    \n {pendingAssignedUser !== undefined && (\n \n {formatReplacementString(\n localizedStrings['%comment_assigning_to%'] ??\n 'Assigning to: {assignedUser}',\n {\n assignedUser: getAssignedUserDisplayName(\n pendingAssignedUser,\n localizedStrings,\n ),\n },\n )}\n \n )}\n \n \n \n \n \n \n {\n if (e.key === 'Escape') {\n e.stopPropagation();\n setIsAssignPopoverOpen(false);\n }\n }}\n >\n \n \n {assignableUsers?.map((user) => (\n {\n if (user !== assignedUser) {\n setPendingAssignedUser(user);\n } else {\n setPendingAssignedUser(undefined);\n }\n setIsAssignPopoverOpen(false);\n }}\n className=\"tw-flex tw-items-center\"\n >\n {getAssignedUserDisplayName(user, localizedStrings)}\n \n ))}\n \n \n \n \n \n \n \n
    \n \n )}\n \n )}\n \n \n \n );\n}\n","import { ListboxOption, useListbox } from '@/hooks/listbox-keyboard-navigation.hook';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport React, { RefObject, useCallback, useEffect, useState } from 'react';\nimport { CommentListProps } from './comment-list.types';\nimport { CommentThread } from './comment-thread.component';\n\n/**\n * Component for rendering a list of comment threads\n *\n * @param CommentListProps Props for the CommentList component\n */\nexport default function CommentList({\n className = '',\n classNameForVerseText,\n threads,\n currentUser,\n localizedStrings,\n handleAddCommentToThread,\n handleUpdateComment,\n handleDeleteComment,\n handleReadStatusChange,\n assignableUsers,\n canUserAddCommentToThread,\n canUserAssignThreadCallback,\n canUserResolveThreadCallback,\n canUserEditOrDeleteCommentCallback,\n selectedThreadId: externalSelectedThreadId,\n onSelectedThreadChange,\n onVerseRefClick,\n}: CommentListProps) {\n const [expandedThreadIds, setExpandedThreadIds] = useState>(new Set());\n const [lastInteractedThreadId, setLastInteractedThreadId] = useState();\n\n // When external selection changes, add it to expanded set\n useEffect(() => {\n if (externalSelectedThreadId) {\n setExpandedThreadIds((prev) => new Set(prev).add(externalSelectedThreadId));\n setLastInteractedThreadId(externalSelectedThreadId);\n }\n }, [externalSelectedThreadId]);\n\n const activeThreads = threads.filter((thread) =>\n thread.comments.some((comment) => !comment.deleted),\n );\n\n const options: ListboxOption[] = activeThreads.map((thread) => ({\n id: thread.id,\n }));\n\n const handleKeyboardSelectThread = useCallback(\n (option: ListboxOption) => {\n setExpandedThreadIds((prev) => new Set(prev).add(option.id));\n setLastInteractedThreadId(option.id);\n onSelectedThreadChange?.(option.id);\n },\n [onSelectedThreadChange],\n );\n\n const handleSelectThread = useCallback(\n (threadId: string) => {\n const isCollapsing = expandedThreadIds.has(threadId);\n setExpandedThreadIds((prev) => {\n const next = new Set(prev);\n if (next.has(threadId)) {\n next.delete(threadId);\n } else {\n next.add(threadId);\n }\n return next;\n });\n setLastInteractedThreadId(threadId);\n onSelectedThreadChange?.(isCollapsing ? undefined : threadId);\n },\n [expandedThreadIds, onSelectedThreadChange],\n );\n\n const { listboxRef, activeId, handleKeyDown } = useListbox({\n options,\n onOptionSelect: handleKeyboardSelectThread,\n });\n\n // Collapse the last interacted thread when Escape is pressed\n const handleKeyDownWithEscape = useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'Escape') {\n if (lastInteractedThreadId && expandedThreadIds.has(lastInteractedThreadId)) {\n setExpandedThreadIds((prev) => {\n const next = new Set(prev);\n next.delete(lastInteractedThreadId);\n return next;\n });\n setLastInteractedThreadId(undefined);\n onSelectedThreadChange?.(undefined);\n }\n event.preventDefault();\n event.stopPropagation();\n } else {\n handleKeyDown(event);\n }\n },\n [lastInteractedThreadId, expandedThreadIds, handleKeyDown, onSelectedThreadChange],\n );\n\n return (\n }\n aria-activedescendant={activeId ?? undefined}\n aria-label=\"Comments\"\n className={cn(\n 'tw-flex tw-w-full tw-flex-col tw-space-y-3 tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-1 focus:tw-ring-offset-background',\n\n className,\n )}\n onKeyDown={handleKeyDownWithEscape}\n >\n {activeThreads.map((thread) => (\n \n \n \n ))}\n \n );\n}\n","import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';\nimport { FilterIcon } from 'lucide-react';\nimport { Table } from '@tanstack/react-table';\n\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n} from '@/components/shadcn-ui/dropdown-menu';\n\ninterface DataTableViewOptionsProps {\n table: Table;\n}\n\nexport function DataTableViewOptions({ table }: DataTableViewOptionsProps) {\n return (\n \n \n \n \n \n Toggle columns\n \n {table\n .getAllColumns()\n .filter((column) => column.getCanHide())\n .map((column) => {\n return (\n column.toggleVisibility(!!value)}\n >\n {column.id}\n \n );\n })}\n \n \n );\n}\n\nexport default DataTableViewOptions;\n","import React from 'react';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react';\n\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cva, VariantProps } from 'class-variance-authority';\n\n/**\n * Props for Select component\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/select}\n */\nexport interface SelectTriggerProps\n extends React.ComponentPropsWithoutRef,\n VariantProps {\n asChild?: boolean;\n}\n\n/**\n * Select components display a list of options for the user to pick fromโ€”triggered by a button.\n * These components are built on Radix UI primitives and styled with Shadcn UI.\n *\n * See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/select See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/select\n */\nconst Select = SelectPrimitive.Root;\n\n/** @inheritdoc Select */\nconst SelectGroup = SelectPrimitive.Group;\n\n/** @inheritdoc Select */\nconst SelectValue = SelectPrimitive.Value;\n\n/**\n * Style variants for the Select Trigger component.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/button}\n */\nexport const selectTriggerVariants = cva(\n 'tw-flex tw-h-10 tw-w-full tw-items-center tw-justify-between tw-rounded-md tw-border tw-border-input tw-bg-background tw-px-3 tw-py-2 tw-text-sm tw-ring-offset-background placeholder:tw-text-muted-foreground focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2 disabled:tw-cursor-not-allowed disabled:tw-opacity-50 [&>span]:tw-line-clamp-1',\n {\n variants: {\n size: {\n default: 'tw-h-10 tw-px-4 tw-py-2',\n sm: 'tw-h-8 tw-rounded-md tw-px-3',\n lg: 'tw-h-11 tw-rounded-md tw-px-8',\n icon: 'tw-h-10 tw-w-10',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n },\n);\n\n/** @inheritdoc Select */\nconst SelectTrigger = React.forwardRef<\n React.ElementRef,\n SelectTriggerProps\n>(({ className, children, size, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n {children}\n \n \n \n \n );\n});\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;\n\n/** @inheritdoc Select */\nconst SelectScrollUpButton = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n));\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;\n\n/** @inheritdoc Select */\nconst SelectScrollDownButton = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n));\nSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;\n\n/** @inheritdoc Select */\nconst SelectContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, position = 'popper', ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n \n \n \n
    {children}
    \n \n \n \n
    \n );\n});\nSelectContent.displayName = SelectPrimitive.Content.displayName;\n\n/** @inheritdoc Select */\nconst SelectLabel = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nSelectLabel.displayName = SelectPrimitive.Label.displayName;\n\n/** @inheritdoc Select */\nconst SelectItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => (\n \n \n \n \n \n \n\n {children}\n \n));\nSelectItem.displayName = SelectPrimitive.Item.displayName;\n\n/** @inheritdoc Select */\nconst SelectSeparator = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName;\n\nexport {\n Select,\n SelectGroup,\n SelectValue,\n SelectTrigger,\n SelectContent,\n SelectLabel,\n SelectItem,\n SelectSeparator,\n SelectScrollUpButton,\n SelectScrollDownButton,\n};\n","import { ChevronLeftIcon, ChevronRightIcon, ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';\nimport { Table } from '@tanstack/react-table';\n\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\n\ninterface DataTablePaginationProps {\n table: Table;\n}\n\nexport function DataTablePagination({ table }: DataTablePaginationProps) {\n return (\n
    \n
    \n
    \n {table.getFilteredSelectedRowModel().rows.length} of{' '}\n {table.getFilteredRowModel().rows.length} row(s) selected\n
    \n
    \n

    Rows per page

    \n {\n table.setPageSize(Number(value));\n }}\n >\n \n \n \n \n {[10, 20, 30, 40, 50].map((pageSize) => (\n \n {pageSize}\n \n ))}\n \n \n
    \n
    \n Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}\n
    \n
    \n table.setPageIndex(0)}\n disabled={!table.getCanPreviousPage()}\n >\n Go to first page\n \n \n table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n >\n Go to previous page\n \n \n table.nextPage()}\n disabled={!table.getCanNextPage()}\n >\n Go to next page\n \n \n table.setPageIndex(table.getPageCount() - 1)}\n disabled={!table.getCanNextPage()}\n >\n Go to last page\n \n \n
    \n
    \n
    \n );\n}\n\nexport default DataTablePagination;\n","/** Defines HTML elements that can be focusable by keyboard as a CSS selector string */\nconst FOCUSABLE_SELECTOR = `\n a[href],\n area[href],\n input:not([disabled]),\n select:not([disabled]),\n textarea:not([disabled]),\n button:not([disabled]),\n iframe,\n object,\n embed,\n [contenteditable],\n tr:not([disabled])\n`;\n\n/** Returns true if the element is visible in the DOM */\nfunction isVisible(el: HTMLElement): boolean {\n return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);\n}\n\n/**\n * Finds all focusable elements in the given container. Focusable elements are all HTML elements\n * that can receive keyboard focus, and are not disabled or hidden from screen readers.\n *\n * @param container The container element to search for focusable elements.\n * @param uniqueQuerySelector An optional CSS selector to filter the focusable elements by.\n * @returns An array of focusable elements.\n */\nexport function getFocusableElements(\n container: HTMLElement,\n uniqueQuerySelector?: string,\n): HTMLElement[] {\n const query = uniqueQuerySelector\n ? `${FOCUSABLE_SELECTOR}, ${uniqueQuerySelector}`\n : FOCUSABLE_SELECTOR;\n return Array.from(container.querySelectorAll(query)).filter(\n (el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden') && isVisible(el),\n );\n}\n","import React from 'react';\nimport { getFocusableElements } from '@/utils/focus.util';\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Table components provide a responsive table. These components are built and styled with Shadcn\n * UI. See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/table\n */\nconst Table = React.forwardRef<\n HTMLTableElement,\n React.HTMLAttributes & { stickyHeader?: boolean }\n>(({ className, stickyHeader, ...props }, ref) => {\n // CUSTOM: Use internal ref to manage keyboard navigation and Enter key behavior\n // This ref gets passed into the table row ref property which expects null and not undefined\n // eslint-disable-next-line no-null/no-null\n const tableRef = React.useRef(null);\n\n // CUSTOM: Assign internal ref to external ref if provided\n React.useEffect(() => {\n if (typeof ref === 'function') {\n ref(tableRef.current);\n } else if (ref && 'current' in ref) {\n ref.current = tableRef.current;\n }\n }, [ref]);\n\n // CUSTOM: Force tabindex -1 on all focusable elements within the table to prevent tab navigation\n React.useEffect(() => {\n const currentTable = tableRef.current;\n if (!currentTable) return;\n\n const setTabIndexes = () => {\n requestAnimationFrame(() => {\n const focusables = getFocusableElements(currentTable, `[tabindex]:not([tabindex=\"-1\"])`);\n focusables.forEach((el) => {\n el.setAttribute('tabindex', '-1');\n });\n });\n };\n\n setTabIndexes();\n\n const observer = new MutationObserver(() => {\n setTabIndexes();\n });\n\n observer.observe(currentTable, {\n childList: true, // Watch for added/removed elements\n subtree: true, // Include descendants\n attributes: true,\n attributeFilter: ['tabindex'], // Watch for tabindex changes\n });\n\n return () => {\n observer.disconnect();\n };\n }, []);\n\n // CUSTOM: Handle keydown events for the table\n const handleKeyDownInTable = (e: React.KeyboardEvent) => {\n const { current: currentTable } = tableRef;\n if (!currentTable) return;\n\n if (e.key === 'ArrowDown') {\n // Move focus to the first row in the table (header or body)\n e.preventDefault();\n const firstRow = getFocusableElements(currentTable)[0];\n firstRow.focus();\n return;\n }\n if (e.key === ' ' && document.activeElement === currentTable) {\n e.preventDefault(); // Prevent scrolling\n }\n };\n\n return (\n
    \n {/* Table element is not interactive by default but we need to add a keydown handler */}\n {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n \n
    \n );\n});\nTable.displayName = 'Table';\n\n/** @inheritdoc Table */\nconst TableHeader = React.forwardRef<\n HTMLTableSectionElement,\n React.HTMLAttributes & { stickyHeader?: boolean }\n>(({ className, stickyHeader, ...props }, ref) => (\n \n));\nTableHeader.displayName = 'TableHeader';\n\n/** @inheritdoc Table */\nconst TableBody = React.forwardRef<\n HTMLTableSectionElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => (\n \n));\nTableBody.displayName = 'TableBody';\n\n/** @inheritdoc Table */\nconst TableFooter = React.forwardRef<\n HTMLTableSectionElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => (\n tr]:last:tw-border-b-0', className)}\n {...props}\n />\n));\nTableFooter.displayName = 'TableFooter';\n\n// CUSTOM: Manage keyboard navigation and Enter key behavior for focusable elements in a row\nfunction useFocusableInRowKeyboardNavigation(rowRef: React.RefObject) {\n React.useEffect(() => {\n const row = rowRef.current;\n if (!row) return;\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (!row.contains(document.activeElement)) return;\n\n if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {\n e.preventDefault();\n e.stopPropagation(); // Helps override internal widget handlers\n const focusables = rowRef.current ? getFocusableElements(rowRef.current) : [];\n // activeElement is generic Element, so we need to cast it to HTMLElement\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const index = focusables.indexOf(document.activeElement as HTMLElement);\n const nextIndex = e.key === 'ArrowRight' ? index + 1 : index - 1;\n if (nextIndex >= 0 && nextIndex < focusables.length) {\n focusables[nextIndex].focus();\n }\n }\n\n if (e.key === 'Escape') {\n e.preventDefault();\n row.focus();\n }\n\n if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n e.preventDefault();\n }\n };\n\n row.addEventListener('keydown', handleKeyDown);\n\n return () => {\n row.removeEventListener('keydown', handleKeyDown);\n };\n }, [rowRef]);\n}\n\n// CUSTOM: Move focus left or right to adjacent focusable items in the same row\nfunction focusAdjacentFocusableElementInRow(\n focusablesInRow: HTMLElement[],\n currentIndexOfFocusables: number,\n direction: 'ArrowLeft' | 'ArrowRight',\n) {\n let nextFocusable: HTMLElement | undefined;\n if (direction === 'ArrowLeft' && currentIndexOfFocusables > 0) {\n nextFocusable = focusablesInRow[currentIndexOfFocusables - 1];\n } else if (direction === 'ArrowRight' && currentIndexOfFocusables < focusablesInRow.length - 1) {\n nextFocusable = focusablesInRow[currentIndexOfFocusables + 1];\n }\n if (nextFocusable) {\n requestAnimationFrame(() => nextFocusable.focus());\n return true;\n }\n return false;\n}\n\n// CUSTOM: Move focus up or down to adjacent rows in the same table\nfunction focusAdjacentRow(\n rowsInTable: HTMLTableRowElement[],\n currentRowIndex: number,\n direction: 'ArrowDown' | 'ArrowUp',\n) {\n let nextRow: HTMLTableRowElement | undefined;\n if (direction === 'ArrowDown' && currentRowIndex < rowsInTable.length - 1) {\n nextRow = rowsInTable[currentRowIndex + 1];\n } else if (direction === 'ArrowUp' && currentRowIndex > 0) {\n nextRow = rowsInTable[currentRowIndex - 1];\n }\n if (nextRow) {\n requestAnimationFrame(() => nextRow.focus());\n return true;\n }\n return false;\n}\n\n/** @inheritdoc Table */\nconst TableRow = React.forwardRef<\n HTMLTableRowElement,\n React.HTMLAttributes & { setFocusAlsoRunsSelect?: boolean }\n>(({ className, onKeyDown, onSelect, setFocusAlsoRunsSelect = false, ...props }, ref) => {\n // CUSTOM: Use internal ref to manage keyboard navigation and Enter key behavior\n // This ref gets passed into the table row ref property which expects null and not undefined\n // eslint-disable-next-line no-null/no-null\n const rowRef = React.useRef(null);\n\n // CUSTOM: Assign internal ref to external ref if provided\n React.useEffect(() => {\n if (typeof ref === 'function') {\n ref(rowRef.current);\n } else if (ref && 'current' in ref) {\n ref.current = rowRef.current;\n }\n }, [ref]);\n\n // CUSTOM: Use internal ref to manage keyboard navigation and Enter key behavior\n useFocusableInRowKeyboardNavigation(rowRef);\n\n // CUSTOM: Get all focusable elements in the current row\n const focusablesInRow = React.useMemo(\n () => (rowRef.current ? getFocusableElements(rowRef.current) : []),\n [rowRef],\n );\n\n // CUSTOM: Handle keydown events for keyboard navigation\n const handleKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n const { current: currentRow } = rowRef;\n if (!currentRow || !currentRow.parentElement) return;\n\n const closestTable = currentRow.closest('table');\n const rowsInTable = closestTable\n ? // getFocusableElements returns an HTMLElement[] but we are filtering for HTMLTableRowElements\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n (getFocusableElements(closestTable) as HTMLTableRowElement[]).filter(\n (element) => element.tagName === 'TR',\n )\n : [];\n const currentRowIndex = rowsInTable.indexOf(currentRow);\n const currentIndexOfFocusables = focusablesInRow.indexOf(\n // activeElement is generic Element, so we need to cast it to HTMLElement\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n document.activeElement as HTMLElement,\n );\n\n if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n e.preventDefault();\n focusAdjacentRow(rowsInTable, currentRowIndex, e.key);\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {\n e.preventDefault();\n focusAdjacentFocusableElementInRow(focusablesInRow, currentIndexOfFocusables, e.key);\n } else if (e.key === 'Escape') {\n e.preventDefault();\n const table = currentRow.closest('table');\n if (table) {\n table.focus();\n }\n }\n\n // Call user-defined onKeyDown handler if provided\n onKeyDown?.(e);\n },\n [rowRef, focusablesInRow, onKeyDown],\n );\n\n const handleFocus = React.useCallback(\n (e: React.FocusEvent) => {\n if (setFocusAlsoRunsSelect) onSelect?.(e);\n },\n [setFocusAlsoRunsSelect, onSelect],\n );\n\n return (\n \n );\n});\nTableRow.displayName = 'TableRow';\n\n/** @inheritdoc Table */\nconst TableHead = React.forwardRef<\n HTMLTableCellElement,\n React.ThHTMLAttributes\n>(({ className, ...props }, ref) => (\n \n));\nTableHead.displayName = 'TableHead';\n\n/** @inheritdoc Table */\nconst TableCell = React.forwardRef<\n HTMLTableCellElement,\n React.TdHTMLAttributes\n>(({ className, ...props }, ref) => (\n \n));\nTableCell.displayName = 'TableCell';\n\n/** @inheritdoc Table */\nconst TableCaption = React.forwardRef<\n HTMLTableCaptionElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => (\n \n));\nTableCaption.displayName = 'TableCaption';\n\nexport { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };\n","import React from 'react';\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Use to show a placeholder while content is loading. This component is from Shadcn UI. See Shadcn\n * UI documentation: https://ui.shadcn.com/docs/components/skeleton\n */\nfunction Skeleton({ className, ...props }: React.HTMLAttributes) {\n return (\n \n );\n}\n\nexport { Skeleton };\n","import React, { useMemo, useState } from 'react';\n\nimport {\n ColumnFiltersState,\n flexRender,\n getCoreRowModel,\n getFilteredRowModel,\n getPaginationRowModel,\n getSortedRowModel,\n SortingState,\n ColumnDef as TSColumnDef,\n Row as TSRow,\n RowSelectionState as TSRowSelectionState,\n SortDirection as TSSortDirection,\n Table as TSTable,\n useReactTable,\n VisibilityState,\n} from '@tanstack/react-table';\n\nimport { DataTableViewOptions } from '@/components/advanced/data-table/data-table-column-toggle.component';\nimport { DataTablePagination } from '@/components/advanced/data-table/data-table-pagination.component';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/shadcn-ui/table';\nimport { Skeleton } from '@/components/shadcn-ui/skeleton';\n\nexport type ColumnDef = TSColumnDef;\nexport type RowContents = TSRow;\nexport type TableContents = TSTable;\nexport type SortDirection = TSSortDirection;\nexport type RowSelectionState = TSRowSelectionState;\n\ninterface DataTableProps {\n columns: ColumnDef[];\n data: TData[] | undefined;\n enablePagination?: boolean;\n showPaginationControls?: boolean;\n showColumnVisibilityControls?: boolean;\n stickyHeader?: boolean;\n onRowClickHandler?: (row: RowContents, table: TableContents) => void;\n id?: string;\n isLoading?: boolean;\n noResultsMessage: string;\n}\n\n/**\n * Feature-rich table component that infuses our basic shadcn-based Table component with features\n * from TanStack's React Table library\n */\nexport function DataTable({\n columns,\n data,\n enablePagination = false,\n showPaginationControls = false,\n showColumnVisibilityControls = false,\n stickyHeader = false,\n onRowClickHandler = () => {},\n id,\n isLoading = false,\n noResultsMessage,\n}: DataTableProps) {\n const [sorting, setSorting] = useState([]);\n const [columnFilters, setColumnFilters] = useState([]);\n const [columnVisibility, setColumnVisibility] = useState({});\n const [rowSelection, setRowSelection] = useState({});\n\n const normalizedData = useMemo(() => data ?? [], [data]);\n\n const table = useReactTable({\n data: normalizedData,\n columns,\n getCoreRowModel: getCoreRowModel(),\n ...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),\n onSortingChange: setSorting,\n getSortedRowModel: getSortedRowModel(),\n onColumnFiltersChange: setColumnFilters,\n getFilteredRowModel: getFilteredRowModel(),\n onColumnVisibilityChange: setColumnVisibility,\n onRowSelectionChange: setRowSelection,\n state: {\n sorting,\n columnFilters,\n columnVisibility,\n rowSelection,\n },\n });\n\n const visibleColumns = table.getVisibleFlatColumns();\n let bodyContent: React.ReactNode;\n\n if (isLoading) {\n const rowCount = 10;\n const skeletonRowIds = Array.from({ length: rowCount }).map((_, idx) => `skeleton-row-${idx}`);\n bodyContent = skeletonRowIds.map((rowId) => (\n \n \n
    \n \n
    \n
    \n
    \n ));\n } else if (table.getRowModel().rows?.length > 0) {\n bodyContent = table.getRowModel().rows.map((row) => (\n onRowClickHandler(row, table)}\n key={row.id}\n data-state={row.getIsSelected() && 'selected'}\n >\n {row.getVisibleCells().map((cell) => (\n \n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n \n ))}\n \n ));\n } else {\n bodyContent = (\n \n \n {noResultsMessage}\n \n \n );\n }\n\n return (\n
    \n {showColumnVisibilityControls && }\n \n \n {table.getHeaderGroups().map((headerGroup) => (\n \n {headerGroup.headers.map((header) => {\n return (\n \n {header.isPlaceholder\n ? undefined\n : flexRender(header.column.columnDef.header, header.getContext())}\n \n );\n })}\n \n ))}\n \n {bodyContent}\n
    \n {enablePagination && (\n
    \n table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n >\n Previous\n \n table.nextPage()}\n disabled={!table.getCanNextPage()}\n >\n Next\n \n
    \n )}\n {enablePagination && showPaginationControls && }\n
    \n );\n}\n\nexport default DataTable;\n","import { cn } from '@/utils/shadcn-ui.util';\nimport Markdown, { MarkdownToJSX } from 'markdown-to-jsx';\nimport { useMemo } from 'react';\n\ninterface MarkdownRendererProps {\n /** Optional unique identifier */\n id?: string;\n /** The markdown string to render */\n markdown: string;\n className?: string;\n /**\n * The [`target` attribute for `a` html\n * tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target). Defaults to not\n * adding a `target` to `a` tags\n */\n anchorTarget?: string;\n /** Optional flag to truncate the content to 3 lines */\n truncate?: boolean;\n}\n\n/**\n * This component renders markdown content given a markdown string. It uses typography styles from\n * the platform.\n *\n * @param MarkdownRendererProps\n * @returns A div containing the rendered markdown content.\n */\nexport function MarkdownRenderer({\n id,\n markdown,\n className,\n anchorTarget,\n truncate,\n}: MarkdownRendererProps) {\n const options: MarkdownToJSX.Options = useMemo(\n () => ({\n overrides: {\n a: {\n props: {\n target: anchorTarget,\n },\n },\n },\n }),\n [anchorTarget],\n );\n return (\n \n {markdown}\n \n );\n}\n\nexport default MarkdownRenderer;\n","import { Copy } from 'lucide-react';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { LocalizedStringValue } from 'platform-bible-utils';\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const ERROR_DUMP_STRING_KEYS = Object.freeze([\n '%webView_error_dump_header%',\n '%webView_error_dump_info_message%',\n] as const);\n\nexport type ErrorDumpLocalizedStrings = {\n [localizedInventoryKey in (typeof ERROR_DUMP_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: ErrorDumpLocalizedStrings,\n key: keyof ErrorDumpLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\n/** Interface to store the parameters for the ErrorDump component */\nexport interface ErrorDumpProps {\n /** String containing the error details to show */\n errorDetails: string;\n /** Handler function to notify the frontend when the error is copied */\n handleCopyNotify?: () => void;\n /**\n * List of localized strings to localize the strings in this component. Relevant keys can be found\n * in `ERROR_DUMP_STRING_KEYS`\n */\n localizedStrings: ErrorDumpLocalizedStrings;\n /** Optional id for the root element */\n id?: string;\n}\n\n/** Component to render an error dump */\nexport function ErrorDump({\n errorDetails,\n handleCopyNotify,\n localizedStrings,\n id,\n}: ErrorDumpProps) {\n const headerText = localizeString(localizedStrings, '%webView_error_dump_header%');\n const infoMessage = localizeString(localizedStrings, '%webView_error_dump_info_message%');\n\n function handleCopy() {\n navigator.clipboard.writeText(errorDetails);\n if (handleCopyNotify) {\n handleCopyNotify();\n }\n }\n\n return (\n \n
    \n
    \n
    \n {headerText}\n
    \n
    \n {infoMessage}\n
    \n
    \n \n
    \n
    \n
    {errorDetails}
    \n
    \n \n );\n}\n","import { PropsWithChildren, useState } from 'react';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { ErrorDump, ErrorDumpProps, ERROR_DUMP_STRING_KEYS } from '../basics/error-dump.component';\nimport { Popover, PopoverContent, PopoverTrigger } from '../shadcn-ui/popover';\nimport { Label } from '../shadcn-ui/label';\n\n/**\n * Object containing all keys used for localization in the ErrorPopover component. This extends\n * ERROR_DUMP_STRING_KEYS with additional keys specific to the ErrorPopover. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const ERROR_POPOVER_STRING_KEYS = Object.freeze([\n ...ERROR_DUMP_STRING_KEYS,\n '%webView_error_dump_copied_message%',\n] as const);\n\nexport type ErrorPopoverLocalizedStrings = {\n [localizedKey in (typeof ERROR_POPOVER_STRING_KEYS)[number]]?: string;\n};\n\n/** Interface to store the parameters for the ErrorPopover component */\nexport type ErrorPopoverProps = PropsWithChildren &\n Omit & {\n /**\n * List of localized strings to localize the strings in this component. Relevant keys can be\n * found in `ERROR_POPOVER_STRING_KEYS`\n */\n localizedStrings: ErrorPopoverLocalizedStrings;\n /** Optional CSS classes to insert into the `PopoverContent` */\n className?: string;\n /** Optional ID for the popover content for accessibility */\n id?: string;\n };\n\n/** A popover component that displays detailed error information using the ErrorDump component. */\nexport function ErrorPopover({\n errorDetails,\n handleCopyNotify,\n localizedStrings,\n children,\n className,\n id,\n}: ErrorPopoverProps) {\n const [isCopySuccess, setIsCopySuccess] = useState(false);\n\n const handleCopyWithNotification = () => {\n setIsCopySuccess(true);\n if (handleCopyNotify) {\n handleCopyNotify();\n }\n };\n\n const handleOpenChange = (open: boolean) => {\n if (!open) {\n setIsCopySuccess(false);\n }\n };\n\n return (\n \n {children}\n \n {isCopySuccess && localizedStrings['%webView_error_dump_copied_message%'] && (\n \n )}\n \n \n \n );\n}\n","import {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuGroup,\n DropdownMenuCheckboxItem,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuRadioGroup,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { ChevronDown, Filter } from 'lucide-react';\nimport { useState } from 'react';\n\n/** The DropdownMenuItemType enum is used to determine the type of the dropdown item */\nexport enum DropdownMenuItemType {\n Check,\n Radio,\n}\n\nexport type DropdownItem = {\n /** Unique identifier for this dropdown */\n id: string;\n /** The label is the text that will be displayed on the dropdown item. */\n label: string;\n /** The onUpdate function is called when the state of a dropdown item is changed. */\n onUpdate: (id: string, checked?: boolean) => void;\n};\n\nexport type DropdownGroup = {\n /**\n * The label is the text that will be displayed on the dropdown group. It is used to categorize\n * the items in the group.\n */\n label: string;\n /** The itemType determines the DropdownMenuItemType type as either Check or Radio. */\n itemType: DropdownMenuItemType;\n /** The items array contains the items that will be displayed in the dropdown group */\n items: DropdownItem[];\n};\n\nexport type FilterDropdownProps = {\n /** Object unique identifier */\n id?: string;\n /** Label for the trigger button */\n label: string;\n /** The groups array contains the groups that will be displayed in the dropdown */\n groups: DropdownGroup[];\n}; // TODO: extend the props later\n\n/**\n * The FilterDropdown component is a dropdown designed for filtering content. It includes groups of\n * items that can be checkboxes or radio items.\n *\n * @param FilterDropdownProps\n * @returns A filter dropdown.\n */\nexport function FilterDropdown({ id, label, groups }: FilterDropdownProps) {\n // Populates the boolean Arrays for the group indexes that are checkbox groups\n const [checkedStates, setCheckedStates] = useState>(\n Object.fromEntries(\n groups\n .map((group, index) =>\n group.itemType === DropdownMenuItemType.Check ? [index, []] : undefined,\n )\n .filter((entry) => !!entry),\n ),\n );\n const [radioStates, setRadioStates] = useState>({});\n\n const handleCheckboxUpdate = (groupIndex: number, index: number) => {\n const newCheckedState = !checkedStates[groupIndex][index];\n // Update the checked state first\n setCheckedStates((oldCheckedStates) => {\n oldCheckedStates[groupIndex][index] = newCheckedState;\n return { ...oldCheckedStates };\n });\n\n // Calls the `onUpdate()` handler function for the dropdown item\n const item = groups[groupIndex].items[index];\n item.onUpdate(item.id, newCheckedState);\n };\n\n const handleRadioUpdate = (groupIndex: number, value: string) => {\n // Updates the radio state first\n setRadioStates((oldRadioStates) => {\n oldRadioStates[groupIndex] = value;\n return { ...oldRadioStates };\n });\n\n // Calls the `onUpdate()` handler function for the dropdown item\n const currentItem = groups[groupIndex].items.find((item) => item.id === value);\n if (currentItem) {\n currentItem.onUpdate(value);\n } else {\n console.error(`Could not find dropdown radio item with id '${value}'!`);\n }\n };\n\n return (\n
    \n {/* TODO: remove this once the DropDown Menu shadcn has an id prop */}\n \n \n \n \n \n {groups.map((group, groupIndex) => (\n
    \n {group.label}\n \n {group.itemType === DropdownMenuItemType.Check ? (\n <>\n {group.items.map((item, index) => (\n
    \n handleCheckboxUpdate(groupIndex, index)}\n >\n {item.label}\n \n
    \n ))}\n \n ) : (\n handleRadioUpdate(groupIndex, value)}\n >\n {group.items.map((item) => (\n
    \n {item.label}\n
    \n ))}\n \n )}\n
    \n \n
    \n ))}\n
    \n
    \n
    \n );\n}\n\nexport default FilterDropdown;\n","import { Button } from '@/components/shadcn-ui/button';\nimport { CircleHelp, Link as LucideLink, User } from 'lucide-react';\nimport { NumberFormat } from 'platform-bible-utils';\n\n/** Interface that stores the parameters passed to the More Info component */\ninterface MoreInfoProps {\n /** Optional unique identifier */\n id?: string;\n /** The category of the extension */\n category: string;\n /** The number of downloads for the extension */\n downloads: Record;\n /** The languages supported by the extension */\n languages: string[];\n /** The URL to the more info page of the extension */\n moreInfoUrl: string;\n /** Handler function triggered when the more info (Website) link is clicked */\n handleMoreInfoLinkClick: () => void;\n /** Optional URL to a website link to get support for the extension */\n supportUrl: string;\n /** Handler function triggered when the support link is clicked */\n handleSupportLinkClick: () => void;\n}\n/**\n * This component displays the more info section of the extension which includes the category,\n * number of downloads, languages, and links to the website and support\n *\n * @param MoreInfoProps\n * @returns The more info component that displays the category, number of downloads, languages, and\n * links to the website and support\n */\nexport function MoreInfo({\n id,\n category,\n downloads,\n languages,\n moreInfoUrl,\n handleMoreInfoLinkClick,\n supportUrl,\n handleSupportLinkClick,\n}: MoreInfoProps) {\n /**\n * This constant formats the number of downloads into a more readable format.\n *\n * @example 1000 -> 1K\n *\n * @example 1000000 -> 1M\n *\n * @returns The formatted number of downloads\n */\n const numberFormatted = new NumberFormat('en', {\n notation: 'compact',\n compactDisplay: 'short',\n }).format(Object.values(downloads).reduce((a: number, b: number) => a + b, 0));\n\n /** This function scrolls the window to the bottom of the page. */\n const handleScrollToBottom = () => {\n window.scrollTo(0, document.body.scrollHeight);\n };\n\n return (\n \n {category && (\n
    \n
    \n {category}\n
    \n CATEGORY\n
    \n )}\n
    \n
    \n \n {numberFormatted}\n
    \n USERS\n
    \n
    \n
    \n {languages.slice(0, 3).map((locale) => (\n \n {locale.toUpperCase()}\n \n ))}\n
    \n {languages.length > 3 && (\n handleScrollToBottom()}\n className=\"tw-text-xs tw-text-foreground tw-underline\"\n >\n +{languages.length - 3} more languages\n \n )}\n
    \n {(moreInfoUrl || supportUrl) && (\n
    \n {moreInfoUrl && (\n
    \n handleMoreInfoLinkClick()}\n variant=\"link\"\n className=\"tw-flex tw-h-auto tw-gap-1 tw-py-0 tw-text-xs tw-font-semibold tw-text-foreground\"\n >\n Website\n \n \n
    \n )}\n {supportUrl && (\n
    \n handleSupportLinkClick()}\n variant=\"link\"\n className=\"tw-flex tw-h-auto tw-gap-1 tw-py-0 tw-text-xs tw-font-semibold tw-text-foreground\"\n >\n Support\n \n \n
    \n )}\n
    \n )}\n \n );\n}\n","import { useState } from 'react';\n\nexport type VersionInformation = {\n /** Date the version was published */\n date: string;\n /** Description of the changes in the version */\n description: string;\n};\n\n/** Type to store the version history information */\nexport type VersionHistoryType = Record;\n\n/** Interface that stores the parameters passed to the Version History component */\ninterface VersionHistoryProps {\n /** Optional unique identifier */\n id?: string;\n /** Object containing the versions mapped with their information */\n versionHistory: VersionHistoryType;\n}\n\n/**\n * Component to render the version history information shown in the footer component. Lists the 5\n * most recent versions, with the options to show all versions by pressing a button.\n *\n * @param VersionHistoryProps\n * @returns Rendered version history for the Footer component\n */\nexport function VersionHistory({ id, versionHistory }: VersionHistoryProps) {\n const [showAllVersions, setShowAllVersions] = useState(false);\n const currentDate = new Date();\n\n /**\n * Function to format the time string for the version history in the form of 'X year(s) ago'.\n *\n * @param dateString ISO Date string to determine the time string from\n * @returns Formatted time string\n */\n function formatTimeString(dateString: string) {\n const date = new Date(dateString);\n const dateDiff = new Date(currentDate.getTime() - date.getTime());\n const yearDiff = dateDiff.getUTCFullYear() - 1970;\n const monthDiff = dateDiff.getUTCMonth();\n const dayDiff = dateDiff.getUTCDate() - 1;\n\n // Determines how long ago the version was published\n let timeString = '';\n if (yearDiff > 0) {\n timeString = `${yearDiff.toString()} year${yearDiff === 1 ? '' : 's'} ago`;\n } else if (monthDiff > 0) {\n timeString = `${monthDiff.toString()} month${monthDiff === 1 ? '' : 's'} ago`;\n } else if (dayDiff === 0) {\n timeString = 'today';\n } else {\n timeString = `${dayDiff.toString()} day${dayDiff === 1 ? '' : 's'} ago`;\n }\n\n return timeString;\n }\n\n // Sorts the version history by version number\n const sortedEntries = Object.entries(versionHistory).sort((a, b) => b[0].localeCompare(a[0]));\n\n return (\n
    \n

    What`s New

    \n
      \n {(showAllVersions ? sortedEntries : sortedEntries.slice(0, 5)).map((entry) => (\n
      \n
      \n
    • \n {entry[1].description}\n
    • \n
      \n
      \n
      Version {entry[0]}
      \n
      {formatTimeString(entry[1].date)}
      \n
      \n
      \n ))}\n
    \n {sortedEntries.length > 5 && (\n setShowAllVersions(!showAllVersions)}\n className=\"tw-text-xs tw-text-foreground tw-underline\"\n >\n {showAllVersions ? 'Show Less Version History' : 'Show All Version History'}\n \n )}\n
    \n );\n}\n\nexport default VersionHistory;\n","import { useMemo } from 'react';\nimport { formatBytes, getCurrentLocale } from 'platform-bible-utils';\nimport { VersionHistory, VersionHistoryType } from './version-history.component';\n\n/** Interface to store the parameters passed to the Footer component */\ninterface FooterProps {\n /** Optional unique identifier */\n id?: string;\n /** Name of the publisher */\n publisherDisplayName: string;\n /** Size of the extension file in bytes */\n fileSize: number;\n /** List of language codes supported by the extension */\n locales: string[];\n /** Object containing the version history mapped with their information */\n versionHistory: VersionHistoryType;\n /** Current version of the extension */\n currentVersion: string;\n}\n\n/**\n * Component to render the footer for the extension details which contains information on the\n * publisher, version history, languages, and file size.\n *\n * @param FooterProps\n * @returns The rendered Footer component\n */\nexport function Footer({\n id,\n publisherDisplayName,\n fileSize,\n locales,\n versionHistory,\n currentVersion,\n}: FooterProps) {\n /** Formats the file size into a human-readable format */\n const formattedFileSize = useMemo(() => formatBytes(fileSize), [fileSize]);\n\n /**\n * This function gets the display names of the languages based on the language codes.\n *\n * @param codes The list of language codes\n * @returns The list of language names\n */\n const getLanguageNames = (codes: string[]) => {\n const displayNames = new Intl.DisplayNames(getCurrentLocale(), { type: 'language' });\n return codes.map((code) => displayNames.of(code));\n };\n\n const languageNames = getLanguageNames(locales);\n\n return (\n
    \n
    \n {Object.entries(versionHistory).length > 0 && (\n \n )}\n
    \n

    Information

    \n
    \n

    \n Publisher\n {publisherDisplayName}\n Size\n {formattedFileSize}\n

    \n
    \n

    \n Version\n {currentVersion}\n Languages\n {languageNames.join(', ')}\n

    \n
    \n
    \n
    \n
    \n
    \n );\n}\n\nexport default Footer;\n","import { Button, buttonVariants } from '@/components/shadcn-ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Check, ChevronsUpDown, Star } from 'lucide-react';\nimport { ReactNode, useCallback, useMemo, useState } from 'react';\nimport { type VariantProps } from 'class-variance-authority';\n\nexport type MultiSelectComboBoxEntry = {\n value: string;\n label: string;\n secondaryLabel?: string;\n starred?: boolean;\n};\n\n/**\n * Props for MultiSelectComboBox component that provides a UI for selecting multiple items from a\n * list. It supports displaying a placeholder, custom selected text, and an optional icon. Users can\n * search through options and view starred items prominently.\n */\nexport interface MultiSelectComboBoxProps {\n /** The list of entries to select from. */\n entries: MultiSelectComboBoxEntry[];\n /** The currently selected values. */\n selected: string[];\n /** Callback function to handle changes in selection. */\n onChange: (values: string[]) => void;\n /** Placeholder text when no items are selected. */\n placeholder: string;\n /** Whether to show select all/clear all buttons. */\n hasToggleAllFeature?: boolean;\n /** Text for the select all button. */\n selectAllText?: string;\n /** Text for the clear all button. */\n clearAllText?: string;\n /** Message displayed when no entries are found. */\n commandEmptyMessage?: string;\n /** Custom text to display when items are selected. */\n customSelectedText?: string;\n /** Whether the dropdown is open (for controlled usage). */\n isOpen?: boolean;\n /** Handler that is called when the dropdown's open state changes. */\n onOpenChange?: (open: boolean) => void;\n /** Flag to disable the component. */\n isDisabled?: boolean;\n /** Flag to sort selected items. */\n sortSelected?: boolean;\n /** Optional icon to display in the button. */\n icon?: ReactNode;\n /** Additional class names for styling. */\n className?: string;\n /** Button variant to use for the trigger button. */\n variant?: VariantProps['variant'];\n /** Optional ID for the component. */\n id?: string;\n}\n\n/** MultiSelectComboBox component for selecting multiple items from a list. */\nexport function MultiSelectComboBox({\n entries,\n selected,\n onChange,\n placeholder,\n hasToggleAllFeature = false,\n selectAllText = 'Select All',\n clearAllText = 'Clear All',\n commandEmptyMessage = 'No entries found',\n customSelectedText,\n isOpen = undefined,\n onOpenChange = undefined,\n isDisabled = false,\n sortSelected = false,\n icon = undefined,\n className = undefined,\n variant = 'ghost',\n id,\n}: MultiSelectComboBoxProps) {\n const [isOpenLocal, setIsOpenLocal] = useState(false);\n\n const handleSelect = useCallback(\n (label: string) => {\n const value = entries.find((entry) => entry.label === label)?.value;\n if (!value) return;\n onChange(\n selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value],\n );\n },\n [entries, selected, onChange],\n );\n\n const getPlaceholderText = () => {\n if (customSelectedText) return customSelectedText;\n return placeholder;\n };\n\n const sortedOptions = useMemo(() => {\n if (!sortSelected) return entries;\n\n const starredItems = entries\n .filter((opt) => opt.starred)\n .sort((a, b) => a.label.localeCompare(b.label));\n const nonStarredItems = entries\n .filter((opt) => !opt.starred)\n .sort((a, b) => {\n const aSelected = selected.includes(a.value);\n const bSelected = selected.includes(b.value);\n if (aSelected && !bSelected) return -1;\n if (!aSelected && bSelected) return 1;\n return a.label.localeCompare(b.label);\n });\n\n return [...starredItems, ...nonStarredItems];\n }, [entries, selected, sortSelected]);\n\n const handleSelectAll = () => {\n onChange(entries.map((entry) => entry.value));\n };\n\n const handleClearAll = () => {\n onChange([]);\n };\n\n const actualIsOpen = isOpen ?? isOpenLocal;\n const actualOnOpenChange = onOpenChange ?? setIsOpenLocal;\n\n return (\n
    \n \n \n \n
    \n {icon && (\n
    \n \n {icon}\n \n
    \n )}\n \n {getPlaceholderText()}\n \n
    \n \n \n
    \n \n \n \n {hasToggleAllFeature && (\n
    \n \n \n
    \n )}\n \n {commandEmptyMessage}\n \n {sortedOptions.map((option) => {\n return (\n \n
    \n \n
    \n {option.starred && }\n
    {option.label}
    \n {option.secondaryLabel && (\n
    \n {option.secondaryLabel}\n
    \n )}\n \n );\n })}\n
    \n
    \n
    \n
    \n
    \n
    \n );\n}\n\nexport default MultiSelectComboBox;\n","import { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { X } from 'lucide-react';\nimport { MultiSelectComboBox, MultiSelectComboBoxProps } from './multi-select-combo-box.component';\n\ninterface FilterProps extends MultiSelectComboBoxProps {\n /**\n * Placeholder text that will be displayed when no items are selected. It will appear at the\n * location where the badges would be if any items were selected.\n */\n badgesPlaceholder: string;\n /** Optional id for the component */\n id?: string;\n}\n\n/**\n * This is a variant of the {@link MultiSelectComboBox}, that shows a {@link Badge} component for each\n * selected item in the combo box. Clicking the 'X' icon on the badge will clear the item from the\n * selected options. A placeholder text must be provided through 'badgesPlaceholder'. This will be\n * displayed if no items are selected,\n */\nexport function Filter({\n entries,\n selected,\n onChange,\n placeholder,\n commandEmptyMessage,\n customSelectedText,\n isDisabled,\n sortSelected,\n icon,\n className,\n badgesPlaceholder,\n id,\n}: FilterProps) {\n return (\n
    \n \n {selected.length > 0 ? (\n
    \n {selected.map((type) => (\n \n onChange(selected.filter((selectedType) => selectedType !== type))}\n >\n \n \n {entries.find((entry) => entry.value === type)?.label}\n \n ))}\n
    \n ) : (\n \n )}\n
    \n );\n}\n\nexport default Filter;\n","import React from 'react';\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Props for Input component\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/input}\n */\nexport interface InputProps extends React.InputHTMLAttributes {}\n\n/**\n * Input component displays a form input field or a component that looks like an input field. This\n * components is built and styled with Shadcn UI.\n *\n * @param InputProps\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/input}\n */\nexport const Input = React.forwardRef(\n ({ className, type, ...props }, ref) => {\n return (\n \n );\n },\n);\nInput.displayName = 'Input';\n","import {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { GENERATOR_NOTE_CALLER, HIDDEN_NOTE_CALLER } from '@eten-tech-foundation/platform-editor';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { KeyboardEvent, useEffect, useRef, useState } from 'react';\nimport { FootnoteCallerType, FootnoteEditorLocalizedStrings } from './footnote-editor.types';\n\ninterface FootnoteCallerDropdownProps {\n /** The caller type value to pass to the dropdown */\n callerType: FootnoteCallerType;\n /** Function to update the caller type */\n updateCallerType: (newCallerType: FootnoteCallerType) => void;\n /** The custom caller to pass to the custom caller input field */\n customCaller: string;\n /** FUnction to update the custom caller */\n updateCustomCaller: (newCustomCaller: string) => void;\n /** Localized strings from the parent component */\n localizedStrings: FootnoteEditorLocalizedStrings;\n}\n\nconst renderCallerButtonContent = (\n callerType: FootnoteCallerType,\n localizedStrings: FootnoteEditorLocalizedStrings,\n customCaller: string,\n) => {\n if (callerType === 'generated') {\n return (\n <>\n

    +

    {localizedStrings['%footnoteEditor_callerDropdown_item_generated%']}\n \n );\n }\n\n if (callerType === 'hidden') {\n return (\n <>\n

    -

    {localizedStrings['%footnoteEditor_callerDropdown_item_hidden%']}\n \n );\n }\n\n return (\n <>\n

    {customCaller}

    {localizedStrings['%footnoteEditor_callerDropdown_item_custom%']}\n \n );\n};\n\nexport function FootnoteCallerDropdown({\n callerType,\n updateCallerType,\n customCaller,\n updateCustomCaller,\n localizedStrings,\n}: FootnoteCallerDropdownProps) {\n // The ref must start with being null to be passed as an element ref\n // eslint-disable-next-line no-null/no-null\n const customCallerInputRef = useRef(null);\n // The ref must start with being null to be passed as an element ref\n // eslint-disable-next-line no-null/no-null\n const customCallerSelectRef = useRef(null);\n // The ref must start with being null to be passed as an element ref\n // eslint-disable-next-line no-null/no-null\n const isCustomCallerInputFocused = useRef(false);\n const [selectedCallerType, setSelectedCallerType] = useState(callerType);\n const [newCustomCaller, setNewCustomCaller] = useState(customCaller);\n const [isDropdownOpen, setIsDropdownOpen] = useState(false);\n\n // If the caller type changes, the selected caller type needs to change also\n useEffect(() => {\n setSelectedCallerType(callerType);\n }, [callerType]);\n\n // If the parent custom caller changes, then the new custom caller should reflect the changes\n useEffect(() => {\n if (newCustomCaller !== customCaller) {\n setNewCustomCaller(customCaller);\n }\n // This can't be triggered when the new custom caller updates because otherwise this will\n // completely prevent the input field from being edited\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [customCaller]);\n\n const handleDropdownOpenChange = (open: boolean) => {\n isCustomCallerInputFocused.current = false;\n setIsDropdownOpen(open);\n if (!open) {\n // This makes it so that if the custom caller is invalid, then reverts back to the previous\n // selected caller\n if (selectedCallerType !== 'custom' || newCustomCaller) {\n updateCallerType(selectedCallerType);\n updateCustomCaller(newCustomCaller);\n } else {\n setSelectedCallerType(callerType);\n setNewCustomCaller(customCaller);\n }\n }\n };\n\n const handleKeyDown = (event: KeyboardEvent) => {\n event.stopPropagation();\n // Allow to navigate to the input field\n if (\n (document.activeElement === customCallerSelectRef.current && event.key === 'ArrowDown') ||\n event.key === 'ArrowRight'\n ) {\n customCallerInputRef.current?.focus();\n isCustomCallerInputFocused.current = true;\n } else if (document.activeElement === customCallerInputRef.current && event.key === 'ArrowUp') {\n customCallerSelectRef.current?.focus();\n isCustomCallerInputFocused.current = false;\n } else if (\n document.activeElement === customCallerInputRef.current &&\n event.key === 'ArrowLeft' &&\n customCallerInputRef.current?.selectionStart === 0\n ) {\n customCallerSelectRef.current?.focus();\n isCustomCallerInputFocused.current = false;\n }\n\n // Allow the dropdown menu to be submitted if the custom caller is selected when you press enter\n if (\n selectedCallerType === 'custom' &&\n event.key === 'Enter' &&\n (document.activeElement === customCallerSelectRef.current ||\n document.activeElement === customCallerInputRef.current)\n ) {\n handleDropdownOpenChange(false);\n }\n };\n\n return (\n \n \n \n \n \n \n \n \n \n {localizedStrings['%footnoteEditor_callerDropdown_tooltip%']}\n \n \n \n {\n if (isCustomCallerInputFocused.current) isCustomCallerInputFocused.current = false;\n }}\n onKeyDown={handleKeyDown}\n onMouseMove={() => {\n if (isCustomCallerInputFocused.current) customCallerInputRef.current?.focus();\n }}\n >\n \n {localizedStrings['%footnoteEditor_callerDropdown_label%']}\n \n \n setSelectedCallerType('generated')}\n >\n
    \n {localizedStrings['%footnoteEditor_callerDropdown_item_generated%']}\n {GENERATOR_NOTE_CALLER}\n
    \n \n setSelectedCallerType('hidden')}\n >\n
    \n {localizedStrings['%footnoteEditor_callerDropdown_item_hidden%']}\n {HIDDEN_NOTE_CALLER}\n
    \n \n setSelectedCallerType('custom')}\n onClick={(event) => {\n event.stopPropagation();\n isCustomCallerInputFocused.current = true;\n customCallerInputRef.current?.focus();\n }}\n onSelect={(event) => event.preventDefault()}\n >\n
    \n {localizedStrings['%footnoteEditor_callerDropdown_item_custom%']}\n {\n event.stopPropagation();\n setSelectedCallerType('custom');\n isCustomCallerInputFocused.current = true;\n }}\n ref={customCallerInputRef}\n className=\"tw-h-auto tw-w-10 tw-p-0 tw-text-center\"\n value={newCustomCaller}\n onKeyDown={(event) => {\n if (\n !(\n event.key === 'Enter' ||\n event.key === 'ArrowUp' ||\n event.key === 'ArrowDown' ||\n event.key === 'ArrowLeft' ||\n event.key === 'ArrowRight'\n )\n )\n event.stopPropagation();\n }}\n maxLength={1}\n onChange={(event) => setNewCustomCaller(event.target.value)}\n />\n
    \n \n \n
    \n );\n}\n","import { Button } from '@/components/shadcn-ui/button';\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport { Tooltip, TooltipContent, TooltipProvider } from '@/components/shadcn-ui/tooltip';\nimport { TooltipTrigger } from '@radix-ui/react-tooltip';\nimport { FunctionSquare, SquareSigma, SquareX } from 'lucide-react';\nimport { formatReplacementString } from 'platform-bible-utils';\nimport { FootnoteEditorLocalizedStrings } from './footnote-editor.types';\n\ninterface FootnoteTypeDropdownProps {\n noteType: string;\n handleNoteTypeChange: (newNoteType: string) => void;\n localizedStrings: FootnoteEditorLocalizedStrings;\n isTypeSwitchable: boolean;\n}\n\nconst renderNoteTypeButtonContent = (\n noteType: string,\n localizedStrings: FootnoteEditorLocalizedStrings,\n) => {\n if (noteType === 'f') {\n return (\n <>\n {localizedStrings['%footnoteEditor_noteType_footnote_label%']}\n \n );\n }\n\n if (noteType === 'fe') {\n return (\n <>\n {localizedStrings['%footnoteEditor_noteType_endNote_label%']}\n \n );\n }\n\n return (\n <>\n {localizedStrings['%footnoteEditor_noteType_crossReference_label%']}\n \n );\n};\n\nconst formatNoteTypeTooltip = (\n noteType: string,\n localizedStrings: FootnoteEditorLocalizedStrings,\n) => {\n if (noteType === 'x') {\n return localizedStrings['%footnoteEditor_noteType_crossReference_label%'];\n }\n\n let noteTypeString = localizedStrings['%footnoteEditor_noteType_endNote_label%'];\n if (noteType === 'f') {\n noteTypeString = localizedStrings['%footnoteEditor_noteType_footnote_label%'];\n }\n\n return formatReplacementString(localizedStrings['%footnoteEditor_noteType_tooltip%'] ?? '', {\n noteType: noteTypeString,\n });\n};\n\nexport function FootnoteTypeDropdown({\n noteType,\n handleNoteTypeChange,\n localizedStrings,\n isTypeSwitchable,\n}: FootnoteTypeDropdownProps) {\n return (\n \n \n \n \n \n \n \n \n \n

    {formatNoteTypeTooltip(noteType, localizedStrings)}

    \n
    \n
    \n
    \n \n \n {localizedStrings['%footnoteEditor_noteTypeDropdown_label%']}\n \n \n handleNoteTypeChange('x')}\n className=\"tw-gap-2\"\n >\n \n {localizedStrings['%footnoteEditor_noteType_crossReference_label%']}\n \n handleNoteTypeChange('f')}\n className=\"tw-gap-2\"\n >\n \n {localizedStrings['%footnoteEditor_noteType_footnote_label%']}\n \n handleNoteTypeChange('fe')}\n className=\"tw-gap-2\"\n >\n \n {localizedStrings['%footnoteEditor_noteType_endNote_label%']}\n \n \n
    \n );\n}\n","import { Button } from '@/components/shadcn-ui/button';\nimport {\n DeltaOp,\n DeltaOpInsertNoteEmbed,\n Editorial,\n EditorOptions,\n EditorRef,\n GENERATOR_NOTE_CALLER,\n getDefaultViewOptions,\n HIDDEN_NOTE_CALLER,\n isInsertEmbedOpOfType,\n} from '@eten-tech-foundation/platform-editor';\nimport { Check, Copy, X } from 'lucide-react';\nimport { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport '@/components/advanced/footnote-editor/editor-overrides.css';\nimport { SerializedVerseRef } from '@sillsdev/scripture';\nimport {\n Tooltip,\n TooltipProvider,\n TooltipTrigger,\n TooltipContent,\n} from '@/components/shadcn-ui/tooltip';\nimport { Usj } from '@eten-tech-foundation/scripture-utilities';\nimport { FootnoteCallerDropdown } from './footnote-caller-dropdown.component';\nimport { FootnoteTypeDropdown } from './footnote-type-dropdown.component';\nimport { FootnoteCallerType, FootnoteEditorLocalizedStrings } from './footnote-editor.types';\n\n/** Interface containing the types of the properties that are passed to the `FootnoteEditor` */\nexport interface FootnoteEditorProps {\n /** Class name for styling the embedded `Editor` component in this editor popover */\n classNameForEditor?: string;\n /** Delta ops for the current note being edited that are applied to the note editorial */\n noteOps: DeltaOpInsertNoteEmbed[] | undefined;\n /** External function to handle saving changes to the footnote */\n onSave: (noteOps: DeltaOpInsertNoteEmbed[]) => void;\n /**\n * External function to handle closing the footnote editor. Gets called when the editor is closed\n * without saving changes\n */\n onClose: () => void;\n /** The scripture reference for the parent editor */\n scrRef: SerializedVerseRef;\n /** The unique note key to identify the note being edited used to apply changes to the note */\n noteKey: string | undefined;\n /** View options of the parent editor */\n editorOptions: EditorOptions;\n /** Localized strings to be passed to the footnote editor component */\n localizedStrings: FootnoteEditorLocalizedStrings;\n}\n\n/**\n * Function to convert a footnote/endnote type node to a cross-reference type node\n *\n * @param op The node to be converted\n */\nfunction footnoteToCrossReferenceOp(op: DeltaOp) {\n // The built-in type for the delta note ops does not contain the types for the attributes\n // so have to cast it here\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const opCharAttribute = op.attributes?.char as Record;\n if (opCharAttribute.style) {\n if (opCharAttribute.style === 'ft') {\n opCharAttribute.style = 'xt';\n }\n\n if (opCharAttribute.style === 'fr') {\n opCharAttribute.style = 'xo';\n }\n\n if (opCharAttribute.style === 'fq') {\n opCharAttribute.style = 'xq';\n }\n }\n}\n\n/**\n * Function to convert a cross-reference type node to a footnote/endnote type node\n *\n * @param op THe node to be converted\n */\nfunction crossReferenceToFootnoteOp(op: DeltaOp) {\n // The built-in type for the delta note ops does not contain the types for the attributes\n // so have to cast it here\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const opCharAttribute = op.attributes?.char as Record;\n if (opCharAttribute.style) {\n if (opCharAttribute.style === 'xt') {\n opCharAttribute.style = 'ft';\n }\n\n if (opCharAttribute.style === 'xo') {\n opCharAttribute.style = 'fr';\n }\n\n if (opCharAttribute.style === 'xq') {\n opCharAttribute.style = 'fq';\n }\n }\n}\n\n// TODO: Remove this once the new marker menu is implemented with correct logic\n/**\n * This is for a temporary fix to get the markers menu to work by having the default usj include a\n * parent paragraph node\n */\nconst PARAGRAPH_USJ: Usj = {\n type: 'USJ',\n version: '3.1',\n content: [\n {\n type: 'para',\n },\n ],\n};\n\n/**\n * Component to edit footnotes from within the editor component\n *\n * @param FootnoteEditorProps - The properties for the footnote editor component\n */\nexport default function FootnoteEditor({\n classNameForEditor,\n noteOps,\n onSave,\n onClose,\n scrRef,\n noteKey,\n editorOptions,\n localizedStrings,\n}: FootnoteEditorProps) {\n // The editor ref component must be this\n // eslint-disable-next-line no-null/no-null\n const editorRef = useRef(null);\n const editorParentRef = createRef();\n\n const [callerType, setCallerType] = useState('generated');\n const [customCaller, setCustomCaller] = useState('*');\n\n const [noteType, setNoteType] = useState('f');\n\n const [isTypeSwitchable, setIsTypeSwitchable] = useState(false);\n\n // Options for the editorial component\n const options = useMemo(\n () => ({\n ...editorOptions,\n markerMenuTrigger: editorOptions.markerMenuTrigger ?? '\\\\',\n hasExternalUI: true,\n view: { ...(editorOptions.view ?? getDefaultViewOptions()), noteMode: 'expanded' },\n }),\n [editorOptions],\n );\n\n // Makes it so that the footnote type change tooltip doesn't automatically focus when the\n // component opens by focusing the editor\n useEffect(() => {\n editorRef.current?.focus();\n });\n\n // When the component loads, applies the note ops to the current editor, gets the note ref and caller\n useEffect(() => {\n let timeout: ReturnType;\n const noteOp = noteOps?.at(0);\n if (noteOp && isInsertEmbedOpOfType('note', noteOp)) {\n const rawCaller = noteOp.insert.note?.caller;\n // Parses the current caller\n let parsedCallerType: FootnoteCallerType = 'custom';\n if (rawCaller === GENERATOR_NOTE_CALLER) {\n parsedCallerType = 'generated';\n } else if (rawCaller === HIDDEN_NOTE_CALLER) {\n parsedCallerType = 'hidden';\n } else if (rawCaller) {\n setCustomCaller(rawCaller);\n }\n setCallerType(parsedCallerType);\n // Assigns note type\n setNoteType(noteOp.insert.note?.style ?? 'f');\n timeout = setTimeout(() => {\n // Inserts the note node to be edited as an delta operation\n editorRef.current?.applyUpdate([noteOp]);\n }, 0);\n }\n\n return () => {\n if (timeout) {\n clearTimeout(timeout);\n }\n };\n }, [noteOps, noteKey]);\n\n const handleSave = useCallback(() => {\n const currentNoteOp = editorRef.current?.getNoteOps(0)?.at(0);\n if (currentNoteOp && isInsertEmbedOpOfType('note', currentNoteOp)) {\n if (currentNoteOp.insert.note) {\n if (callerType === 'custom') {\n currentNoteOp.insert.note.caller = customCaller;\n } else {\n currentNoteOp.insert.note.caller =\n callerType === 'generated' ? GENERATOR_NOTE_CALLER : HIDDEN_NOTE_CALLER;\n }\n }\n onSave([currentNoteOp]);\n }\n }, [callerType, customCaller, onSave]);\n\n const handleCopy = () => {\n const editorInput = editorParentRef.current?.getElementsByClassName('editor-input')[0];\n if (editorInput?.textContent) {\n navigator.clipboard.writeText(editorInput.textContent);\n }\n };\n\n const handleNoteTypeChange = (value: string) => {\n setNoteType(value);\n\n // Changes the note type for the current note that is being edited\n const currentNoteOp = editorRef.current?.getNoteOps(0)?.at(0);\n if (currentNoteOp && isInsertEmbedOpOfType('note', currentNoteOp)) {\n if (currentNoteOp.insert.note) currentNoteOp.insert.note.style = value;\n\n // If switching between cross-reference and footnote/endnote, need to switch the nodes inside\n const innerNoteOps = currentNoteOp.insert.note?.contents?.ops;\n if (noteType !== 'x' && value === 'x') {\n innerNoteOps?.forEach((op) => footnoteToCrossReferenceOp(op));\n } else if (noteType === 'x' && value !== 'x') {\n innerNoteOps?.forEach((op) => crossReferenceToFootnoteOp(op));\n }\n\n // Inserts the new footnote/cross-reference and deletes the old one\n editorRef.current?.applyUpdate([currentNoteOp, { delete: 1 }]);\n }\n };\n\n const handleUsjChange = (usj: Usj) => {\n const noteOp = editorRef.current?.getNoteOps(0)?.at(0);\n if (noteOp && isInsertEmbedOpOfType('note', noteOp)) {\n // Prevents adding additional note nodes or other nodes after the main footnote node\n if (usj.content.length > 1) {\n setTimeout(() => {\n // Retains the first two nodes which are the added paragraph node (for now) and the\n // footnote/cross-reference and deletes the unwanted node that was just inserted\n editorRef.current?.applyUpdate([{ retain: 2 }, { delete: 1 }]);\n }, 0);\n }\n const currentNoteType = noteOp.insert.note?.style;\n const innerNoteOps = noteOp.insert.note?.contents?.ops;\n if (!currentNoteType) setIsTypeSwitchable(false);\n\n if (currentNoteType === 'x') {\n setIsTypeSwitchable(\n !!innerNoteOps?.every((op) => {\n if (!op.attributes?.char) return true;\n // The built-in type for the delta note ops does not contain the types for the attributes\n // so have to cast it here\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const nodeType = (op.attributes?.char as Record).style;\n return nodeType === 'xt' || nodeType === 'xo' || nodeType === 'xq';\n }),\n );\n } else {\n setIsTypeSwitchable(\n !!innerNoteOps?.every((op) => {\n if (!op.attributes?.char) return true;\n // The built-in type for the delta note ops does not contain the types for the attributes\n // so have to cast it here\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const nodeType = (op.attributes?.char as Record).style;\n return nodeType === 'ft' || nodeType === 'fr' || nodeType === 'fq';\n }),\n );\n }\n } else {\n setIsTypeSwitchable(false);\n }\n };\n\n return (\n
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n \n \n \n

    {localizedStrings['%footnoteEditor_cancelButton_tooltip%']}

    \n
    \n
    \n
    \n \n \n \n \n \n \n \n \n {localizedStrings['%footnoteEditor_saveButton_tooltip%']}\n \n \n \n
    \n
    \n \n
    \n {}}\n scrRef={scrRef}\n ref={editorRef}\n />\n
    \n
    \n \n \n \n \n \n \n

    {localizedStrings['%footnoteEditor_copyButton_tooltip%']}

    \n
    \n
    \n
    \n
    \n
    \n \n );\n}\n","/**\n * Object containing all keys used for localization in the FootnoteEditor component. If you're using\n * this component in an extension, you can pass it into the useLocalizedStrings hook to easily\n * obtain the localized strings and pass them into the localizedStrings prop of this component\n */\nexport const FOOTNOTE_EDITOR_STRING_KEYS = Object.freeze([\n '%footnoteEditor_callerDropdown_label%',\n '%footnoteEditor_callerDropdown_item_generated%',\n '%footnoteEditor_callerDropdown_item_hidden%',\n '%footnoteEditor_callerDropdown_item_custom%',\n '%footnoteEditor_callerDropdown_tooltip%',\n '%footnoteEditor_cancelButton_tooltip%',\n '%footnoteEditor_copyButton_tooltip%',\n '%footnoteEditor_noteType_crossReference_label%',\n '%footnoteEditor_noteType_endNote_label%',\n '%footnoteEditor_noteType_footnote_label%',\n '%footnoteEditor_noteType_tooltip%',\n '%footnoteEditor_noteTypeDropdown_label%',\n '%footnoteEditor_saveButton_tooltip%',\n] as const);\n\nexport type FootnoteEditorLocalizedStrings = {\n [localizedKey in (typeof FOOTNOTE_EDITOR_STRING_KEYS)[number]]?: string;\n};\n\nexport type FootnoteCallerType = 'generated' | 'hidden' | 'custom';\n","import React from 'react';\nimport { MarkerContent, MarkerObject } from '@eten-tech-foundation/scripture-utilities';\nimport { AlertCircle } from 'lucide-react';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { FootnoteItemProps } from './footnotes.types';\n\nfunction makeKey(parentMarker: string | undefined, content?: MarkerContent[]): string {\n if (!content || content.length === 0) return parentMarker ?? 'empty';\n\n const firstString = content.find((part) => typeof part === 'string');\n if (firstString) {\n return `key-${parentMarker ?? 'unknown'}-${firstString.slice(0, 10)}`;\n }\n\n // Fallback: combine markers\n const firstMarker =\n typeof content[0] === 'string' ? 'impossible' : (content[0].marker ?? 'unknown');\n return `key-${parentMarker ?? 'unknown'}-${firstMarker}`;\n}\n\nfunction renderParagraphs(\n parentMarker: string | undefined,\n content?: MarkerContent[],\n showMarkers = true,\n footnoteClosing: React.ReactNode | undefined = undefined,\n): React.ReactNode {\n if (!content || content.length === 0) return undefined;\n\n const markerHierarchy: string[] = [];\n\n const paragraphs: MarkerContent[][] = [];\n let current: MarkerContent[] = [];\n\n content.forEach((part) => {\n if (typeof part !== 'string' && part.marker === 'fp') {\n // End current paragraph before starting new one\n if (current.length > 0) paragraphs.push(current);\n\n // Start new paragraph that *includes* the fp marker itself\n current = [part];\n } else {\n current.push(part);\n }\n });\n\n if (current.length > 0) paragraphs.push(current);\n\n return paragraphs.map((para, i) => {\n const isLast = i === paragraphs.length - 1;\n return (\n

    \n {renderContent(parentMarker, para, showMarkers, true, markerHierarchy)}\n {isLast && footnoteClosing}\n

    \n );\n });\n}\n\nfunction renderContent(\n parentMarker: string | undefined,\n content?: MarkerContent[],\n showMarkers = true,\n allowUnmarkedText = true,\n markerHierarchy: string[] = [],\n): React.ReactNode {\n if (!content || content.length === 0) return undefined;\n\n return content.map((footnotePart) => {\n if (typeof footnotePart === 'string') {\n // Build a key based on the hierarchy and text\n const key = `${parentMarker}-text-${footnotePart.slice(0, 10)}`;\n if (allowUnmarkedText) {\n const classes = cn(`usfm_${parentMarker}`);\n return (\n \n {footnotePart}\n \n );\n }\n return (\n \n \n {footnotePart}\n \n \n );\n }\n\n return renderMarkerObject(\n footnotePart,\n makeKey(`${parentMarker}\\\\${footnotePart.marker}`, [footnotePart]),\n showMarkers,\n [...markerHierarchy, parentMarker ?? 'unknown'],\n );\n });\n}\n\nfunction renderMarkerObject(\n markerObj: MarkerObject,\n key: React.Key,\n showMarkers: boolean,\n markerHierarchy: string[] = [],\n): React.ReactNode {\n const { marker } = markerObj;\n\n return (\n \n {marker ? (\n showMarkers && {`\\\\${marker} `}\n ) : (\n \n )}\n {renderContent(marker, markerObj.content, showMarkers, true, [\n ...markerHierarchy,\n marker ?? 'unknown',\n ])}\n \n );\n}\n\n/** `FootnoteItem` is a component that provides a read-only display of a single USFM/JSX footnote. */\nexport function FootnoteItem({\n footnote,\n layout = 'horizontal',\n formatCaller,\n showMarkers = true,\n}: FootnoteItemProps) {\n const caller = formatCaller ? formatCaller(footnote.caller) : footnote.caller;\n const isCallerFormatted = caller !== footnote.caller;\n\n // Split out target reference (first top-level fr/xo, if any)\n let targetRef: MarkerContent | undefined;\n let remainingContent = footnote.content;\n\n if (\n Array.isArray(footnote.content) &&\n footnote.content.length > 0 &&\n typeof footnote.content[0] !== 'string' &&\n (footnote.content[0].marker === 'fr' || footnote.content[0].marker === 'xo')\n ) {\n [targetRef, ...remainingContent] = footnote.content;\n }\n\n const footnoteOpening = showMarkers ? (\n {`\\\\${footnote.marker} `}\n ) : undefined;\n\n const footnoteClosing = showMarkers ? (\n {` \\\\${footnote.marker}*`}\n ) : undefined;\n\n const footnoteCaller = caller && (\n // USFM does not specify a marker for caller, so instead of a usfm_* class, we use a\n // specific class name in case styling is needed.\n \n {caller}{' '}\n \n );\n const footnoteTargetRef = targetRef && (\n <>{renderContent(footnote.marker, [targetRef], showMarkers, false)} \n );\n\n const layoutClass = layout === 'horizontal' ? 'horizontal' : 'vertical';\n const markerClass = showMarkers ? 'marker-visible' : '';\n const footnoteBodyClass =\n layout === 'horizontal' ? 'tw-col-span-1' : 'tw-col-span-2 tw-col-start-1 tw-row-start-2';\n const baseClasses = cn(layoutClass, markerClass);\n\n return (\n <>\n
    \n {footnoteOpening}\n {footnoteCaller}\n
    \n
    \n {footnoteTargetRef}\n
    \n \n {remainingContent && remainingContent.length > 0 && (\n <>{renderParagraphs(footnote.marker, remainingContent, showMarkers, footnoteClosing)}\n )}\n \n \n );\n}\n\nexport default FootnoteItem;\n","import { MarkerObject } from '@eten-tech-foundation/scripture-utilities';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Separator } from '@/components/shadcn-ui/separator';\nimport { getFormatCallerFunction } from 'platform-bible-utils';\nimport React, { useEffect, useRef, useState } from 'react';\nimport { FootnoteItem } from './footnote-item.component';\nimport { FootnoteListProps } from './footnotes.types';\n\n/** `FootnoteList` is a component that provides a read-only display of a list of USFM/JSX footnote. */\nexport function FootnoteList({\n className,\n classNameForItems,\n footnotes,\n layout = 'horizontal',\n listId,\n selectedFootnote,\n showMarkers = true,\n suppressFormatting = false,\n formatCaller,\n onFootnoteSelected,\n}: FootnoteListProps) {\n const handleFormatCaller = formatCaller ?? getFormatCallerFunction(footnotes, undefined);\n const handleFootnoteClick = (footnote: MarkerObject, index: number) => {\n onFootnoteSelected?.(footnote, index, listId);\n };\n\n const initialFocusedIndex = selectedFootnote\n ? footnotes.findIndex((f) => f === selectedFootnote)\n : -1;\n\n const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex);\n\n const handleFootnoteKeyDown = (\n e: React.KeyboardEvent,\n footnote: MarkerObject,\n index: number,\n ) => {\n if (!footnotes.length) return;\n\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n onFootnoteSelected?.(footnote, index, listId);\n break;\n\n default:\n break;\n }\n };\n\n const handleListKeyDown = (e: React.KeyboardEvent) => {\n if (!footnotes.length) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n setFocusedIndex((prev) => Math.min(prev + 1, footnotes.length - 1));\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n setFocusedIndex((prev) => Math.max(prev - 1, 0));\n break;\n\n default:\n break;\n }\n };\n\n const rowRefs = useRef<(HTMLLIElement | null)[]>([]);\n\n useEffect(() => {\n if (focusedIndex >= 0 && focusedIndex < rowRefs.current.length) {\n rowRefs.current[focusedIndex]?.focus();\n }\n }, [focusedIndex]);\n\n /*\n * TODO(PT-3743): After upgrading to Tailwind v4, move to using @container and @sm/@lg css\n * styling to replace the use of the `layout` variable to distinguish between\n * wide/skinny layouts.\n */\n return (\n \n \n {footnotes.map((footnote, idx) => {\n const isSelected = footnote === selectedFootnote;\n const key = `${listId}-${idx}`;\n return (\n <>\n {\n rowRefs.current[idx] = el;\n }}\n role=\"option\"\n aria-selected={isSelected}\n key={key}\n data-marker={footnote.marker}\n data-state={isSelected ? 'selected' : undefined}\n tabIndex={idx === focusedIndex ? 0 : -1}\n className={cn(\n 'tw-gap-x-3 tw-gap-y-1 tw-p-2 data-[state=selected]:tw-bg-muted',\n onFootnoteSelected && 'hover:tw-bg-muted/50',\n 'tw-w-full tw-rounded-sm tw-border-0 tw-bg-transparent tw-shadow-none',\n 'focus:tw-outline-none focus-visible:tw-outline-none',\n /* ENHANCE: After considerable fiddling, this set of styles makes a focus ring\n that looks great in Storybook. However, the left edge of the ring is clipped in\n P.B app. These are similar, but not identical to, the customizations made in\n our shadcn table component.\n */\n 'focus-visible:tw-ring-offset-0.5 focus-visible:tw-relative focus-visible:tw-z-10 focus-visible:tw-ring-2 focus-visible:tw-ring-ring',\n 'tw-grid tw-grid-flow-col tw-grid-cols-subgrid',\n layout === 'horizontal' ? 'tw-col-span-3' : 'tw-col-span-2 tw-row-span-2',\n classNameForItems,\n )}\n onClick={() => handleFootnoteClick(footnote, idx)}\n onKeyDown={(e) => handleFootnoteKeyDown(e, footnote, idx)}\n >\n handleFormatCaller(footnote.caller, idx)}\n showMarkers={showMarkers}\n />\n \n {/* Only render separator if not the last item */}\n {idx < footnotes.length - 1 && layout === 'vertical' && (\n \n )}\n \n );\n })}\n
\n \n );\n}\n\nexport default FootnoteList;\n","import {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/shadcn-ui/table';\nimport { SerializedVerseRef } from '@sillsdev/scripture';\nimport { formatScrRef, LanguageStrings } from 'platform-bible-utils';\nimport { ReactNode, useMemo } from 'react';\nimport { InventoryItemOccurrence } from './inventory-utils';\n\n/**\n * Convert text with `\\\\word\\\\` markers to React elements with bold formatting.\n *\n * @param text Text containing `\\\\word\\\\` markers for bolding\n * @returns Array of React nodes with text and bold elements\n */\nfunction formatTextWithBold(text: string): ReactNode[] {\n const parts: ReactNode[] = [];\n let lastIndex = 0;\n // Look for text wrapped in double backslashes (e.g., \\\\bolded text\\\\)\n const regex = /\\\\\\\\(.+?)\\\\\\\\/g;\n let match;\n\n // regex.exec() returns null when no match is found\n // eslint-disable-next-line no-null/no-null, no-cond-assign\n while ((match = regex.exec(text)) !== null) {\n // Add text before the match\n if (match.index > lastIndex) {\n parts.push(text.substring(lastIndex, match.index));\n }\n // Add the bold text\n parts.push({match[1]});\n lastIndex = regex.lastIndex;\n }\n\n // Add any remaining text after the last match\n if (lastIndex < text.length) {\n parts.push(text.substring(lastIndex));\n }\n\n return parts.length > 0 ? parts : [text];\n}\n\n/** Props for the OccurrencesTable component */\ntype OccurrencesTableProps = {\n /** Data that contains scriptures references and snippets of scripture */\n occurrenceData: InventoryItemOccurrence[];\n /** Callback function that is executed when the scripture reference is changed */\n setScriptureReference: (scriptureReference: SerializedVerseRef) => void;\n /**\n * Object with all localized strings that the OccurrencesTable needs to work well across multiple\n * languages\n */\n localizedStrings: LanguageStrings;\n /** Class name to apply to the occurrence text */\n classNameForText?: string;\n};\n\n/**\n * Table that shows occurrences of specified inventory item(s). The first column shows the related\n * scripture reference. The second column shows the snippet of scripture that contains the specified\n * inventory item\n */\nexport function OccurrencesTable({\n occurrenceData,\n setScriptureReference,\n localizedStrings,\n classNameForText,\n}: OccurrencesTableProps) {\n const referenceHeaderText =\n localizedStrings['%webView_inventory_occurrences_table_header_reference%'];\n const occurrenceHeaderText =\n localizedStrings['%webView_inventory_occurrences_table_header_occurrence%'];\n\n const occurrences: InventoryItemOccurrence[] = useMemo(() => {\n const uniqueOccurrences: InventoryItemOccurrence[] = [];\n const seen = new Set();\n\n occurrenceData.forEach((occurrence) => {\n const key = `${occurrence.reference.book}:${occurrence.reference.chapterNum}:${occurrence.reference.verseNum}:${occurrence.text}`;\n\n if (!seen.has(key)) {\n seen.add(key);\n uniqueOccurrences.push(occurrence);\n }\n });\n\n return uniqueOccurrences;\n }, [occurrenceData]);\n\n return (\n \n \n \n {referenceHeaderText}\n {occurrenceHeaderText}\n \n \n \n {occurrences.length > 0 &&\n occurrences.map((occurrence) => (\n {\n setScriptureReference(occurrence.reference);\n }}\n >\n {formatScrRef(occurrence.reference, 'English')}\n \n {formatTextWithBold(occurrence.text)}\n \n \n ))}\n \n
\n );\n}\n\nexport default OccurrencesTable;\n","import React from 'react';\nimport * as CheckboxPrimitive from '@radix-ui/react-checkbox';\nimport { Check } from 'lucide-react';\n\nimport { cn } from '@/utils/shadcn-ui.util';\n\n/**\n * Checkbox component provides a control that allows the user to toggle between checked and not\n * checked. This components is built on Radix UI primitives and styled with Shadcn UI.\n *\n * @see Shadcn UI Documentation: {@link https://ui.shadcn.com/docs/components/checkbox}\n * @see Radix UI Documentation: {@link https://www.radix-ui.com/primitives/docs/components/checkbox}\n */\nexport const Checkbox = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n \n \n \n \n));\nCheckbox.displayName = CheckboxPrimitive.Root.displayName;\n\nexport default Checkbox;\n","import { ColumnDef, SortDirection } from '@/components/advanced/data-table/data-table.component';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/shadcn-ui/toggle-group';\nimport {\n ArrowDownIcon,\n ArrowUpDownIcon,\n ArrowUpIcon,\n CircleCheckIcon,\n CircleHelpIcon,\n CircleXIcon,\n} from 'lucide-react';\nimport { ReactNode } from 'react';\nimport { InventoryTableData, Status } from './inventory-utils';\n\n/**\n * Gets an icon that indicates the current sorting direction based on the provided input\n *\n * @param sortDirection Sorting direction. Can be ascending ('asc'), descending ('desc') or false (\n * i.e. not sorted)\n * @returns The appropriate sorting icon for the provided sorting direction\n */\nconst getSortingIcon = (sortDirection: false | SortDirection): ReactNode => {\n if (sortDirection === 'asc') {\n return ;\n }\n if (sortDirection === 'desc') {\n return ;\n }\n return ;\n};\n\n/**\n * Function that creates the item column for inventories\n *\n * @param itemLabel Localized label for the item column (e.g. 'Character', 'Repeated Word', etc.)\n * @returns Column that shows the inventory items. Should be used with the DataTable component\n */\nexport const inventoryItemColumn = (itemLabel: string): ColumnDef => {\n return {\n accessorKey: 'item',\n accessorFn: (row: InventoryTableData) => row.items[0],\n header: ({ column }) => (\n \n ),\n };\n};\n\n/**\n * Function that creates the additional item columns for inventories\n *\n * @param additionalItemLabel Localized label for the additional item column (e.g. 'Preceding\n * Marker')\n * @param additionalItemIndex Index that locates the desired item in the items array of the\n * inventory\n * @returns Column that shows additional inventory items. Should be used with the DataTable\n * component\n */\nexport const inventoryAdditionalItemColumn = (\n additionalItemLabel: string,\n additionalItemIndex: number,\n): ColumnDef => {\n return {\n accessorKey: `item${additionalItemIndex}`,\n accessorFn: (row: InventoryTableData) => row.items[additionalItemIndex],\n header: ({ column }) => (\n \n ),\n };\n};\n\n/**\n * Function that creates the count column for inventories. Should be used with the DataTable\n * component.\n *\n * @param countLabel Localized label for the count column\n * @returns Column that shows the number of occurrences of the related inventory items\n */\nexport const inventoryCountColumn = (countLabel: string): ColumnDef => {\n return {\n accessorKey: 'count',\n header: ({ column }) => (\n
\n \n
\n ),\n cell: ({ row }) =>
{row.getValue('count')}
,\n };\n};\n\n/**\n * Function that updates project settings when status for item(s) changes\n *\n * @param changedItems Array of items for which the status is being updated\n * @param newStatus The status that the items are being given\n * @param approvedItems Array of currently approved items\n * @param onApprovedItemsChange Callback function that stores the updated list of approved items\n * @param unapprovedItems Array of currently unapproved items\n * @param onUnapprovedItemsChange Callback function that stores the updated list of unapproved items\n */\nconst statusChangeHandler = (\n changedItems: string[],\n newStatus: Status,\n approvedItems: string[],\n onApprovedItemsChange: (items: string[]) => void,\n unapprovedItems: string[],\n onUnapprovedItemsChange: (items: string[]) => void,\n) => {\n let newApprovedItems: string[] = [...approvedItems];\n changedItems.forEach((item) => {\n if (newStatus === 'approved') {\n if (!newApprovedItems.includes(item)) {\n newApprovedItems.push(item);\n }\n } else {\n newApprovedItems = newApprovedItems.filter((validItem) => validItem !== item);\n }\n });\n onApprovedItemsChange(newApprovedItems);\n\n let newUnapprovedItems: string[] = [...unapprovedItems];\n changedItems.forEach((item) => {\n if (newStatus === 'unapproved') {\n if (!newUnapprovedItems.includes(item)) {\n newUnapprovedItems.push(item);\n }\n } else {\n newUnapprovedItems = newUnapprovedItems.filter((unapprovedItem) => unapprovedItem !== item);\n }\n });\n onUnapprovedItemsChange(newUnapprovedItems);\n};\n\n/**\n * Function that creates the status column for inventories. Should be used with the DataTable\n * component.\n *\n * @param statusLabel Localized label for the status column\n * @param approvedItems Array of approved items, typically as defined in `Settings.xml`\n * @param onApprovedItemsChange Callback function that stores the updated list of approved items\n * @param unapprovedItems Array of unapproved items, typically as defined in `Settings.xml`\n * @param onUnapprovedItemsChange Callback function that stores the updated list of unapproved items\n * @returns Column that shows the status buttons for the related inventory item. The button for the\n * current status of the item is selected\n */\nexport const inventoryStatusColumn = (\n statusLabel: string,\n approvedItems: string[],\n onApprovedItemsChange: (items: string[]) => void,\n unapprovedItems: string[],\n onUnapprovedItemsChange: (items: string[]) => void,\n): ColumnDef => {\n return {\n accessorKey: 'status',\n header: ({ column }) => {\n return (\n
\n \n
\n );\n },\n cell: ({ row }) => {\n const status: Status = row.getValue('status');\n const item: string = row.getValue('item');\n return (\n \n {\n event.stopPropagation();\n statusChangeHandler(\n [item],\n 'approved',\n approvedItems,\n onApprovedItemsChange,\n unapprovedItems,\n onUnapprovedItemsChange,\n );\n }}\n value=\"approved\"\n >\n \n \n {\n event.stopPropagation();\n statusChangeHandler(\n [item],\n 'unapproved',\n approvedItems,\n onApprovedItemsChange,\n unapprovedItems,\n onUnapprovedItemsChange,\n );\n }}\n value=\"unapproved\"\n >\n \n \n {\n event.stopPropagation();\n statusChangeHandler(\n [item],\n 'unknown',\n approvedItems,\n onApprovedItemsChange,\n unapprovedItems,\n onUnapprovedItemsChange,\n );\n }}\n value=\"unknown\"\n >\n \n \n \n );\n },\n };\n};\n","import { SerializedVerseRef } from '@sillsdev/scripture';\n\n/* #region Types */\n\n/**\n * Status of items that appear in inventories. 'approved' and 'unapproved' items are defined in the\n * project's `Settings.xml`. All other items are defined as 'unknown'\n */\nexport type Status = 'approved' | 'unapproved' | 'unknown';\n\n/** Occurrence of item in inventory. Primarily used by table that shows occurrences */\nexport type InventoryItemOccurrence = {\n /** Reference to scripture where the item appears */\n reference: SerializedVerseRef;\n /** Snippet of scripture that contains the occurrence */\n text: string;\n};\n\n/** Data structure that contains all information on an item that is shown in an inventory */\nexport type InventoryTableData = {\n /**\n * The item (e.g. a character in the characters inventory, a marker in the marker inventory) In\n * most cases the array will only have one element. In case of additional items (e.g. the\n * preceding marker in the markers check), the primary item should be stored in the first index.\n * To show additional items in the inventory, make sure to configure the `additionalItemsLabels`\n * prop for the Inventory component\n */\n items: string[];\n /** The number of times this item occurs in the selected scope */\n count: number;\n /** The status of this item (see documentation for `Status` type for more information) */\n status: Status;\n /** Occurrences of this item in the scripture text for the selected scope */\n occurrences: InventoryItemOccurrence[];\n};\n\n/* #endregion */\n\n/* #region Functions */\n\n/**\n * Splits USFM string into shorter line-like segments\n *\n * @param text A single (likely very large) USFM string\n * @returns An array containing the input text, split into shorter segments\n */\nexport const getLinesFromUSFM = (text: string) => {\n // Splits on (CR)LF, CR, \\v, \\c and \\id\n return text.split(/(?:\\r?\\n|\\r)|(?=(?:\\\\(?:v|c|id)))/g);\n};\n\n/**\n * Extracts chapter or verse number from USFM strings that start with a \\c or \\v marker\n *\n * @param text USFM string that is expected to start with \\c or \\v marker\n * @returns Chapter or verse number if one is found. Else returns 0.\n */\nexport const getNumberFromUSFM = (text: string): number | undefined => {\n // Captures all digits that follow \\v or \\c markers followed by whitespace located at the start of a string\n const regex = /^\\\\[vc]\\s+(\\d+)/;\n const match = text.match(regex);\n\n if (match) {\n return +match[1];\n }\n return undefined;\n};\n\n/**\n * Gets book ID from USFM string that starts with the \\id marker, and returns book number for it\n *\n * @param text USFM string that is expected to start with \\id marker\n * @returns Book number corresponding to the \\id marker in the input text. Returns 0 if no marker is\n * found or the marker is not valid\n */\nexport const getBookIdFromUSFM = (text: string): string => {\n // Captures all digits that follow an \\id marker followed by whitespace located at the start of a string\n const match = text.match(/^\\\\id\\s+([A-Za-z]+)/);\n if (match) {\n return match[1];\n }\n return '';\n};\n\n/**\n * Gets the status for an item, typically used in the Inventory component\n *\n * @param item The item for which the status is being requested\n * @param approvedItems Array of approved items, typically as defined in `Settings.xml`\n * @param unapprovedItems Array of unapproved items, typically as defined in `Settings.xml`\n * @returns The status for the specified item\n */\nexport const getStatusForItem = (\n item: string,\n approvedItems: string[],\n unapprovedItems: string[],\n): Status => {\n if (unapprovedItems.includes(item)) return 'unapproved';\n if (approvedItems.includes(item)) return 'approved';\n return 'unknown';\n};\n\n/* #endregion */\n","import {\n ColumnDef,\n DataTable,\n RowContents,\n RowSelectionState,\n TableContents,\n} from '@/components/advanced/data-table/data-table.component';\nimport { OccurrencesTable } from '@/components/advanced/inventory/occurrences-table.component';\nimport { Checkbox } from '@/components/shadcn-ui/checkbox';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { Label } from '@/components/shadcn-ui/label';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport { Scope } from '@/components/utils/scripture.util';\nimport { SerializedVerseRef } from '@sillsdev/scripture';\nimport { deepEqual, isString, LocalizedStringValue } from 'platform-bible-utils';\nimport { useEffect, useMemo, useState } from 'react';\nimport { inventoryAdditionalItemColumn } from './inventory-columns';\nimport {\n getStatusForItem,\n InventoryItemOccurrence,\n InventoryTableData,\n Status,\n} from './inventory-utils';\n\n/**\n * Represents an item in the inventory with associated text and verse reference.\n *\n * @deprecated 12 January 2026. Use InventorySummaryItem instead for better performance and\n * functionality.\n */\nexport type InventoryItem = {\n /**\n * The label by which the item is shown in the inventory (e.g. the word that is repeated in case\n * of the Repeated Words check). It serves as a unique identifier for the item. It usually is a\n * string, but can be a string[] when there are multiple defining attributes (e.g. when 'show\n * preceding marker' is enabled for the Markers Inventory, the preceding marker will be stored as\n * the second item in the array)\n */\n inventoryText: string | string[];\n /** The snippet of scripture where this occurrence of the `inventoryItem` is found */\n verse: string;\n /** The reference to the location where the `verse` can be found in scripture */\n verseRef: SerializedVerseRef;\n /**\n * Offset used to locate the `inventoryText` (or inventoryText[0] in case of an array) in the\n * `verse` string\n */\n offset: number;\n};\n\n/**\n * Represents a summary item in the inventory with aggregated count and optional detailed\n * occurrences. This type is used for displaying inventory data in a summarized format, where each\n * item shows the total count and can optionally include detailed occurrence information that gets\n * loaded dynamically when the user selects the item.\n */\nexport type InventorySummaryItem = {\n /** The item key (e.g., character, word, etc.) */\n key: string | string[];\n /** Total count of occurrences */\n count: number;\n /** Status of the item */\n status?: Status;\n /** Detailed occurrences - optional, loaded on demand */\n occurrences?: InventoryItemOccurrence[];\n};\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const INVENTORY_STRING_KEYS = Object.freeze([\n '%webView_inventory_all%',\n '%webView_inventory_approved%',\n '%webView_inventory_unapproved%',\n '%webView_inventory_unknown%',\n '%webView_inventory_scope_currentBook%',\n '%webView_inventory_scope_chapter%',\n '%webView_inventory_scope_verse%',\n '%webView_inventory_filter_text%',\n '%webView_inventory_show_additional_items%',\n '%webView_inventory_occurrences_table_header_reference%',\n '%webView_inventory_occurrences_table_header_occurrence%',\n '%webView_inventory_no_results%',\n] as const);\n\nexport type InventoryLocalizedStrings = {\n [localizedInventoryKey in (typeof INVENTORY_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/** Status values that the status filter can select from */\ntype StatusFilter = Status | 'all';\n\n/** Text labels for the inventory columns and the control components of additional inventory items */\ntype AdditionalItemsLabels = {\n checkboxText?: string;\n tableHeaders?: string[];\n};\n\n/**\n * Filters data that is shown in the DataTable section of the Inventory\n *\n * @param itemData All inventory items and their related information\n * @param statusFilter Allows filtering by status (i.e. show all items, or only items that are\n * 'approved', 'unapproved' or 'unknown')\n * @param textFilter Allows filtering by text. All items that include the filter text will be\n * selected.\n * @returns Array of items and their related information that are matched by the specified filters\n */\nconst filterItemData = (\n itemData: InventoryTableData[],\n statusFilter: StatusFilter,\n textFilter: string,\n): InventoryTableData[] => {\n let filteredItemData: InventoryTableData[] = itemData;\n\n if (statusFilter !== 'all') {\n filteredItemData = filteredItemData.filter(\n (item) =>\n (statusFilter === 'approved' && item.status === 'approved') ||\n (statusFilter === 'unapproved' && item.status === 'unapproved') ||\n (statusFilter === 'unknown' && item.status === 'unknown'),\n );\n }\n\n if (textFilter !== '')\n filteredItemData = filteredItemData.filter((item) => item.items[0].includes(textFilter));\n\n return filteredItemData;\n};\n\n/**\n * Processes InventorySummaryItem array into InventoryTableData for display\n *\n * @param inventoryItems Summary items with counts and optional occurrences\n * @param approvedItems Array of approved items\n * @param unapprovedItems Array of unapproved items\n * @returns Array of table data for display\n */\nconst processSummaryItems = (\n inventoryItems: InventorySummaryItem[],\n approvedItems: string[],\n unapprovedItems: string[],\n): InventoryTableData[] => {\n return inventoryItems.map((item) => {\n const itemKey = isString(item.key) ? item.key : item.key[0];\n const items = isString(item.key) ? [item.key] : item.key;\n\n return {\n items,\n count: item.count,\n status: item.status || getStatusForItem(itemKey, approvedItems, unapprovedItems),\n occurrences: item.occurrences || [],\n };\n });\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: InventoryLocalizedStrings,\n key: keyof InventoryLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\n/** Props for the Inventory component */\ntype InventoryProps = {\n /** The inventory items that the inventory should be populated with */\n inventoryItems: InventorySummaryItem[] | undefined;\n /** Callback function that is executed when the scripture reference is changed */\n setVerseRef: (scriptureReference: SerializedVerseRef) => void;\n /**\n * Object with all localized strings that the Inventory needs to work well across multiple\n * languages. When using this component with Platform.Bible, you can import\n * `INVENTORY_STRING_KEYS` from this library, pass it in to the Platform's localization hook, and\n * pass the localized keys that are returned by the hook into this prop.\n */\n localizedStrings: InventoryLocalizedStrings;\n /**\n * Text labels for control elements and additional column headers in case your Inventory has more\n * than one item to show (e.g. The 'Preceding Marker' in the Markers Inventory)\n */\n additionalItemsLabels?: AdditionalItemsLabels;\n /** Array of approved items, typically as defined in `Settings.xml` */\n approvedItems: string[];\n /** Array of unapproved items, typically as defined in `Settings.xml` */\n unapprovedItems: string[];\n /** Scope of scripture that the inventory will operate on */\n scope: Scope;\n /** Callback function that is executed when the scope is changed from the Inventory */\n onScopeChange: (scope: Scope) => void;\n /**\n * Column definitions for the Inventory data table. The most commonly used column definitions are\n * pre-configured for your convenience and can be imported (e.g. inventoryItemColumn,\n * inventoryAdditionalItemColumn inventoryCountColumn, and inventoryStatusColumn). If you need any\n * other columns you can add these yourself\n */\n columns: ColumnDef[];\n /** Unique identifier for the Inventory component */\n id?: string;\n /** Whether the inventory items are still loading */\n areInventoryItemsLoading?: boolean;\n /** Class name to apply to the provided occurrence verse text in the `OccurrencesTable` component */\n classNameForVerseText?: string;\n /** Optional callback that is called when an item is selected. Receives the selected item key. */\n onItemSelected?: (itemKey: string) => void;\n};\n\n/** Inventory component that is used to view and control the status of provided project settings */\nexport function Inventory({\n inventoryItems,\n setVerseRef,\n localizedStrings,\n additionalItemsLabels,\n approvedItems,\n unapprovedItems,\n scope,\n onScopeChange,\n columns,\n id,\n areInventoryItemsLoading = false,\n classNameForVerseText,\n onItemSelected,\n}: InventoryProps) {\n const allItemsText = localizeString(localizedStrings, '%webView_inventory_all%');\n const approvedItemsText = localizeString(localizedStrings, '%webView_inventory_approved%');\n const unapprovedItemsText = localizeString(localizedStrings, '%webView_inventory_unapproved%');\n const unknownItemsText = localizeString(localizedStrings, '%webView_inventory_unknown%');\n const scopeBookText = localizeString(localizedStrings, '%webView_inventory_scope_currentBook%');\n const scopeChapterText = localizeString(localizedStrings, '%webView_inventory_scope_chapter%');\n const scopeVerseText = localizeString(localizedStrings, '%webView_inventory_scope_verse%');\n const filterText = localizeString(localizedStrings, '%webView_inventory_filter_text%');\n const showAdditionalItemsText = localizeString(\n localizedStrings,\n '%webView_inventory_show_additional_items%',\n );\n const noResultsText = localizeString(localizedStrings, '%webView_inventory_no_results%');\n\n const [showAdditionalItems, setShowAdditionalItems] = useState(false);\n const [statusFilter, setStatusFilter] = useState('all');\n const [textFilter, setTextFilter] = useState('');\n const [selectedItem, setSelectedItem] = useState([]);\n\n const tableData: InventoryTableData[] = useMemo(() => {\n const safeInventoryItems = inventoryItems ?? [];\n if (safeInventoryItems.length === 0) return [];\n return processSummaryItems(safeInventoryItems, approvedItems, unapprovedItems);\n }, [inventoryItems, approvedItems, unapprovedItems]);\n\n const reducedTableData: InventoryTableData[] = useMemo(() => {\n if (showAdditionalItems) return tableData;\n\n const newTableData: InventoryTableData[] = [];\n\n tableData.forEach((tableEntry) => {\n const firstItem = tableEntry.items[0];\n\n const existingEntry = newTableData.find(\n (newTableEntry) => newTableEntry.items[0] === firstItem,\n );\n\n if (existingEntry) {\n existingEntry.count += tableEntry.count;\n existingEntry.occurrences = existingEntry.occurrences.concat(tableEntry.occurrences);\n } else {\n newTableData.push({\n items: [firstItem],\n count: tableEntry.count,\n occurrences: tableEntry.occurrences,\n status: tableEntry.status,\n });\n }\n });\n\n return newTableData;\n }, [showAdditionalItems, tableData]);\n\n const filteredTableData: InventoryTableData[] = useMemo(() => {\n if (reducedTableData.length === 0) return [];\n return filterItemData(reducedTableData, statusFilter, textFilter);\n }, [reducedTableData, statusFilter, textFilter]);\n\n const allColumns: ColumnDef[] = useMemo(() => {\n if (!showAdditionalItems) return columns;\n\n const numberOfAdditionalItems = additionalItemsLabels?.tableHeaders?.length;\n if (!numberOfAdditionalItems) return columns;\n\n const additionalColumns: ColumnDef[] = [];\n\n for (let index = 0; index < numberOfAdditionalItems; index++) {\n additionalColumns.push(\n inventoryAdditionalItemColumn(\n additionalItemsLabels?.tableHeaders?.[index] || 'Additional Item',\n index + 1,\n ),\n );\n }\n\n return [...additionalColumns, ...columns];\n }, [additionalItemsLabels?.tableHeaders, columns, showAdditionalItems]);\n\n useEffect(() => {\n if (filteredTableData.length === 0) {\n setSelectedItem([]);\n } else if (filteredTableData.length === 1) {\n setSelectedItem(filteredTableData[0].items);\n }\n }, [filteredTableData]);\n\n const rowClickHandler = (\n row: RowContents,\n table: TableContents,\n ) => {\n table.setRowSelection(() => {\n const newSelection: RowSelectionState = {};\n newSelection[row.index] = true;\n return newSelection;\n });\n\n const selectedItems = row.original.items;\n setSelectedItem(selectedItems);\n\n // Call the callback if provided, passing the first item as the key\n if (onItemSelected && selectedItems.length > 0) {\n onItemSelected(selectedItems[0]);\n }\n };\n\n const handleScopeChange = (value: string) => {\n if (value === 'book' || value === 'chapter' || value === 'verse') {\n onScopeChange(value);\n } else {\n throw new Error(`Invalid scope value: ${value}`);\n }\n };\n\n const handleStatusFilterChange = (value: string) => {\n if (value === 'all' || value === 'approved' || value === 'unapproved' || value === 'unknown') {\n setStatusFilter(value);\n } else {\n throw new Error(`Invalid status filter value: ${value}`);\n }\n };\n\n const occurrenceData: InventoryItemOccurrence[] = useMemo(() => {\n if (reducedTableData.length === 0 || selectedItem.length === 0) return [];\n const occurrence = reducedTableData.filter((tableEntry: InventoryTableData) => {\n return deepEqual(\n showAdditionalItems ? tableEntry.items : [tableEntry.items[0]],\n selectedItem,\n );\n });\n if (occurrence.length > 1) throw new Error('Selected item is not unique');\n if (occurrence.length === 0) return [];\n return occurrence[0].occurrences;\n }, [selectedItem, showAdditionalItems, reducedTableData]);\n\n return (\n
\n
\n handleStatusFilterChange(value)}\n defaultValue={statusFilter}\n >\n \n \n \n \n {allItemsText}\n {approvedItemsText}\n {unapprovedItemsText}\n {unknownItemsText}\n \n \n \n {\n setTextFilter(event.target.value);\n }}\n />\n {additionalItemsLabels && (\n
\n {\n setShowAdditionalItems(checked);\n }}\n />\n \n
\n )}\n
\n
\n \n
\n {occurrenceData.length > 0 && (\n
\n \n
\n )}\n
\n );\n}\n\nexport default Inventory;\n","import { FC, useMemo, useState } from 'react';\nimport { Ban } from 'lucide-react';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandShortcut,\n} from '../shadcn-ui/command';\n\n/**\n * Object containing all keys used for localization in the FootnoteEditor component. If you're using\n * this component in an extension, you can pass it into the useLocalizedStrings hook to easily\n * obtain the localized strings and pass them into the localizedStrings prop of this component\n */\nexport const MARKER_MENU_STRING_KEYS = Object.freeze([\n '%markerMenu_deprecated_label%',\n '%markerMenu_disallowed_label%',\n '%markerMenu_noResults%',\n '%markerMenu_searchPlaceholder%',\n] as const);\n\nexport type MarkerMenuLocalizedStrings = {\n [localizedKey in (typeof MARKER_MENU_STRING_KEYS)[number]]?: string;\n};\n\n/** Interface that includes the properties that the provided icon element should have */\nexport interface MarkerIconProps {\n /** CSS class name to apply to the icon */\n className?: string;\n /** Size in px that the icon should be */\n size?: string | number;\n}\n\n/** Type for the markers that contain all necessary information to be displayed in the list */\nexport interface MarkerMenuItem {\n /** If the item is a marker, then this is the marker code */\n marker?: string;\n /** The main title for the marker or command */\n title: string;\n /** An optional subtitle for the marker */\n subtitle?: string;\n /** Optional name of icon to use instead of the marker */\n icon?: FC;\n /** Whether the command/marker is deprecated */\n isDeprecated?: boolean;\n /** Whether the command/marker is disallowed for this project */\n isDisallowed?: boolean;\n /** Function to be triggered when the marker or command is selected */\n action: () => void;\n}\n\n/** Props for the marker menu component */\nexport interface MarkerMenuProps {\n /** Localized strings to pass through for the marker menu */\n localizedStrings: MarkerMenuLocalizedStrings;\n /**\n * A list of the marker menu items which can either be a marker to insert or some basic command\n * actions\n */\n markerMenuItems: MarkerMenuItem[];\n}\n\n/** Function to format the marker menu icon and size it accordingly */\nfunction MenuMarkerIcon({ icon, className }: { icon?: FC; className?: string }) {\n const IconComponent = icon ?? Ban;\n return ;\n}\n\n/** Marker menu component to render the list of markers and a few commands in the scripture editor */\nexport function MarkerMenu({ localizedStrings, markerMenuItems }: MarkerMenuProps) {\n const [commandSearch, setCommandSearch] = useState('');\n\n const filteredMarkerItems = useMemo(() => {\n const query = commandSearch.trim().toLowerCase();\n if (!query) {\n return markerMenuItems;\n }\n\n return markerMenuItems.filter(\n (markerItem) =>\n markerItem.marker?.toLowerCase().includes(query) ||\n markerItem.title.toLowerCase().includes(query),\n );\n }, [commandSearch, markerMenuItems]);\n\n return (\n \n setCommandSearch(value)}\n placeholder={localizedStrings['%markerMenu_searchPlaceholder%']}\n autoFocus={false}\n />\n \n {localizedStrings['%markerMenu_noResults%']}\n \n {filteredMarkerItems.map((item) => (\n \n
\n {item.marker ? (\n {item.marker}\n ) : (\n
\n \n
\n )}\n
\n
\n

{item.title}

\n {item.subtitle && (\n

{item.subtitle}

\n )}\n
\n {(item.isDisallowed || item.isDeprecated) && (\n \n {item.isDisallowed\n ? localizedStrings['%markerMenu_disallowed_label%']\n : localizedStrings['%markerMenu_deprecated_label%']}\n \n )}\n \n ))}\n
\n
\n
\n );\n}\n","import React from 'react';\nimport { Slot } from '@radix-ui/react-slot';\nimport { VariantProps, cva } from 'class-variance-authority';\nimport { PanelLeft, PanelRight } from 'lucide-react';\n\nimport { Button } from '@/components/shadcn-ui/button';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { Separator } from '@/components/shadcn-ui/separator';\nimport { Skeleton } from '@/components/shadcn-ui/skeleton';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/**\n * CUSTOM: Changes from the original code from Shadcn- Removed uses of useIsMobile, Sheet, and\n * SheetContent. Also removed the parts setting COOKIES.\n */\n\nconst SIDEBAR_WIDTH = '16rem';\nconst SIDEBAR_WIDTH_ICON = '3rem';\n// CUSTOM: Commented this out pending a discussion with UX about keyboard shortcuts\n// const SIDEBAR_KEYBOARD_SHORTCUT = 'b';\n\ntype Side = 'primary' | 'secondary';\n\ntype SidebarContextProps = {\n state: 'expanded' | 'collapsed';\n open: boolean;\n setOpen: (open: boolean) => void;\n toggleSidebar: () => void;\n // CUSTOM: this was moved from Sidebar to SidebarProvider to also be able to flip the icon based on the side\n side: Side;\n};\n\nconst SidebarContext = React.createContext(undefined);\n\n/** @inheritdoc SidebarProvider */\nfunction useSidebar() {\n const context = React.useContext(SidebarContext);\n if (!context) {\n throw new Error('useSidebar must be used within a SidebarProvider.');\n }\n\n return context;\n}\n\n/**\n * Sidebar components providing an accessible sidebar along with all the sub components that can be\n * used to populate and style it. These components are adapted from Shadcn UI. See Shadcn UI\n * Documentation: https://ui.shadcn.com/docs/components/sidebar\n */\nconst SidebarProvider = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<'div'> & {\n /** Whether the sidebar is initially open. */\n defaultOpen?: boolean;\n /** Whether the sidebar is open. */\n open?: boolean;\n /** Callback fired when the open state changes. */\n onOpenChange?: (open: boolean) => void;\n /** The side of the sidebar. */\n side?: Side;\n }\n>(\n (\n {\n defaultOpen = true,\n open: openProp,\n onOpenChange: setOpenProp,\n className,\n style,\n children,\n side = 'primary',\n ...props\n },\n ref,\n ) => {\n // This is the internal state of the sidebar.\n // We use openProp and setOpenProp for control from outside the component.\n // eslint-disable-next-line @typescript-eslint/naming-convention\n const [_open, _setOpen] = React.useState(defaultOpen);\n const isOpen = openProp ?? _open;\n const setOpen = React.useCallback(\n (value: boolean | ((value: boolean) => boolean)) => {\n const openState = typeof value === 'function' ? value(isOpen) : value;\n if (setOpenProp) {\n setOpenProp(openState);\n } else {\n _setOpen(openState);\n }\n },\n [setOpenProp, isOpen],\n );\n\n // Helper to toggle the sidebar.\n const toggleSidebar = React.useCallback(() => {\n return setOpen((open) => !open);\n }, [setOpen]);\n\n // CUSTOM: Commented this out pending a discussion with UX about keyboard shortcuts\n // Adds a keyboard shortcut to toggle the sidebar.\n // React.useEffect(() => {\n // const handleKeyDown = (event: KeyboardEvent) => {\n // if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {\n // event.preventDefault();\n // toggleSidebar();\n // }\n // };\n\n // window.addEventListener('keydown', handleKeyDown);\n // return () => window.removeEventListener('keydown', handleKeyDown);\n // }, [toggleSidebar]);\n\n // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n // This makes it easier to style the sidebar with Tailwind classes.\n const state = isOpen ? 'expanded' : 'collapsed';\n\n const dir: Direction = readDirection();\n const oppositeSide: Side = side === 'primary' ? 'secondary' : 'primary';\n const directionAwareSide = dir === 'ltr' ? side : oppositeSide;\n\n const contextValue = React.useMemo(\n () => ({\n state,\n open: isOpen,\n setOpen,\n toggleSidebar,\n side: directionAwareSide,\n }),\n [state, isOpen, setOpen, toggleSidebar, directionAwareSide],\n );\n\n return (\n \n \n \n {children}\n \n \n \n );\n },\n);\nSidebarProvider.displayName = 'SidebarProvider';\n\n/** @inheritdoc SidebarProvider */\nconst Sidebar = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<'div'> & {\n variant?: 'sidebar' | 'floating' | 'inset';\n collapsible?: 'offcanvas' | 'icon' | 'none';\n }\n>(({ variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }, ref) => {\n const context = useSidebar();\n\n if (collapsible === 'none') {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {/* This is what handles the sidebar gap on desktop */}\n \n \n \n {children}\n \n \n \n );\n});\nSidebar.displayName = 'Sidebar';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentProps\n>(({ className, onClick, ...props }, ref) => {\n const context = useSidebar();\n\n return (\n {\n onClick?.(event);\n context.toggleSidebar();\n }}\n {...props}\n >\n {context.side === 'primary' ? : }\n Toggle Sidebar\n \n );\n});\nSidebarTrigger.displayName = 'SidebarTrigger';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarRail = React.forwardRef>(\n ({ className, ...props }, ref) => {\n const { toggleSidebar } = useSidebar();\n\n return (\n \n );\n },\n);\nSidebarRail.displayName = 'SidebarRail';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarInset = React.forwardRef>(\n ({ className, ...props }, ref) => {\n return (\n \n );\n },\n);\nSidebarInset.displayName = 'SidebarInset';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarInput = React.forwardRef<\n React.ElementRef,\n React.ComponentProps\n>(({ className, ...props }, ref) => {\n return (\n \n );\n});\nSidebarInput.displayName = 'SidebarInput';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarHeader = React.forwardRef>(\n ({ className, ...props }, ref) => {\n return (\n \n );\n },\n);\nSidebarHeader.displayName = 'SidebarHeader';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarFooter = React.forwardRef>(\n ({ className, ...props }, ref) => {\n return (\n \n );\n },\n);\nSidebarFooter.displayName = 'SidebarFooter';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarSeparator = React.forwardRef<\n React.ElementRef,\n React.ComponentProps\n>(({ className, ...props }, ref) => {\n return (\n \n );\n});\nSidebarSeparator.displayName = 'SidebarSeparator';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarContent = React.forwardRef>(\n ({ className, ...props }, ref) => {\n return (\n \n );\n },\n);\nSidebarContent.displayName = 'SidebarContent';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarGroup = React.forwardRef>(\n ({ className, ...props }, ref) => {\n return (\n \n );\n },\n);\nSidebarGroup.displayName = 'SidebarGroup';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarGroupLabel = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<'div'> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : 'div';\n\n return (\n svg]:tw-size-4 [&>svg]:tw-shrink-0',\n 'group-data-[collapsible=icon]:tw--mt-8 group-data-[collapsible=icon]:tw-opacity-0',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarGroupLabel.displayName = 'SidebarGroupLabel';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarGroupAction = React.forwardRef<\n HTMLButtonElement,\n React.ComponentProps<'button'> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : 'button';\n\n return (\n svg]:tw-size-4 [&>svg]:tw-shrink-0',\n // Increases the hit area of the button on mobile.\n 'after:tw-absolute after:tw--inset-2 after:md:tw-hidden',\n 'group-data-[collapsible=icon]:tw-hidden',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarGroupAction.displayName = 'SidebarGroupAction';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarGroupContent = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nSidebarGroupContent.displayName = 'SidebarGroupContent';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenu = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nSidebarMenu.displayName = 'SidebarMenu';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuItem = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nSidebarMenuItem.displayName = 'SidebarMenuItem';\n\nconst sidebarMenuButtonVariants = cva(\n 'tw-peer/menu-button tw-flex tw-w-full tw-items-center tw-gap-2 tw-overflow-hidden tw-rounded-md tw-p-2 tw-text-left tw-text-sm tw-outline-none tw-ring-sidebar-ring tw-transition-[width,height,padding] hover:tw-bg-sidebar-accent hover:tw-text-sidebar-accent-foreground focus-visible:tw-ring-2 active:tw-bg-sidebar-accent active:tw-text-sidebar-accent-foreground disabled:tw-pointer-events-none disabled:tw-opacity-50 tw-group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:tw-pointer-events-none aria-disabled:tw-opacity-50 data-[active=true]:tw-font-medium data-[active=true]:tw-text-sidebar-accent-foreground data-[active=true]:tw-bg-sidebar-accent data-[state=open]:hover:tw-bg-sidebar-accent data-[state=open]:hover:tw-text-sidebar-accent-foreground group-data-[collapsible=icon]:tw-!size-8 group-data-[collapsible=icon]:tw-!p-2 [&>span:last-child]:tw-truncate [&>svg]:tw-size-4 [&>svg]:tw-shrink-0',\n {\n variants: {\n variant: {\n default: 'hover:tw-bg-sidebar-accent hover:tw-text-sidebar-accent-foreground',\n outline:\n 'tw-bg-background tw-shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:tw-bg-sidebar-accent hover:tw-text-sidebar-accent-foreground hover:tw-shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',\n },\n size: {\n default: 'tw-h-8 tw-text-sm',\n sm: 'tw-h-7 tw-text-xs',\n lg: 'tw-h-12 tw-text-sm group-data-[collapsible=icon]:tw-!p-0',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n },\n);\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuButton = React.forwardRef<\n HTMLButtonElement,\n React.ComponentProps<'button'> & {\n asChild?: boolean;\n isActive?: boolean;\n tooltip?: string | React.ComponentProps;\n } & VariantProps\n>(\n (\n {\n asChild = false,\n isActive = false,\n variant = 'default',\n size = 'default',\n tooltip,\n className,\n ...props\n },\n ref,\n ) => {\n const Comp = asChild ? Slot : 'button';\n const { state } = useSidebar();\n\n const button = (\n \n );\n\n if (!tooltip) {\n return button;\n }\n\n if (typeof tooltip === 'string') {\n // eslint-disable-next-line no-param-reassign\n tooltip = {\n children: tooltip,\n };\n }\n\n return (\n \n {button}\n \n );\n },\n);\nSidebarMenuButton.displayName = 'SidebarMenuButton';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuAction = React.forwardRef<\n HTMLButtonElement,\n React.ComponentProps<'button'> & {\n asChild?: boolean;\n showOnHover?: boolean;\n }\n>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {\n const Comp = asChild ? Slot : 'button';\n\n return (\n svg]:tw-size-4 [&>svg]:tw-shrink-0',\n // Increases the hit area of the button on mobile.\n 'after:tw-absolute after:tw--inset-2 after:md:tw-hidden',\n 'tw-peer-data-[size=sm]/menu-button:top-1',\n 'tw-peer-data-[size=default]/menu-button:top-1.5',\n 'tw-peer-data-[size=lg]/menu-button:top-2.5',\n 'group-data-[collapsible=icon]:tw-hidden',\n showOnHover &&\n 'tw-group-focus-within/menu-item:opacity-100 tw-group-hover/menu-item:opacity-100 tw-peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:tw-opacity-100 md:tw-opacity-0',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarMenuAction.displayName = 'SidebarMenuAction';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuBadge = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nSidebarMenuBadge.displayName = 'SidebarMenuBadge';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSkeleton = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<'div'> & {\n showIcon?: boolean;\n }\n>(({ className, showIcon = false, ...props }, ref) => {\n // Random width between 50 to 90%.\n const width = React.useMemo(() => {\n return `${Math.floor(Math.random() * 40) + 50}%`;\n }, []);\n\n return (\n \n {showIcon && (\n \n )}\n \n \n );\n});\nSidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSub = React.forwardRef>(\n ({ className, ...props }, ref) => (\n \n ),\n);\nSidebarMenuSub.displayName = 'SidebarMenuSub';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSubItem = React.forwardRef>(\n ({ ...props }, ref) =>
  • ,\n);\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSubButton = React.forwardRef<\n HTMLAnchorElement,\n React.ComponentProps<'a'> & {\n asChild?: boolean;\n size?: 'sm' | 'md';\n isActive?: boolean;\n }\n>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {\n const Comp = asChild ? Slot : 'a';\n\n return (\n span:last-child]:tw-truncate [&>svg]:tw-size-4 [&>svg]:tw-shrink-0 [&>svg]:tw-text-sidebar-accent-foreground',\n 'data-[active=true]:tw-bg-sidebar-accent data-[active=true]:tw-text-sidebar-accent-foreground',\n size === 'sm' && 'tw-text-xs',\n size === 'md' && 'tw-text-sm',\n 'group-data-[collapsible=icon]:tw-hidden',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarMenuSubButton.displayName = 'SidebarMenuSubButton';\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupAction,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInput,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSkeleton,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n SidebarProvider,\n SidebarRail,\n SidebarSeparator,\n SidebarTrigger,\n useSidebar,\n};\n","import { ComboBox } from '@/components/basics/combo-box.component';\nimport {\n Sidebar,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n} from '@/components/shadcn-ui/sidebar';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { ScrollText } from 'lucide-react';\nimport { useCallback } from 'react';\n\nexport type SelectedSettingsSidebarItem = {\n label: string;\n projectId?: string;\n};\n\nexport type ProjectInfo = { projectId: string; projectName: string };\n\nexport type SettingsSidebarProps = {\n /** Optional id for testing */\n id?: string;\n\n /** Extension labels from contribution */\n extensionLabels: Record;\n\n /** Project names and ids */\n projectInfo: ProjectInfo[];\n\n /** Handler for selecting a sidebar item */\n handleSelectSidebarItem: (key: string, projectId?: string) => void;\n\n /** The current selected value in the sidebar */\n selectedSidebarItem: SelectedSettingsSidebarItem;\n\n /** Label for the group of extensions setting groups */\n extensionsSidebarGroupLabel: string;\n\n /** Label for the group of projects settings */\n projectsSidebarGroupLabel: string;\n\n /** Placeholder text for the button */\n buttonPlaceholderText: string;\n\n /** Additional css classes to help with unique styling of the sidebar */\n className?: string;\n};\n\n/**\n * The SettingsSidebar component is a sidebar that displays a list of extension settings and project\n * settings. It can be used to navigate to different settings pages. Must be wrapped in a\n * SidebarProvider component otherwise produces errors.\n *\n * @param props - {@link SettingsSidebarProps} The props for the component.\n */\nexport function SettingsSidebar({\n id,\n extensionLabels,\n projectInfo,\n handleSelectSidebarItem,\n selectedSidebarItem,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n className,\n}: SettingsSidebarProps) {\n const handleSelectItem = useCallback(\n (item: string, projectId?: string) => {\n handleSelectSidebarItem(item, projectId);\n },\n [handleSelectSidebarItem],\n );\n\n const getProjectNameFromProjectId = useCallback(\n (projectId: string) => {\n const project = projectInfo.find((info) => info.projectId === projectId);\n return project ? project.projectName : projectId;\n },\n [projectInfo],\n );\n\n const getIsActive: (label: string) => boolean = useCallback(\n (label: string) => !selectedSidebarItem.projectId && label === selectedSidebarItem.label,\n [selectedSidebarItem],\n );\n\n return (\n \n \n \n \n {extensionsSidebarGroupLabel}\n \n \n \n {Object.entries(extensionLabels).map(([key, label]) => (\n \n handleSelectItem(key)}\n isActive={getIsActive(key)}\n >\n {label}\n \n \n ))}\n \n \n \n \n {projectsSidebarGroupLabel}\n \n info.projectId)}\n getOptionLabel={getProjectNameFromProjectId}\n buttonPlaceholder={buttonPlaceholderText}\n onChange={(projectId: string) => {\n const selectedProjectName = getProjectNameFromProjectId(projectId);\n handleSelectItem(selectedProjectName, projectId);\n }}\n value={selectedSidebarItem?.projectId ?? undefined}\n icon={}\n />\n \n \n \n \n );\n}\n\nexport default SettingsSidebar;\n","import { Button } from '@/components/shadcn-ui/button';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Search, X } from 'lucide-react';\nimport { forwardRef } from 'react';\n\n/** Props for the SearchBar component. */\nexport type SearchBarProps = {\n /** Search query for the search bar */\n value: string;\n /**\n * Callback fired to handle the search query is updated\n *\n * @param searchQuery\n */\n onSearch: (searchQuery: string) => void;\n\n /** Optional string that appears in the search bar without a search string */\n placeholder?: string;\n\n /** Optional boolean to set the input base to full width */\n isFullWidth?: boolean;\n\n /** Additional css classes to help with unique styling of the search bar */\n className?: string;\n\n /** Optional boolean to disable the search bar */\n isDisabled?: boolean;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A search bar component with a search icon and a clear button when the search query is not empty.\n *\n * @param {SearchBarProps} props - The props for the component.\n * @param {string} props.value - The search query for the search bar\n * @param {(searchQuery: string) => void} props.onSearch - Callback fired to handle the search query\n * is updated\n * @param {string} [props.placeholder] - Optional string that appears in the search bar without a\n * search string\n * @param {boolean} [props.isFullWidth] - Optional boolean to set the input base to full width\n * @param {string} [props.className] - Additional css classes to help with unique styling of the\n * search bar\n * @param {boolean} [props.isDisabled] - Optional boolean to disable the search bar\n * @param {string} [props.id] - Optional id for the root element\n */\nexport const SearchBar = forwardRef(\n ({ value, onSearch, placeholder, isFullWidth, className, isDisabled = false, id }, inputRef) => {\n const dir: Direction = readDirection();\n\n return (\n
    \n \n onSearch(e.target.value)}\n disabled={isDisabled}\n />\n {value && (\n {\n onSearch('');\n }}\n >\n \n Clear\n \n )}\n
    \n );\n },\n);\n\nSearchBar.displayName = 'SearchBar';\n\nexport default SearchBar;\n","import { SidebarInset, SidebarProvider } from '@/components/shadcn-ui/sidebar';\nimport { PropsWithChildren } from 'react';\nimport { SearchBar } from '@/components/basics/search-bar.component';\nimport { SettingsSidebar, SettingsSidebarProps } from './settings-sidebar.component';\n\nexport type SettingsSidebarContentSearchProps = SettingsSidebarProps &\n PropsWithChildren & {\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n };\n\n/**\n * A component that wraps a search bar and a settings sidebar, providing a way to search and\n * navigate to different settings pages.\n *\n * @param {SettingsSidebarContentSearchProps} props - The props for the component.\n * @param {string} props.id - The id of the sidebar.\n */\nexport function SettingsSidebarContentSearch({\n id,\n extensionLabels,\n projectInfo,\n children,\n handleSelectSidebarItem,\n selectedSidebarItem,\n searchValue,\n onSearch,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n}: SettingsSidebarContentSearchProps) {\n return (\n
    \n
    \n \n
    \n \n \n {children}\n \n
    \n );\n}\n\nexport default SettingsSidebarContentSearch;\n","import { Button } from '@/components/shadcn-ui/button';\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/shadcn-ui/table';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Canon } from '@sillsdev/scripture';\nimport {\n Cell,\n ColumnDef,\n flexRender,\n getCoreRowModel,\n getExpandedRowModel,\n getGroupedRowModel,\n getSortedRowModel,\n GroupingState,\n Row,\n RowSelectionState,\n SortingState,\n useReactTable,\n} from '@tanstack/react-table';\nimport '@/components/advanced/scripture-results-viewer/scripture-results-viewer.component.css';\nimport {\n compareScrRefs,\n formatScrRef,\n ScriptureSelection,\n scrRefToBBBCCCVVV,\n} from 'platform-bible-utils';\nimport { MouseEvent, useEffect, useMemo, useState } from 'react';\nimport { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/**\n * Information (e.g., a checking error or some other type of \"transient\" annotation) about something\n * noteworthy at a specific place in an instance of the Scriptures.\n */\nexport type ScriptureItemDetail = ScriptureSelection & {\n /**\n * Text of the error, note, etc. In the future, we might want to support something more than just\n * text so that a JSX element could be provided with a link or some other controls related to the\n * issue being reported.\n */\n detail: string;\n};\n\n/**\n * A uniquely identifiable source of results that can be displayed in the ScriptureResultsViewer.\n * Generally, the source will be a particular Scripture check, but there may be other types of\n * sources.\n */\nexport type ResultsSource = {\n /**\n * Uniquely identifies the source.\n *\n * @type {string}\n */\n id: string;\n\n /**\n * Name (potentially localized) of the source, suitable for display in the UI.\n *\n * @type {string}\n */\n displayName: string;\n};\n\nexport type ScriptureSrcItemDetail = ScriptureItemDetail & {\n /** Source/type of detail. Can be used for grouping. */\n source: ResultsSource;\n};\n\n/**\n * Represents a set of results keyed by Scripture reference. Generally, the source will be a\n * particular Scripture check, but this type also allows for other types of uniquely identifiable\n * sources.\n */\nexport type ResultsSet = {\n /**\n * The backing source associated with this set of results.\n *\n * @type {ResultsSource}\n */\n source: ResultsSource;\n\n /**\n * Array of Scripture item details (messages keyed by Scripture reference).\n *\n * @type {ScriptureItemDetail[]}\n */\n data: ScriptureItemDetail[];\n};\n\nconst scrBookColId = 'scrBook';\nconst scrRefColId = 'scrRef';\nconst typeColId = 'source';\nconst detailsColId = 'details';\n\nconst defaultScrRefColumnName = 'Scripture Reference';\nconst defaultScrBookGroupName = 'Scripture Book';\nconst defaultTypeColumnName = 'Type';\nconst defaultDetailsColumnName = 'Details';\n\nexport type ScriptureResultsViewerColumnInfo = {\n /** Optional header to display for the Reference column. Default value: 'Scripture Reference'. */\n scriptureReferenceColumnName?: string;\n\n /** Optional text to display to refer to the Scripture book group. Default value: 'Scripture Book'. */\n scriptureBookGroupName?: string;\n\n /** Optional header to display for the Type column. Default value: 'Type'. */\n typeColumnName?: string;\n\n /** Optional header to display for the Details column. Default value: 'Details' */\n detailsColumnName?: string;\n};\n\nexport type ScriptureResultsViewerProps = ScriptureResultsViewerColumnInfo & {\n /** Groups of ScriptureItemDetail objects from particular sources (e.g., Scripture checks) */\n sources: ResultsSet[];\n\n /** Flag indicating whether to display column headers. Default is false. */\n showColumnHeaders?: boolean;\n\n /** Flag indicating whether to display source column. Default is false. */\n showSourceColumn?: boolean;\n\n /** Callback function to notify when a row is selected */\n onRowSelected?: (selectedRow: ScriptureSrcItemDetail | undefined) => void;\n\n /** Optional id attribute for the outermost element */\n id?: string;\n};\n\nfunction getColumns(\n colInfo?: ScriptureResultsViewerColumnInfo,\n showSourceColumn?: boolean,\n): ColumnDef[] {\n const showSrcCol = showSourceColumn ?? false;\n return [\n {\n accessorFn: (row) => `${row.start.book} ${row.start.chapterNum}:${row.start.verseNum}`,\n id: scrBookColId,\n header: colInfo?.scriptureReferenceColumnName ?? defaultScrRefColumnName,\n cell: (info) => {\n const row = info.row.original;\n if (info.row.getIsGrouped()) {\n return Canon.bookIdToEnglishName(row.start.book);\n }\n return info.row.groupingColumnId === scrBookColId ? formatScrRef(row.start) : undefined;\n },\n getGroupingValue: (row) => Canon.bookIdToNumber(row.start.book),\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: true,\n },\n {\n accessorFn: (row) => formatScrRef(row.start),\n id: scrRefColId,\n header: undefined,\n cell: (info) => {\n const row = info.row.original;\n return info.row.getIsGrouped() ? undefined : formatScrRef(row.start);\n },\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: false,\n },\n {\n accessorFn: (row) => row.source.displayName,\n id: typeColId,\n header: showSrcCol ? (colInfo?.typeColumnName ?? defaultTypeColumnName) : undefined,\n cell: (info) => (showSrcCol || info.row.getIsGrouped() ? info.getValue() : undefined),\n getGroupingValue: (row) => row.source.id,\n sortingFn: (a, b) =>\n a.original.source.displayName.localeCompare(b.original.source.displayName),\n enableGrouping: true,\n },\n {\n accessorFn: (row) => row.detail,\n id: detailsColId,\n header: colInfo?.detailsColumnName ?? defaultDetailsColumnName,\n cell: (info) => info.getValue(),\n enableGrouping: false,\n },\n ];\n}\n\nconst toRefOrRange = (scriptureSelection: ScriptureSelection) => {\n if (!('offset' in scriptureSelection.start))\n throw new Error('No offset available in range start');\n if (scriptureSelection.end && !('offset' in scriptureSelection.end))\n throw new Error('No offset available in range end');\n const { offset: offsetStart } = scriptureSelection.start;\n let offsetEnd: number = 0;\n if (scriptureSelection.end) ({ offset: offsetEnd } = scriptureSelection.end);\n if (\n !scriptureSelection.end ||\n compareScrRefs(scriptureSelection.start, scriptureSelection.end) === 0\n )\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}`;\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}-${scrRefToBBBCCCVVV(scriptureSelection.end)}+${offsetEnd}`;\n};\n\nconst getRowKey = (row: ScriptureSrcItemDetail) =>\n `${toRefOrRange({ start: row.start, end: row.end })} ${row.source.displayName} ${row.detail}`;\n\n/**\n * Component to display a combined list of detailed items from one or more sources, where the items\n * are keyed primarily by Scripture reference. This is particularly useful for displaying a list of\n * results from Scripture checks, but more generally could be used to display any \"results\" from any\n * source(s). The component allows for grouping by Scripture book, source, or both. By default, it\n * displays somewhat \"tree-like\" which allows it to be more horizontally compact and intuitive. But\n * it also has the option of displaying as a traditional table with column headings (with or without\n * the source column showing).\n */\nexport function ScriptureResultsViewer({\n sources,\n showColumnHeaders = false,\n showSourceColumn = false,\n scriptureReferenceColumnName,\n scriptureBookGroupName,\n typeColumnName,\n detailsColumnName,\n onRowSelected,\n id,\n}: ScriptureResultsViewerProps) {\n const [grouping, setGrouping] = useState([]);\n const [sorting, setSorting] = useState([{ id: scrBookColId, desc: false }]);\n const [rowSelection, setRowSelection] = useState({});\n\n const scriptureResults = useMemo(\n () =>\n sources.flatMap((source) => {\n return source.data.map((item) => ({\n ...item,\n source: source.source,\n }));\n }),\n [sources],\n );\n\n const columns = useMemo(\n () =>\n getColumns(\n {\n scriptureReferenceColumnName,\n typeColumnName,\n detailsColumnName,\n },\n showSourceColumn,\n ),\n [scriptureReferenceColumnName, typeColumnName, detailsColumnName, showSourceColumn],\n );\n\n useEffect(() => {\n // Ensure sorting is applied correctly when grouped by type\n if (grouping.includes(typeColId)) {\n setSorting([\n { id: typeColId, desc: false },\n { id: scrBookColId, desc: false },\n ]);\n } else {\n setSorting([{ id: scrBookColId, desc: false }]);\n }\n }, [grouping]);\n\n const table = useReactTable({\n data: scriptureResults,\n columns,\n state: {\n grouping,\n sorting,\n rowSelection,\n },\n onGroupingChange: setGrouping,\n onSortingChange: setSorting,\n onRowSelectionChange: setRowSelection,\n getExpandedRowModel: getExpandedRowModel(),\n getGroupedRowModel: getGroupedRowModel(),\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getRowId: getRowKey,\n autoResetExpanded: false,\n enableMultiRowSelection: false,\n enableSubRowSelection: false,\n });\n\n useEffect(() => {\n if (onRowSelected) {\n const selectedRows = table.getSelectedRowModel().rowsById;\n const keys = Object.keys(selectedRows);\n if (keys.length === 1) {\n const selectedRow = scriptureResults.find((row) => getRowKey(row) === keys[0]) || undefined;\n if (selectedRow) onRowSelected(selectedRow);\n }\n }\n }, [rowSelection, scriptureResults, onRowSelected, table]);\n\n // Define possible grouping options\n const scrBookGroupName = scriptureBookGroupName ?? defaultScrBookGroupName;\n const typeGroupName = typeColumnName ?? defaultTypeColumnName;\n\n const groupingOptions = [\n { label: 'No Grouping', value: [] },\n { label: `Group by ${scrBookGroupName}`, value: [scrBookColId] },\n { label: `Group by ${typeGroupName}`, value: [typeColId] },\n {\n label: `Group by ${scrBookGroupName} and ${typeGroupName}`,\n value: [scrBookColId, typeColId],\n },\n {\n label: `Group by ${typeGroupName} and ${scrBookGroupName}`,\n value: [typeColId, scrBookColId],\n },\n ];\n\n const handleSelectChange = (selectedGrouping: string) => {\n setGrouping(JSON.parse(selectedGrouping));\n };\n\n const handleRowClick = (row: Row, event: MouseEvent) => {\n if (!row.getIsGrouped() && !row.getIsSelected()) {\n row.getToggleSelectedHandler()(event);\n }\n };\n\n const getEvenOrOddBandingStyle = (row: Row, index: number) => {\n if (row.getIsGrouped()) return '';\n // UX has now said they don't think they want banding. I'm leaving in the code to\n // set even and odd styles, but there's nothing in the CSS to style them differently.\n // The \"even\" style used to also have tw-bg-neutral-300 (along with even) to create\n // a visual banding effect. That could be added back in if UX changes the decision.\n return cn('banded-row', index % 2 === 0 ? 'even' : 'odd');\n };\n\n const getIndent = (\n groupingState: GroupingState,\n row: Row,\n cell: Cell,\n ) => {\n if (groupingState?.length === 0 || row.depth < cell.column.getGroupedIndex()) return undefined;\n if (row.getIsGrouped()) {\n switch (row.depth) {\n case 1:\n return 'tw-ps-4';\n default:\n return undefined;\n }\n }\n switch (row.depth) {\n case 1:\n return 'tw-ps-8';\n case 2:\n return 'tw-ps-12';\n default:\n return undefined;\n }\n };\n\n return (\n
    \n {!showColumnHeaders && (\n {\n handleSelectChange(value);\n }}\n >\n \n \n \n \n \n {groupingOptions.map((option) => (\n \n {option.label}\n \n ))}\n \n \n \n )}\n \n {showColumnHeaders && (\n \n {table.getHeaderGroups().map((headerGroup) => (\n \n {headerGroup.headers\n .filter((h) => h.column.columnDef.header)\n .map((header) => (\n /* For sticky column headers to work, we probably need to change the default definition of the shadcn Table component. See https://github.com/shadcn-ui/ui/issues/1151 */\n \n {header.isPlaceholder ? undefined : (\n
    \n {header.column.getCanGroup() ? (\n \n {header.column.getIsGrouped() ? `๐Ÿ›‘` : `๐Ÿ‘Š `}\n \n ) : undefined}{' '}\n {flexRender(header.column.columnDef.header, header.getContext())}\n
    \n )}\n
    \n ))}\n
    \n ))}\n
    \n )}\n \n {table.getRowModel().rows.map((row, rowIndex) => {\n const dir: Direction = readDirection();\n return (\n handleRowClick(row, event)}\n >\n {row.getVisibleCells().map((cell) => {\n if (\n cell.getIsPlaceholder() ||\n (cell.column.columnDef.enableGrouping &&\n !cell.getIsGrouped() &&\n (cell.column.columnDef.id !== typeColId || !showSourceColumn))\n )\n return undefined;\n return (\n \n {(() => {\n if (cell.getIsGrouped()) {\n return (\n \n {row.getIsExpanded() && }\n {!row.getIsExpanded() &&\n (dir === 'ltr' ? : )}{' '}\n {flexRender(cell.column.columnDef.cell, cell.getContext())} (\n {row.subRows.length})\n \n );\n }\n\n // if (cell.getIsAggregated()) {\n // flexRender(\n // cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,\n // cell.getContext(),\n // );\n // }\n\n return flexRender(cell.column.columnDef.cell, cell.getContext());\n })()}\n \n );\n })}\n \n );\n })}\n \n
    \n
    \n );\n}\n\nexport default ScriptureResultsViewer;\n","import { getSectionForBook, Section } from 'platform-bible-utils';\n\n/**\n * Filters an array of book IDs to only include books from a specific section\n *\n * @param bookIds Array of book IDs to filter\n * @param section The section to filter by\n * @returns Array of book IDs that belong to the specified section\n */\nexport const getBooksForSection = (bookIds: string[], section: Section) => {\n return bookIds.filter((bookId) => {\n try {\n return getSectionForBook(bookId) === section;\n } catch {\n return false;\n }\n });\n};\n\n/**\n * Checks if all books in a given section are included in the selectedBookIds array\n *\n * @param bookIds Array of all available book IDs\n * @param section The section to check\n * @param selectedBookIds Array of currently selected book IDs\n * @returns True if all books from the specified section are selected, false otherwise\n */\nexport const isSectionFullySelected = (\n bookIds: string[],\n section: Section,\n selectedBookIds: string[],\n) => getBooksForSection(bookIds, section).every((bookId) => selectedBookIds.includes(bookId));\n","import { Button } from '@/components/shadcn-ui/button';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { LanguageStrings, Section } from 'platform-bible-utils';\nimport { getSectionShortName } from '@/components/shared/book.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\n\n/**\n * A button component that represents a scripture section (testament) in the book selector. The\n * button shows a different state when all books in its section are selected and becomes disabled\n * when no books are available in its section.\n */\nfunction SectionButton({\n section,\n availableBookIds,\n selectedBookIds,\n onToggle,\n localizedStrings,\n}: {\n section: Section;\n availableBookIds: string[];\n selectedBookIds: string[];\n onToggle: (section: Section) => void;\n localizedStrings: LanguageStrings;\n}) {\n const isDisabled = getBooksForSection(availableBookIds, section).length === 0;\n\n const sectionOtShortText = localizedStrings['%scripture_section_ot_short%'];\n const sectionNtShortText = localizedStrings['%scripture_section_nt_short%'];\n const sectionDcShortText = localizedStrings['%scripture_section_dc_short%'];\n const sectionExtraShortText = localizedStrings['%scripture_section_extra_short%'];\n\n return (\n onToggle(section)}\n className={cn(\n isSectionFullySelected(availableBookIds, section, selectedBookIds) &&\n !isDisabled &&\n 'tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/70 hover:tw-text-primary-foreground',\n )}\n disabled={isDisabled}\n >\n {getSectionShortName(\n section,\n sectionOtShortText,\n sectionNtShortText,\n sectionDcShortText,\n sectionExtraShortText,\n )}\n \n );\n}\n\nexport default SectionButton;\n","import { BookItem } from '@/components/shared/book-item.component';\nimport { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandList,\n CommandSeparator,\n} from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { Canon } from '@sillsdev/scripture';\nimport { ChevronsUpDown } from 'lucide-react';\nimport { getSectionForBook, LanguageStrings, Section } from 'platform-bible-utils';\nimport {\n getSectionLongName,\n getLocalizedBookName,\n doesBookMatchQuery,\n} from '@/components/shared/book.utils';\nimport { Fragment, MouseEvent, useCallback, useMemo, useRef, useState } from 'react';\nimport { generateCommandValue } from '@/components/shared/book-item.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\nimport SectionButton from './section-button.component';\n\n/** Maximum number of badges to show before collapsing into a \"+X more\" badge */\nconst VISIBLE_BADGES_COUNT = 5;\n/** Maximum number of badges that can be shown without triggering the collapse */\nconst MAX_VISIBLE_BADGES = 6;\n\ntype BookSelectorProps = {\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n /** Callback function that is executed when the book selection changes */\n onChangeSelectedBookIds: (books: string[]) => void;\n /** Object containing the localized strings for the component */\n localizedStrings: LanguageStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n};\n\n/**\n * A component for selecting multiple books from the Bible canon. It provides:\n *\n * - Quick selection buttons for major sections (OT, NT, DC, Extra)\n * - A searchable dropdown with all available books\n * - Support for shift-click range selection\n * - Visual feedback with badges showing selected books\n */\nexport function BookSelector({\n availableBookInfo,\n selectedBookIds,\n onChangeSelectedBookIds,\n localizedStrings,\n localizedBookNames,\n}: BookSelectorProps) {\n const booksSelectedText = localizedStrings['%webView_book_selector_books_selected%'];\n const selectBooksText = localizedStrings['%webView_book_selector_select_books%'];\n const searchBooksText = localizedStrings['%webView_book_selector_search_books%'];\n const selectAllText = localizedStrings['%webView_book_selector_select_all%'];\n const clearAllText = localizedStrings['%webView_book_selector_clear_all%'];\n const noBookFoundText = localizedStrings['%webView_book_selector_no_book_found%'];\n const moreText = localizedStrings['%webView_book_selector_more%'];\n\n const { otLong, ntLong, dcLong, extraLong } = {\n otLong: localizedStrings?.['%scripture_section_ot_long%'],\n ntLong: localizedStrings?.['%scripture_section_nt_long%'],\n dcLong: localizedStrings?.['%scripture_section_dc_long%'],\n extraLong: localizedStrings?.['%scripture_section_extra_long%'],\n };\n\n const [isBooksSelectorOpen, setIsBooksSelectorOpen] = useState(false);\n const [inputValue, setInputValue] = useState('');\n const lastSelectedBookRef = useRef(undefined);\n const lastKeyEventShiftKey = useRef(false);\n\n if (availableBookInfo.length !== Canon.allBookIds.length) {\n throw new Error('availableBookInfo length must match Canon.allBookIds length');\n }\n\n const availableBooksIds = useMemo(() => {\n return Canon.allBookIds.filter(\n (bookId, index) =>\n availableBookInfo[index] === '1' && !Canon.isObsolete(Canon.bookIdToNumber(bookId)),\n );\n }, [availableBookInfo]);\n\n const filteredBooksBySection = useMemo(() => {\n if (!inputValue.trim()) {\n const allBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n availableBooksIds.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n allBooks[section].push(bookId);\n });\n\n return allBooks;\n }\n\n const filteredBooks = availableBooksIds.filter((bookId) =>\n doesBookMatchQuery(bookId, inputValue, localizedBookNames),\n );\n\n const matchingBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n filteredBooks.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n matchingBooks[section].push(bookId);\n });\n\n return matchingBooks;\n }, [availableBooksIds, inputValue, localizedBookNames]);\n\n const toggleBook = useCallback(\n (bookId: string, shiftKey = false) => {\n if (!shiftKey || !lastSelectedBookRef.current) {\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((id) => id !== bookId)\n : [...selectedBookIds, bookId],\n );\n lastSelectedBookRef.current = bookId;\n return;\n }\n\n const lastIndex = availableBooksIds.findIndex((id) => id === lastSelectedBookRef.current);\n const currentIndex = availableBooksIds.findIndex((id) => id === bookId);\n\n if (lastIndex === -1 || currentIndex === -1) return;\n\n const [startIndex, endIndex] = [\n Math.min(lastIndex, currentIndex),\n Math.max(lastIndex, currentIndex),\n ];\n const booksInRange = availableBooksIds.slice(startIndex, endIndex + 1).map((id) => id);\n\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((shortname) => !booksInRange.includes(shortname))\n : [...new Set([...selectedBookIds, ...booksInRange])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleKeyboardSelect = (bookId: string) => {\n toggleBook(bookId, lastKeyEventShiftKey.current);\n lastKeyEventShiftKey.current = false;\n };\n\n const handleMouseDown = (event: MouseEvent, bookId: string) => {\n event.preventDefault();\n toggleBook(bookId, event.shiftKey);\n };\n\n const toggleSection = useCallback(\n (section: Section) => {\n const sectionBooks = getBooksForSection(availableBooksIds, section).map((bookId) => bookId);\n onChangeSelectedBookIds(\n isSectionFullySelected(availableBooksIds, section, selectedBookIds)\n ? selectedBookIds.filter((shortname) => !sectionBooks.includes(shortname))\n : [...new Set([...selectedBookIds, ...sectionBooks])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleSelectAll = () => {\n onChangeSelectedBookIds(availableBooksIds.map((bookId) => bookId));\n };\n\n const handleClearAll = () => {\n onChangeSelectedBookIds([]);\n };\n\n return (\n
    \n
    \n {Object.values(Section).map((section) => {\n return (\n \n );\n })}\n
    \n\n {\n setIsBooksSelectorOpen(open);\n if (!open) {\n setInputValue(''); // Reset search when closing\n }\n }}\n >\n \n \n {selectedBookIds.length > 0\n ? `${booksSelectedText}: ${selectedBookIds.length}`\n : selectBooksText}\n \n \n \n \n {\n if (e.key === 'Enter') {\n // Store shift state in a ref that will be used by onSelect\n lastKeyEventShiftKey.current = e.shiftKey;\n }\n }}\n >\n \n
    \n \n \n
    \n \n {noBookFoundText}\n {Object.values(Section).map((section, index) => {\n const sectionBooks = filteredBooksBySection[section];\n\n if (sectionBooks.length === 0) return undefined;\n\n return (\n \n \n {sectionBooks.map((bookId) => (\n handleKeyboardSelect(bookId)}\n onMouseDown={(event) => handleMouseDown(event, bookId)}\n section={getSectionForBook(bookId)}\n showCheck\n localizedBookNames={localizedBookNames}\n commandValue={generateCommandValue(bookId, localizedBookNames)}\n className=\"tw-flex tw-items-center\"\n />\n ))}\n \n {index < Object.values(Section).length - 1 && }\n \n );\n })}\n \n \n
    \n \n\n {selectedBookIds.length > 0 && (\n
    \n {selectedBookIds\n .slice(\n 0,\n selectedBookIds.length === MAX_VISIBLE_BADGES\n ? MAX_VISIBLE_BADGES\n : VISIBLE_BADGES_COUNT,\n )\n .map((bookId) => (\n \n {getLocalizedBookName(bookId, localizedBookNames)}\n \n ))}\n {selectedBookIds.length > MAX_VISIBLE_BADGES && (\n {`+${selectedBookIds.length - VISIBLE_BADGES_COUNT} ${moreText}`}\n )}\n
    \n )}\n
    \n );\n}\n","import { BookSelector } from '@/components/advanced/scope-selector/book-selector.component';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { RadioGroup, RadioGroupItem } from '@/components/shadcn-ui/radio-group';\nimport { Scope } from '@/components/utils/scripture.util';\nimport { LocalizedStringValue } from 'platform-bible-utils';\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const SCOPE_SELECTOR_STRING_KEYS = Object.freeze([\n '%webView_scope_selector_selected_text%',\n '%webView_scope_selector_current_verse%',\n '%webView_scope_selector_current_chapter%',\n '%webView_scope_selector_current_book%',\n '%webView_scope_selector_choose_books%',\n '%webView_scope_selector_scope%',\n '%webView_scope_selector_select_books%',\n '%webView_book_selector_books_selected%',\n '%webView_book_selector_select_books%',\n '%webView_book_selector_search_books%',\n '%webView_book_selector_select_all%',\n '%webView_book_selector_clear_all%',\n '%webView_book_selector_no_book_found%',\n '%webView_book_selector_more%',\n '%scripture_section_ot_long%',\n '%scripture_section_ot_short%',\n '%scripture_section_nt_long%',\n '%scripture_section_nt_short%',\n '%scripture_section_dc_long%',\n '%scripture_section_dc_short%',\n '%scripture_section_extra_long%',\n '%scripture_section_extra_short%',\n] as const);\n\n/** Type definition for the localized strings used in this component */\nexport type ScopeSelectorLocalizedStrings = {\n [localizedInventoryKey in (typeof SCOPE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: ScopeSelectorLocalizedStrings,\n key: keyof ScopeSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\n/** Props for configuring the ScopeSelector component */\ninterface ScopeSelectorProps {\n /** The current scope selection */\n scope: Scope;\n\n /**\n * Optional array of scopes that should be available in the selector. If not provided, all scopes\n * will be shown as defined in the Scope type\n */\n availableScopes?: Scope[];\n\n /** Callback function that is executed when the user changes the scope selection */\n onScopeChange: (scope: Scope) => void;\n\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n\n /** Callback function that is executed when the user changes the book selection */\n onSelectedBookIdsChange: (books: string[]) => void;\n\n /**\n * Object with all localized strings that the component needs to work well across multiple\n * languages. When using this component with Platform.Bible, you can import\n * `SCOPE_SELECTOR_STRING_KEYS` from this library, pass it in to the Platform's localization hook,\n * and pass the localized keys that are returned by the hook into this prop.\n */\n localizedStrings: ScopeSelectorLocalizedStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n /** Optional ID that is applied to the root element of this component */\n id?: string;\n}\n\n/**\n * A component that allows users to select the scope of their search or operation. Available scopes\n * are defined in the Scope type. When 'selectedBooks' is chosen as the scope, a BookSelector\n * component is displayed to allow users to choose specific books.\n */\nexport function ScopeSelector({\n scope,\n availableScopes,\n onScopeChange,\n availableBookInfo,\n selectedBookIds,\n onSelectedBookIdsChange,\n localizedStrings,\n localizedBookNames,\n id,\n}: ScopeSelectorProps) {\n const selectedTextText = localizeString(\n localizedStrings,\n '%webView_scope_selector_selected_text%',\n );\n const currentVerseText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_verse%',\n );\n const currentChapterText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_chapter%',\n );\n const currentBookText = localizeString(localizedStrings, '%webView_scope_selector_current_book%');\n const chooseBooksText = localizeString(localizedStrings, '%webView_scope_selector_choose_books%');\n const scopeText = localizeString(localizedStrings, '%webView_scope_selector_scope%');\n const selectBooksText = localizeString(localizedStrings, '%webView_scope_selector_select_books%');\n\n const SCOPE_OPTIONS: Array<{ value: Scope; label: string; id: string }> = [\n { value: 'selectedText', label: selectedTextText, id: 'scope-selected-text' },\n { value: 'verse', label: currentVerseText, id: 'scope-verse' },\n { value: 'chapter', label: currentChapterText, id: 'scope-chapter' },\n { value: 'book', label: currentBookText, id: 'scope-book' },\n { value: 'selectedBooks', label: chooseBooksText, id: 'scope-selected' },\n ];\n\n const displayedScopes = availableScopes\n ? SCOPE_OPTIONS.filter((option) => availableScopes.includes(option.value))\n : SCOPE_OPTIONS;\n\n return (\n
    \n
    \n \n \n {displayedScopes.map(({ value, label, id: scopeId }) => (\n
    \n \n \n
    \n ))}\n \n
    \n\n {scope === 'selectedBooks' && (\n
    \n \n \n
    \n )}\n
    \n );\n}\n\nexport default ScopeSelector;\n","import {\n getLocalizeKeyForScrollGroupId,\n LanguageStrings,\n ScrollGroupId,\n} from 'platform-bible-utils';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\n\nconst DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS = {\n [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n [getLocalizeKeyForScrollGroupId(0)]: 'A',\n [getLocalizeKeyForScrollGroupId(1)]: 'B',\n [getLocalizeKeyForScrollGroupId(2)]: 'C',\n [getLocalizeKeyForScrollGroupId(3)]: 'D',\n [getLocalizeKeyForScrollGroupId(4)]: 'E',\n [getLocalizeKeyForScrollGroupId(5)]: 'F',\n [getLocalizeKeyForScrollGroupId(6)]: 'G',\n [getLocalizeKeyForScrollGroupId(7)]: 'H',\n [getLocalizeKeyForScrollGroupId(8)]: 'I',\n [getLocalizeKeyForScrollGroupId(9)]: 'J',\n [getLocalizeKeyForScrollGroupId(10)]: 'K',\n [getLocalizeKeyForScrollGroupId(11)]: 'L',\n [getLocalizeKeyForScrollGroupId(12)]: 'M',\n [getLocalizeKeyForScrollGroupId(13)]: 'N',\n [getLocalizeKeyForScrollGroupId(14)]: 'O',\n [getLocalizeKeyForScrollGroupId(15)]: 'P',\n [getLocalizeKeyForScrollGroupId(16)]: 'Q',\n [getLocalizeKeyForScrollGroupId(17)]: 'R',\n [getLocalizeKeyForScrollGroupId(18)]: 'S',\n [getLocalizeKeyForScrollGroupId(19)]: 'T',\n [getLocalizeKeyForScrollGroupId(20)]: 'U',\n [getLocalizeKeyForScrollGroupId(21)]: 'V',\n [getLocalizeKeyForScrollGroupId(22)]: 'W',\n [getLocalizeKeyForScrollGroupId(23)]: 'X',\n [getLocalizeKeyForScrollGroupId(24)]: 'Y',\n [getLocalizeKeyForScrollGroupId(25)]: 'Z',\n};\n\nexport type ScrollGroupSelectorProps = {\n /**\n * List of scroll group ids to show to the user. Either a `ScrollGroupId` or `undefined` for no\n * scroll group\n */\n availableScrollGroupIds: (ScrollGroupId | undefined)[];\n /** Currently selected scroll group id. `undefined` for no scroll group */\n scrollGroupId: ScrollGroupId | undefined;\n /** Callback function run when the user tries to change the scroll group id */\n onChangeScrollGroupId: (newScrollGroupId: ScrollGroupId | undefined) => void;\n /**\n * Localized strings to use for displaying scroll group ids. Must be an object whose keys are\n * `getLocalizeKeyForScrollGroupId(scrollGroupId)` for all scroll group ids (and `undefined` if\n * included) in {@link ScrollGroupSelectorProps.availableScrollGroupIds} and whose values are the\n * localized strings to use for those scroll group ids.\n *\n * Defaults to English localizations of English alphabet for scroll groups 0-25 (e.g. 0 is A) and\n * ร˜ for `undefined`. Will fill in any that are not provided with these English localizations.\n * Also, if any values match the keys, the English localization will be used. This is useful in\n * case you want to pass in a temporary version of the localized strings while your localized\n * strings load.\n *\n * @example\n *\n * ```typescript\n * const myScrollGroupIdLocalizedStrings = {\n * [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n * [getLocalizeKeyForScrollGroupId(0)]: 'A',\n * [getLocalizeKeyForScrollGroupId(1)]: 'B',\n * [getLocalizeKeyForScrollGroupId(2)]: 'C',\n * [getLocalizeKeyForScrollGroupId(3)]: 'D',\n * [getLocalizeKeyForScrollGroupId(4)]: 'E',\n * };\n * ```\n *\n * @example\n *\n * ```tsx\n * const availableScrollGroupIds = [undefined, 0, 1, 2, 3, 4];\n *\n * const localizeKeys = getLocalizeKeysForScrollGroupIds();\n *\n * const [localizedStrings] = useLocalizedStrings(localizeKeys);\n *\n * ...\n *\n * \n * ```\n */\n localizedStrings?: LanguageStrings;\n\n /** Size of the scroll group dropdown button. Defaults to 'sm' */\n size?: 'default' | 'sm' | 'lg' | 'icon';\n\n /** Additional css classes to help with unique styling */\n className?: string;\n\n /** Optional id for the select element */\n id?: string;\n};\n\n/** Selector component for choosing a scroll group */\nexport function ScrollGroupSelector({\n availableScrollGroupIds,\n scrollGroupId,\n onChangeScrollGroupId,\n localizedStrings = {},\n size = 'sm',\n className,\n id,\n}: ScrollGroupSelectorProps) {\n const localizedStringsDefaulted = {\n ...DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS,\n ...Object.fromEntries(\n Object.entries(localizedStrings).map(\n ([localizedStringKey, localizedStringValue]: [string, string]) => [\n localizedStringKey,\n localizedStringKey === localizedStringValue &&\n localizedStringKey in DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS\n ? DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS[localizedStringKey]\n : localizedStringValue,\n ],\n ),\n ),\n };\n\n const dir: Direction = readDirection();\n\n return (\n \n onChangeScrollGroupId(\n newScrollGroupString === 'undefined' ? undefined : parseInt(newScrollGroupString, 10),\n )\n }\n >\n \n \n \n \n {availableScrollGroupIds.map((scrollGroupOptionId) => (\n \n {localizedStringsDefaulted[getLocalizeKeyForScrollGroupId(scrollGroupOptionId)]}\n \n ))}\n \n \n );\n}\n\nexport default ScrollGroupSelector;\n","import { PropsWithChildren } from 'react';\nimport { Separator } from '@/components/shadcn-ui/separator';\n\n/** Props for the SettingsList component, currently just children */\ntype SettingsListProps = PropsWithChildren;\n\n/**\n * SettingsList component is a wrapper for list items. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param children To populate the list with\n * @returns Formatted div encompassing the children\n */\nexport function SettingsList({ children }: SettingsListProps) {\n return
    {children}
    ;\n}\n\n/** Props for SettingsListItem component */\ntype SettingsListItemProps = PropsWithChildren & {\n /** Primary text of the list item */\n primary: string;\n\n /** Optional text of the list item */\n secondary?: string | undefined;\n\n /** Optional boolean to display a message if the children aren't loaded yet. Defaults to false */\n isLoading?: boolean;\n\n /** Optional message to display if isLoading */\n loadingMessage?: string;\n};\n\n/**\n * SettingsListItem component is a common list item. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListItemProps\n * @returns Formatted div encompassing the list item content\n */\nexport function SettingsListItem({\n primary,\n secondary,\n children,\n isLoading = false,\n loadingMessage,\n}: SettingsListItemProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    \n {secondary}\n

    \n
    \n\n {isLoading ? (\n

    {loadingMessage}

    \n ) : (\n
    {children}
    \n )}\n
    \n );\n}\n\n/** Props for SettingsListHeader component */\ntype SettingsListHeaderProps = {\n /** The primary text of the list header */\n primary: string;\n\n /** Optional secondary text of the list header */\n secondary?: string | undefined;\n\n /** Optional boolean to include a separator underneath the secondary text. Defaults to false */\n includeSeparator?: boolean;\n};\n\n/**\n * SettingsListHeader component displays text above the list\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListHeaderProps\n * @returns Formatted div with list header content\n */\nexport function SettingsListHeader({\n primary,\n secondary,\n includeSeparator = false,\n}: SettingsListHeaderProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    {secondary}

    \n
    \n {includeSeparator ? : ''}\n
    \n );\n}\n","import { GroupsInMultiColumnMenu, Localized } from 'platform-bible-utils';\n\n/**\n * Function that looks up the key of a sub-menu group using the value of it's `menuItem` property.\n *\n * @example\n *\n * ```ts\n * const groups = {\n * 'platform.subMenu': { menuItem: 'platform.subMenuId', order: 1 },\n * 'platform.subSubMenu': { menuItem: 'platform.subSubMenuId', order: 2 },\n * };\n * const id = 'platform.subMenuId';\n * const groupKey = getSubMenuGroupKeyForMenuItemId(groups, id);\n * console.log(groupKey); // Output: 'platform.subMenu'\n * ```\n *\n * @param groups The JSON Object containing the group definitions\n * @param id The value of the `menuItem` property of the group to look up\n * @returns The key of the group that has the `menuItem` property with the value of `id` or\n * `undefined` if no such group exists.\n */\nexport function getSubMenuGroupKeyForMenuItemId(\n groups: Localized,\n id: string,\n): string | undefined {\n return Object.entries(groups).find(\n ([, value]) => 'menuItem' in value && value.menuItem === id,\n )?.[0];\n}\n","import { cn } from '@/utils/shadcn-ui.util';\n\ntype MenuItemIconProps = {\n /** The icon to display */\n icon: string;\n /** The label of the menu item */\n menuLabel: string;\n /** Whether the icon is leading or trailing */\n leading?: boolean;\n};\n\nfunction MenuItemIcon({ icon, menuLabel, leading }: MenuItemIconProps) {\n return icon ? (\n \n ) : undefined;\n}\n\nexport default MenuItemIcon;\n","import {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuPortal,\n DropdownMenuSeparator,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport { MenuIcon } from 'lucide-react';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { Fragment, ReactNode } from 'react';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport { SelectMenuItemHandler } from './platform-menubar.component';\nimport MenuItemIcon from './menu-icon.component';\n\nconst getGroupContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey]) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n\n \n \n {getGroupContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n return groupItems;\n });\n};\n\nexport type TabDropdownMenuProps = {\n /** The handler to use for menu commands */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /** The menu data to show on the dropdown menu */\n menuData: Localized;\n\n /** Defines a string value that labels the current element */\n tabLabel: string;\n\n /** Optional icon for the dropdown menu trigger. Defaults to hamburger icon. */\n icon?: ReactNode;\n\n /** Additional css class(es) to help with unique styling of the tab dropdown menu */\n className?: string;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n\n buttonVariant?: 'default' | 'ghost' | 'outline' | 'secondary';\n\n /** Optional unique identifier */\n id?: string;\n};\n\n/**\n * Dropdown menu designed to be used with Platform.Bible menu data. Column headers are ignored.\n * Column data is separated by a horizontal divider, so groups are not distinguishable. Tooltips are\n * displayed on hovering over menu items, if a tooltip is defined for them.\n *\n * A child component can be passed in to show as an icon on the menu trigger button.\n */\nexport default function TabDropdownMenu({\n onSelectMenuItem,\n menuData,\n tabLabel,\n icon,\n className,\n variant,\n buttonVariant = 'ghost',\n id,\n}: TabDropdownMenuProps) {\n return (\n \n \n \n \n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey], index, array) => (\n \n \n \n {getGroupContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n\n {index < array.length - 1 && }\n \n ))}\n \n \n );\n}\n","import { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport React, { PropsWithChildren, ReactNode } from 'react';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\n\nexport type TabToolbarCommonProps = {\n /**\n * The handler to use for toolbar item commands related to the project menu. Here is a basic\n * example of how to create this:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectProjectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component for the project menu. In an extension,\n * the menu data comes from menus.json in the contributions folder. To access that info, use\n * useMemo to get the WebViewMenu.\n */\n projectMenuData?: Localized;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n\n /** Icon that will be displayed on the Menu Button. Defaults to the hamburger menu icon. */\n menuButtonIcon?: ReactNode;\n};\n\nexport type TabToolbarContainerProps = PropsWithChildren<{\n /** Optional unique identifier */\n id?: string;\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n}>;\n\n/** Wrapper that allows consistent styling for both TabToolbar and TabFloatingMenu. */\nexport const TabToolbarContainer = React.forwardRef(\n ({ id, className, children }, ref) => (\n \n {children}\n \n ),\n);\n\nexport default TabToolbarContainer;\n","import { ReactNode } from 'react';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { Menu, EllipsisVertical } from 'lucide-react';\nimport TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\nexport type TabToolbarProps = TabToolbarCommonProps & {\n /**\n * The handler to use for toolbar item commands related to the tab view menu. Here is a basic\n * example of how to create this from the hello-rock3 extension:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectViewInfoMenuItem: SelectMenuItemHandler;\n\n /** Menu data that is used to populate the Menubar component for the view info menu */\n tabViewMenuData?: Localized;\n\n /**\n * Toolbar children to be put at the start of the the toolbar after the project menu icon (left\n * side in ltr, right side in rtl). Recommended for inner navigation.\n */\n startAreaChildren?: ReactNode;\n\n /** Toolbar children to be put in the center area of the the toolbar. Recommended for tools. */\n centerAreaChildren?: ReactNode;\n\n /**\n * Toolbar children to be put at the end of the the toolbar before the tab view menu icon (right\n * side in ltr, left side in rtl). Recommended for secondary tools and view options.\n */\n endAreaChildren?: ReactNode;\n};\n\n/**\n * Toolbar that holds the project menu icon on one side followed by three different areas/categories\n * for toolbar icons followed by an optional view info menu icon. See the Tab Floating Menu Button\n * component for a menu component that takes up less screen real estate yet is always visible.\n */\nexport function TabToolbar({\n onSelectProjectMenuItem,\n onSelectViewInfoMenuItem,\n projectMenuData,\n tabViewMenuData,\n id,\n className,\n startAreaChildren,\n centerAreaChildren,\n endAreaChildren,\n menuButtonIcon,\n}: TabToolbarProps) {\n return (\n \n {projectMenuData && (\n }\n buttonVariant=\"ghost\"\n />\n )}\n {startAreaChildren && (\n
    \n {startAreaChildren}\n
    \n )}\n {centerAreaChildren && (\n
    \n {centerAreaChildren}\n
    \n )}\n
    \n {tabViewMenuData && (\n }\n className=\"tw-h-full\"\n />\n )}\n {endAreaChildren}\n
    \n
    \n );\n}\n\nexport default TabToolbar;\n","import TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\n/**\n * Renders a TabDropdownMenu with a trigger button that looks like the menuButtonIcon or like the\n * default of three stacked horizontal lines (aka the hamburger). The menu \"floats\" over the content\n * so it is always visible. When clicked, it displays a dropdown menu with the projectMenuData.\n */\nexport function TabFloatingMenu({\n onSelectProjectMenuItem,\n projectMenuData,\n id,\n className,\n menuButtonIcon,\n}: TabToolbarCommonProps) {\n return (\n \n {projectMenuData && (\n \n )}\n \n );\n}\n\nexport default TabFloatingMenu;\n","// adapted from: https://github.com/shadcn-ui/ui/discussions/752\n\n'use client';\n\nimport { TabsContentProps, TabsListProps, TabsTriggerProps } from '@/components/shadcn-ui/tabs';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport React from 'react';\n\nexport type VerticalTabsProps = React.ComponentPropsWithoutRef & {\n className?: string;\n};\n\nexport type LeftTabsTriggerProps = TabsTriggerProps & {\n value: string;\n ref?: React.Ref;\n};\n\n/**\n * Tabs components provide a set of layered sections of contentโ€”known as tab panelsโ€“that are\n * displayed one at a time. These components are built on Radix UI primitives and styled with Shadcn\n * UI. See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/tabs See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/tabs\n */\nexport const VerticalTabs = React.forwardRef<\n React.ElementRef,\n VerticalTabsProps\n>(({ className, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n );\n});\n\nVerticalTabs.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsList = React.forwardRef<\n React.ElementRef,\n TabsListProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsList.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsTrigger = React.forwardRef<\n React.ElementRef,\n LeftTabsTriggerProps\n>(({ className, ...props }, ref) => (\n \n));\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsContent = React.forwardRef<\n React.ElementRef,\n TabsContentProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsContent.displayName = TabsPrimitive.Content.displayName;\n","import { SearchBar } from '@/components/basics/search-bar.component';\nimport {\n VerticalTabs,\n VerticalTabsContent,\n VerticalTabsList,\n VerticalTabsTrigger,\n} from '@/components/basics/tabs-vertical';\nimport { ReactNode } from 'react';\n\nexport type TabKeyValueContent = {\n key: string;\n value: string;\n content: ReactNode;\n};\n\nexport type TabNavigationContentSearchProps = {\n /** List of values and keys for each tab this component should provide */\n tabList: TabKeyValueContent[];\n\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n\n /** Optional placeholder for the search bar */\n searchPlaceholder?: string;\n\n /** Optional title to include in the header */\n headerTitle?: string;\n\n /** Optional className to modify the search input */\n searchClassName?: string;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * TabNavigationContentSearch component provides a vertical tab navigation interface with a search\n * bar at the top. This component allows users to filter content within tabs based on a search\n * query.\n *\n * @param {TabNavigationContentSearchProps} props\n * @param {TabKeyValueContent[]} props.tabList - List of objects containing keys, values, and\n * content for each tab to be displayed.\n * @param {string} props.searchValue - The current value of the search input.\n * @param {function} props.onSearch - Callback function called when the search input changes;\n * receives the new search query as an argument.\n * @param {string} [props.searchPlaceholder] - Optional placeholder text for the search input.\n * @param {string} [props.headerTitle] - Optional title to display above the search input.\n * @param {string} [props.searchClassName] - Optional CSS class name to apply custom styles to the\n * search input.\n * @param {string} [props.id] - Optional id for the root element.\n */\nexport function TabNavigationContentSearch({\n tabList,\n searchValue,\n onSearch,\n searchPlaceholder,\n headerTitle,\n searchClassName,\n id,\n}: TabNavigationContentSearchProps) {\n return (\n
    \n
    \n {headerTitle ?

    {headerTitle}

    : ''}\n \n
    \n \n \n {tabList.map((tab) => (\n \n {tab.value}\n \n ))}\n \n {tabList.map((tab) => (\n \n {tab.content}\n \n ))}\n \n
    \n );\n}\n\nexport default TabNavigationContentSearch;\n","import {\n MenuContext,\n MenuContextProps,\n menuVariants,\n useMenuContext,\n} from '@/context/menu.context';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as MenubarPrimitive from '@radix-ui/react-menubar';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\nimport React from 'react';\n\nfunction MenubarMenu({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarPortal({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarRadioGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarSub({ ...props }: React.ComponentProps) {\n return ;\n}\n\nconst Menubar = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n variant?: MenuContextProps['variant'];\n }\n>(({ className, variant = 'default', ...props }, ref) => {\n /* #region CUSTOM provide context to add variants */\n const contextValue = React.useMemo(\n () => ({\n variant,\n }),\n [variant],\n );\n return (\n \n {/* #endregion CUSTOM */}\n \n \n );\n});\nMenubar.displayName = MenubarPrimitive.Root.displayName;\n\nconst MenubarTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;\n\nconst MenubarSubTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n {children}\n \n \n );\n});\nMenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;\n\nconst MenubarSubContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;\n\nconst MenubarContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n );\n});\nMenubarContent.displayName = MenubarPrimitive.Content.displayName;\n\nconst MenubarItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarItem.displayName = MenubarPrimitive.Item.displayName;\n\nconst MenubarCheckboxItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, checked, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;\n\nconst MenubarRadioItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;\n\nconst MenubarLabel = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => (\n \n));\nMenubarLabel.displayName = MenubarPrimitive.Label.displayName;\n\nconst MenubarSeparator = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nMenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;\n\nfunction MenubarShortcut({ className, ...props }: React.HTMLAttributes) {\n return (\n \n );\n}\nMenubarShortcut.displayname = 'MenubarShortcut';\n\nexport {\n Menubar,\n MenubarCheckboxItem,\n MenubarContent,\n MenubarGroup,\n MenubarItem,\n MenubarLabel,\n MenubarMenu,\n MenubarPortal,\n MenubarRadioGroup,\n MenubarRadioItem,\n MenubarSeparator,\n MenubarShortcut,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n};\n","import {\n Menubar,\n MenubarContent,\n MenubarItem,\n MenubarMenu,\n MenubarSeparator,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n} from '@/components/shadcn-ui/menubar';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { RefObject, useEffect, useRef } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport MenuItemIcon from './menu-icon.component';\n\n/**\n * Callback function that is invoked when a user selects a menu item. Receives the full\n * `MenuItemContainingCommand` object as an argument.\n */\nexport interface SelectMenuItemHandler {\n (selectedMenuItem: MenuItemContainingCommand): void;\n}\n\nconst simulateKeyPress = (ref: RefObject, keys: KeyboardEventInit[]) => {\n setTimeout(() => {\n keys.forEach((key) => {\n ref.current?.dispatchEvent(new KeyboardEvent('keydown', key));\n });\n }, 0);\n};\n\nconst getMenubarContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey], index) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n \n {getMenubarContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n const itemsWithSeparator = [...groupItems];\n if (groupItems.length > 0 && index < sortedGroupsForColumn.length - 1) {\n itemsWithSeparator.push();\n }\n\n return itemsWithSeparator;\n });\n};\n\ntype PlatformMenubarProps = {\n /** Menu data that is used to populate the Menubar component. */\n menuData: Localized;\n\n /** The handler to use for menu commands. */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Optional callback function that is executed whenever a menu on the Menubar is opened or closed.\n * Helpful for handling updates to the menu, as changing menu data when the menu is opened is not\n * desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n};\n\n/** Menubar component tailored to work with Platform.Bible menu data */\nexport function PlatformMenubar({\n menuData,\n onSelectMenuItem,\n onOpenChange,\n variant,\n}: PlatformMenubarProps) {\n // These refs will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const menubarRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const projectMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const windowMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const layoutMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const helpMenuRef = useRef(undefined!);\n\n const getRefForColumn = (columnKey: string) => {\n switch (columnKey) {\n case 'platform.app':\n return projectMenuRef;\n case 'platform.window':\n return windowMenuRef;\n case 'platform.layout':\n return layoutMenuRef;\n case 'platform.help':\n return helpMenuRef;\n default:\n return undefined;\n }\n };\n\n // This is a quick and dirty way to implement some shortcuts by simulating key presses\n useHotkeys(['alt', 'alt+p', 'alt+l', 'alt+n', 'alt+h'], (event, handler) => {\n event.preventDefault();\n\n const escKey: KeyboardEventInit = { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true };\n const spaceKey: KeyboardEventInit = { key: ' ', code: 'Space', keyCode: 32, bubbles: true };\n\n switch (handler.hotkey) {\n case 'alt':\n simulateKeyPress(projectMenuRef, [escKey]);\n break;\n case 'alt+p':\n projectMenuRef.current?.focus();\n simulateKeyPress(projectMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+l':\n windowMenuRef.current?.focus();\n simulateKeyPress(windowMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+n':\n layoutMenuRef.current?.focus();\n simulateKeyPress(layoutMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+h':\n helpMenuRef.current?.focus();\n simulateKeyPress(helpMenuRef, [escKey, spaceKey]);\n break;\n default:\n break;\n }\n });\n\n useEffect(() => {\n if (!onOpenChange || !menubarRef.current) return;\n\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.attributeName === 'data-state' && mutation.target instanceof HTMLElement) {\n const state = mutation.target.getAttribute('data-state');\n\n if (state === 'open') {\n onOpenChange(true);\n } else {\n onOpenChange(false);\n }\n }\n });\n });\n\n const menubarElement = menubarRef.current;\n const dataStateAttributes = menubarElement.querySelectorAll('[data-state]');\n\n dataStateAttributes.forEach((element) => {\n observer.observe(element, { attributes: true });\n });\n\n return () => observer.disconnect();\n }, [onOpenChange]);\n\n if (!menuData) return undefined;\n\n return (\n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey, column]) => (\n \n \n {typeof column === 'object' && 'label' in column && column.label}\n \n \n \n {getMenubarContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n \n ))}\n \n );\n}\n\nexport default PlatformMenubar;\n","import {\n SelectMenuItemHandler,\n PlatformMenubar,\n} from '@/components/advanced/menus/platform-menubar.component';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { PropsWithChildren, ReactNode, useRef } from 'react';\n\nexport type ToolbarProps = PropsWithChildren<{\n /** The handler to use for menu commands (and eventually toolbar commands). */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component. If empty object, no menus will be\n * shown on the App Menubar\n */\n menuData?: Localized;\n\n /**\n * Optional callback function that is executed whenever a menu on the App Menubar is opened or\n * closed. Helpful for handling updates to the menu, as changing menu data when the menu is opened\n * is not desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the toolbar */\n className?: string;\n\n /**\n * Whether the toolbar should be used as a draggable area for moving the application. This will\n * add an electron specific style `WebkitAppRegion: 'drag'` to the toolbar in order to make it\n * draggable. See:\n * https://www.electronjs.org/docs/latest/tutorial/custom-title-bar#create-a-custom-title-bar\n */\n shouldUseAsAppDragArea?: boolean;\n\n /** Toolbar children to be put at the start of the toolbar (left side in ltr, right side in rtl) */\n appMenuAreaChildren?: ReactNode;\n\n /** Toolbar children to be put at the end of the toolbar (right side in ltr, left side in rtl) */\n configAreaChildren?: ReactNode;\n\n /** Variant of the menubar */\n menubarVariant?: 'default' | 'muted';\n}>;\n\n/**\n * Get tailwind class for reserved space for the window controls / macos \"traffic lights\". Passing\n * 'darwin' will reserve the necessary space for macos traffic lights at the start, otherwise a\n * different amount of space at the end for the window controls.\n *\n * Apply to the toolbar like: `` or ``\n *\n * @param operatingSystem The os platform: 'darwin' (macos) | anything else\n * @returns The class name to apply to the toolbar if os specific space should be reserved\n */\nexport function getToolbarOSReservedSpaceClassName(\n operatingSystem: string | undefined,\n): string | undefined {\n switch (operatingSystem) {\n case undefined:\n return undefined;\n case 'darwin':\n return 'tw-ps-[85px]';\n default:\n return 'tw-pe-[calc(138px+1rem)]';\n }\n}\n\n/**\n * A customizable toolbar component with a menubar, content area, and configure area.\n *\n * This component is designed to be used in the window title bar of an electron application.\n *\n * @param {ToolbarProps} props - The props for the component.\n */\nexport function Toolbar({\n menuData,\n onOpenChange,\n onSelectMenuItem,\n className,\n id,\n children,\n appMenuAreaChildren,\n configAreaChildren,\n shouldUseAsAppDragArea,\n menubarVariant = 'default',\n}: ToolbarProps) {\n // This ref will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const containerRef = useRef(undefined!);\n\n return (\n \n \n {/* App Menu area */}\n
    \n \n {appMenuAreaChildren}\n\n {menuData && (\n \n )}\n
    \n \n\n {/* Content area */}\n \n {children}\n \n\n {/* Configure area */}\n
    \n \n {configAreaChildren}\n
    \n \n \n \n );\n}\n\nexport default Toolbar;\n","import { useState } from 'react';\nimport { LocalizedStringValue, formatReplacementString } from 'platform-bible-utils';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../shadcn-ui/select';\nimport { Label } from '../shadcn-ui/label';\n\n/**\n * Immutable array containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const UI_LANGUAGE_SELECTOR_STRING_KEYS = Object.freeze([\n '%settings_uiLanguageSelector_fallbackLanguages%',\n] as const);\n\nexport type UiLanguageSelectorLocalizedStrings = {\n [localizedUiLanguageSelectorKey in (typeof UI_LANGUAGE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: UiLanguageSelectorLocalizedStrings,\n key: keyof UiLanguageSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\nexport type LanguageInfo = {\n /** The name of the language to be displayed (in its native script) */\n autonym: string;\n /**\n * The name of the language in other languages, so that the language can also be displayed in the\n * current UI language, if known.\n */\n uiNames?: Record;\n /**\n * Other known names of the language (for searching). This can include pejorative names and should\n * never be displayed unless typed by the user.\n */\n otherNames?: string[];\n};\n\nexport type UiLanguageSelectorProps = {\n /** Full set of known languages to display. The keys are valid BCP-47 tags. */\n knownUiLanguages: Record;\n /** IETF BCP-47 language tag of the current primary UI language. `undefined` => 'en' */\n primaryLanguage: string;\n /**\n * Ordered list of fallback language tags to use if the localization key can't be found in the\n * current primary UI language. This list never contains English ('en') because it is the ultimate\n * fallback.\n */\n fallbackLanguages: string[] | undefined;\n /**\n * Handler for when either the primary or the fallback languages change (or both). For this\n * handler, the primary UI language is the first one in the array, followed by the fallback\n * languages in order of decreasing preference.\n */\n onLanguagesChange?: (newUiLanguages: string[]) => void;\n /** Handler for the primary language changes. */\n onPrimaryLanguageChange?: (newPrimaryUiLanguage: string) => void;\n /**\n * Handler for when the fallback languages change. The array contains the fallback languages in\n * order of decreasing preference.\n */\n onFallbackLanguagesChange?: (newFallbackLanguages: string[]) => void;\n /**\n * Map whose keys are localized string keys as contained in UI_LANGUAGE_SELECTOR_STRING_KEYS and\n * whose values are the localized strings (in the current UI language).\n */\n localizedStrings: UiLanguageSelectorLocalizedStrings;\n /** Additional css classes to help with unique styling of the control */\n className?: string;\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A component for selecting the user interface language and managing fallback languages. Allows\n * users to choose a primary UI language and optionally select fallback languages.\n *\n * @param {UiLanguageSelectorProps} props - The props for the component.\n */\nexport function UiLanguageSelector({\n knownUiLanguages,\n primaryLanguage = 'en',\n fallbackLanguages = [],\n onLanguagesChange,\n onPrimaryLanguageChange,\n onFallbackLanguagesChange,\n localizedStrings,\n className,\n id,\n}: UiLanguageSelectorProps) {\n const fallbackLanguagesText = localizeString(\n localizedStrings,\n '%settings_uiLanguageSelector_fallbackLanguages%',\n );\n const [isOpen, setIsOpen] = useState(false);\n\n const handleLanguageChange = (code: string) => {\n if (onPrimaryLanguageChange) onPrimaryLanguageChange(code);\n // REVIEW: Should fallback languages be preserved when primary language changes?\n if (onLanguagesChange)\n onLanguagesChange([code, ...fallbackLanguages.filter((lang) => lang !== code)]);\n if (onFallbackLanguagesChange && fallbackLanguages.find((l) => l === code))\n onFallbackLanguagesChange([...fallbackLanguages.filter((lang) => lang !== code)]);\n setIsOpen(false); // Close the dropdown when a selection is made\n };\n\n /**\n * Gets the display name for the given language. This will typically include the autonym (in the\n * native script), along with the name of the language in the current UI locale if known, with a\n * fallback to the English name (if known).\n *\n * @param {string} lang - The BCP-47 code of the language whose display name is being requested.\n * @param {string} uiLang - The BCP-47 code of the current user-interface language used used to\n * try to look up the name of the language in a form that is likely to be helpful to the user if\n * they do not recognize the autonym.\n * @returns {string} The display name of the language.\n */\n const getLanguageDisplayName = (lang: string, uiLang: string) => {\n const altName =\n uiLang !== lang\n ? (knownUiLanguages[lang]?.uiNames?.[uiLang] ?? knownUiLanguages[lang]?.uiNames?.en)\n : undefined;\n\n return altName\n ? `${knownUiLanguages[lang]?.autonym} (${altName})`\n : knownUiLanguages[lang]?.autonym;\n };\n\n return (\n
    \n {/* Language Selector */}\n setIsOpen(open)}\n >\n \n \n \n \n {Object.keys(knownUiLanguages).map((key) => {\n return (\n \n {getLanguageDisplayName(key, primaryLanguage)}\n \n );\n })}\n \n \n\n {/* Fallback Language Button */}\n {primaryLanguage !== 'en' && (\n
    \n \n
    \n )}\n
    \n );\n}\n\nexport default UiLanguageSelector;\n","import { Label } from '@/components/shadcn-ui/label';\nimport { ReactNode } from 'react';\n\ntype SmartLabelProps = {\n item: string;\n createLabel?: (item: string) => string;\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Create labels with text, react elements (e.g. links), or text + react elements */\nfunction SmartLabel({ item, createLabel, createComplexLabel }: SmartLabelProps): ReactNode {\n if (createLabel) {\n return ;\n }\n if (createComplexLabel) {\n return ;\n }\n return ;\n}\n\nexport default SmartLabel;\n","import { Checkbox } from '@/components/shadcn-ui/checkbox';\nimport { ReactNode } from 'react';\nimport SmartLabel from './smart-label.component';\n\nexport type ChecklistProps = {\n /** Optional string representing the id attribute of the Checklist */\n id?: string;\n /** Optional string representing CSS class name(s) for styling */\n className?: string;\n /** Array of strings representing the checkable items */\n listItems: string[];\n /** Array of strings representing the checked items */\n selectedListItems: string[];\n /**\n * Function that is called when a checkbox item is selected or deselected\n *\n * @param item The string description for this item\n * @param selected True if selected, false if not selected\n */\n handleSelectListItem: (item: string, selected: boolean) => void;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created\n * @returns A string representing the label text for the checkbox associated with that item\n */\n createLabel?: (item: string) => string;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created, including text and any additional\n * elements (e.g. links)\n * @returns A react node representing the label text and any additional elements (e.g. links) for\n * the checkbox associated with that item\n */\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Renders a list of checkboxes. Each checkbox corresponds to an item from the `listItems` array. */\nexport function Checklist({\n id,\n className,\n listItems,\n selectedListItems,\n handleSelectListItem,\n createLabel,\n createComplexLabel,\n}: ChecklistProps) {\n return (\n
    \n {listItems.map((item) => (\n
    \n handleSelectListItem(item, value)}\n />\n \n
    \n ))}\n
    \n );\n}\n\nexport default Checklist;\n","import { cn } from '@/utils/shadcn-ui.util';\nimport { MoreHorizontal } from 'lucide-react';\nimport React, { ReactNode } from 'react';\nimport { Button } from '../shadcn-ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../shadcn-ui/dropdown-menu';\n\n/** Props interface for the ResultsCard base component */\nexport interface ResultsCardProps {\n /** Unique key for the card */\n cardKey: string;\n /** Whether this card is currently selected/focused */\n isSelected: boolean;\n /** Callback function called when the card is clicked */\n onSelect: () => void;\n /** Whether the content of this card are in a denied state */\n isDenied?: boolean;\n /** Whether the card should be hidden */\n isHidden?: boolean;\n /** Additional CSS classes to apply to the card */\n className?: string;\n /** Main content to display on the card */\n children: ReactNode;\n /** Content to show in the dropdown menu when selected */\n dropdownContent?: ReactNode;\n /** Additional content to show below the main content when selected */\n additionalSelectedContent?: ReactNode;\n /** Color to use for the card's accent border */\n accentColor?: string;\n}\n\n/**\n * ResultsCard is a base component for displaying scripture-related results in a card format, even\n * though it is not based on the Card component. It provides common functionality like selection\n * state, dropdown menus, and expandable content.\n */\nexport function ResultsCard({\n cardKey,\n isSelected,\n onSelect,\n isDenied,\n isHidden = false,\n className,\n children,\n dropdownContent,\n additionalSelectedContent,\n accentColor,\n}: ResultsCardProps) {\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n onSelect();\n }\n };\n\n return (\n
  • ,\n);\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSubButton = React.forwardRef<\n HTMLAnchorElement,\n React.ComponentProps<'a'> & {\n asChild?: boolean;\n size?: 'sm' | 'md';\n isActive?: boolean;\n }\n>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {\n const Comp = asChild ? Slot : 'a';\n\n return (\n span:last-child]:tw-truncate [&>svg]:tw-size-4 [&>svg]:tw-shrink-0 [&>svg]:tw-text-sidebar-accent-foreground',\n 'data-[active=true]:tw-bg-sidebar-accent data-[active=true]:tw-text-sidebar-accent-foreground',\n size === 'sm' && 'tw-text-xs',\n size === 'md' && 'tw-text-sm',\n 'group-data-[collapsible=icon]:tw-hidden',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarMenuSubButton.displayName = 'SidebarMenuSubButton';\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupAction,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInput,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSkeleton,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n SidebarProvider,\n SidebarRail,\n SidebarSeparator,\n SidebarTrigger,\n useSidebar,\n};\n","import { ComboBox } from '@/components/basics/combo-box.component';\nimport {\n Sidebar,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n} from '@/components/shadcn-ui/sidebar';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { ScrollText } from 'lucide-react';\nimport { useCallback } from 'react';\n\nexport type SelectedSettingsSidebarItem = {\n label: string;\n projectId?: string;\n};\n\nexport type ProjectInfo = { projectId: string; projectName: string };\n\nexport type SettingsSidebarProps = {\n /** Optional id for testing */\n id?: string;\n\n /** Extension labels from contribution */\n extensionLabels: Record;\n\n /** Project names and ids */\n projectInfo: ProjectInfo[];\n\n /** Handler for selecting a sidebar item */\n handleSelectSidebarItem: (key: string, projectId?: string) => void;\n\n /** The current selected value in the sidebar */\n selectedSidebarItem: SelectedSettingsSidebarItem;\n\n /** Label for the group of extensions setting groups */\n extensionsSidebarGroupLabel: string;\n\n /** Label for the group of projects settings */\n projectsSidebarGroupLabel: string;\n\n /** Placeholder text for the button */\n buttonPlaceholderText: string;\n\n /** Additional css classes to help with unique styling of the sidebar */\n className?: string;\n};\n\n/**\n * The SettingsSidebar component is a sidebar that displays a list of extension settings and project\n * settings. It can be used to navigate to different settings pages. Must be wrapped in a\n * SidebarProvider component otherwise produces errors.\n *\n * @param props - {@link SettingsSidebarProps} The props for the component.\n */\nexport function SettingsSidebar({\n id,\n extensionLabels,\n projectInfo,\n handleSelectSidebarItem,\n selectedSidebarItem,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n className,\n}: SettingsSidebarProps) {\n const handleSelectItem = useCallback(\n (item: string, projectId?: string) => {\n handleSelectSidebarItem(item, projectId);\n },\n [handleSelectSidebarItem],\n );\n\n const getProjectNameFromProjectId = useCallback(\n (projectId: string) => {\n const project = projectInfo.find((info) => info.projectId === projectId);\n return project ? project.projectName : projectId;\n },\n [projectInfo],\n );\n\n const getIsActive: (label: string) => boolean = useCallback(\n (label: string) => !selectedSidebarItem.projectId && label === selectedSidebarItem.label,\n [selectedSidebarItem],\n );\n\n return (\n \n \n \n \n {extensionsSidebarGroupLabel}\n \n \n \n {Object.entries(extensionLabels).map(([key, label]) => (\n \n handleSelectItem(key)}\n isActive={getIsActive(key)}\n >\n {label}\n \n \n ))}\n \n \n \n \n {projectsSidebarGroupLabel}\n \n info.projectId)}\n getOptionLabel={getProjectNameFromProjectId}\n buttonPlaceholder={buttonPlaceholderText}\n onChange={(projectId: string) => {\n const selectedProjectName = getProjectNameFromProjectId(projectId);\n handleSelectItem(selectedProjectName, projectId);\n }}\n value={selectedSidebarItem?.projectId ?? undefined}\n icon={}\n />\n \n \n \n \n );\n}\n\nexport default SettingsSidebar;\n","import { Button } from '@/components/shadcn-ui/button';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Search, X } from 'lucide-react';\nimport { forwardRef } from 'react';\n\n/** Props for the SearchBar component. */\nexport type SearchBarProps = {\n /** Search query for the search bar */\n value: string;\n /**\n * Callback fired to handle the search query is updated\n *\n * @param searchQuery\n */\n onSearch: (searchQuery: string) => void;\n\n /** Optional string that appears in the search bar without a search string */\n placeholder?: string;\n\n /** Optional boolean to set the input base to full width */\n isFullWidth?: boolean;\n\n /** Additional css classes to help with unique styling of the search bar */\n className?: string;\n\n /** Optional boolean to disable the search bar */\n isDisabled?: boolean;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A search bar component with a search icon and a clear button when the search query is not empty.\n *\n * @param {SearchBarProps} props - The props for the component.\n * @param {string} props.value - The search query for the search bar\n * @param {(searchQuery: string) => void} props.onSearch - Callback fired to handle the search query\n * is updated\n * @param {string} [props.placeholder] - Optional string that appears in the search bar without a\n * search string\n * @param {boolean} [props.isFullWidth] - Optional boolean to set the input base to full width\n * @param {string} [props.className] - Additional css classes to help with unique styling of the\n * search bar\n * @param {boolean} [props.isDisabled] - Optional boolean to disable the search bar\n * @param {string} [props.id] - Optional id for the root element\n */\nexport const SearchBar = forwardRef(\n ({ value, onSearch, placeholder, isFullWidth, className, isDisabled = false, id }, inputRef) => {\n const dir: Direction = readDirection();\n\n return (\n
    \n \n onSearch(e.target.value)}\n disabled={isDisabled}\n />\n {value && (\n {\n onSearch('');\n }}\n >\n \n Clear\n \n )}\n
    \n );\n },\n);\n\nSearchBar.displayName = 'SearchBar';\n\nexport default SearchBar;\n","import { SidebarInset, SidebarProvider } from '@/components/shadcn-ui/sidebar';\nimport { PropsWithChildren } from 'react';\nimport { SearchBar } from '@/components/basics/search-bar.component';\nimport { SettingsSidebar, SettingsSidebarProps } from './settings-sidebar.component';\n\nexport type SettingsSidebarContentSearchProps = SettingsSidebarProps &\n PropsWithChildren & {\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n };\n\n/**\n * A component that wraps a search bar and a settings sidebar, providing a way to search and\n * navigate to different settings pages.\n *\n * @param {SettingsSidebarContentSearchProps} props - The props for the component.\n * @param {string} props.id - The id of the sidebar.\n */\nexport function SettingsSidebarContentSearch({\n id,\n extensionLabels,\n projectInfo,\n children,\n handleSelectSidebarItem,\n selectedSidebarItem,\n searchValue,\n onSearch,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n}: SettingsSidebarContentSearchProps) {\n return (\n
    \n
    \n \n
    \n \n \n {children}\n \n
    \n );\n}\n\nexport default SettingsSidebarContentSearch;\n","import { Button } from '@/components/shadcn-ui/button';\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/shadcn-ui/table';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Canon } from '@sillsdev/scripture';\nimport {\n Cell,\n ColumnDef,\n flexRender,\n getCoreRowModel,\n getExpandedRowModel,\n getGroupedRowModel,\n getSortedRowModel,\n GroupingState,\n Row,\n RowSelectionState,\n SortingState,\n useReactTable,\n} from '@tanstack/react-table';\nimport '@/components/advanced/scripture-results-viewer/scripture-results-viewer.component.css';\nimport {\n compareScrRefs,\n formatScrRef,\n ScriptureSelection,\n scrRefToBBBCCCVVV,\n} from 'platform-bible-utils';\nimport { MouseEvent, useEffect, useMemo, useState } from 'react';\nimport { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/**\n * Information (e.g., a checking error or some other type of \"transient\" annotation) about something\n * noteworthy at a specific place in an instance of the Scriptures.\n */\nexport type ScriptureItemDetail = ScriptureSelection & {\n /**\n * Text of the error, note, etc. In the future, we might want to support something more than just\n * text so that a JSX element could be provided with a link or some other controls related to the\n * issue being reported.\n */\n detail: string;\n};\n\n/**\n * A uniquely identifiable source of results that can be displayed in the ScriptureResultsViewer.\n * Generally, the source will be a particular Scripture check, but there may be other types of\n * sources.\n */\nexport type ResultsSource = {\n /**\n * Uniquely identifies the source.\n *\n * @type {string}\n */\n id: string;\n\n /**\n * Name (potentially localized) of the source, suitable for display in the UI.\n *\n * @type {string}\n */\n displayName: string;\n};\n\nexport type ScriptureSrcItemDetail = ScriptureItemDetail & {\n /** Source/type of detail. Can be used for grouping. */\n source: ResultsSource;\n};\n\n/**\n * Represents a set of results keyed by Scripture reference. Generally, the source will be a\n * particular Scripture check, but this type also allows for other types of uniquely identifiable\n * sources.\n */\nexport type ResultsSet = {\n /**\n * The backing source associated with this set of results.\n *\n * @type {ResultsSource}\n */\n source: ResultsSource;\n\n /**\n * Array of Scripture item details (messages keyed by Scripture reference).\n *\n * @type {ScriptureItemDetail[]}\n */\n data: ScriptureItemDetail[];\n};\n\nconst scrBookColId = 'scrBook';\nconst scrRefColId = 'scrRef';\nconst typeColId = 'source';\nconst detailsColId = 'details';\n\nconst defaultScrRefColumnName = 'Scripture Reference';\nconst defaultScrBookGroupName = 'Scripture Book';\nconst defaultTypeColumnName = 'Type';\nconst defaultDetailsColumnName = 'Details';\n\nexport type ScriptureResultsViewerColumnInfo = {\n /** Optional header to display for the Reference column. Default value: 'Scripture Reference'. */\n scriptureReferenceColumnName?: string;\n\n /** Optional text to display to refer to the Scripture book group. Default value: 'Scripture Book'. */\n scriptureBookGroupName?: string;\n\n /** Optional header to display for the Type column. Default value: 'Type'. */\n typeColumnName?: string;\n\n /** Optional header to display for the Details column. Default value: 'Details' */\n detailsColumnName?: string;\n};\n\nexport type ScriptureResultsViewerProps = ScriptureResultsViewerColumnInfo & {\n /** Groups of ScriptureItemDetail objects from particular sources (e.g., Scripture checks) */\n sources: ResultsSet[];\n\n /** Flag indicating whether to display column headers. Default is false. */\n showColumnHeaders?: boolean;\n\n /** Flag indicating whether to display source column. Default is false. */\n showSourceColumn?: boolean;\n\n /** Callback function to notify when a row is selected */\n onRowSelected?: (selectedRow: ScriptureSrcItemDetail | undefined) => void;\n\n /** Optional id attribute for the outermost element */\n id?: string;\n};\n\nfunction getColumns(\n colInfo?: ScriptureResultsViewerColumnInfo,\n showSourceColumn?: boolean,\n): ColumnDef[] {\n const showSrcCol = showSourceColumn ?? false;\n return [\n {\n accessorFn: (row) => `${row.start.book} ${row.start.chapterNum}:${row.start.verseNum}`,\n id: scrBookColId,\n header: colInfo?.scriptureReferenceColumnName ?? defaultScrRefColumnName,\n cell: (info) => {\n const row = info.row.original;\n if (info.row.getIsGrouped()) {\n return Canon.bookIdToEnglishName(row.start.book);\n }\n return info.row.groupingColumnId === scrBookColId ? formatScrRef(row.start) : undefined;\n },\n getGroupingValue: (row) => Canon.bookIdToNumber(row.start.book),\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: true,\n },\n {\n accessorFn: (row) => formatScrRef(row.start),\n id: scrRefColId,\n header: undefined,\n cell: (info) => {\n const row = info.row.original;\n return info.row.getIsGrouped() ? undefined : formatScrRef(row.start);\n },\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: false,\n },\n {\n accessorFn: (row) => row.source.displayName,\n id: typeColId,\n header: showSrcCol ? (colInfo?.typeColumnName ?? defaultTypeColumnName) : undefined,\n cell: (info) => (showSrcCol || info.row.getIsGrouped() ? info.getValue() : undefined),\n getGroupingValue: (row) => row.source.id,\n sortingFn: (a, b) =>\n a.original.source.displayName.localeCompare(b.original.source.displayName),\n enableGrouping: true,\n },\n {\n accessorFn: (row) => row.detail,\n id: detailsColId,\n header: colInfo?.detailsColumnName ?? defaultDetailsColumnName,\n cell: (info) => info.getValue(),\n enableGrouping: false,\n },\n ];\n}\n\nconst toRefOrRange = (scriptureSelection: ScriptureSelection) => {\n if (!('offset' in scriptureSelection.start))\n throw new Error('No offset available in range start');\n if (scriptureSelection.end && !('offset' in scriptureSelection.end))\n throw new Error('No offset available in range end');\n const { offset: offsetStart } = scriptureSelection.start;\n let offsetEnd: number = 0;\n if (scriptureSelection.end) ({ offset: offsetEnd } = scriptureSelection.end);\n if (\n !scriptureSelection.end ||\n compareScrRefs(scriptureSelection.start, scriptureSelection.end) === 0\n )\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}`;\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}-${scrRefToBBBCCCVVV(scriptureSelection.end)}+${offsetEnd}`;\n};\n\nconst getRowKey = (row: ScriptureSrcItemDetail) =>\n `${toRefOrRange({ start: row.start, end: row.end })} ${row.source.displayName} ${row.detail}`;\n\n/**\n * Component to display a combined list of detailed items from one or more sources, where the items\n * are keyed primarily by Scripture reference. This is particularly useful for displaying a list of\n * results from Scripture checks, but more generally could be used to display any \"results\" from any\n * source(s). The component allows for grouping by Scripture book, source, or both. By default, it\n * displays somewhat \"tree-like\" which allows it to be more horizontally compact and intuitive. But\n * it also has the option of displaying as a traditional table with column headings (with or without\n * the source column showing).\n */\nexport function ScriptureResultsViewer({\n sources,\n showColumnHeaders = false,\n showSourceColumn = false,\n scriptureReferenceColumnName,\n scriptureBookGroupName,\n typeColumnName,\n detailsColumnName,\n onRowSelected,\n id,\n}: ScriptureResultsViewerProps) {\n const [grouping, setGrouping] = useState([]);\n const [sorting, setSorting] = useState([{ id: scrBookColId, desc: false }]);\n const [rowSelection, setRowSelection] = useState({});\n\n const scriptureResults = useMemo(\n () =>\n sources.flatMap((source) => {\n return source.data.map((item) => ({\n ...item,\n source: source.source,\n }));\n }),\n [sources],\n );\n\n const columns = useMemo(\n () =>\n getColumns(\n {\n scriptureReferenceColumnName,\n typeColumnName,\n detailsColumnName,\n },\n showSourceColumn,\n ),\n [scriptureReferenceColumnName, typeColumnName, detailsColumnName, showSourceColumn],\n );\n\n useEffect(() => {\n // Ensure sorting is applied correctly when grouped by type\n if (grouping.includes(typeColId)) {\n setSorting([\n { id: typeColId, desc: false },\n { id: scrBookColId, desc: false },\n ]);\n } else {\n setSorting([{ id: scrBookColId, desc: false }]);\n }\n }, [grouping]);\n\n const table = useReactTable({\n data: scriptureResults,\n columns,\n state: {\n grouping,\n sorting,\n rowSelection,\n },\n onGroupingChange: setGrouping,\n onSortingChange: setSorting,\n onRowSelectionChange: setRowSelection,\n getExpandedRowModel: getExpandedRowModel(),\n getGroupedRowModel: getGroupedRowModel(),\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getRowId: getRowKey,\n autoResetExpanded: false,\n enableMultiRowSelection: false,\n enableSubRowSelection: false,\n });\n\n useEffect(() => {\n if (onRowSelected) {\n const selectedRows = table.getSelectedRowModel().rowsById;\n const keys = Object.keys(selectedRows);\n if (keys.length === 1) {\n const selectedRow = scriptureResults.find((row) => getRowKey(row) === keys[0]) || undefined;\n if (selectedRow) onRowSelected(selectedRow);\n }\n }\n }, [rowSelection, scriptureResults, onRowSelected, table]);\n\n // Define possible grouping options\n const scrBookGroupName = scriptureBookGroupName ?? defaultScrBookGroupName;\n const typeGroupName = typeColumnName ?? defaultTypeColumnName;\n\n const groupingOptions = [\n { label: 'No Grouping', value: [] },\n { label: `Group by ${scrBookGroupName}`, value: [scrBookColId] },\n { label: `Group by ${typeGroupName}`, value: [typeColId] },\n {\n label: `Group by ${scrBookGroupName} and ${typeGroupName}`,\n value: [scrBookColId, typeColId],\n },\n {\n label: `Group by ${typeGroupName} and ${scrBookGroupName}`,\n value: [typeColId, scrBookColId],\n },\n ];\n\n const handleSelectChange = (selectedGrouping: string) => {\n setGrouping(JSON.parse(selectedGrouping));\n };\n\n const handleRowClick = (row: Row, event: MouseEvent) => {\n if (!row.getIsGrouped() && !row.getIsSelected()) {\n row.getToggleSelectedHandler()(event);\n }\n };\n\n const getEvenOrOddBandingStyle = (row: Row, index: number) => {\n if (row.getIsGrouped()) return '';\n // UX has now said they don't think they want banding. I'm leaving in the code to\n // set even and odd styles, but there's nothing in the CSS to style them differently.\n // The \"even\" style used to also have tw-bg-neutral-300 (along with even) to create\n // a visual banding effect. That could be added back in if UX changes the decision.\n return cn('banded-row', index % 2 === 0 ? 'even' : 'odd');\n };\n\n const getIndent = (\n groupingState: GroupingState,\n row: Row,\n cell: Cell,\n ) => {\n if (groupingState?.length === 0 || row.depth < cell.column.getGroupedIndex()) return undefined;\n if (row.getIsGrouped()) {\n switch (row.depth) {\n case 1:\n return 'tw-ps-4';\n default:\n return undefined;\n }\n }\n switch (row.depth) {\n case 1:\n return 'tw-ps-8';\n case 2:\n return 'tw-ps-12';\n default:\n return undefined;\n }\n };\n\n return (\n
    \n {!showColumnHeaders && (\n {\n handleSelectChange(value);\n }}\n >\n \n \n \n \n \n {groupingOptions.map((option) => (\n \n {option.label}\n \n ))}\n \n \n \n )}\n \n {showColumnHeaders && (\n \n {table.getHeaderGroups().map((headerGroup) => (\n \n {headerGroup.headers\n .filter((h) => h.column.columnDef.header)\n .map((header) => (\n /* For sticky column headers to work, we probably need to change the default definition of the shadcn Table component. See https://github.com/shadcn-ui/ui/issues/1151 */\n \n {header.isPlaceholder ? undefined : (\n
    \n {header.column.getCanGroup() ? (\n \n {header.column.getIsGrouped() ? `๐Ÿ›‘` : `๐Ÿ‘Š `}\n \n ) : undefined}{' '}\n {flexRender(header.column.columnDef.header, header.getContext())}\n
    \n )}\n
    \n ))}\n
    \n ))}\n
    \n )}\n \n {table.getRowModel().rows.map((row, rowIndex) => {\n const dir: Direction = readDirection();\n return (\n handleRowClick(row, event)}\n >\n {row.getVisibleCells().map((cell) => {\n if (\n cell.getIsPlaceholder() ||\n (cell.column.columnDef.enableGrouping &&\n !cell.getIsGrouped() &&\n (cell.column.columnDef.id !== typeColId || !showSourceColumn))\n )\n return undefined;\n return (\n \n {(() => {\n if (cell.getIsGrouped()) {\n return (\n \n {row.getIsExpanded() && }\n {!row.getIsExpanded() &&\n (dir === 'ltr' ? : )}{' '}\n {flexRender(cell.column.columnDef.cell, cell.getContext())} (\n {row.subRows.length})\n \n );\n }\n\n // if (cell.getIsAggregated()) {\n // flexRender(\n // cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,\n // cell.getContext(),\n // );\n // }\n\n return flexRender(cell.column.columnDef.cell, cell.getContext());\n })()}\n \n );\n })}\n \n );\n })}\n \n
    \n
    \n );\n}\n\nexport default ScriptureResultsViewer;\n","import { getSectionForBook, Section } from 'platform-bible-utils';\n\n/**\n * Filters an array of book IDs to only include books from a specific section\n *\n * @param bookIds Array of book IDs to filter\n * @param section The section to filter by\n * @returns Array of book IDs that belong to the specified section\n */\nexport const getBooksForSection = (bookIds: string[], section: Section) => {\n return bookIds.filter((bookId) => {\n try {\n return getSectionForBook(bookId) === section;\n } catch {\n return false;\n }\n });\n};\n\n/**\n * Checks if all books in a given section are included in the selectedBookIds array\n *\n * @param bookIds Array of all available book IDs\n * @param section The section to check\n * @param selectedBookIds Array of currently selected book IDs\n * @returns True if all books from the specified section are selected, false otherwise\n */\nexport const isSectionFullySelected = (\n bookIds: string[],\n section: Section,\n selectedBookIds: string[],\n) => getBooksForSection(bookIds, section).every((bookId) => selectedBookIds.includes(bookId));\n","import { Button } from '@/components/shadcn-ui/button';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { LanguageStrings, Section } from 'platform-bible-utils';\nimport { getSectionShortName } from '@/components/shared/book.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\n\n/**\n * A button component that represents a scripture section (testament) in the book selector. The\n * button shows a different state when all books in its section are selected and becomes disabled\n * when no books are available in its section.\n */\nfunction SectionButton({\n section,\n availableBookIds,\n selectedBookIds,\n onToggle,\n localizedStrings,\n}: {\n section: Section;\n availableBookIds: string[];\n selectedBookIds: string[];\n onToggle: (section: Section) => void;\n localizedStrings: LanguageStrings;\n}) {\n const isDisabled = getBooksForSection(availableBookIds, section).length === 0;\n\n const sectionOtShortText = localizedStrings['%scripture_section_ot_short%'];\n const sectionNtShortText = localizedStrings['%scripture_section_nt_short%'];\n const sectionDcShortText = localizedStrings['%scripture_section_dc_short%'];\n const sectionExtraShortText = localizedStrings['%scripture_section_extra_short%'];\n\n return (\n onToggle(section)}\n className={cn(\n isSectionFullySelected(availableBookIds, section, selectedBookIds) &&\n !isDisabled &&\n 'tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/70 hover:tw-text-primary-foreground',\n )}\n disabled={isDisabled}\n >\n {getSectionShortName(\n section,\n sectionOtShortText,\n sectionNtShortText,\n sectionDcShortText,\n sectionExtraShortText,\n )}\n \n );\n}\n\nexport default SectionButton;\n","import { BookItem } from '@/components/shared/book-item.component';\nimport { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandList,\n CommandSeparator,\n} from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { Canon } from '@sillsdev/scripture';\nimport { ChevronsUpDown } from 'lucide-react';\nimport { getSectionForBook, LanguageStrings, Section } from 'platform-bible-utils';\nimport {\n getSectionLongName,\n getLocalizedBookName,\n doesBookMatchQuery,\n} from '@/components/shared/book.utils';\nimport { Fragment, MouseEvent, useCallback, useMemo, useRef, useState } from 'react';\nimport { generateCommandValue } from '@/components/shared/book-item.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\nimport SectionButton from './section-button.component';\n\n/** Maximum number of badges to show before collapsing into a \"+X more\" badge */\nconst VISIBLE_BADGES_COUNT = 5;\n/** Maximum number of badges that can be shown without triggering the collapse */\nconst MAX_VISIBLE_BADGES = 6;\n\ntype BookSelectorProps = {\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n /** Callback function that is executed when the book selection changes */\n onChangeSelectedBookIds: (books: string[]) => void;\n /** Object containing the localized strings for the component */\n localizedStrings: LanguageStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n};\n\n/**\n * A component for selecting multiple books from the Bible canon. It provides:\n *\n * - Quick selection buttons for major sections (OT, NT, DC, Extra)\n * - A searchable dropdown with all available books\n * - Support for shift-click range selection\n * - Visual feedback with badges showing selected books\n */\nexport function BookSelector({\n availableBookInfo,\n selectedBookIds,\n onChangeSelectedBookIds,\n localizedStrings,\n localizedBookNames,\n}: BookSelectorProps) {\n const booksSelectedText = localizedStrings['%webView_book_selector_books_selected%'];\n const selectBooksText = localizedStrings['%webView_book_selector_select_books%'];\n const searchBooksText = localizedStrings['%webView_book_selector_search_books%'];\n const selectAllText = localizedStrings['%webView_book_selector_select_all%'];\n const clearAllText = localizedStrings['%webView_book_selector_clear_all%'];\n const noBookFoundText = localizedStrings['%webView_book_selector_no_book_found%'];\n const moreText = localizedStrings['%webView_book_selector_more%'];\n\n const { otLong, ntLong, dcLong, extraLong } = {\n otLong: localizedStrings?.['%scripture_section_ot_long%'],\n ntLong: localizedStrings?.['%scripture_section_nt_long%'],\n dcLong: localizedStrings?.['%scripture_section_dc_long%'],\n extraLong: localizedStrings?.['%scripture_section_extra_long%'],\n };\n\n const [isBooksSelectorOpen, setIsBooksSelectorOpen] = useState(false);\n const [inputValue, setInputValue] = useState('');\n const lastSelectedBookRef = useRef(undefined);\n const lastKeyEventShiftKey = useRef(false);\n\n if (availableBookInfo.length !== Canon.allBookIds.length) {\n throw new Error('availableBookInfo length must match Canon.allBookIds length');\n }\n\n const availableBooksIds = useMemo(() => {\n return Canon.allBookIds.filter(\n (bookId, index) =>\n availableBookInfo[index] === '1' && !Canon.isObsolete(Canon.bookIdToNumber(bookId)),\n );\n }, [availableBookInfo]);\n\n const filteredBooksBySection = useMemo(() => {\n if (!inputValue.trim()) {\n const allBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n availableBooksIds.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n allBooks[section].push(bookId);\n });\n\n return allBooks;\n }\n\n const filteredBooks = availableBooksIds.filter((bookId) =>\n doesBookMatchQuery(bookId, inputValue, localizedBookNames),\n );\n\n const matchingBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n filteredBooks.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n matchingBooks[section].push(bookId);\n });\n\n return matchingBooks;\n }, [availableBooksIds, inputValue, localizedBookNames]);\n\n const toggleBook = useCallback(\n (bookId: string, shiftKey = false) => {\n if (!shiftKey || !lastSelectedBookRef.current) {\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((id) => id !== bookId)\n : [...selectedBookIds, bookId],\n );\n lastSelectedBookRef.current = bookId;\n return;\n }\n\n const lastIndex = availableBooksIds.findIndex((id) => id === lastSelectedBookRef.current);\n const currentIndex = availableBooksIds.findIndex((id) => id === bookId);\n\n if (lastIndex === -1 || currentIndex === -1) return;\n\n const [startIndex, endIndex] = [\n Math.min(lastIndex, currentIndex),\n Math.max(lastIndex, currentIndex),\n ];\n const booksInRange = availableBooksIds.slice(startIndex, endIndex + 1).map((id) => id);\n\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((shortname) => !booksInRange.includes(shortname))\n : [...new Set([...selectedBookIds, ...booksInRange])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleKeyboardSelect = (bookId: string) => {\n toggleBook(bookId, lastKeyEventShiftKey.current);\n lastKeyEventShiftKey.current = false;\n };\n\n const handleMouseDown = (event: MouseEvent, bookId: string) => {\n event.preventDefault();\n toggleBook(bookId, event.shiftKey);\n };\n\n const toggleSection = useCallback(\n (section: Section) => {\n const sectionBooks = getBooksForSection(availableBooksIds, section).map((bookId) => bookId);\n onChangeSelectedBookIds(\n isSectionFullySelected(availableBooksIds, section, selectedBookIds)\n ? selectedBookIds.filter((shortname) => !sectionBooks.includes(shortname))\n : [...new Set([...selectedBookIds, ...sectionBooks])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleSelectAll = () => {\n onChangeSelectedBookIds(availableBooksIds.map((bookId) => bookId));\n };\n\n const handleClearAll = () => {\n onChangeSelectedBookIds([]);\n };\n\n return (\n
    \n
    \n {Object.values(Section).map((section) => {\n return (\n \n );\n })}\n
    \n\n {\n setIsBooksSelectorOpen(open);\n if (!open) {\n setInputValue(''); // Reset search when closing\n }\n }}\n >\n \n \n {selectedBookIds.length > 0\n ? `${booksSelectedText}: ${selectedBookIds.length}`\n : selectBooksText}\n \n \n \n \n {\n if (e.key === 'Enter') {\n // Store shift state in a ref that will be used by onSelect\n lastKeyEventShiftKey.current = e.shiftKey;\n }\n }}\n >\n \n
    \n \n \n
    \n \n {noBookFoundText}\n {Object.values(Section).map((section, index) => {\n const sectionBooks = filteredBooksBySection[section];\n\n if (sectionBooks.length === 0) return undefined;\n\n return (\n \n \n {sectionBooks.map((bookId) => (\n handleKeyboardSelect(bookId)}\n onMouseDown={(event) => handleMouseDown(event, bookId)}\n section={getSectionForBook(bookId)}\n showCheck\n localizedBookNames={localizedBookNames}\n commandValue={generateCommandValue(bookId, localizedBookNames)}\n className=\"tw-flex tw-items-center\"\n />\n ))}\n \n {index < Object.values(Section).length - 1 && }\n \n );\n })}\n \n \n
    \n \n\n {selectedBookIds.length > 0 && (\n
    \n {selectedBookIds\n .slice(\n 0,\n selectedBookIds.length === MAX_VISIBLE_BADGES\n ? MAX_VISIBLE_BADGES\n : VISIBLE_BADGES_COUNT,\n )\n .map((bookId) => (\n \n {getLocalizedBookName(bookId, localizedBookNames)}\n \n ))}\n {selectedBookIds.length > MAX_VISIBLE_BADGES && (\n {`+${selectedBookIds.length - VISIBLE_BADGES_COUNT} ${moreText}`}\n )}\n
    \n )}\n
    \n );\n}\n","import { BookSelector } from '@/components/advanced/scope-selector/book-selector.component';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { RadioGroup, RadioGroupItem } from '@/components/shadcn-ui/radio-group';\nimport { Scope } from '@/components/utils/scripture.util';\nimport { LocalizedStringValue } from 'platform-bible-utils';\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const SCOPE_SELECTOR_STRING_KEYS = Object.freeze([\n '%webView_scope_selector_selected_text%',\n '%webView_scope_selector_current_verse%',\n '%webView_scope_selector_current_chapter%',\n '%webView_scope_selector_current_book%',\n '%webView_scope_selector_choose_books%',\n '%webView_scope_selector_scope%',\n '%webView_scope_selector_select_books%',\n '%webView_book_selector_books_selected%',\n '%webView_book_selector_select_books%',\n '%webView_book_selector_search_books%',\n '%webView_book_selector_select_all%',\n '%webView_book_selector_clear_all%',\n '%webView_book_selector_no_book_found%',\n '%webView_book_selector_more%',\n '%scripture_section_ot_long%',\n '%scripture_section_ot_short%',\n '%scripture_section_nt_long%',\n '%scripture_section_nt_short%',\n '%scripture_section_dc_long%',\n '%scripture_section_dc_short%',\n '%scripture_section_extra_long%',\n '%scripture_section_extra_short%',\n] as const);\n\n/** Type definition for the localized strings used in this component */\nexport type ScopeSelectorLocalizedStrings = {\n [localizedInventoryKey in (typeof SCOPE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: ScopeSelectorLocalizedStrings,\n key: keyof ScopeSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\n/** Props for configuring the ScopeSelector component */\ninterface ScopeSelectorProps {\n /** The current scope selection */\n scope: Scope;\n\n /**\n * Optional array of scopes that should be available in the selector. If not provided, all scopes\n * will be shown as defined in the Scope type\n */\n availableScopes?: Scope[];\n\n /** Callback function that is executed when the user changes the scope selection */\n onScopeChange: (scope: Scope) => void;\n\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n\n /** Callback function that is executed when the user changes the book selection */\n onSelectedBookIdsChange: (books: string[]) => void;\n\n /**\n * Object with all localized strings that the component needs to work well across multiple\n * languages. When using this component with Platform.Bible, you can import\n * `SCOPE_SELECTOR_STRING_KEYS` from this library, pass it in to the Platform's localization hook,\n * and pass the localized keys that are returned by the hook into this prop.\n */\n localizedStrings: ScopeSelectorLocalizedStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n /** Optional ID that is applied to the root element of this component */\n id?: string;\n}\n\n/**\n * A component that allows users to select the scope of their search or operation. Available scopes\n * are defined in the Scope type. When 'selectedBooks' is chosen as the scope, a BookSelector\n * component is displayed to allow users to choose specific books.\n */\nexport function ScopeSelector({\n scope,\n availableScopes,\n onScopeChange,\n availableBookInfo,\n selectedBookIds,\n onSelectedBookIdsChange,\n localizedStrings,\n localizedBookNames,\n id,\n}: ScopeSelectorProps) {\n const selectedTextText = localizeString(\n localizedStrings,\n '%webView_scope_selector_selected_text%',\n );\n const currentVerseText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_verse%',\n );\n const currentChapterText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_chapter%',\n );\n const currentBookText = localizeString(localizedStrings, '%webView_scope_selector_current_book%');\n const chooseBooksText = localizeString(localizedStrings, '%webView_scope_selector_choose_books%');\n const scopeText = localizeString(localizedStrings, '%webView_scope_selector_scope%');\n const selectBooksText = localizeString(localizedStrings, '%webView_scope_selector_select_books%');\n\n const SCOPE_OPTIONS: Array<{ value: Scope; label: string; id: string }> = [\n { value: 'selectedText', label: selectedTextText, id: 'scope-selected-text' },\n { value: 'verse', label: currentVerseText, id: 'scope-verse' },\n { value: 'chapter', label: currentChapterText, id: 'scope-chapter' },\n { value: 'book', label: currentBookText, id: 'scope-book' },\n { value: 'selectedBooks', label: chooseBooksText, id: 'scope-selected' },\n ];\n\n const displayedScopes = availableScopes\n ? SCOPE_OPTIONS.filter((option) => availableScopes.includes(option.value))\n : SCOPE_OPTIONS;\n\n return (\n
    \n
    \n \n \n {displayedScopes.map(({ value, label, id: scopeId }) => (\n
    \n \n \n
    \n ))}\n \n
    \n\n {scope === 'selectedBooks' && (\n
    \n \n \n
    \n )}\n
    \n );\n}\n\nexport default ScopeSelector;\n","import {\n getLocalizeKeyForScrollGroupId,\n LanguageStrings,\n ScrollGroupId,\n} from 'platform-bible-utils';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\n\nconst DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS = {\n [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n [getLocalizeKeyForScrollGroupId(0)]: 'A',\n [getLocalizeKeyForScrollGroupId(1)]: 'B',\n [getLocalizeKeyForScrollGroupId(2)]: 'C',\n [getLocalizeKeyForScrollGroupId(3)]: 'D',\n [getLocalizeKeyForScrollGroupId(4)]: 'E',\n [getLocalizeKeyForScrollGroupId(5)]: 'F',\n [getLocalizeKeyForScrollGroupId(6)]: 'G',\n [getLocalizeKeyForScrollGroupId(7)]: 'H',\n [getLocalizeKeyForScrollGroupId(8)]: 'I',\n [getLocalizeKeyForScrollGroupId(9)]: 'J',\n [getLocalizeKeyForScrollGroupId(10)]: 'K',\n [getLocalizeKeyForScrollGroupId(11)]: 'L',\n [getLocalizeKeyForScrollGroupId(12)]: 'M',\n [getLocalizeKeyForScrollGroupId(13)]: 'N',\n [getLocalizeKeyForScrollGroupId(14)]: 'O',\n [getLocalizeKeyForScrollGroupId(15)]: 'P',\n [getLocalizeKeyForScrollGroupId(16)]: 'Q',\n [getLocalizeKeyForScrollGroupId(17)]: 'R',\n [getLocalizeKeyForScrollGroupId(18)]: 'S',\n [getLocalizeKeyForScrollGroupId(19)]: 'T',\n [getLocalizeKeyForScrollGroupId(20)]: 'U',\n [getLocalizeKeyForScrollGroupId(21)]: 'V',\n [getLocalizeKeyForScrollGroupId(22)]: 'W',\n [getLocalizeKeyForScrollGroupId(23)]: 'X',\n [getLocalizeKeyForScrollGroupId(24)]: 'Y',\n [getLocalizeKeyForScrollGroupId(25)]: 'Z',\n};\n\nexport type ScrollGroupSelectorProps = {\n /**\n * List of scroll group ids to show to the user. Either a `ScrollGroupId` or `undefined` for no\n * scroll group\n */\n availableScrollGroupIds: (ScrollGroupId | undefined)[];\n /** Currently selected scroll group id. `undefined` for no scroll group */\n scrollGroupId: ScrollGroupId | undefined;\n /** Callback function run when the user tries to change the scroll group id */\n onChangeScrollGroupId: (newScrollGroupId: ScrollGroupId | undefined) => void;\n /**\n * Localized strings to use for displaying scroll group ids. Must be an object whose keys are\n * `getLocalizeKeyForScrollGroupId(scrollGroupId)` for all scroll group ids (and `undefined` if\n * included) in {@link ScrollGroupSelectorProps.availableScrollGroupIds} and whose values are the\n * localized strings to use for those scroll group ids.\n *\n * Defaults to English localizations of English alphabet for scroll groups 0-25 (e.g. 0 is A) and\n * ร˜ for `undefined`. Will fill in any that are not provided with these English localizations.\n * Also, if any values match the keys, the English localization will be used. This is useful in\n * case you want to pass in a temporary version of the localized strings while your localized\n * strings load.\n *\n * @example\n *\n * ```typescript\n * const myScrollGroupIdLocalizedStrings = {\n * [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n * [getLocalizeKeyForScrollGroupId(0)]: 'A',\n * [getLocalizeKeyForScrollGroupId(1)]: 'B',\n * [getLocalizeKeyForScrollGroupId(2)]: 'C',\n * [getLocalizeKeyForScrollGroupId(3)]: 'D',\n * [getLocalizeKeyForScrollGroupId(4)]: 'E',\n * };\n * ```\n *\n * @example\n *\n * ```tsx\n * const availableScrollGroupIds = [undefined, 0, 1, 2, 3, 4];\n *\n * const localizeKeys = getLocalizeKeysForScrollGroupIds();\n *\n * const [localizedStrings] = useLocalizedStrings(localizeKeys);\n *\n * ...\n *\n * \n * ```\n */\n localizedStrings?: LanguageStrings;\n\n /** Size of the scroll group dropdown button. Defaults to 'sm' */\n size?: 'default' | 'sm' | 'lg' | 'icon';\n\n /** Additional css classes to help with unique styling */\n className?: string;\n\n /** Optional id for the select element */\n id?: string;\n};\n\n/** Selector component for choosing a scroll group */\nexport function ScrollGroupSelector({\n availableScrollGroupIds,\n scrollGroupId,\n onChangeScrollGroupId,\n localizedStrings = {},\n size = 'sm',\n className,\n id,\n}: ScrollGroupSelectorProps) {\n const localizedStringsDefaulted = {\n ...DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS,\n ...Object.fromEntries(\n Object.entries(localizedStrings).map(\n ([localizedStringKey, localizedStringValue]: [string, string]) => [\n localizedStringKey,\n localizedStringKey === localizedStringValue &&\n localizedStringKey in DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS\n ? DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS[localizedStringKey]\n : localizedStringValue,\n ],\n ),\n ),\n };\n\n const dir: Direction = readDirection();\n\n return (\n \n onChangeScrollGroupId(\n newScrollGroupString === 'undefined' ? undefined : parseInt(newScrollGroupString, 10),\n )\n }\n >\n \n \n \n \n {availableScrollGroupIds.map((scrollGroupOptionId) => (\n \n {localizedStringsDefaulted[getLocalizeKeyForScrollGroupId(scrollGroupOptionId)]}\n \n ))}\n \n \n );\n}\n\nexport default ScrollGroupSelector;\n","import { PropsWithChildren } from 'react';\nimport { Separator } from '@/components/shadcn-ui/separator';\n\n/** Props for the SettingsList component, currently just children */\ntype SettingsListProps = PropsWithChildren;\n\n/**\n * SettingsList component is a wrapper for list items. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param children To populate the list with\n * @returns Formatted div encompassing the children\n */\nexport function SettingsList({ children }: SettingsListProps) {\n return
    {children}
    ;\n}\n\n/** Props for SettingsListItem component */\ntype SettingsListItemProps = PropsWithChildren & {\n /** Primary text of the list item */\n primary: string;\n\n /** Optional text of the list item */\n secondary?: string | undefined;\n\n /** Optional boolean to display a message if the children aren't loaded yet. Defaults to false */\n isLoading?: boolean;\n\n /** Optional message to display if isLoading */\n loadingMessage?: string;\n};\n\n/**\n * SettingsListItem component is a common list item. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListItemProps\n * @returns Formatted div encompassing the list item content\n */\nexport function SettingsListItem({\n primary,\n secondary,\n children,\n isLoading = false,\n loadingMessage,\n}: SettingsListItemProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    \n {secondary}\n

    \n
    \n\n {isLoading ? (\n

    {loadingMessage}

    \n ) : (\n
    {children}
    \n )}\n
    \n );\n}\n\n/** Props for SettingsListHeader component */\ntype SettingsListHeaderProps = {\n /** The primary text of the list header */\n primary: string;\n\n /** Optional secondary text of the list header */\n secondary?: string | undefined;\n\n /** Optional boolean to include a separator underneath the secondary text. Defaults to false */\n includeSeparator?: boolean;\n};\n\n/**\n * SettingsListHeader component displays text above the list\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListHeaderProps\n * @returns Formatted div with list header content\n */\nexport function SettingsListHeader({\n primary,\n secondary,\n includeSeparator = false,\n}: SettingsListHeaderProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    {secondary}

    \n
    \n {includeSeparator ? : ''}\n
    \n );\n}\n","import { GroupsInMultiColumnMenu, Localized } from 'platform-bible-utils';\n\n/**\n * Function that looks up the key of a sub-menu group using the value of it's `menuItem` property.\n *\n * @example\n *\n * ```ts\n * const groups = {\n * 'platform.subMenu': { menuItem: 'platform.subMenuId', order: 1 },\n * 'platform.subSubMenu': { menuItem: 'platform.subSubMenuId', order: 2 },\n * };\n * const id = 'platform.subMenuId';\n * const groupKey = getSubMenuGroupKeyForMenuItemId(groups, id);\n * console.log(groupKey); // Output: 'platform.subMenu'\n * ```\n *\n * @param groups The JSON Object containing the group definitions\n * @param id The value of the `menuItem` property of the group to look up\n * @returns The key of the group that has the `menuItem` property with the value of `id` or\n * `undefined` if no such group exists.\n */\nexport function getSubMenuGroupKeyForMenuItemId(\n groups: Localized,\n id: string,\n): string | undefined {\n return Object.entries(groups).find(\n ([, value]) => 'menuItem' in value && value.menuItem === id,\n )?.[0];\n}\n","import { cn } from '@/utils/shadcn-ui.util';\n\ntype MenuItemIconProps = {\n /** The icon to display */\n icon: string;\n /** The label of the menu item */\n menuLabel: string;\n /** Whether the icon is leading or trailing */\n leading?: boolean;\n};\n\nfunction MenuItemIcon({ icon, menuLabel, leading }: MenuItemIconProps) {\n return icon ? (\n \n ) : undefined;\n}\n\nexport default MenuItemIcon;\n","import {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuPortal,\n DropdownMenuSeparator,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport { MenuIcon } from 'lucide-react';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { Fragment, ReactNode } from 'react';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport { SelectMenuItemHandler } from './platform-menubar.component';\nimport MenuItemIcon from './menu-icon.component';\n\nconst getGroupContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey]) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n\n \n \n {getGroupContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n return groupItems;\n });\n};\n\nexport type TabDropdownMenuProps = {\n /** The handler to use for menu commands */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /** The menu data to show on the dropdown menu */\n menuData: Localized;\n\n /** Defines a string value that labels the current element */\n tabLabel: string;\n\n /** Optional icon for the dropdown menu trigger. Defaults to hamburger icon. */\n icon?: ReactNode;\n\n /** Additional css class(es) to help with unique styling of the tab dropdown menu */\n className?: string;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n\n buttonVariant?: 'default' | 'ghost' | 'outline' | 'secondary';\n\n /** Optional unique identifier */\n id?: string;\n};\n\n/**\n * Dropdown menu designed to be used with Platform.Bible menu data. Column headers are ignored.\n * Column data is separated by a horizontal divider, so groups are not distinguishable. Tooltips are\n * displayed on hovering over menu items, if a tooltip is defined for them.\n *\n * A child component can be passed in to show as an icon on the menu trigger button.\n */\nexport default function TabDropdownMenu({\n onSelectMenuItem,\n menuData,\n tabLabel,\n icon,\n className,\n variant,\n buttonVariant = 'ghost',\n id,\n}: TabDropdownMenuProps) {\n return (\n \n \n \n \n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey], index, array) => (\n \n \n \n {getGroupContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n\n {index < array.length - 1 && }\n \n ))}\n \n \n );\n}\n","import { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport React, { PropsWithChildren, ReactNode } from 'react';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\n\nexport type TabToolbarCommonProps = {\n /**\n * The handler to use for toolbar item commands related to the project menu. Here is a basic\n * example of how to create this:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectProjectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component for the project menu. In an extension,\n * the menu data comes from menus.json in the contributions folder. To access that info, use\n * useMemo to get the WebViewMenu.\n */\n projectMenuData?: Localized;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n\n /** Icon that will be displayed on the Menu Button. Defaults to the hamburger menu icon. */\n menuButtonIcon?: ReactNode;\n};\n\nexport type TabToolbarContainerProps = PropsWithChildren<{\n /** Optional unique identifier */\n id?: string;\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n}>;\n\n/** Wrapper that allows consistent styling for both TabToolbar and TabFloatingMenu. */\nexport const TabToolbarContainer = React.forwardRef(\n ({ id, className, children }, ref) => (\n \n {children}\n \n ),\n);\n\nexport default TabToolbarContainer;\n","import { ReactNode } from 'react';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { Menu, EllipsisVertical } from 'lucide-react';\nimport TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\nexport type TabToolbarProps = TabToolbarCommonProps & {\n /**\n * The handler to use for toolbar item commands related to the tab view menu. Here is a basic\n * example of how to create this from the hello-rock3 extension:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectViewInfoMenuItem: SelectMenuItemHandler;\n\n /** Menu data that is used to populate the Menubar component for the view info menu */\n tabViewMenuData?: Localized;\n\n /**\n * Toolbar children to be put at the start of the the toolbar after the project menu icon (left\n * side in ltr, right side in rtl). Recommended for inner navigation.\n */\n startAreaChildren?: ReactNode;\n\n /** Toolbar children to be put in the center area of the the toolbar. Recommended for tools. */\n centerAreaChildren?: ReactNode;\n\n /**\n * Toolbar children to be put at the end of the the toolbar before the tab view menu icon (right\n * side in ltr, left side in rtl). Recommended for secondary tools and view options.\n */\n endAreaChildren?: ReactNode;\n};\n\n/**\n * Toolbar that holds the project menu icon on one side followed by three different areas/categories\n * for toolbar icons followed by an optional view info menu icon. See the Tab Floating Menu Button\n * component for a menu component that takes up less screen real estate yet is always visible.\n */\nexport function TabToolbar({\n onSelectProjectMenuItem,\n onSelectViewInfoMenuItem,\n projectMenuData,\n tabViewMenuData,\n id,\n className,\n startAreaChildren,\n centerAreaChildren,\n endAreaChildren,\n menuButtonIcon,\n}: TabToolbarProps) {\n return (\n \n {projectMenuData && (\n }\n buttonVariant=\"ghost\"\n />\n )}\n {startAreaChildren && (\n
    \n {startAreaChildren}\n
    \n )}\n {centerAreaChildren && (\n
    \n {centerAreaChildren}\n
    \n )}\n
    \n {tabViewMenuData && (\n }\n className=\"tw-h-full\"\n />\n )}\n {endAreaChildren}\n
    \n
    \n );\n}\n\nexport default TabToolbar;\n","import TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\n/**\n * Renders a TabDropdownMenu with a trigger button that looks like the menuButtonIcon or like the\n * default of three stacked horizontal lines (aka the hamburger). The menu \"floats\" over the content\n * so it is always visible. When clicked, it displays a dropdown menu with the projectMenuData.\n */\nexport function TabFloatingMenu({\n onSelectProjectMenuItem,\n projectMenuData,\n id,\n className,\n menuButtonIcon,\n}: TabToolbarCommonProps) {\n return (\n \n {projectMenuData && (\n \n )}\n \n );\n}\n\nexport default TabFloatingMenu;\n","// adapted from: https://github.com/shadcn-ui/ui/discussions/752\n\n'use client';\n\nimport { TabsContentProps, TabsListProps, TabsTriggerProps } from '@/components/shadcn-ui/tabs';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport React from 'react';\n\nexport type VerticalTabsProps = React.ComponentPropsWithoutRef & {\n className?: string;\n};\n\nexport type LeftTabsTriggerProps = TabsTriggerProps & {\n value: string;\n ref?: React.Ref;\n};\n\n/**\n * Tabs components provide a set of layered sections of contentโ€”known as tab panelsโ€“that are\n * displayed one at a time. These components are built on Radix UI primitives and styled with Shadcn\n * UI. See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/tabs See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/tabs\n */\nexport const VerticalTabs = React.forwardRef<\n React.ElementRef,\n VerticalTabsProps\n>(({ className, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n );\n});\n\nVerticalTabs.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsList = React.forwardRef<\n React.ElementRef,\n TabsListProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsList.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsTrigger = React.forwardRef<\n React.ElementRef,\n LeftTabsTriggerProps\n>(({ className, ...props }, ref) => (\n \n));\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsContent = React.forwardRef<\n React.ElementRef,\n TabsContentProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsContent.displayName = TabsPrimitive.Content.displayName;\n","import { SearchBar } from '@/components/basics/search-bar.component';\nimport {\n VerticalTabs,\n VerticalTabsContent,\n VerticalTabsList,\n VerticalTabsTrigger,\n} from '@/components/basics/tabs-vertical';\nimport { ReactNode } from 'react';\n\nexport type TabKeyValueContent = {\n key: string;\n value: string;\n content: ReactNode;\n};\n\nexport type TabNavigationContentSearchProps = {\n /** List of values and keys for each tab this component should provide */\n tabList: TabKeyValueContent[];\n\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n\n /** Optional placeholder for the search bar */\n searchPlaceholder?: string;\n\n /** Optional title to include in the header */\n headerTitle?: string;\n\n /** Optional className to modify the search input */\n searchClassName?: string;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * TabNavigationContentSearch component provides a vertical tab navigation interface with a search\n * bar at the top. This component allows users to filter content within tabs based on a search\n * query.\n *\n * @param {TabNavigationContentSearchProps} props\n * @param {TabKeyValueContent[]} props.tabList - List of objects containing keys, values, and\n * content for each tab to be displayed.\n * @param {string} props.searchValue - The current value of the search input.\n * @param {function} props.onSearch - Callback function called when the search input changes;\n * receives the new search query as an argument.\n * @param {string} [props.searchPlaceholder] - Optional placeholder text for the search input.\n * @param {string} [props.headerTitle] - Optional title to display above the search input.\n * @param {string} [props.searchClassName] - Optional CSS class name to apply custom styles to the\n * search input.\n * @param {string} [props.id] - Optional id for the root element.\n */\nexport function TabNavigationContentSearch({\n tabList,\n searchValue,\n onSearch,\n searchPlaceholder,\n headerTitle,\n searchClassName,\n id,\n}: TabNavigationContentSearchProps) {\n return (\n
    \n
    \n {headerTitle ?

    {headerTitle}

    : ''}\n \n
    \n \n \n {tabList.map((tab) => (\n \n {tab.value}\n \n ))}\n \n {tabList.map((tab) => (\n \n {tab.content}\n \n ))}\n \n
    \n );\n}\n\nexport default TabNavigationContentSearch;\n","import {\n MenuContext,\n MenuContextProps,\n menuVariants,\n useMenuContext,\n} from '@/context/menu.context';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as MenubarPrimitive from '@radix-ui/react-menubar';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\nimport React from 'react';\n\nfunction MenubarMenu({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarPortal({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarRadioGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarSub({ ...props }: React.ComponentProps) {\n return ;\n}\n\nconst Menubar = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n variant?: MenuContextProps['variant'];\n }\n>(({ className, variant = 'default', ...props }, ref) => {\n /* #region CUSTOM provide context to add variants */\n const contextValue = React.useMemo(\n () => ({\n variant,\n }),\n [variant],\n );\n return (\n \n {/* #endregion CUSTOM */}\n \n \n );\n});\nMenubar.displayName = MenubarPrimitive.Root.displayName;\n\nconst MenubarTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;\n\nconst MenubarSubTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n {children}\n \n \n );\n});\nMenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;\n\nconst MenubarSubContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;\n\nconst MenubarContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n );\n});\nMenubarContent.displayName = MenubarPrimitive.Content.displayName;\n\nconst MenubarItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarItem.displayName = MenubarPrimitive.Item.displayName;\n\nconst MenubarCheckboxItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, checked, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;\n\nconst MenubarRadioItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;\n\nconst MenubarLabel = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => (\n \n));\nMenubarLabel.displayName = MenubarPrimitive.Label.displayName;\n\nconst MenubarSeparator = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nMenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;\n\nfunction MenubarShortcut({ className, ...props }: React.HTMLAttributes) {\n return (\n \n );\n}\nMenubarShortcut.displayname = 'MenubarShortcut';\n\nexport {\n Menubar,\n MenubarCheckboxItem,\n MenubarContent,\n MenubarGroup,\n MenubarItem,\n MenubarLabel,\n MenubarMenu,\n MenubarPortal,\n MenubarRadioGroup,\n MenubarRadioItem,\n MenubarSeparator,\n MenubarShortcut,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n};\n","import {\n Menubar,\n MenubarContent,\n MenubarItem,\n MenubarMenu,\n MenubarSeparator,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n} from '@/components/shadcn-ui/menubar';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { RefObject, useEffect, useRef } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport MenuItemIcon from './menu-icon.component';\n\n/**\n * Callback function that is invoked when a user selects a menu item. Receives the full\n * `MenuItemContainingCommand` object as an argument.\n */\nexport interface SelectMenuItemHandler {\n (selectedMenuItem: MenuItemContainingCommand): void;\n}\n\nconst simulateKeyPress = (ref: RefObject, keys: KeyboardEventInit[]) => {\n setTimeout(() => {\n keys.forEach((key) => {\n ref.current?.dispatchEvent(new KeyboardEvent('keydown', key));\n });\n }, 0);\n};\n\nconst getMenubarContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey], index) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n \n {getMenubarContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n const itemsWithSeparator = [...groupItems];\n if (groupItems.length > 0 && index < sortedGroupsForColumn.length - 1) {\n itemsWithSeparator.push();\n }\n\n return itemsWithSeparator;\n });\n};\n\ntype PlatformMenubarProps = {\n /** Menu data that is used to populate the Menubar component. */\n menuData: Localized;\n\n /** The handler to use for menu commands. */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Optional callback function that is executed whenever a menu on the Menubar is opened or closed.\n * Helpful for handling updates to the menu, as changing menu data when the menu is opened is not\n * desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n};\n\n/** Menubar component tailored to work with Platform.Bible menu data */\nexport function PlatformMenubar({\n menuData,\n onSelectMenuItem,\n onOpenChange,\n variant,\n}: PlatformMenubarProps) {\n // These refs will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const menubarRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const projectMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const windowMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const layoutMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const helpMenuRef = useRef(undefined!);\n\n const getRefForColumn = (columnKey: string) => {\n switch (columnKey) {\n case 'platform.app':\n return projectMenuRef;\n case 'platform.window':\n return windowMenuRef;\n case 'platform.layout':\n return layoutMenuRef;\n case 'platform.help':\n return helpMenuRef;\n default:\n return undefined;\n }\n };\n\n // This is a quick and dirty way to implement some shortcuts by simulating key presses\n useHotkeys(['alt', 'alt+p', 'alt+l', 'alt+n', 'alt+h'], (event, handler) => {\n event.preventDefault();\n\n const escKey: KeyboardEventInit = { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true };\n const spaceKey: KeyboardEventInit = { key: ' ', code: 'Space', keyCode: 32, bubbles: true };\n\n switch (handler.hotkey) {\n case 'alt':\n simulateKeyPress(projectMenuRef, [escKey]);\n break;\n case 'alt+p':\n projectMenuRef.current?.focus();\n simulateKeyPress(projectMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+l':\n windowMenuRef.current?.focus();\n simulateKeyPress(windowMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+n':\n layoutMenuRef.current?.focus();\n simulateKeyPress(layoutMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+h':\n helpMenuRef.current?.focus();\n simulateKeyPress(helpMenuRef, [escKey, spaceKey]);\n break;\n default:\n break;\n }\n });\n\n useEffect(() => {\n if (!onOpenChange || !menubarRef.current) return;\n\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.attributeName === 'data-state' && mutation.target instanceof HTMLElement) {\n const state = mutation.target.getAttribute('data-state');\n\n if (state === 'open') {\n onOpenChange(true);\n } else {\n onOpenChange(false);\n }\n }\n });\n });\n\n const menubarElement = menubarRef.current;\n const dataStateAttributes = menubarElement.querySelectorAll('[data-state]');\n\n dataStateAttributes.forEach((element) => {\n observer.observe(element, { attributes: true });\n });\n\n return () => observer.disconnect();\n }, [onOpenChange]);\n\n if (!menuData) return undefined;\n\n return (\n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey, column]) => (\n \n \n {typeof column === 'object' && 'label' in column && column.label}\n \n \n \n {getMenubarContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n \n ))}\n \n );\n}\n\nexport default PlatformMenubar;\n","import {\n SelectMenuItemHandler,\n PlatformMenubar,\n} from '@/components/advanced/menus/platform-menubar.component';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { PropsWithChildren, ReactNode, useRef } from 'react';\n\nexport type ToolbarProps = PropsWithChildren<{\n /** The handler to use for menu commands (and eventually toolbar commands). */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component. If empty object, no menus will be\n * shown on the App Menubar\n */\n menuData?: Localized;\n\n /**\n * Optional callback function that is executed whenever a menu on the App Menubar is opened or\n * closed. Helpful for handling updates to the menu, as changing menu data when the menu is opened\n * is not desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the toolbar */\n className?: string;\n\n /**\n * Whether the toolbar should be used as a draggable area for moving the application. This will\n * add an electron specific style `WebkitAppRegion: 'drag'` to the toolbar in order to make it\n * draggable. See:\n * https://www.electronjs.org/docs/latest/tutorial/custom-title-bar#create-a-custom-title-bar\n */\n shouldUseAsAppDragArea?: boolean;\n\n /** Toolbar children to be put at the start of the toolbar (left side in ltr, right side in rtl) */\n appMenuAreaChildren?: ReactNode;\n\n /** Toolbar children to be put at the end of the toolbar (right side in ltr, left side in rtl) */\n configAreaChildren?: ReactNode;\n\n /** Variant of the menubar */\n menubarVariant?: 'default' | 'muted';\n}>;\n\n/**\n * Get tailwind class for reserved space for the window controls / macos \"traffic lights\". Passing\n * 'darwin' will reserve the necessary space for macos traffic lights at the start, otherwise a\n * different amount of space at the end for the window controls.\n *\n * Apply to the toolbar like: `` or ``\n *\n * @param operatingSystem The os platform: 'darwin' (macos) | anything else\n * @returns The class name to apply to the toolbar if os specific space should be reserved\n */\nexport function getToolbarOSReservedSpaceClassName(\n operatingSystem: string | undefined,\n): string | undefined {\n switch (operatingSystem) {\n case undefined:\n return undefined;\n case 'darwin':\n return 'tw-ps-[85px]';\n default:\n return 'tw-pe-[calc(138px+1rem)]';\n }\n}\n\n/**\n * A customizable toolbar component with a menubar, content area, and configure area.\n *\n * This component is designed to be used in the window title bar of an electron application.\n *\n * @param {ToolbarProps} props - The props for the component.\n */\nexport function Toolbar({\n menuData,\n onOpenChange,\n onSelectMenuItem,\n className,\n id,\n children,\n appMenuAreaChildren,\n configAreaChildren,\n shouldUseAsAppDragArea,\n menubarVariant = 'default',\n}: ToolbarProps) {\n // This ref will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const containerRef = useRef(undefined!);\n\n return (\n \n \n {/* App Menu area */}\n
    \n \n {appMenuAreaChildren}\n\n {menuData && (\n \n )}\n
    \n \n\n {/* Content area */}\n \n {children}\n \n\n {/* Configure area */}\n
    \n \n {configAreaChildren}\n
    \n \n \n \n );\n}\n\nexport default Toolbar;\n","import { useState } from 'react';\nimport { LocalizedStringValue, formatReplacementString } from 'platform-bible-utils';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../shadcn-ui/select';\nimport { Label } from '../shadcn-ui/label';\n\n/**\n * Immutable array containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const UI_LANGUAGE_SELECTOR_STRING_KEYS = Object.freeze([\n '%settings_uiLanguageSelector_fallbackLanguages%',\n] as const);\n\nexport type UiLanguageSelectorLocalizedStrings = {\n [localizedUiLanguageSelectorKey in (typeof UI_LANGUAGE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: UiLanguageSelectorLocalizedStrings,\n key: keyof UiLanguageSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\nexport type LanguageInfo = {\n /** The name of the language to be displayed (in its native script) */\n autonym: string;\n /**\n * The name of the language in other languages, so that the language can also be displayed in the\n * current UI language, if known.\n */\n uiNames?: Record;\n /**\n * Other known names of the language (for searching). This can include pejorative names and should\n * never be displayed unless typed by the user.\n */\n otherNames?: string[];\n};\n\nexport type UiLanguageSelectorProps = {\n /** Full set of known languages to display. The keys are valid BCP-47 tags. */\n knownUiLanguages: Record;\n /** IETF BCP-47 language tag of the current primary UI language. `undefined` => 'en' */\n primaryLanguage: string;\n /**\n * Ordered list of fallback language tags to use if the localization key can't be found in the\n * current primary UI language. This list never contains English ('en') because it is the ultimate\n * fallback.\n */\n fallbackLanguages: string[] | undefined;\n /**\n * Handler for when either the primary or the fallback languages change (or both). For this\n * handler, the primary UI language is the first one in the array, followed by the fallback\n * languages in order of decreasing preference.\n */\n onLanguagesChange?: (newUiLanguages: string[]) => void;\n /** Handler for the primary language changes. */\n onPrimaryLanguageChange?: (newPrimaryUiLanguage: string) => void;\n /**\n * Handler for when the fallback languages change. The array contains the fallback languages in\n * order of decreasing preference.\n */\n onFallbackLanguagesChange?: (newFallbackLanguages: string[]) => void;\n /**\n * Map whose keys are localized string keys as contained in UI_LANGUAGE_SELECTOR_STRING_KEYS and\n * whose values are the localized strings (in the current UI language).\n */\n localizedStrings: UiLanguageSelectorLocalizedStrings;\n /** Additional css classes to help with unique styling of the control */\n className?: string;\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A component for selecting the user interface language and managing fallback languages. Allows\n * users to choose a primary UI language and optionally select fallback languages.\n *\n * @param {UiLanguageSelectorProps} props - The props for the component.\n */\nexport function UiLanguageSelector({\n knownUiLanguages,\n primaryLanguage = 'en',\n fallbackLanguages = [],\n onLanguagesChange,\n onPrimaryLanguageChange,\n onFallbackLanguagesChange,\n localizedStrings,\n className,\n id,\n}: UiLanguageSelectorProps) {\n const fallbackLanguagesText = localizeString(\n localizedStrings,\n '%settings_uiLanguageSelector_fallbackLanguages%',\n );\n const [isOpen, setIsOpen] = useState(false);\n\n const handleLanguageChange = (code: string) => {\n if (onPrimaryLanguageChange) onPrimaryLanguageChange(code);\n // REVIEW: Should fallback languages be preserved when primary language changes?\n if (onLanguagesChange)\n onLanguagesChange([code, ...fallbackLanguages.filter((lang) => lang !== code)]);\n if (onFallbackLanguagesChange && fallbackLanguages.find((l) => l === code))\n onFallbackLanguagesChange([...fallbackLanguages.filter((lang) => lang !== code)]);\n setIsOpen(false); // Close the dropdown when a selection is made\n };\n\n /**\n * Gets the display name for the given language. This will typically include the autonym (in the\n * native script), along with the name of the language in the current UI locale if known, with a\n * fallback to the English name (if known).\n *\n * @param {string} lang - The BCP-47 code of the language whose display name is being requested.\n * @param {string} uiLang - The BCP-47 code of the current user-interface language used used to\n * try to look up the name of the language in a form that is likely to be helpful to the user if\n * they do not recognize the autonym.\n * @returns {string} The display name of the language.\n */\n const getLanguageDisplayName = (lang: string, uiLang: string) => {\n const altName =\n uiLang !== lang\n ? (knownUiLanguages[lang]?.uiNames?.[uiLang] ?? knownUiLanguages[lang]?.uiNames?.en)\n : undefined;\n\n return altName\n ? `${knownUiLanguages[lang]?.autonym} (${altName})`\n : knownUiLanguages[lang]?.autonym;\n };\n\n return (\n
    \n {/* Language Selector */}\n setIsOpen(open)}\n >\n \n \n \n \n {Object.keys(knownUiLanguages).map((key) => {\n return (\n \n {getLanguageDisplayName(key, primaryLanguage)}\n \n );\n })}\n \n \n\n {/* Fallback Language Button */}\n {primaryLanguage !== 'en' && (\n
    \n \n
    \n )}\n
    \n );\n}\n\nexport default UiLanguageSelector;\n","import { Label } from '@/components/shadcn-ui/label';\nimport { ReactNode } from 'react';\n\ntype SmartLabelProps = {\n item: string;\n createLabel?: (item: string) => string;\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Create labels with text, react elements (e.g. links), or text + react elements */\nfunction SmartLabel({ item, createLabel, createComplexLabel }: SmartLabelProps): ReactNode {\n if (createLabel) {\n return ;\n }\n if (createComplexLabel) {\n return ;\n }\n return ;\n}\n\nexport default SmartLabel;\n","import { Checkbox } from '@/components/shadcn-ui/checkbox';\nimport { ReactNode } from 'react';\nimport SmartLabel from './smart-label.component';\n\nexport type ChecklistProps = {\n /** Optional string representing the id attribute of the Checklist */\n id?: string;\n /** Optional string representing CSS class name(s) for styling */\n className?: string;\n /** Array of strings representing the checkable items */\n listItems: string[];\n /** Array of strings representing the checked items */\n selectedListItems: string[];\n /**\n * Function that is called when a checkbox item is selected or deselected\n *\n * @param item The string description for this item\n * @param selected True if selected, false if not selected\n */\n handleSelectListItem: (item: string, selected: boolean) => void;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created\n * @returns A string representing the label text for the checkbox associated with that item\n */\n createLabel?: (item: string) => string;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created, including text and any additional\n * elements (e.g. links)\n * @returns A react node representing the label text and any additional elements (e.g. links) for\n * the checkbox associated with that item\n */\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Renders a list of checkboxes. Each checkbox corresponds to an item from the `listItems` array. */\nexport function Checklist({\n id,\n className,\n listItems,\n selectedListItems,\n handleSelectListItem,\n createLabel,\n createComplexLabel,\n}: ChecklistProps) {\n return (\n
    \n {listItems.map((item) => (\n
    \n handleSelectListItem(item, value)}\n />\n \n
    \n ))}\n
    \n );\n}\n\nexport default Checklist;\n","import { cn } from '@/utils/shadcn-ui.util';\nimport { MoreHorizontal } from 'lucide-react';\nimport React, { ReactNode } from 'react';\nimport { Button } from '../shadcn-ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../shadcn-ui/dropdown-menu';\n\n/** Props interface for the ResultsCard base component */\nexport interface ResultsCardProps {\n /** Unique key for the card */\n cardKey: string;\n /** Whether this card is currently selected/focused */\n isSelected: boolean;\n /** Callback function called when the card is clicked */\n onSelect: () => void;\n /** Whether the content of this card are in a denied state */\n isDenied?: boolean;\n /** Whether the card should be hidden */\n isHidden?: boolean;\n /** Additional CSS classes to apply to the card */\n className?: string;\n /** Main content to display on the card */\n children: ReactNode;\n /** Content to show in the dropdown menu when selected */\n dropdownContent?: ReactNode;\n /** Additional content to show below the main content when selected */\n additionalSelectedContent?: ReactNode;\n /** Color to use for the card's accent border */\n accentColor?: string;\n}\n\n/**\n * ResultsCard is a base component for displaying scripture-related results in a card format, even\n * though it is not based on the Card component. It provides common functionality like selection\n * state, dropdown menus, and expandable content.\n */\nexport function ResultsCard({\n cardKey,\n isSelected,\n onSelect,\n isDenied,\n isHidden = false,\n className,\n children,\n dropdownContent,\n additionalSelectedContent,\n accentColor,\n}: ResultsCardProps) {\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n onSelect();\n }\n };\n\n return (\n
  • ,\n);\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSubButton = React.forwardRef<\n HTMLAnchorElement,\n React.ComponentProps<'a'> & {\n asChild?: boolean;\n size?: 'sm' | 'md';\n isActive?: boolean;\n }\n>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {\n const Comp = asChild ? Slot : 'a';\n\n return (\n span:last-child]:tw-truncate [&>svg]:tw-size-4 [&>svg]:tw-shrink-0 [&>svg]:tw-text-sidebar-accent-foreground',\n 'data-[active=true]:tw-bg-sidebar-accent data-[active=true]:tw-text-sidebar-accent-foreground',\n size === 'sm' && 'tw-text-xs',\n size === 'md' && 'tw-text-sm',\n 'group-data-[collapsible=icon]:tw-hidden',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarMenuSubButton.displayName = 'SidebarMenuSubButton';\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupAction,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInput,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSkeleton,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n SidebarProvider,\n SidebarRail,\n SidebarSeparator,\n SidebarTrigger,\n useSidebar,\n};\n","import { ComboBox } from '@/components/basics/combo-box.component';\nimport {\n Sidebar,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n} from '@/components/shadcn-ui/sidebar';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { ScrollText } from 'lucide-react';\nimport { useCallback } from 'react';\n\nexport type SelectedSettingsSidebarItem = {\n label: string;\n projectId?: string;\n};\n\nexport type ProjectInfo = { projectId: string; projectName: string };\n\nexport type SettingsSidebarProps = {\n /** Optional id for testing */\n id?: string;\n\n /** Extension labels from contribution */\n extensionLabels: Record;\n\n /** Project names and ids */\n projectInfo: ProjectInfo[];\n\n /** Handler for selecting a sidebar item */\n handleSelectSidebarItem: (key: string, projectId?: string) => void;\n\n /** The current selected value in the sidebar */\n selectedSidebarItem: SelectedSettingsSidebarItem;\n\n /** Label for the group of extensions setting groups */\n extensionsSidebarGroupLabel: string;\n\n /** Label for the group of projects settings */\n projectsSidebarGroupLabel: string;\n\n /** Placeholder text for the button */\n buttonPlaceholderText: string;\n\n /** Additional css classes to help with unique styling of the sidebar */\n className?: string;\n};\n\n/**\n * The SettingsSidebar component is a sidebar that displays a list of extension settings and project\n * settings. It can be used to navigate to different settings pages. Must be wrapped in a\n * SidebarProvider component otherwise produces errors.\n *\n * @param props - {@link SettingsSidebarProps} The props for the component.\n */\nexport function SettingsSidebar({\n id,\n extensionLabels,\n projectInfo,\n handleSelectSidebarItem,\n selectedSidebarItem,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n className,\n}: SettingsSidebarProps) {\n const handleSelectItem = useCallback(\n (item: string, projectId?: string) => {\n handleSelectSidebarItem(item, projectId);\n },\n [handleSelectSidebarItem],\n );\n\n const getProjectNameFromProjectId = useCallback(\n (projectId: string) => {\n const project = projectInfo.find((info) => info.projectId === projectId);\n return project ? project.projectName : projectId;\n },\n [projectInfo],\n );\n\n const getIsActive: (label: string) => boolean = useCallback(\n (label: string) => !selectedSidebarItem.projectId && label === selectedSidebarItem.label,\n [selectedSidebarItem],\n );\n\n return (\n \n \n \n \n {extensionsSidebarGroupLabel}\n \n \n \n {Object.entries(extensionLabels).map(([key, label]) => (\n \n handleSelectItem(key)}\n isActive={getIsActive(key)}\n >\n {label}\n \n \n ))}\n \n \n \n \n {projectsSidebarGroupLabel}\n \n info.projectId)}\n getOptionLabel={getProjectNameFromProjectId}\n buttonPlaceholder={buttonPlaceholderText}\n onChange={(projectId: string) => {\n const selectedProjectName = getProjectNameFromProjectId(projectId);\n handleSelectItem(selectedProjectName, projectId);\n }}\n value={selectedSidebarItem?.projectId ?? undefined}\n icon={}\n />\n \n \n \n \n );\n}\n\nexport default SettingsSidebar;\n","import { Button } from '@/components/shadcn-ui/button';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Search, X } from 'lucide-react';\nimport { forwardRef } from 'react';\n\n/** Props for the SearchBar component. */\nexport type SearchBarProps = {\n /** Search query for the search bar */\n value: string;\n /**\n * Callback fired to handle the search query is updated\n *\n * @param searchQuery\n */\n onSearch: (searchQuery: string) => void;\n\n /** Optional string that appears in the search bar without a search string */\n placeholder?: string;\n\n /** Optional boolean to set the input base to full width */\n isFullWidth?: boolean;\n\n /** Additional css classes to help with unique styling of the search bar */\n className?: string;\n\n /** Optional boolean to disable the search bar */\n isDisabled?: boolean;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A search bar component with a search icon and a clear button when the search query is not empty.\n *\n * @param {SearchBarProps} props - The props for the component.\n * @param {string} props.value - The search query for the search bar\n * @param {(searchQuery: string) => void} props.onSearch - Callback fired to handle the search query\n * is updated\n * @param {string} [props.placeholder] - Optional string that appears in the search bar without a\n * search string\n * @param {boolean} [props.isFullWidth] - Optional boolean to set the input base to full width\n * @param {string} [props.className] - Additional css classes to help with unique styling of the\n * search bar\n * @param {boolean} [props.isDisabled] - Optional boolean to disable the search bar\n * @param {string} [props.id] - Optional id for the root element\n */\nexport const SearchBar = forwardRef(\n ({ value, onSearch, placeholder, isFullWidth, className, isDisabled = false, id }, inputRef) => {\n const dir: Direction = readDirection();\n\n return (\n
    \n \n onSearch(e.target.value)}\n disabled={isDisabled}\n />\n {value && (\n {\n onSearch('');\n }}\n >\n \n Clear\n \n )}\n
    \n );\n },\n);\n\nSearchBar.displayName = 'SearchBar';\n\nexport default SearchBar;\n","import { SidebarInset, SidebarProvider } from '@/components/shadcn-ui/sidebar';\nimport { PropsWithChildren } from 'react';\nimport { SearchBar } from '@/components/basics/search-bar.component';\nimport { SettingsSidebar, SettingsSidebarProps } from './settings-sidebar.component';\n\nexport type SettingsSidebarContentSearchProps = SettingsSidebarProps &\n PropsWithChildren & {\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n };\n\n/**\n * A component that wraps a search bar and a settings sidebar, providing a way to search and\n * navigate to different settings pages.\n *\n * @param {SettingsSidebarContentSearchProps} props - The props for the component.\n * @param {string} props.id - The id of the sidebar.\n */\nexport function SettingsSidebarContentSearch({\n id,\n extensionLabels,\n projectInfo,\n children,\n handleSelectSidebarItem,\n selectedSidebarItem,\n searchValue,\n onSearch,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n}: SettingsSidebarContentSearchProps) {\n return (\n
    \n
    \n \n
    \n \n \n {children}\n \n
    \n );\n}\n\nexport default SettingsSidebarContentSearch;\n","import { Button } from '@/components/shadcn-ui/button';\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/shadcn-ui/table';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Canon } from '@sillsdev/scripture';\nimport {\n Cell,\n ColumnDef,\n flexRender,\n getCoreRowModel,\n getExpandedRowModel,\n getGroupedRowModel,\n getSortedRowModel,\n GroupingState,\n Row,\n RowSelectionState,\n SortingState,\n useReactTable,\n} from '@tanstack/react-table';\nimport '@/components/advanced/scripture-results-viewer/scripture-results-viewer.component.css';\nimport {\n compareScrRefs,\n formatScrRef,\n ScriptureSelection,\n scrRefToBBBCCCVVV,\n} from 'platform-bible-utils';\nimport { MouseEvent, useEffect, useMemo, useState } from 'react';\nimport { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/**\n * Information (e.g., a checking error or some other type of \"transient\" annotation) about something\n * noteworthy at a specific place in an instance of the Scriptures.\n */\nexport type ScriptureItemDetail = ScriptureSelection & {\n /**\n * Text of the error, note, etc. In the future, we might want to support something more than just\n * text so that a JSX element could be provided with a link or some other controls related to the\n * issue being reported.\n */\n detail: string;\n};\n\n/**\n * A uniquely identifiable source of results that can be displayed in the ScriptureResultsViewer.\n * Generally, the source will be a particular Scripture check, but there may be other types of\n * sources.\n */\nexport type ResultsSource = {\n /**\n * Uniquely identifies the source.\n *\n * @type {string}\n */\n id: string;\n\n /**\n * Name (potentially localized) of the source, suitable for display in the UI.\n *\n * @type {string}\n */\n displayName: string;\n};\n\nexport type ScriptureSrcItemDetail = ScriptureItemDetail & {\n /** Source/type of detail. Can be used for grouping. */\n source: ResultsSource;\n};\n\n/**\n * Represents a set of results keyed by Scripture reference. Generally, the source will be a\n * particular Scripture check, but this type also allows for other types of uniquely identifiable\n * sources.\n */\nexport type ResultsSet = {\n /**\n * The backing source associated with this set of results.\n *\n * @type {ResultsSource}\n */\n source: ResultsSource;\n\n /**\n * Array of Scripture item details (messages keyed by Scripture reference).\n *\n * @type {ScriptureItemDetail[]}\n */\n data: ScriptureItemDetail[];\n};\n\nconst scrBookColId = 'scrBook';\nconst scrRefColId = 'scrRef';\nconst typeColId = 'source';\nconst detailsColId = 'details';\n\nconst defaultScrRefColumnName = 'Scripture Reference';\nconst defaultScrBookGroupName = 'Scripture Book';\nconst defaultTypeColumnName = 'Type';\nconst defaultDetailsColumnName = 'Details';\n\nexport type ScriptureResultsViewerColumnInfo = {\n /** Optional header to display for the Reference column. Default value: 'Scripture Reference'. */\n scriptureReferenceColumnName?: string;\n\n /** Optional text to display to refer to the Scripture book group. Default value: 'Scripture Book'. */\n scriptureBookGroupName?: string;\n\n /** Optional header to display for the Type column. Default value: 'Type'. */\n typeColumnName?: string;\n\n /** Optional header to display for the Details column. Default value: 'Details' */\n detailsColumnName?: string;\n};\n\nexport type ScriptureResultsViewerProps = ScriptureResultsViewerColumnInfo & {\n /** Groups of ScriptureItemDetail objects from particular sources (e.g., Scripture checks) */\n sources: ResultsSet[];\n\n /** Flag indicating whether to display column headers. Default is false. */\n showColumnHeaders?: boolean;\n\n /** Flag indicating whether to display source column. Default is false. */\n showSourceColumn?: boolean;\n\n /** Callback function to notify when a row is selected */\n onRowSelected?: (selectedRow: ScriptureSrcItemDetail | undefined) => void;\n\n /** Optional id attribute for the outermost element */\n id?: string;\n};\n\nfunction getColumns(\n colInfo?: ScriptureResultsViewerColumnInfo,\n showSourceColumn?: boolean,\n): ColumnDef[] {\n const showSrcCol = showSourceColumn ?? false;\n return [\n {\n accessorFn: (row) => `${row.start.book} ${row.start.chapterNum}:${row.start.verseNum}`,\n id: scrBookColId,\n header: colInfo?.scriptureReferenceColumnName ?? defaultScrRefColumnName,\n cell: (info) => {\n const row = info.row.original;\n if (info.row.getIsGrouped()) {\n return Canon.bookIdToEnglishName(row.start.book);\n }\n return info.row.groupingColumnId === scrBookColId ? formatScrRef(row.start) : undefined;\n },\n getGroupingValue: (row) => Canon.bookIdToNumber(row.start.book),\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: true,\n },\n {\n accessorFn: (row) => formatScrRef(row.start),\n id: scrRefColId,\n header: undefined,\n cell: (info) => {\n const row = info.row.original;\n return info.row.getIsGrouped() ? undefined : formatScrRef(row.start);\n },\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: false,\n },\n {\n accessorFn: (row) => row.source.displayName,\n id: typeColId,\n header: showSrcCol ? (colInfo?.typeColumnName ?? defaultTypeColumnName) : undefined,\n cell: (info) => (showSrcCol || info.row.getIsGrouped() ? info.getValue() : undefined),\n getGroupingValue: (row) => row.source.id,\n sortingFn: (a, b) =>\n a.original.source.displayName.localeCompare(b.original.source.displayName),\n enableGrouping: true,\n },\n {\n accessorFn: (row) => row.detail,\n id: detailsColId,\n header: colInfo?.detailsColumnName ?? defaultDetailsColumnName,\n cell: (info) => info.getValue(),\n enableGrouping: false,\n },\n ];\n}\n\nconst toRefOrRange = (scriptureSelection: ScriptureSelection) => {\n if (!('offset' in scriptureSelection.start))\n throw new Error('No offset available in range start');\n if (scriptureSelection.end && !('offset' in scriptureSelection.end))\n throw new Error('No offset available in range end');\n const { offset: offsetStart } = scriptureSelection.start;\n let offsetEnd: number = 0;\n if (scriptureSelection.end) ({ offset: offsetEnd } = scriptureSelection.end);\n if (\n !scriptureSelection.end ||\n compareScrRefs(scriptureSelection.start, scriptureSelection.end) === 0\n )\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}`;\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}-${scrRefToBBBCCCVVV(scriptureSelection.end)}+${offsetEnd}`;\n};\n\nconst getRowKey = (row: ScriptureSrcItemDetail) =>\n `${toRefOrRange({ start: row.start, end: row.end })} ${row.source.displayName} ${row.detail}`;\n\n/**\n * Component to display a combined list of detailed items from one or more sources, where the items\n * are keyed primarily by Scripture reference. This is particularly useful for displaying a list of\n * results from Scripture checks, but more generally could be used to display any \"results\" from any\n * source(s). The component allows for grouping by Scripture book, source, or both. By default, it\n * displays somewhat \"tree-like\" which allows it to be more horizontally compact and intuitive. But\n * it also has the option of displaying as a traditional table with column headings (with or without\n * the source column showing).\n */\nexport function ScriptureResultsViewer({\n sources,\n showColumnHeaders = false,\n showSourceColumn = false,\n scriptureReferenceColumnName,\n scriptureBookGroupName,\n typeColumnName,\n detailsColumnName,\n onRowSelected,\n id,\n}: ScriptureResultsViewerProps) {\n const [grouping, setGrouping] = useState([]);\n const [sorting, setSorting] = useState([{ id: scrBookColId, desc: false }]);\n const [rowSelection, setRowSelection] = useState({});\n\n const scriptureResults = useMemo(\n () =>\n sources.flatMap((source) => {\n return source.data.map((item) => ({\n ...item,\n source: source.source,\n }));\n }),\n [sources],\n );\n\n const columns = useMemo(\n () =>\n getColumns(\n {\n scriptureReferenceColumnName,\n typeColumnName,\n detailsColumnName,\n },\n showSourceColumn,\n ),\n [scriptureReferenceColumnName, typeColumnName, detailsColumnName, showSourceColumn],\n );\n\n useEffect(() => {\n // Ensure sorting is applied correctly when grouped by type\n if (grouping.includes(typeColId)) {\n setSorting([\n { id: typeColId, desc: false },\n { id: scrBookColId, desc: false },\n ]);\n } else {\n setSorting([{ id: scrBookColId, desc: false }]);\n }\n }, [grouping]);\n\n const table = useReactTable({\n data: scriptureResults,\n columns,\n state: {\n grouping,\n sorting,\n rowSelection,\n },\n onGroupingChange: setGrouping,\n onSortingChange: setSorting,\n onRowSelectionChange: setRowSelection,\n getExpandedRowModel: getExpandedRowModel(),\n getGroupedRowModel: getGroupedRowModel(),\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getRowId: getRowKey,\n autoResetExpanded: false,\n enableMultiRowSelection: false,\n enableSubRowSelection: false,\n });\n\n useEffect(() => {\n if (onRowSelected) {\n const selectedRows = table.getSelectedRowModel().rowsById;\n const keys = Object.keys(selectedRows);\n if (keys.length === 1) {\n const selectedRow = scriptureResults.find((row) => getRowKey(row) === keys[0]) || undefined;\n if (selectedRow) onRowSelected(selectedRow);\n }\n }\n }, [rowSelection, scriptureResults, onRowSelected, table]);\n\n // Define possible grouping options\n const scrBookGroupName = scriptureBookGroupName ?? defaultScrBookGroupName;\n const typeGroupName = typeColumnName ?? defaultTypeColumnName;\n\n const groupingOptions = [\n { label: 'No Grouping', value: [] },\n { label: `Group by ${scrBookGroupName}`, value: [scrBookColId] },\n { label: `Group by ${typeGroupName}`, value: [typeColId] },\n {\n label: `Group by ${scrBookGroupName} and ${typeGroupName}`,\n value: [scrBookColId, typeColId],\n },\n {\n label: `Group by ${typeGroupName} and ${scrBookGroupName}`,\n value: [typeColId, scrBookColId],\n },\n ];\n\n const handleSelectChange = (selectedGrouping: string) => {\n setGrouping(JSON.parse(selectedGrouping));\n };\n\n const handleRowClick = (row: Row, event: MouseEvent) => {\n if (!row.getIsGrouped() && !row.getIsSelected()) {\n row.getToggleSelectedHandler()(event);\n }\n };\n\n const getEvenOrOddBandingStyle = (row: Row, index: number) => {\n if (row.getIsGrouped()) return '';\n // UX has now said they don't think they want banding. I'm leaving in the code to\n // set even and odd styles, but there's nothing in the CSS to style them differently.\n // The \"even\" style used to also have tw-bg-neutral-300 (along with even) to create\n // a visual banding effect. That could be added back in if UX changes the decision.\n return cn('banded-row', index % 2 === 0 ? 'even' : 'odd');\n };\n\n const getIndent = (\n groupingState: GroupingState,\n row: Row,\n cell: Cell,\n ) => {\n if (groupingState?.length === 0 || row.depth < cell.column.getGroupedIndex()) return undefined;\n if (row.getIsGrouped()) {\n switch (row.depth) {\n case 1:\n return 'tw-ps-4';\n default:\n return undefined;\n }\n }\n switch (row.depth) {\n case 1:\n return 'tw-ps-8';\n case 2:\n return 'tw-ps-12';\n default:\n return undefined;\n }\n };\n\n return (\n
    \n {!showColumnHeaders && (\n {\n handleSelectChange(value);\n }}\n >\n \n \n \n \n \n {groupingOptions.map((option) => (\n \n {option.label}\n \n ))}\n \n \n \n )}\n \n {showColumnHeaders && (\n \n {table.getHeaderGroups().map((headerGroup) => (\n \n {headerGroup.headers\n .filter((h) => h.column.columnDef.header)\n .map((header) => (\n /* For sticky column headers to work, we probably need to change the default definition of the shadcn Table component. See https://github.com/shadcn-ui/ui/issues/1151 */\n \n {header.isPlaceholder ? undefined : (\n
    \n {header.column.getCanGroup() ? (\n \n {header.column.getIsGrouped() ? `๐Ÿ›‘` : `๐Ÿ‘Š `}\n \n ) : undefined}{' '}\n {flexRender(header.column.columnDef.header, header.getContext())}\n
    \n )}\n
    \n ))}\n
    \n ))}\n
    \n )}\n \n {table.getRowModel().rows.map((row, rowIndex) => {\n const dir: Direction = readDirection();\n return (\n handleRowClick(row, event)}\n >\n {row.getVisibleCells().map((cell) => {\n if (\n cell.getIsPlaceholder() ||\n (cell.column.columnDef.enableGrouping &&\n !cell.getIsGrouped() &&\n (cell.column.columnDef.id !== typeColId || !showSourceColumn))\n )\n return undefined;\n return (\n \n {(() => {\n if (cell.getIsGrouped()) {\n return (\n \n {row.getIsExpanded() && }\n {!row.getIsExpanded() &&\n (dir === 'ltr' ? : )}{' '}\n {flexRender(cell.column.columnDef.cell, cell.getContext())} (\n {row.subRows.length})\n \n );\n }\n\n // if (cell.getIsAggregated()) {\n // flexRender(\n // cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,\n // cell.getContext(),\n // );\n // }\n\n return flexRender(cell.column.columnDef.cell, cell.getContext());\n })()}\n \n );\n })}\n \n );\n })}\n \n
    \n
    \n );\n}\n\nexport default ScriptureResultsViewer;\n","import { getSectionForBook, Section } from 'platform-bible-utils';\n\n/**\n * Filters an array of book IDs to only include books from a specific section\n *\n * @param bookIds Array of book IDs to filter\n * @param section The section to filter by\n * @returns Array of book IDs that belong to the specified section\n */\nexport const getBooksForSection = (bookIds: string[], section: Section) => {\n return bookIds.filter((bookId) => {\n try {\n return getSectionForBook(bookId) === section;\n } catch {\n return false;\n }\n });\n};\n\n/**\n * Checks if all books in a given section are included in the selectedBookIds array\n *\n * @param bookIds Array of all available book IDs\n * @param section The section to check\n * @param selectedBookIds Array of currently selected book IDs\n * @returns True if all books from the specified section are selected, false otherwise\n */\nexport const isSectionFullySelected = (\n bookIds: string[],\n section: Section,\n selectedBookIds: string[],\n) => getBooksForSection(bookIds, section).every((bookId) => selectedBookIds.includes(bookId));\n","import { Button } from '@/components/shadcn-ui/button';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { LanguageStrings, Section } from 'platform-bible-utils';\nimport { getSectionShortName } from '@/components/shared/book.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\n\n/**\n * A button component that represents a scripture section (testament) in the book selector. The\n * button shows a different state when all books in its section are selected and becomes disabled\n * when no books are available in its section.\n */\nfunction SectionButton({\n section,\n availableBookIds,\n selectedBookIds,\n onToggle,\n localizedStrings,\n}: {\n section: Section;\n availableBookIds: string[];\n selectedBookIds: string[];\n onToggle: (section: Section) => void;\n localizedStrings: LanguageStrings;\n}) {\n const isDisabled = getBooksForSection(availableBookIds, section).length === 0;\n\n const sectionOtShortText = localizedStrings['%scripture_section_ot_short%'];\n const sectionNtShortText = localizedStrings['%scripture_section_nt_short%'];\n const sectionDcShortText = localizedStrings['%scripture_section_dc_short%'];\n const sectionExtraShortText = localizedStrings['%scripture_section_extra_short%'];\n\n return (\n onToggle(section)}\n className={cn(\n isSectionFullySelected(availableBookIds, section, selectedBookIds) &&\n !isDisabled &&\n 'tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/70 hover:tw-text-primary-foreground',\n )}\n disabled={isDisabled}\n >\n {getSectionShortName(\n section,\n sectionOtShortText,\n sectionNtShortText,\n sectionDcShortText,\n sectionExtraShortText,\n )}\n \n );\n}\n\nexport default SectionButton;\n","import { BookItem } from '@/components/shared/book-item.component';\nimport { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandList,\n CommandSeparator,\n} from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { Canon } from '@sillsdev/scripture';\nimport { ChevronsUpDown } from 'lucide-react';\nimport { getSectionForBook, LanguageStrings, Section } from 'platform-bible-utils';\nimport {\n getSectionLongName,\n getLocalizedBookName,\n doesBookMatchQuery,\n} from '@/components/shared/book.utils';\nimport { Fragment, MouseEvent, useCallback, useMemo, useRef, useState } from 'react';\nimport { generateCommandValue } from '@/components/shared/book-item.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\nimport SectionButton from './section-button.component';\n\n/** Maximum number of badges to show before collapsing into a \"+X more\" badge */\nconst VISIBLE_BADGES_COUNT = 5;\n/** Maximum number of badges that can be shown without triggering the collapse */\nconst MAX_VISIBLE_BADGES = 6;\n\ntype BookSelectorProps = {\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n /** Callback function that is executed when the book selection changes */\n onChangeSelectedBookIds: (books: string[]) => void;\n /** Object containing the localized strings for the component */\n localizedStrings: LanguageStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n};\n\n/**\n * A component for selecting multiple books from the Bible canon. It provides:\n *\n * - Quick selection buttons for major sections (OT, NT, DC, Extra)\n * - A searchable dropdown with all available books\n * - Support for shift-click range selection\n * - Visual feedback with badges showing selected books\n */\nexport function BookSelector({\n availableBookInfo,\n selectedBookIds,\n onChangeSelectedBookIds,\n localizedStrings,\n localizedBookNames,\n}: BookSelectorProps) {\n const booksSelectedText = localizedStrings['%webView_book_selector_books_selected%'];\n const selectBooksText = localizedStrings['%webView_book_selector_select_books%'];\n const searchBooksText = localizedStrings['%webView_book_selector_search_books%'];\n const selectAllText = localizedStrings['%webView_book_selector_select_all%'];\n const clearAllText = localizedStrings['%webView_book_selector_clear_all%'];\n const noBookFoundText = localizedStrings['%webView_book_selector_no_book_found%'];\n const moreText = localizedStrings['%webView_book_selector_more%'];\n\n const { otLong, ntLong, dcLong, extraLong } = {\n otLong: localizedStrings?.['%scripture_section_ot_long%'],\n ntLong: localizedStrings?.['%scripture_section_nt_long%'],\n dcLong: localizedStrings?.['%scripture_section_dc_long%'],\n extraLong: localizedStrings?.['%scripture_section_extra_long%'],\n };\n\n const [isBooksSelectorOpen, setIsBooksSelectorOpen] = useState(false);\n const [inputValue, setInputValue] = useState('');\n const lastSelectedBookRef = useRef(undefined);\n const lastKeyEventShiftKey = useRef(false);\n\n if (availableBookInfo.length !== Canon.allBookIds.length) {\n throw new Error('availableBookInfo length must match Canon.allBookIds length');\n }\n\n const availableBooksIds = useMemo(() => {\n return Canon.allBookIds.filter(\n (bookId, index) =>\n availableBookInfo[index] === '1' && !Canon.isObsolete(Canon.bookIdToNumber(bookId)),\n );\n }, [availableBookInfo]);\n\n const filteredBooksBySection = useMemo(() => {\n if (!inputValue.trim()) {\n const allBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n availableBooksIds.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n allBooks[section].push(bookId);\n });\n\n return allBooks;\n }\n\n const filteredBooks = availableBooksIds.filter((bookId) =>\n doesBookMatchQuery(bookId, inputValue, localizedBookNames),\n );\n\n const matchingBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n filteredBooks.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n matchingBooks[section].push(bookId);\n });\n\n return matchingBooks;\n }, [availableBooksIds, inputValue, localizedBookNames]);\n\n const toggleBook = useCallback(\n (bookId: string, shiftKey = false) => {\n if (!shiftKey || !lastSelectedBookRef.current) {\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((id) => id !== bookId)\n : [...selectedBookIds, bookId],\n );\n lastSelectedBookRef.current = bookId;\n return;\n }\n\n const lastIndex = availableBooksIds.findIndex((id) => id === lastSelectedBookRef.current);\n const currentIndex = availableBooksIds.findIndex((id) => id === bookId);\n\n if (lastIndex === -1 || currentIndex === -1) return;\n\n const [startIndex, endIndex] = [\n Math.min(lastIndex, currentIndex),\n Math.max(lastIndex, currentIndex),\n ];\n const booksInRange = availableBooksIds.slice(startIndex, endIndex + 1).map((id) => id);\n\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((shortname) => !booksInRange.includes(shortname))\n : [...new Set([...selectedBookIds, ...booksInRange])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleKeyboardSelect = (bookId: string) => {\n toggleBook(bookId, lastKeyEventShiftKey.current);\n lastKeyEventShiftKey.current = false;\n };\n\n const handleMouseDown = (event: MouseEvent, bookId: string) => {\n event.preventDefault();\n toggleBook(bookId, event.shiftKey);\n };\n\n const toggleSection = useCallback(\n (section: Section) => {\n const sectionBooks = getBooksForSection(availableBooksIds, section).map((bookId) => bookId);\n onChangeSelectedBookIds(\n isSectionFullySelected(availableBooksIds, section, selectedBookIds)\n ? selectedBookIds.filter((shortname) => !sectionBooks.includes(shortname))\n : [...new Set([...selectedBookIds, ...sectionBooks])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleSelectAll = () => {\n onChangeSelectedBookIds(availableBooksIds.map((bookId) => bookId));\n };\n\n const handleClearAll = () => {\n onChangeSelectedBookIds([]);\n };\n\n return (\n
    \n
    \n {Object.values(Section).map((section) => {\n return (\n \n );\n })}\n
    \n\n {\n setIsBooksSelectorOpen(open);\n if (!open) {\n setInputValue(''); // Reset search when closing\n }\n }}\n >\n \n \n {selectedBookIds.length > 0\n ? `${booksSelectedText}: ${selectedBookIds.length}`\n : selectBooksText}\n \n \n \n \n {\n if (e.key === 'Enter') {\n // Store shift state in a ref that will be used by onSelect\n lastKeyEventShiftKey.current = e.shiftKey;\n }\n }}\n >\n \n
    \n \n \n
    \n \n {noBookFoundText}\n {Object.values(Section).map((section, index) => {\n const sectionBooks = filteredBooksBySection[section];\n\n if (sectionBooks.length === 0) return undefined;\n\n return (\n \n \n {sectionBooks.map((bookId) => (\n handleKeyboardSelect(bookId)}\n onMouseDown={(event) => handleMouseDown(event, bookId)}\n section={getSectionForBook(bookId)}\n showCheck\n localizedBookNames={localizedBookNames}\n commandValue={generateCommandValue(bookId, localizedBookNames)}\n className=\"tw-flex tw-items-center\"\n />\n ))}\n \n {index < Object.values(Section).length - 1 && }\n \n );\n })}\n \n \n
    \n \n\n {selectedBookIds.length > 0 && (\n
    \n {selectedBookIds\n .slice(\n 0,\n selectedBookIds.length === MAX_VISIBLE_BADGES\n ? MAX_VISIBLE_BADGES\n : VISIBLE_BADGES_COUNT,\n )\n .map((bookId) => (\n \n {getLocalizedBookName(bookId, localizedBookNames)}\n \n ))}\n {selectedBookIds.length > MAX_VISIBLE_BADGES && (\n {`+${selectedBookIds.length - VISIBLE_BADGES_COUNT} ${moreText}`}\n )}\n
    \n )}\n
    \n );\n}\n","import { BookSelector } from '@/components/advanced/scope-selector/book-selector.component';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { RadioGroup, RadioGroupItem } from '@/components/shadcn-ui/radio-group';\nimport { Scope } from '@/components/utils/scripture.util';\nimport { LocalizedStringValue } from 'platform-bible-utils';\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const SCOPE_SELECTOR_STRING_KEYS = Object.freeze([\n '%webView_scope_selector_selected_text%',\n '%webView_scope_selector_current_verse%',\n '%webView_scope_selector_current_chapter%',\n '%webView_scope_selector_current_book%',\n '%webView_scope_selector_choose_books%',\n '%webView_scope_selector_scope%',\n '%webView_scope_selector_select_books%',\n '%webView_book_selector_books_selected%',\n '%webView_book_selector_select_books%',\n '%webView_book_selector_search_books%',\n '%webView_book_selector_select_all%',\n '%webView_book_selector_clear_all%',\n '%webView_book_selector_no_book_found%',\n '%webView_book_selector_more%',\n '%scripture_section_ot_long%',\n '%scripture_section_ot_short%',\n '%scripture_section_nt_long%',\n '%scripture_section_nt_short%',\n '%scripture_section_dc_long%',\n '%scripture_section_dc_short%',\n '%scripture_section_extra_long%',\n '%scripture_section_extra_short%',\n] as const);\n\n/** Type definition for the localized strings used in this component */\nexport type ScopeSelectorLocalizedStrings = {\n [localizedInventoryKey in (typeof SCOPE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: ScopeSelectorLocalizedStrings,\n key: keyof ScopeSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\n/** Props for configuring the ScopeSelector component */\ninterface ScopeSelectorProps {\n /** The current scope selection */\n scope: Scope;\n\n /**\n * Optional array of scopes that should be available in the selector. If not provided, all scopes\n * will be shown as defined in the Scope type\n */\n availableScopes?: Scope[];\n\n /** Callback function that is executed when the user changes the scope selection */\n onScopeChange: (scope: Scope) => void;\n\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n\n /** Callback function that is executed when the user changes the book selection */\n onSelectedBookIdsChange: (books: string[]) => void;\n\n /**\n * Object with all localized strings that the component needs to work well across multiple\n * languages. When using this component with Platform.Bible, you can import\n * `SCOPE_SELECTOR_STRING_KEYS` from this library, pass it in to the Platform's localization hook,\n * and pass the localized keys that are returned by the hook into this prop.\n */\n localizedStrings: ScopeSelectorLocalizedStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n /** Optional ID that is applied to the root element of this component */\n id?: string;\n}\n\n/**\n * A component that allows users to select the scope of their search or operation. Available scopes\n * are defined in the Scope type. When 'selectedBooks' is chosen as the scope, a BookSelector\n * component is displayed to allow users to choose specific books.\n */\nexport function ScopeSelector({\n scope,\n availableScopes,\n onScopeChange,\n availableBookInfo,\n selectedBookIds,\n onSelectedBookIdsChange,\n localizedStrings,\n localizedBookNames,\n id,\n}: ScopeSelectorProps) {\n const selectedTextText = localizeString(\n localizedStrings,\n '%webView_scope_selector_selected_text%',\n );\n const currentVerseText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_verse%',\n );\n const currentChapterText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_chapter%',\n );\n const currentBookText = localizeString(localizedStrings, '%webView_scope_selector_current_book%');\n const chooseBooksText = localizeString(localizedStrings, '%webView_scope_selector_choose_books%');\n const scopeText = localizeString(localizedStrings, '%webView_scope_selector_scope%');\n const selectBooksText = localizeString(localizedStrings, '%webView_scope_selector_select_books%');\n\n const SCOPE_OPTIONS: Array<{ value: Scope; label: string; id: string }> = [\n { value: 'selectedText', label: selectedTextText, id: 'scope-selected-text' },\n { value: 'verse', label: currentVerseText, id: 'scope-verse' },\n { value: 'chapter', label: currentChapterText, id: 'scope-chapter' },\n { value: 'book', label: currentBookText, id: 'scope-book' },\n { value: 'selectedBooks', label: chooseBooksText, id: 'scope-selected' },\n ];\n\n const displayedScopes = availableScopes\n ? SCOPE_OPTIONS.filter((option) => availableScopes.includes(option.value))\n : SCOPE_OPTIONS;\n\n return (\n
    \n
    \n \n \n {displayedScopes.map(({ value, label, id: scopeId }) => (\n
    \n \n \n
    \n ))}\n \n
    \n\n {scope === 'selectedBooks' && (\n
    \n \n \n
    \n )}\n
    \n );\n}\n\nexport default ScopeSelector;\n","import {\n getLocalizeKeyForScrollGroupId,\n LanguageStrings,\n ScrollGroupId,\n} from 'platform-bible-utils';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\n\nconst DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS = {\n [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n [getLocalizeKeyForScrollGroupId(0)]: 'A',\n [getLocalizeKeyForScrollGroupId(1)]: 'B',\n [getLocalizeKeyForScrollGroupId(2)]: 'C',\n [getLocalizeKeyForScrollGroupId(3)]: 'D',\n [getLocalizeKeyForScrollGroupId(4)]: 'E',\n [getLocalizeKeyForScrollGroupId(5)]: 'F',\n [getLocalizeKeyForScrollGroupId(6)]: 'G',\n [getLocalizeKeyForScrollGroupId(7)]: 'H',\n [getLocalizeKeyForScrollGroupId(8)]: 'I',\n [getLocalizeKeyForScrollGroupId(9)]: 'J',\n [getLocalizeKeyForScrollGroupId(10)]: 'K',\n [getLocalizeKeyForScrollGroupId(11)]: 'L',\n [getLocalizeKeyForScrollGroupId(12)]: 'M',\n [getLocalizeKeyForScrollGroupId(13)]: 'N',\n [getLocalizeKeyForScrollGroupId(14)]: 'O',\n [getLocalizeKeyForScrollGroupId(15)]: 'P',\n [getLocalizeKeyForScrollGroupId(16)]: 'Q',\n [getLocalizeKeyForScrollGroupId(17)]: 'R',\n [getLocalizeKeyForScrollGroupId(18)]: 'S',\n [getLocalizeKeyForScrollGroupId(19)]: 'T',\n [getLocalizeKeyForScrollGroupId(20)]: 'U',\n [getLocalizeKeyForScrollGroupId(21)]: 'V',\n [getLocalizeKeyForScrollGroupId(22)]: 'W',\n [getLocalizeKeyForScrollGroupId(23)]: 'X',\n [getLocalizeKeyForScrollGroupId(24)]: 'Y',\n [getLocalizeKeyForScrollGroupId(25)]: 'Z',\n};\n\nexport type ScrollGroupSelectorProps = {\n /**\n * List of scroll group ids to show to the user. Either a `ScrollGroupId` or `undefined` for no\n * scroll group\n */\n availableScrollGroupIds: (ScrollGroupId | undefined)[];\n /** Currently selected scroll group id. `undefined` for no scroll group */\n scrollGroupId: ScrollGroupId | undefined;\n /** Callback function run when the user tries to change the scroll group id */\n onChangeScrollGroupId: (newScrollGroupId: ScrollGroupId | undefined) => void;\n /**\n * Localized strings to use for displaying scroll group ids. Must be an object whose keys are\n * `getLocalizeKeyForScrollGroupId(scrollGroupId)` for all scroll group ids (and `undefined` if\n * included) in {@link ScrollGroupSelectorProps.availableScrollGroupIds} and whose values are the\n * localized strings to use for those scroll group ids.\n *\n * Defaults to English localizations of English alphabet for scroll groups 0-25 (e.g. 0 is A) and\n * ร˜ for `undefined`. Will fill in any that are not provided with these English localizations.\n * Also, if any values match the keys, the English localization will be used. This is useful in\n * case you want to pass in a temporary version of the localized strings while your localized\n * strings load.\n *\n * @example\n *\n * ```typescript\n * const myScrollGroupIdLocalizedStrings = {\n * [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n * [getLocalizeKeyForScrollGroupId(0)]: 'A',\n * [getLocalizeKeyForScrollGroupId(1)]: 'B',\n * [getLocalizeKeyForScrollGroupId(2)]: 'C',\n * [getLocalizeKeyForScrollGroupId(3)]: 'D',\n * [getLocalizeKeyForScrollGroupId(4)]: 'E',\n * };\n * ```\n *\n * @example\n *\n * ```tsx\n * const availableScrollGroupIds = [undefined, 0, 1, 2, 3, 4];\n *\n * const localizeKeys = getLocalizeKeysForScrollGroupIds();\n *\n * const [localizedStrings] = useLocalizedStrings(localizeKeys);\n *\n * ...\n *\n * \n * ```\n */\n localizedStrings?: LanguageStrings;\n\n /** Size of the scroll group dropdown button. Defaults to 'sm' */\n size?: 'default' | 'sm' | 'lg' | 'icon';\n\n /** Additional css classes to help with unique styling */\n className?: string;\n\n /** Optional id for the select element */\n id?: string;\n};\n\n/** Selector component for choosing a scroll group */\nexport function ScrollGroupSelector({\n availableScrollGroupIds,\n scrollGroupId,\n onChangeScrollGroupId,\n localizedStrings = {},\n size = 'sm',\n className,\n id,\n}: ScrollGroupSelectorProps) {\n const localizedStringsDefaulted = {\n ...DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS,\n ...Object.fromEntries(\n Object.entries(localizedStrings).map(\n ([localizedStringKey, localizedStringValue]: [string, string]) => [\n localizedStringKey,\n localizedStringKey === localizedStringValue &&\n localizedStringKey in DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS\n ? DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS[localizedStringKey]\n : localizedStringValue,\n ],\n ),\n ),\n };\n\n const dir: Direction = readDirection();\n\n return (\n \n onChangeScrollGroupId(\n newScrollGroupString === 'undefined' ? undefined : parseInt(newScrollGroupString, 10),\n )\n }\n >\n \n \n \n \n {availableScrollGroupIds.map((scrollGroupOptionId) => (\n \n {localizedStringsDefaulted[getLocalizeKeyForScrollGroupId(scrollGroupOptionId)]}\n \n ))}\n \n \n );\n}\n\nexport default ScrollGroupSelector;\n","import { PropsWithChildren } from 'react';\nimport { Separator } from '@/components/shadcn-ui/separator';\n\n/** Props for the SettingsList component, currently just children */\ntype SettingsListProps = PropsWithChildren;\n\n/**\n * SettingsList component is a wrapper for list items. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param children To populate the list with\n * @returns Formatted div encompassing the children\n */\nexport function SettingsList({ children }: SettingsListProps) {\n return
    {children}
    ;\n}\n\n/** Props for SettingsListItem component */\ntype SettingsListItemProps = PropsWithChildren & {\n /** Primary text of the list item */\n primary: string;\n\n /** Optional text of the list item */\n secondary?: string | undefined;\n\n /** Optional boolean to display a message if the children aren't loaded yet. Defaults to false */\n isLoading?: boolean;\n\n /** Optional message to display if isLoading */\n loadingMessage?: string;\n};\n\n/**\n * SettingsListItem component is a common list item. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListItemProps\n * @returns Formatted div encompassing the list item content\n */\nexport function SettingsListItem({\n primary,\n secondary,\n children,\n isLoading = false,\n loadingMessage,\n}: SettingsListItemProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    \n {secondary}\n

    \n
    \n\n {isLoading ? (\n

    {loadingMessage}

    \n ) : (\n
    {children}
    \n )}\n
    \n );\n}\n\n/** Props for SettingsListHeader component */\ntype SettingsListHeaderProps = {\n /** The primary text of the list header */\n primary: string;\n\n /** Optional secondary text of the list header */\n secondary?: string | undefined;\n\n /** Optional boolean to include a separator underneath the secondary text. Defaults to false */\n includeSeparator?: boolean;\n};\n\n/**\n * SettingsListHeader component displays text above the list\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListHeaderProps\n * @returns Formatted div with list header content\n */\nexport function SettingsListHeader({\n primary,\n secondary,\n includeSeparator = false,\n}: SettingsListHeaderProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    {secondary}

    \n
    \n {includeSeparator ? : ''}\n
    \n );\n}\n","import { GroupsInMultiColumnMenu, Localized } from 'platform-bible-utils';\n\n/**\n * Function that looks up the key of a sub-menu group using the value of it's `menuItem` property.\n *\n * @example\n *\n * ```ts\n * const groups = {\n * 'platform.subMenu': { menuItem: 'platform.subMenuId', order: 1 },\n * 'platform.subSubMenu': { menuItem: 'platform.subSubMenuId', order: 2 },\n * };\n * const id = 'platform.subMenuId';\n * const groupKey = getSubMenuGroupKeyForMenuItemId(groups, id);\n * console.log(groupKey); // Output: 'platform.subMenu'\n * ```\n *\n * @param groups The JSON Object containing the group definitions\n * @param id The value of the `menuItem` property of the group to look up\n * @returns The key of the group that has the `menuItem` property with the value of `id` or\n * `undefined` if no such group exists.\n */\nexport function getSubMenuGroupKeyForMenuItemId(\n groups: Localized,\n id: string,\n): string | undefined {\n return Object.entries(groups).find(\n ([, value]) => 'menuItem' in value && value.menuItem === id,\n )?.[0];\n}\n","import { cn } from '@/utils/shadcn-ui.util';\n\ntype MenuItemIconProps = {\n /** The icon to display */\n icon: string;\n /** The label of the menu item */\n menuLabel: string;\n /** Whether the icon is leading or trailing */\n leading?: boolean;\n};\n\nfunction MenuItemIcon({ icon, menuLabel, leading }: MenuItemIconProps) {\n return icon ? (\n \n ) : undefined;\n}\n\nexport default MenuItemIcon;\n","import {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuPortal,\n DropdownMenuSeparator,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport { MenuIcon } from 'lucide-react';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { Fragment, ReactNode } from 'react';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport { SelectMenuItemHandler } from './platform-menubar.component';\nimport MenuItemIcon from './menu-icon.component';\n\nconst getGroupContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey]) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n\n \n \n {getGroupContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n return groupItems;\n });\n};\n\nexport type TabDropdownMenuProps = {\n /** The handler to use for menu commands */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /** The menu data to show on the dropdown menu */\n menuData: Localized;\n\n /** Defines a string value that labels the current element */\n tabLabel: string;\n\n /** Optional icon for the dropdown menu trigger. Defaults to hamburger icon. */\n icon?: ReactNode;\n\n /** Additional css class(es) to help with unique styling of the tab dropdown menu */\n className?: string;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n\n buttonVariant?: 'default' | 'ghost' | 'outline' | 'secondary';\n\n /** Optional unique identifier */\n id?: string;\n};\n\n/**\n * Dropdown menu designed to be used with Platform.Bible menu data. Column headers are ignored.\n * Column data is separated by a horizontal divider, so groups are not distinguishable. Tooltips are\n * displayed on hovering over menu items, if a tooltip is defined for them.\n *\n * A child component can be passed in to show as an icon on the menu trigger button.\n */\nexport default function TabDropdownMenu({\n onSelectMenuItem,\n menuData,\n tabLabel,\n icon,\n className,\n variant,\n buttonVariant = 'ghost',\n id,\n}: TabDropdownMenuProps) {\n return (\n \n \n \n \n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey], index, array) => (\n \n \n \n {getGroupContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n\n {index < array.length - 1 && }\n \n ))}\n \n \n );\n}\n","import { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport React, { PropsWithChildren, ReactNode } from 'react';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\n\nexport type TabToolbarCommonProps = {\n /**\n * The handler to use for toolbar item commands related to the project menu. Here is a basic\n * example of how to create this:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectProjectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component for the project menu. In an extension,\n * the menu data comes from menus.json in the contributions folder. To access that info, use\n * useMemo to get the WebViewMenu.\n */\n projectMenuData?: Localized;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n\n /** Icon that will be displayed on the Menu Button. Defaults to the hamburger menu icon. */\n menuButtonIcon?: ReactNode;\n};\n\nexport type TabToolbarContainerProps = PropsWithChildren<{\n /** Optional unique identifier */\n id?: string;\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n}>;\n\n/** Wrapper that allows consistent styling for both TabToolbar and TabFloatingMenu. */\nexport const TabToolbarContainer = React.forwardRef(\n ({ id, className, children }, ref) => (\n \n {children}\n \n ),\n);\n\nexport default TabToolbarContainer;\n","import { ReactNode } from 'react';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { Menu, EllipsisVertical } from 'lucide-react';\nimport TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\nexport type TabToolbarProps = TabToolbarCommonProps & {\n /**\n * The handler to use for toolbar item commands related to the tab view menu. Here is a basic\n * example of how to create this from the hello-rock3 extension:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectViewInfoMenuItem: SelectMenuItemHandler;\n\n /** Menu data that is used to populate the Menubar component for the view info menu */\n tabViewMenuData?: Localized;\n\n /**\n * Toolbar children to be put at the start of the the toolbar after the project menu icon (left\n * side in ltr, right side in rtl). Recommended for inner navigation.\n */\n startAreaChildren?: ReactNode;\n\n /** Toolbar children to be put in the center area of the the toolbar. Recommended for tools. */\n centerAreaChildren?: ReactNode;\n\n /**\n * Toolbar children to be put at the end of the the toolbar before the tab view menu icon (right\n * side in ltr, left side in rtl). Recommended for secondary tools and view options.\n */\n endAreaChildren?: ReactNode;\n};\n\n/**\n * Toolbar that holds the project menu icon on one side followed by three different areas/categories\n * for toolbar icons followed by an optional view info menu icon. See the Tab Floating Menu Button\n * component for a menu component that takes up less screen real estate yet is always visible.\n */\nexport function TabToolbar({\n onSelectProjectMenuItem,\n onSelectViewInfoMenuItem,\n projectMenuData,\n tabViewMenuData,\n id,\n className,\n startAreaChildren,\n centerAreaChildren,\n endAreaChildren,\n menuButtonIcon,\n}: TabToolbarProps) {\n return (\n \n {projectMenuData && (\n }\n buttonVariant=\"ghost\"\n />\n )}\n {startAreaChildren && (\n
    \n {startAreaChildren}\n
    \n )}\n {centerAreaChildren && (\n
    \n {centerAreaChildren}\n
    \n )}\n
    \n {tabViewMenuData && (\n }\n className=\"tw-h-full\"\n />\n )}\n {endAreaChildren}\n
    \n
    \n );\n}\n\nexport default TabToolbar;\n","import TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\n/**\n * Renders a TabDropdownMenu with a trigger button that looks like the menuButtonIcon or like the\n * default of three stacked horizontal lines (aka the hamburger). The menu \"floats\" over the content\n * so it is always visible. When clicked, it displays a dropdown menu with the projectMenuData.\n */\nexport function TabFloatingMenu({\n onSelectProjectMenuItem,\n projectMenuData,\n id,\n className,\n menuButtonIcon,\n}: TabToolbarCommonProps) {\n return (\n \n {projectMenuData && (\n \n )}\n \n );\n}\n\nexport default TabFloatingMenu;\n","// adapted from: https://github.com/shadcn-ui/ui/discussions/752\n\n'use client';\n\nimport { TabsContentProps, TabsListProps, TabsTriggerProps } from '@/components/shadcn-ui/tabs';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport React from 'react';\n\nexport type VerticalTabsProps = React.ComponentPropsWithoutRef & {\n className?: string;\n};\n\nexport type LeftTabsTriggerProps = TabsTriggerProps & {\n value: string;\n ref?: React.Ref;\n};\n\n/**\n * Tabs components provide a set of layered sections of contentโ€”known as tab panelsโ€“that are\n * displayed one at a time. These components are built on Radix UI primitives and styled with Shadcn\n * UI. See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/tabs See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/tabs\n */\nexport const VerticalTabs = React.forwardRef<\n React.ElementRef,\n VerticalTabsProps\n>(({ className, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n );\n});\n\nVerticalTabs.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsList = React.forwardRef<\n React.ElementRef,\n TabsListProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsList.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsTrigger = React.forwardRef<\n React.ElementRef,\n LeftTabsTriggerProps\n>(({ className, ...props }, ref) => (\n \n));\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsContent = React.forwardRef<\n React.ElementRef,\n TabsContentProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsContent.displayName = TabsPrimitive.Content.displayName;\n","import { SearchBar } from '@/components/basics/search-bar.component';\nimport {\n VerticalTabs,\n VerticalTabsContent,\n VerticalTabsList,\n VerticalTabsTrigger,\n} from '@/components/basics/tabs-vertical';\nimport { ReactNode } from 'react';\n\nexport type TabKeyValueContent = {\n key: string;\n value: string;\n content: ReactNode;\n};\n\nexport type TabNavigationContentSearchProps = {\n /** List of values and keys for each tab this component should provide */\n tabList: TabKeyValueContent[];\n\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n\n /** Optional placeholder for the search bar */\n searchPlaceholder?: string;\n\n /** Optional title to include in the header */\n headerTitle?: string;\n\n /** Optional className to modify the search input */\n searchClassName?: string;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * TabNavigationContentSearch component provides a vertical tab navigation interface with a search\n * bar at the top. This component allows users to filter content within tabs based on a search\n * query.\n *\n * @param {TabNavigationContentSearchProps} props\n * @param {TabKeyValueContent[]} props.tabList - List of objects containing keys, values, and\n * content for each tab to be displayed.\n * @param {string} props.searchValue - The current value of the search input.\n * @param {function} props.onSearch - Callback function called when the search input changes;\n * receives the new search query as an argument.\n * @param {string} [props.searchPlaceholder] - Optional placeholder text for the search input.\n * @param {string} [props.headerTitle] - Optional title to display above the search input.\n * @param {string} [props.searchClassName] - Optional CSS class name to apply custom styles to the\n * search input.\n * @param {string} [props.id] - Optional id for the root element.\n */\nexport function TabNavigationContentSearch({\n tabList,\n searchValue,\n onSearch,\n searchPlaceholder,\n headerTitle,\n searchClassName,\n id,\n}: TabNavigationContentSearchProps) {\n return (\n
    \n
    \n {headerTitle ?

    {headerTitle}

    : ''}\n \n
    \n \n \n {tabList.map((tab) => (\n \n {tab.value}\n \n ))}\n \n {tabList.map((tab) => (\n \n {tab.content}\n \n ))}\n \n
    \n );\n}\n\nexport default TabNavigationContentSearch;\n","import {\n MenuContext,\n MenuContextProps,\n menuVariants,\n useMenuContext,\n} from '@/context/menu.context';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as MenubarPrimitive from '@radix-ui/react-menubar';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\nimport React from 'react';\n\nfunction MenubarMenu({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarPortal({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarRadioGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarSub({ ...props }: React.ComponentProps) {\n return ;\n}\n\nconst Menubar = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n variant?: MenuContextProps['variant'];\n }\n>(({ className, variant = 'default', ...props }, ref) => {\n /* #region CUSTOM provide context to add variants */\n const contextValue = React.useMemo(\n () => ({\n variant,\n }),\n [variant],\n );\n return (\n \n {/* #endregion CUSTOM */}\n \n \n );\n});\nMenubar.displayName = MenubarPrimitive.Root.displayName;\n\nconst MenubarTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;\n\nconst MenubarSubTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n {children}\n \n \n );\n});\nMenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;\n\nconst MenubarSubContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;\n\nconst MenubarContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n );\n});\nMenubarContent.displayName = MenubarPrimitive.Content.displayName;\n\nconst MenubarItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarItem.displayName = MenubarPrimitive.Item.displayName;\n\nconst MenubarCheckboxItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, checked, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;\n\nconst MenubarRadioItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;\n\nconst MenubarLabel = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => (\n \n));\nMenubarLabel.displayName = MenubarPrimitive.Label.displayName;\n\nconst MenubarSeparator = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nMenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;\n\nfunction MenubarShortcut({ className, ...props }: React.HTMLAttributes) {\n return (\n \n );\n}\nMenubarShortcut.displayname = 'MenubarShortcut';\n\nexport {\n Menubar,\n MenubarCheckboxItem,\n MenubarContent,\n MenubarGroup,\n MenubarItem,\n MenubarLabel,\n MenubarMenu,\n MenubarPortal,\n MenubarRadioGroup,\n MenubarRadioItem,\n MenubarSeparator,\n MenubarShortcut,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n};\n","import {\n Menubar,\n MenubarContent,\n MenubarItem,\n MenubarMenu,\n MenubarSeparator,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n} from '@/components/shadcn-ui/menubar';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { RefObject, useEffect, useRef } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport MenuItemIcon from './menu-icon.component';\n\n/**\n * Callback function that is invoked when a user selects a menu item. Receives the full\n * `MenuItemContainingCommand` object as an argument.\n */\nexport interface SelectMenuItemHandler {\n (selectedMenuItem: MenuItemContainingCommand): void;\n}\n\nconst simulateKeyPress = (ref: RefObject, keys: KeyboardEventInit[]) => {\n setTimeout(() => {\n keys.forEach((key) => {\n ref.current?.dispatchEvent(new KeyboardEvent('keydown', key));\n });\n }, 0);\n};\n\nconst getMenubarContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey], index) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n \n {getMenubarContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n const itemsWithSeparator = [...groupItems];\n if (groupItems.length > 0 && index < sortedGroupsForColumn.length - 1) {\n itemsWithSeparator.push();\n }\n\n return itemsWithSeparator;\n });\n};\n\ntype PlatformMenubarProps = {\n /** Menu data that is used to populate the Menubar component. */\n menuData: Localized;\n\n /** The handler to use for menu commands. */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Optional callback function that is executed whenever a menu on the Menubar is opened or closed.\n * Helpful for handling updates to the menu, as changing menu data when the menu is opened is not\n * desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n};\n\n/** Menubar component tailored to work with Platform.Bible menu data */\nexport function PlatformMenubar({\n menuData,\n onSelectMenuItem,\n onOpenChange,\n variant,\n}: PlatformMenubarProps) {\n // These refs will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const menubarRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const projectMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const windowMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const layoutMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const helpMenuRef = useRef(undefined!);\n\n const getRefForColumn = (columnKey: string) => {\n switch (columnKey) {\n case 'platform.app':\n return projectMenuRef;\n case 'platform.window':\n return windowMenuRef;\n case 'platform.layout':\n return layoutMenuRef;\n case 'platform.help':\n return helpMenuRef;\n default:\n return undefined;\n }\n };\n\n // This is a quick and dirty way to implement some shortcuts by simulating key presses\n useHotkeys(['alt', 'alt+p', 'alt+l', 'alt+n', 'alt+h'], (event, handler) => {\n event.preventDefault();\n\n const escKey: KeyboardEventInit = { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true };\n const spaceKey: KeyboardEventInit = { key: ' ', code: 'Space', keyCode: 32, bubbles: true };\n\n switch (handler.hotkey) {\n case 'alt':\n simulateKeyPress(projectMenuRef, [escKey]);\n break;\n case 'alt+p':\n projectMenuRef.current?.focus();\n simulateKeyPress(projectMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+l':\n windowMenuRef.current?.focus();\n simulateKeyPress(windowMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+n':\n layoutMenuRef.current?.focus();\n simulateKeyPress(layoutMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+h':\n helpMenuRef.current?.focus();\n simulateKeyPress(helpMenuRef, [escKey, spaceKey]);\n break;\n default:\n break;\n }\n });\n\n useEffect(() => {\n if (!onOpenChange || !menubarRef.current) return;\n\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.attributeName === 'data-state' && mutation.target instanceof HTMLElement) {\n const state = mutation.target.getAttribute('data-state');\n\n if (state === 'open') {\n onOpenChange(true);\n } else {\n onOpenChange(false);\n }\n }\n });\n });\n\n const menubarElement = menubarRef.current;\n const dataStateAttributes = menubarElement.querySelectorAll('[data-state]');\n\n dataStateAttributes.forEach((element) => {\n observer.observe(element, { attributes: true });\n });\n\n return () => observer.disconnect();\n }, [onOpenChange]);\n\n if (!menuData) return undefined;\n\n return (\n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey, column]) => (\n \n \n {typeof column === 'object' && 'label' in column && column.label}\n \n \n \n {getMenubarContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n \n ))}\n \n );\n}\n\nexport default PlatformMenubar;\n","import {\n SelectMenuItemHandler,\n PlatformMenubar,\n} from '@/components/advanced/menus/platform-menubar.component';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { PropsWithChildren, ReactNode, useRef } from 'react';\n\nexport type ToolbarProps = PropsWithChildren<{\n /** The handler to use for menu commands (and eventually toolbar commands). */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component. If empty object, no menus will be\n * shown on the App Menubar\n */\n menuData?: Localized;\n\n /**\n * Optional callback function that is executed whenever a menu on the App Menubar is opened or\n * closed. Helpful for handling updates to the menu, as changing menu data when the menu is opened\n * is not desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the toolbar */\n className?: string;\n\n /**\n * Whether the toolbar should be used as a draggable area for moving the application. This will\n * add an electron specific style `WebkitAppRegion: 'drag'` to the toolbar in order to make it\n * draggable. See:\n * https://www.electronjs.org/docs/latest/tutorial/custom-title-bar#create-a-custom-title-bar\n */\n shouldUseAsAppDragArea?: boolean;\n\n /** Toolbar children to be put at the start of the toolbar (left side in ltr, right side in rtl) */\n appMenuAreaChildren?: ReactNode;\n\n /** Toolbar children to be put at the end of the toolbar (right side in ltr, left side in rtl) */\n configAreaChildren?: ReactNode;\n\n /** Variant of the menubar */\n menubarVariant?: 'default' | 'muted';\n}>;\n\n/**\n * Get tailwind class for reserved space for the window controls / macos \"traffic lights\". Passing\n * 'darwin' will reserve the necessary space for macos traffic lights at the start, otherwise a\n * different amount of space at the end for the window controls.\n *\n * Apply to the toolbar like: `` or ``\n *\n * @param operatingSystem The os platform: 'darwin' (macos) | anything else\n * @returns The class name to apply to the toolbar if os specific space should be reserved\n */\nexport function getToolbarOSReservedSpaceClassName(\n operatingSystem: string | undefined,\n): string | undefined {\n switch (operatingSystem) {\n case undefined:\n return undefined;\n case 'darwin':\n return 'tw-ps-[85px]';\n default:\n return 'tw-pe-[calc(138px+1rem)]';\n }\n}\n\n/**\n * A customizable toolbar component with a menubar, content area, and configure area.\n *\n * This component is designed to be used in the window title bar of an electron application.\n *\n * @param {ToolbarProps} props - The props for the component.\n */\nexport function Toolbar({\n menuData,\n onOpenChange,\n onSelectMenuItem,\n className,\n id,\n children,\n appMenuAreaChildren,\n configAreaChildren,\n shouldUseAsAppDragArea,\n menubarVariant = 'default',\n}: ToolbarProps) {\n // This ref will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const containerRef = useRef(undefined!);\n\n return (\n \n \n {/* App Menu area */}\n
    \n \n {appMenuAreaChildren}\n\n {menuData && (\n \n )}\n
    \n \n\n {/* Content area */}\n \n {children}\n \n\n {/* Configure area */}\n
    \n \n {configAreaChildren}\n
    \n \n \n \n );\n}\n\nexport default Toolbar;\n","import { useState } from 'react';\nimport { LocalizedStringValue, formatReplacementString } from 'platform-bible-utils';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../shadcn-ui/select';\nimport { Label } from '../shadcn-ui/label';\n\n/**\n * Immutable array containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const UI_LANGUAGE_SELECTOR_STRING_KEYS = Object.freeze([\n '%settings_uiLanguageSelector_fallbackLanguages%',\n] as const);\n\nexport type UiLanguageSelectorLocalizedStrings = {\n [localizedUiLanguageSelectorKey in (typeof UI_LANGUAGE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: UiLanguageSelectorLocalizedStrings,\n key: keyof UiLanguageSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\nexport type LanguageInfo = {\n /** The name of the language to be displayed (in its native script) */\n autonym: string;\n /**\n * The name of the language in other languages, so that the language can also be displayed in the\n * current UI language, if known.\n */\n uiNames?: Record;\n /**\n * Other known names of the language (for searching). This can include pejorative names and should\n * never be displayed unless typed by the user.\n */\n otherNames?: string[];\n};\n\nexport type UiLanguageSelectorProps = {\n /** Full set of known languages to display. The keys are valid BCP-47 tags. */\n knownUiLanguages: Record;\n /** IETF BCP-47 language tag of the current primary UI language. `undefined` => 'en' */\n primaryLanguage: string;\n /**\n * Ordered list of fallback language tags to use if the localization key can't be found in the\n * current primary UI language. This list never contains English ('en') because it is the ultimate\n * fallback.\n */\n fallbackLanguages: string[] | undefined;\n /**\n * Handler for when either the primary or the fallback languages change (or both). For this\n * handler, the primary UI language is the first one in the array, followed by the fallback\n * languages in order of decreasing preference.\n */\n onLanguagesChange?: (newUiLanguages: string[]) => void;\n /** Handler for the primary language changes. */\n onPrimaryLanguageChange?: (newPrimaryUiLanguage: string) => void;\n /**\n * Handler for when the fallback languages change. The array contains the fallback languages in\n * order of decreasing preference.\n */\n onFallbackLanguagesChange?: (newFallbackLanguages: string[]) => void;\n /**\n * Map whose keys are localized string keys as contained in UI_LANGUAGE_SELECTOR_STRING_KEYS and\n * whose values are the localized strings (in the current UI language).\n */\n localizedStrings: UiLanguageSelectorLocalizedStrings;\n /** Additional css classes to help with unique styling of the control */\n className?: string;\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A component for selecting the user interface language and managing fallback languages. Allows\n * users to choose a primary UI language and optionally select fallback languages.\n *\n * @param {UiLanguageSelectorProps} props - The props for the component.\n */\nexport function UiLanguageSelector({\n knownUiLanguages,\n primaryLanguage = 'en',\n fallbackLanguages = [],\n onLanguagesChange,\n onPrimaryLanguageChange,\n onFallbackLanguagesChange,\n localizedStrings,\n className,\n id,\n}: UiLanguageSelectorProps) {\n const fallbackLanguagesText = localizeString(\n localizedStrings,\n '%settings_uiLanguageSelector_fallbackLanguages%',\n );\n const [isOpen, setIsOpen] = useState(false);\n\n const handleLanguageChange = (code: string) => {\n if (onPrimaryLanguageChange) onPrimaryLanguageChange(code);\n // REVIEW: Should fallback languages be preserved when primary language changes?\n if (onLanguagesChange)\n onLanguagesChange([code, ...fallbackLanguages.filter((lang) => lang !== code)]);\n if (onFallbackLanguagesChange && fallbackLanguages.find((l) => l === code))\n onFallbackLanguagesChange([...fallbackLanguages.filter((lang) => lang !== code)]);\n setIsOpen(false); // Close the dropdown when a selection is made\n };\n\n /**\n * Gets the display name for the given language. This will typically include the autonym (in the\n * native script), along with the name of the language in the current UI locale if known, with a\n * fallback to the English name (if known).\n *\n * @param {string} lang - The BCP-47 code of the language whose display name is being requested.\n * @param {string} uiLang - The BCP-47 code of the current user-interface language used used to\n * try to look up the name of the language in a form that is likely to be helpful to the user if\n * they do not recognize the autonym.\n * @returns {string} The display name of the language.\n */\n const getLanguageDisplayName = (lang: string, uiLang: string) => {\n const altName =\n uiLang !== lang\n ? (knownUiLanguages[lang]?.uiNames?.[uiLang] ?? knownUiLanguages[lang]?.uiNames?.en)\n : undefined;\n\n return altName\n ? `${knownUiLanguages[lang]?.autonym} (${altName})`\n : knownUiLanguages[lang]?.autonym;\n };\n\n return (\n
    \n {/* Language Selector */}\n setIsOpen(open)}\n >\n \n \n \n \n {Object.keys(knownUiLanguages).map((key) => {\n return (\n \n {getLanguageDisplayName(key, primaryLanguage)}\n \n );\n })}\n \n \n\n {/* Fallback Language Button */}\n {primaryLanguage !== 'en' && (\n
    \n \n
    \n )}\n
    \n );\n}\n\nexport default UiLanguageSelector;\n","import { Label } from '@/components/shadcn-ui/label';\nimport { ReactNode } from 'react';\n\ntype SmartLabelProps = {\n item: string;\n createLabel?: (item: string) => string;\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Create labels with text, react elements (e.g. links), or text + react elements */\nfunction SmartLabel({ item, createLabel, createComplexLabel }: SmartLabelProps): ReactNode {\n if (createLabel) {\n return ;\n }\n if (createComplexLabel) {\n return ;\n }\n return ;\n}\n\nexport default SmartLabel;\n","import { Checkbox } from '@/components/shadcn-ui/checkbox';\nimport { ReactNode } from 'react';\nimport SmartLabel from './smart-label.component';\n\nexport type ChecklistProps = {\n /** Optional string representing the id attribute of the Checklist */\n id?: string;\n /** Optional string representing CSS class name(s) for styling */\n className?: string;\n /** Array of strings representing the checkable items */\n listItems: string[];\n /** Array of strings representing the checked items */\n selectedListItems: string[];\n /**\n * Function that is called when a checkbox item is selected or deselected\n *\n * @param item The string description for this item\n * @param selected True if selected, false if not selected\n */\n handleSelectListItem: (item: string, selected: boolean) => void;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created\n * @returns A string representing the label text for the checkbox associated with that item\n */\n createLabel?: (item: string) => string;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created, including text and any additional\n * elements (e.g. links)\n * @returns A react node representing the label text and any additional elements (e.g. links) for\n * the checkbox associated with that item\n */\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Renders a list of checkboxes. Each checkbox corresponds to an item from the `listItems` array. */\nexport function Checklist({\n id,\n className,\n listItems,\n selectedListItems,\n handleSelectListItem,\n createLabel,\n createComplexLabel,\n}: ChecklistProps) {\n return (\n
    \n {listItems.map((item) => (\n
    \n handleSelectListItem(item, value)}\n />\n \n
    \n ))}\n
    \n );\n}\n\nexport default Checklist;\n","import { cn } from '@/utils/shadcn-ui.util';\nimport { MoreHorizontal } from 'lucide-react';\nimport React, { ReactNode } from 'react';\nimport { Button } from '../shadcn-ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../shadcn-ui/dropdown-menu';\n\n/** Props interface for the ResultsCard base component */\nexport interface ResultsCardProps {\n /** Unique key for the card */\n cardKey: string;\n /** Whether this card is currently selected/focused */\n isSelected: boolean;\n /** Callback function called when the card is clicked */\n onSelect: () => void;\n /** Whether the content of this card are in a denied state */\n isDenied?: boolean;\n /** Whether the card should be hidden */\n isHidden?: boolean;\n /** Additional CSS classes to apply to the card */\n className?: string;\n /** Main content to display on the card */\n children: ReactNode;\n /** Content to show in the dropdown menu when selected */\n dropdownContent?: ReactNode;\n /** Additional content to show below the main content when selected */\n additionalSelectedContent?: ReactNode;\n /** Color to use for the card's accent border */\n accentColor?: string;\n}\n\n/**\n * ResultsCard is a base component for displaying scripture-related results in a card format, even\n * though it is not based on the Card component. It provides common functionality like selection\n * state, dropdown menus, and expandable content.\n */\nexport function ResultsCard({\n cardKey,\n isSelected,\n onSelect,\n isDenied,\n isHidden = false,\n className,\n children,\n dropdownContent,\n additionalSelectedContent,\n accentColor,\n}: ResultsCardProps) {\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n onSelect();\n }\n };\n\n return (\n
  • ,\n);\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\n\n/** @inheritdoc SidebarProvider */\nconst SidebarMenuSubButton = React.forwardRef<\n HTMLAnchorElement,\n React.ComponentProps<'a'> & {\n asChild?: boolean;\n size?: 'sm' | 'md';\n isActive?: boolean;\n }\n>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {\n const Comp = asChild ? Slot : 'a';\n\n return (\n span:last-child]:tw-truncate [&>svg]:tw-size-4 [&>svg]:tw-shrink-0 [&>svg]:tw-text-sidebar-accent-foreground',\n 'data-[active=true]:tw-bg-sidebar-accent data-[active=true]:tw-text-sidebar-accent-foreground',\n size === 'sm' && 'tw-text-xs',\n size === 'md' && 'tw-text-sm',\n 'group-data-[collapsible=icon]:tw-hidden',\n className,\n )}\n {...props}\n />\n );\n});\nSidebarMenuSubButton.displayName = 'SidebarMenuSubButton';\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupAction,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInput,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarMenuSkeleton,\n SidebarMenuSub,\n SidebarMenuSubButton,\n SidebarMenuSubItem,\n SidebarProvider,\n SidebarRail,\n SidebarSeparator,\n SidebarTrigger,\n useSidebar,\n};\n","import { ComboBox } from '@/components/basics/combo-box.component';\nimport {\n Sidebar,\n SidebarContent,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupContent,\n SidebarMenu,\n SidebarMenuItem,\n SidebarMenuButton,\n} from '@/components/shadcn-ui/sidebar';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { ScrollText } from 'lucide-react';\nimport { useCallback } from 'react';\n\nexport type SelectedSettingsSidebarItem = {\n label: string;\n projectId?: string;\n};\n\nexport type ProjectInfo = { projectId: string; projectName: string };\n\nexport type SettingsSidebarProps = {\n /** Optional id for testing */\n id?: string;\n\n /** Extension labels from contribution */\n extensionLabels: Record;\n\n /** Project names and ids */\n projectInfo: ProjectInfo[];\n\n /** Handler for selecting a sidebar item */\n handleSelectSidebarItem: (key: string, projectId?: string) => void;\n\n /** The current selected value in the sidebar */\n selectedSidebarItem: SelectedSettingsSidebarItem;\n\n /** Label for the group of extensions setting groups */\n extensionsSidebarGroupLabel: string;\n\n /** Label for the group of projects settings */\n projectsSidebarGroupLabel: string;\n\n /** Placeholder text for the button */\n buttonPlaceholderText: string;\n\n /** Additional css classes to help with unique styling of the sidebar */\n className?: string;\n};\n\n/**\n * The SettingsSidebar component is a sidebar that displays a list of extension settings and project\n * settings. It can be used to navigate to different settings pages. Must be wrapped in a\n * SidebarProvider component otherwise produces errors.\n *\n * @param props - {@link SettingsSidebarProps} The props for the component.\n */\nexport function SettingsSidebar({\n id,\n extensionLabels,\n projectInfo,\n handleSelectSidebarItem,\n selectedSidebarItem,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n className,\n}: SettingsSidebarProps) {\n const handleSelectItem = useCallback(\n (item: string, projectId?: string) => {\n handleSelectSidebarItem(item, projectId);\n },\n [handleSelectSidebarItem],\n );\n\n const getProjectNameFromProjectId = useCallback(\n (projectId: string) => {\n const project = projectInfo.find((info) => info.projectId === projectId);\n return project ? project.projectName : projectId;\n },\n [projectInfo],\n );\n\n const getIsActive: (label: string) => boolean = useCallback(\n (label: string) => !selectedSidebarItem.projectId && label === selectedSidebarItem.label,\n [selectedSidebarItem],\n );\n\n return (\n \n \n \n \n {extensionsSidebarGroupLabel}\n \n \n \n {Object.entries(extensionLabels).map(([key, label]) => (\n \n handleSelectItem(key)}\n isActive={getIsActive(key)}\n >\n {label}\n \n \n ))}\n \n \n \n \n {projectsSidebarGroupLabel}\n \n info.projectId)}\n getOptionLabel={getProjectNameFromProjectId}\n buttonPlaceholder={buttonPlaceholderText}\n onChange={(projectId: string) => {\n const selectedProjectName = getProjectNameFromProjectId(projectId);\n handleSelectItem(selectedProjectName, projectId);\n }}\n value={selectedSidebarItem?.projectId ?? undefined}\n icon={}\n />\n \n \n \n \n );\n}\n\nexport default SettingsSidebar;\n","import { Button } from '@/components/shadcn-ui/button';\nimport { Input } from '@/components/shadcn-ui/input';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Search, X } from 'lucide-react';\nimport { forwardRef } from 'react';\n\n/** Props for the SearchBar component. */\nexport type SearchBarProps = {\n /** Search query for the search bar */\n value: string;\n /**\n * Callback fired to handle the search query is updated\n *\n * @param searchQuery\n */\n onSearch: (searchQuery: string) => void;\n\n /** Optional string that appears in the search bar without a search string */\n placeholder?: string;\n\n /** Optional boolean to set the input base to full width */\n isFullWidth?: boolean;\n\n /** Additional css classes to help with unique styling of the search bar */\n className?: string;\n\n /** Optional boolean to disable the search bar */\n isDisabled?: boolean;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A search bar component with a search icon and a clear button when the search query is not empty.\n *\n * @param {SearchBarProps} props - The props for the component.\n * @param {string} props.value - The search query for the search bar\n * @param {(searchQuery: string) => void} props.onSearch - Callback fired to handle the search query\n * is updated\n * @param {string} [props.placeholder] - Optional string that appears in the search bar without a\n * search string\n * @param {boolean} [props.isFullWidth] - Optional boolean to set the input base to full width\n * @param {string} [props.className] - Additional css classes to help with unique styling of the\n * search bar\n * @param {boolean} [props.isDisabled] - Optional boolean to disable the search bar\n * @param {string} [props.id] - Optional id for the root element\n */\nexport const SearchBar = forwardRef(\n ({ value, onSearch, placeholder, isFullWidth, className, isDisabled = false, id }, inputRef) => {\n const dir: Direction = readDirection();\n\n return (\n
    \n \n onSearch(e.target.value)}\n disabled={isDisabled}\n />\n {value && (\n {\n onSearch('');\n }}\n >\n \n Clear\n \n )}\n
    \n );\n },\n);\n\nSearchBar.displayName = 'SearchBar';\n\nexport default SearchBar;\n","import { SidebarInset, SidebarProvider } from '@/components/shadcn-ui/sidebar';\nimport { PropsWithChildren } from 'react';\nimport { SearchBar } from '@/components/basics/search-bar.component';\nimport { SettingsSidebar, SettingsSidebarProps } from './settings-sidebar.component';\n\nexport type SettingsSidebarContentSearchProps = SettingsSidebarProps &\n PropsWithChildren & {\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n };\n\n/**\n * A component that wraps a search bar and a settings sidebar, providing a way to search and\n * navigate to different settings pages.\n *\n * @param {SettingsSidebarContentSearchProps} props - The props for the component.\n * @param {string} props.id - The id of the sidebar.\n */\nexport function SettingsSidebarContentSearch({\n id,\n extensionLabels,\n projectInfo,\n children,\n handleSelectSidebarItem,\n selectedSidebarItem,\n searchValue,\n onSearch,\n extensionsSidebarGroupLabel,\n projectsSidebarGroupLabel,\n buttonPlaceholderText,\n}: SettingsSidebarContentSearchProps) {\n return (\n
    \n
    \n \n
    \n \n \n {children}\n \n
    \n );\n}\n\nexport default SettingsSidebarContentSearch;\n","import { Button } from '@/components/shadcn-ui/button';\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/components/shadcn-ui/table';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Canon } from '@sillsdev/scripture';\nimport {\n Cell,\n ColumnDef,\n flexRender,\n getCoreRowModel,\n getExpandedRowModel,\n getGroupedRowModel,\n getSortedRowModel,\n GroupingState,\n Row,\n RowSelectionState,\n SortingState,\n useReactTable,\n} from '@tanstack/react-table';\nimport '@/components/advanced/scripture-results-viewer/scripture-results-viewer.component.css';\nimport {\n compareScrRefs,\n formatScrRef,\n ScriptureSelection,\n scrRefToBBBCCCVVV,\n} from 'platform-bible-utils';\nimport { MouseEvent, useEffect, useMemo, useState } from 'react';\nimport { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\n\n/**\n * Information (e.g., a checking error or some other type of \"transient\" annotation) about something\n * noteworthy at a specific place in an instance of the Scriptures.\n */\nexport type ScriptureItemDetail = ScriptureSelection & {\n /**\n * Text of the error, note, etc. In the future, we might want to support something more than just\n * text so that a JSX element could be provided with a link or some other controls related to the\n * issue being reported.\n */\n detail: string;\n};\n\n/**\n * A uniquely identifiable source of results that can be displayed in the ScriptureResultsViewer.\n * Generally, the source will be a particular Scripture check, but there may be other types of\n * sources.\n */\nexport type ResultsSource = {\n /**\n * Uniquely identifies the source.\n *\n * @type {string}\n */\n id: string;\n\n /**\n * Name (potentially localized) of the source, suitable for display in the UI.\n *\n * @type {string}\n */\n displayName: string;\n};\n\nexport type ScriptureSrcItemDetail = ScriptureItemDetail & {\n /** Source/type of detail. Can be used for grouping. */\n source: ResultsSource;\n};\n\n/**\n * Represents a set of results keyed by Scripture reference. Generally, the source will be a\n * particular Scripture check, but this type also allows for other types of uniquely identifiable\n * sources.\n */\nexport type ResultsSet = {\n /**\n * The backing source associated with this set of results.\n *\n * @type {ResultsSource}\n */\n source: ResultsSource;\n\n /**\n * Array of Scripture item details (messages keyed by Scripture reference).\n *\n * @type {ScriptureItemDetail[]}\n */\n data: ScriptureItemDetail[];\n};\n\nconst scrBookColId = 'scrBook';\nconst scrRefColId = 'scrRef';\nconst typeColId = 'source';\nconst detailsColId = 'details';\n\nconst defaultScrRefColumnName = 'Scripture Reference';\nconst defaultScrBookGroupName = 'Scripture Book';\nconst defaultTypeColumnName = 'Type';\nconst defaultDetailsColumnName = 'Details';\n\nexport type ScriptureResultsViewerColumnInfo = {\n /** Optional header to display for the Reference column. Default value: 'Scripture Reference'. */\n scriptureReferenceColumnName?: string;\n\n /** Optional text to display to refer to the Scripture book group. Default value: 'Scripture Book'. */\n scriptureBookGroupName?: string;\n\n /** Optional header to display for the Type column. Default value: 'Type'. */\n typeColumnName?: string;\n\n /** Optional header to display for the Details column. Default value: 'Details' */\n detailsColumnName?: string;\n};\n\nexport type ScriptureResultsViewerProps = ScriptureResultsViewerColumnInfo & {\n /** Groups of ScriptureItemDetail objects from particular sources (e.g., Scripture checks) */\n sources: ResultsSet[];\n\n /** Flag indicating whether to display column headers. Default is false. */\n showColumnHeaders?: boolean;\n\n /** Flag indicating whether to display source column. Default is false. */\n showSourceColumn?: boolean;\n\n /** Callback function to notify when a row is selected */\n onRowSelected?: (selectedRow: ScriptureSrcItemDetail | undefined) => void;\n\n /** Optional id attribute for the outermost element */\n id?: string;\n};\n\nfunction getColumns(\n colInfo?: ScriptureResultsViewerColumnInfo,\n showSourceColumn?: boolean,\n): ColumnDef[] {\n const showSrcCol = showSourceColumn ?? false;\n return [\n {\n accessorFn: (row) => `${row.start.book} ${row.start.chapterNum}:${row.start.verseNum}`,\n id: scrBookColId,\n header: colInfo?.scriptureReferenceColumnName ?? defaultScrRefColumnName,\n cell: (info) => {\n const row = info.row.original;\n if (info.row.getIsGrouped()) {\n return Canon.bookIdToEnglishName(row.start.book);\n }\n return info.row.groupingColumnId === scrBookColId ? formatScrRef(row.start) : undefined;\n },\n getGroupingValue: (row) => Canon.bookIdToNumber(row.start.book),\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: true,\n },\n {\n accessorFn: (row) => formatScrRef(row.start),\n id: scrRefColId,\n header: undefined,\n cell: (info) => {\n const row = info.row.original;\n return info.row.getIsGrouped() ? undefined : formatScrRef(row.start);\n },\n sortingFn: (a, b) => {\n return compareScrRefs(a.original.start, b.original.start);\n },\n enableGrouping: false,\n },\n {\n accessorFn: (row) => row.source.displayName,\n id: typeColId,\n header: showSrcCol ? (colInfo?.typeColumnName ?? defaultTypeColumnName) : undefined,\n cell: (info) => (showSrcCol || info.row.getIsGrouped() ? info.getValue() : undefined),\n getGroupingValue: (row) => row.source.id,\n sortingFn: (a, b) =>\n a.original.source.displayName.localeCompare(b.original.source.displayName),\n enableGrouping: true,\n },\n {\n accessorFn: (row) => row.detail,\n id: detailsColId,\n header: colInfo?.detailsColumnName ?? defaultDetailsColumnName,\n cell: (info) => info.getValue(),\n enableGrouping: false,\n },\n ];\n}\n\nconst toRefOrRange = (scriptureSelection: ScriptureSelection) => {\n if (!('offset' in scriptureSelection.start))\n throw new Error('No offset available in range start');\n if (scriptureSelection.end && !('offset' in scriptureSelection.end))\n throw new Error('No offset available in range end');\n const { offset: offsetStart } = scriptureSelection.start;\n let offsetEnd: number = 0;\n if (scriptureSelection.end) ({ offset: offsetEnd } = scriptureSelection.end);\n if (\n !scriptureSelection.end ||\n compareScrRefs(scriptureSelection.start, scriptureSelection.end) === 0\n )\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}`;\n return `${scrRefToBBBCCCVVV(scriptureSelection.start)}+${offsetStart}-${scrRefToBBBCCCVVV(scriptureSelection.end)}+${offsetEnd}`;\n};\n\nconst getRowKey = (row: ScriptureSrcItemDetail) =>\n `${toRefOrRange({ start: row.start, end: row.end })} ${row.source.displayName} ${row.detail}`;\n\n/**\n * Component to display a combined list of detailed items from one or more sources, where the items\n * are keyed primarily by Scripture reference. This is particularly useful for displaying a list of\n * results from Scripture checks, but more generally could be used to display any \"results\" from any\n * source(s). The component allows for grouping by Scripture book, source, or both. By default, it\n * displays somewhat \"tree-like\" which allows it to be more horizontally compact and intuitive. But\n * it also has the option of displaying as a traditional table with column headings (with or without\n * the source column showing).\n */\nexport function ScriptureResultsViewer({\n sources,\n showColumnHeaders = false,\n showSourceColumn = false,\n scriptureReferenceColumnName,\n scriptureBookGroupName,\n typeColumnName,\n detailsColumnName,\n onRowSelected,\n id,\n}: ScriptureResultsViewerProps) {\n const [grouping, setGrouping] = useState([]);\n const [sorting, setSorting] = useState([{ id: scrBookColId, desc: false }]);\n const [rowSelection, setRowSelection] = useState({});\n\n const scriptureResults = useMemo(\n () =>\n sources.flatMap((source) => {\n return source.data.map((item) => ({\n ...item,\n source: source.source,\n }));\n }),\n [sources],\n );\n\n const columns = useMemo(\n () =>\n getColumns(\n {\n scriptureReferenceColumnName,\n typeColumnName,\n detailsColumnName,\n },\n showSourceColumn,\n ),\n [scriptureReferenceColumnName, typeColumnName, detailsColumnName, showSourceColumn],\n );\n\n useEffect(() => {\n // Ensure sorting is applied correctly when grouped by type\n if (grouping.includes(typeColId)) {\n setSorting([\n { id: typeColId, desc: false },\n { id: scrBookColId, desc: false },\n ]);\n } else {\n setSorting([{ id: scrBookColId, desc: false }]);\n }\n }, [grouping]);\n\n const table = useReactTable({\n data: scriptureResults,\n columns,\n state: {\n grouping,\n sorting,\n rowSelection,\n },\n onGroupingChange: setGrouping,\n onSortingChange: setSorting,\n onRowSelectionChange: setRowSelection,\n getExpandedRowModel: getExpandedRowModel(),\n getGroupedRowModel: getGroupedRowModel(),\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getRowId: getRowKey,\n autoResetExpanded: false,\n enableMultiRowSelection: false,\n enableSubRowSelection: false,\n });\n\n useEffect(() => {\n if (onRowSelected) {\n const selectedRows = table.getSelectedRowModel().rowsById;\n const keys = Object.keys(selectedRows);\n if (keys.length === 1) {\n const selectedRow = scriptureResults.find((row) => getRowKey(row) === keys[0]) || undefined;\n if (selectedRow) onRowSelected(selectedRow);\n }\n }\n }, [rowSelection, scriptureResults, onRowSelected, table]);\n\n // Define possible grouping options\n const scrBookGroupName = scriptureBookGroupName ?? defaultScrBookGroupName;\n const typeGroupName = typeColumnName ?? defaultTypeColumnName;\n\n const groupingOptions = [\n { label: 'No Grouping', value: [] },\n { label: `Group by ${scrBookGroupName}`, value: [scrBookColId] },\n { label: `Group by ${typeGroupName}`, value: [typeColId] },\n {\n label: `Group by ${scrBookGroupName} and ${typeGroupName}`,\n value: [scrBookColId, typeColId],\n },\n {\n label: `Group by ${typeGroupName} and ${scrBookGroupName}`,\n value: [typeColId, scrBookColId],\n },\n ];\n\n const handleSelectChange = (selectedGrouping: string) => {\n setGrouping(JSON.parse(selectedGrouping));\n };\n\n const handleRowClick = (row: Row, event: MouseEvent) => {\n if (!row.getIsGrouped() && !row.getIsSelected()) {\n row.getToggleSelectedHandler()(event);\n }\n };\n\n const getEvenOrOddBandingStyle = (row: Row, index: number) => {\n if (row.getIsGrouped()) return '';\n // UX has now said they don't think they want banding. I'm leaving in the code to\n // set even and odd styles, but there's nothing in the CSS to style them differently.\n // The \"even\" style used to also have tw-bg-neutral-300 (along with even) to create\n // a visual banding effect. That could be added back in if UX changes the decision.\n return cn('banded-row', index % 2 === 0 ? 'even' : 'odd');\n };\n\n const getIndent = (\n groupingState: GroupingState,\n row: Row,\n cell: Cell,\n ) => {\n if (groupingState?.length === 0 || row.depth < cell.column.getGroupedIndex()) return undefined;\n if (row.getIsGrouped()) {\n switch (row.depth) {\n case 1:\n return 'tw-ps-4';\n default:\n return undefined;\n }\n }\n switch (row.depth) {\n case 1:\n return 'tw-ps-8';\n case 2:\n return 'tw-ps-12';\n default:\n return undefined;\n }\n };\n\n return (\n
    \n {!showColumnHeaders && (\n {\n handleSelectChange(value);\n }}\n >\n \n \n \n \n \n {groupingOptions.map((option) => (\n \n {option.label}\n \n ))}\n \n \n \n )}\n \n {showColumnHeaders && (\n \n {table.getHeaderGroups().map((headerGroup) => (\n \n {headerGroup.headers\n .filter((h) => h.column.columnDef.header)\n .map((header) => (\n /* For sticky column headers to work, we probably need to change the default definition of the shadcn Table component. See https://github.com/shadcn-ui/ui/issues/1151 */\n \n {header.isPlaceholder ? undefined : (\n
    \n {header.column.getCanGroup() ? (\n \n {header.column.getIsGrouped() ? `๐Ÿ›‘` : `๐Ÿ‘Š `}\n \n ) : undefined}{' '}\n {flexRender(header.column.columnDef.header, header.getContext())}\n
    \n )}\n
    \n ))}\n
    \n ))}\n
    \n )}\n \n {table.getRowModel().rows.map((row, rowIndex) => {\n const dir: Direction = readDirection();\n return (\n handleRowClick(row, event)}\n >\n {row.getVisibleCells().map((cell) => {\n if (\n cell.getIsPlaceholder() ||\n (cell.column.columnDef.enableGrouping &&\n !cell.getIsGrouped() &&\n (cell.column.columnDef.id !== typeColId || !showSourceColumn))\n )\n return undefined;\n return (\n \n {(() => {\n if (cell.getIsGrouped()) {\n return (\n \n {row.getIsExpanded() && }\n {!row.getIsExpanded() &&\n (dir === 'ltr' ? : )}{' '}\n {flexRender(cell.column.columnDef.cell, cell.getContext())} (\n {row.subRows.length})\n \n );\n }\n\n // if (cell.getIsAggregated()) {\n // flexRender(\n // cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,\n // cell.getContext(),\n // );\n // }\n\n return flexRender(cell.column.columnDef.cell, cell.getContext());\n })()}\n \n );\n })}\n \n );\n })}\n \n
    \n
    \n );\n}\n\nexport default ScriptureResultsViewer;\n","import { getSectionForBook, Section } from 'platform-bible-utils';\n\n/**\n * Filters an array of book IDs to only include books from a specific section\n *\n * @param bookIds Array of book IDs to filter\n * @param section The section to filter by\n * @returns Array of book IDs that belong to the specified section\n */\nexport const getBooksForSection = (bookIds: string[], section: Section) => {\n return bookIds.filter((bookId) => {\n try {\n return getSectionForBook(bookId) === section;\n } catch {\n return false;\n }\n });\n};\n\n/**\n * Checks if all books in a given section are included in the selectedBookIds array\n *\n * @param bookIds Array of all available book IDs\n * @param section The section to check\n * @param selectedBookIds Array of currently selected book IDs\n * @returns True if all books from the specified section are selected, false otherwise\n */\nexport const isSectionFullySelected = (\n bookIds: string[],\n section: Section,\n selectedBookIds: string[],\n) => getBooksForSection(bookIds, section).every((bookId) => selectedBookIds.includes(bookId));\n","import { Button } from '@/components/shadcn-ui/button';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { LanguageStrings, Section } from 'platform-bible-utils';\nimport { getSectionShortName } from '@/components/shared/book.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\n\n/**\n * A button component that represents a scripture section (testament) in the book selector. The\n * button shows a different state when all books in its section are selected and becomes disabled\n * when no books are available in its section.\n */\nfunction SectionButton({\n section,\n availableBookIds,\n selectedBookIds,\n onToggle,\n localizedStrings,\n}: {\n section: Section;\n availableBookIds: string[];\n selectedBookIds: string[];\n onToggle: (section: Section) => void;\n localizedStrings: LanguageStrings;\n}) {\n const isDisabled = getBooksForSection(availableBookIds, section).length === 0;\n\n const sectionOtShortText = localizedStrings['%scripture_section_ot_short%'];\n const sectionNtShortText = localizedStrings['%scripture_section_nt_short%'];\n const sectionDcShortText = localizedStrings['%scripture_section_dc_short%'];\n const sectionExtraShortText = localizedStrings['%scripture_section_extra_short%'];\n\n return (\n onToggle(section)}\n className={cn(\n isSectionFullySelected(availableBookIds, section, selectedBookIds) &&\n !isDisabled &&\n 'tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/70 hover:tw-text-primary-foreground',\n )}\n disabled={isDisabled}\n >\n {getSectionShortName(\n section,\n sectionOtShortText,\n sectionNtShortText,\n sectionDcShortText,\n sectionExtraShortText,\n )}\n \n );\n}\n\nexport default SectionButton;\n","import { BookItem } from '@/components/shared/book-item.component';\nimport { Badge } from '@/components/shadcn-ui/badge';\nimport { Button } from '@/components/shadcn-ui/button';\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandList,\n CommandSeparator,\n} from '@/components/shadcn-ui/command';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn-ui/popover';\nimport { Canon } from '@sillsdev/scripture';\nimport { ChevronsUpDown } from 'lucide-react';\nimport { getSectionForBook, LanguageStrings, Section } from 'platform-bible-utils';\nimport {\n getSectionLongName,\n getLocalizedBookName,\n doesBookMatchQuery,\n} from '@/components/shared/book.utils';\nimport { Fragment, MouseEvent, useCallback, useMemo, useRef, useState } from 'react';\nimport { generateCommandValue } from '@/components/shared/book-item.utils';\nimport { getBooksForSection, isSectionFullySelected } from './scope-selector.utils';\nimport SectionButton from './section-button.component';\n\n/** Maximum number of badges to show before collapsing into a \"+X more\" badge */\nconst VISIBLE_BADGES_COUNT = 5;\n/** Maximum number of badges that can be shown without triggering the collapse */\nconst MAX_VISIBLE_BADGES = 6;\n\ntype BookSelectorProps = {\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n /** Callback function that is executed when the book selection changes */\n onChangeSelectedBookIds: (books: string[]) => void;\n /** Object containing the localized strings for the component */\n localizedStrings: LanguageStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n};\n\n/**\n * A component for selecting multiple books from the Bible canon. It provides:\n *\n * - Quick selection buttons for major sections (OT, NT, DC, Extra)\n * - A searchable dropdown with all available books\n * - Support for shift-click range selection\n * - Visual feedback with badges showing selected books\n */\nexport function BookSelector({\n availableBookInfo,\n selectedBookIds,\n onChangeSelectedBookIds,\n localizedStrings,\n localizedBookNames,\n}: BookSelectorProps) {\n const booksSelectedText = localizedStrings['%webView_book_selector_books_selected%'];\n const selectBooksText = localizedStrings['%webView_book_selector_select_books%'];\n const searchBooksText = localizedStrings['%webView_book_selector_search_books%'];\n const selectAllText = localizedStrings['%webView_book_selector_select_all%'];\n const clearAllText = localizedStrings['%webView_book_selector_clear_all%'];\n const noBookFoundText = localizedStrings['%webView_book_selector_no_book_found%'];\n const moreText = localizedStrings['%webView_book_selector_more%'];\n\n const { otLong, ntLong, dcLong, extraLong } = {\n otLong: localizedStrings?.['%scripture_section_ot_long%'],\n ntLong: localizedStrings?.['%scripture_section_nt_long%'],\n dcLong: localizedStrings?.['%scripture_section_dc_long%'],\n extraLong: localizedStrings?.['%scripture_section_extra_long%'],\n };\n\n const [isBooksSelectorOpen, setIsBooksSelectorOpen] = useState(false);\n const [inputValue, setInputValue] = useState('');\n const lastSelectedBookRef = useRef(undefined);\n const lastKeyEventShiftKey = useRef(false);\n\n if (availableBookInfo.length !== Canon.allBookIds.length) {\n throw new Error('availableBookInfo length must match Canon.allBookIds length');\n }\n\n const availableBooksIds = useMemo(() => {\n return Canon.allBookIds.filter(\n (bookId, index) =>\n availableBookInfo[index] === '1' && !Canon.isObsolete(Canon.bookIdToNumber(bookId)),\n );\n }, [availableBookInfo]);\n\n const filteredBooksBySection = useMemo(() => {\n if (!inputValue.trim()) {\n const allBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n availableBooksIds.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n allBooks[section].push(bookId);\n });\n\n return allBooks;\n }\n\n const filteredBooks = availableBooksIds.filter((bookId) =>\n doesBookMatchQuery(bookId, inputValue, localizedBookNames),\n );\n\n const matchingBooks: Record = {\n [Section.OT]: [],\n [Section.NT]: [],\n [Section.DC]: [],\n [Section.Extra]: [],\n };\n\n filteredBooks.forEach((bookId) => {\n const section = getSectionForBook(bookId);\n matchingBooks[section].push(bookId);\n });\n\n return matchingBooks;\n }, [availableBooksIds, inputValue, localizedBookNames]);\n\n const toggleBook = useCallback(\n (bookId: string, shiftKey = false) => {\n if (!shiftKey || !lastSelectedBookRef.current) {\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((id) => id !== bookId)\n : [...selectedBookIds, bookId],\n );\n lastSelectedBookRef.current = bookId;\n return;\n }\n\n const lastIndex = availableBooksIds.findIndex((id) => id === lastSelectedBookRef.current);\n const currentIndex = availableBooksIds.findIndex((id) => id === bookId);\n\n if (lastIndex === -1 || currentIndex === -1) return;\n\n const [startIndex, endIndex] = [\n Math.min(lastIndex, currentIndex),\n Math.max(lastIndex, currentIndex),\n ];\n const booksInRange = availableBooksIds.slice(startIndex, endIndex + 1).map((id) => id);\n\n onChangeSelectedBookIds(\n selectedBookIds.includes(bookId)\n ? selectedBookIds.filter((shortname) => !booksInRange.includes(shortname))\n : [...new Set([...selectedBookIds, ...booksInRange])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleKeyboardSelect = (bookId: string) => {\n toggleBook(bookId, lastKeyEventShiftKey.current);\n lastKeyEventShiftKey.current = false;\n };\n\n const handleMouseDown = (event: MouseEvent, bookId: string) => {\n event.preventDefault();\n toggleBook(bookId, event.shiftKey);\n };\n\n const toggleSection = useCallback(\n (section: Section) => {\n const sectionBooks = getBooksForSection(availableBooksIds, section).map((bookId) => bookId);\n onChangeSelectedBookIds(\n isSectionFullySelected(availableBooksIds, section, selectedBookIds)\n ? selectedBookIds.filter((shortname) => !sectionBooks.includes(shortname))\n : [...new Set([...selectedBookIds, ...sectionBooks])],\n );\n },\n [selectedBookIds, onChangeSelectedBookIds, availableBooksIds],\n );\n\n const handleSelectAll = () => {\n onChangeSelectedBookIds(availableBooksIds.map((bookId) => bookId));\n };\n\n const handleClearAll = () => {\n onChangeSelectedBookIds([]);\n };\n\n return (\n
    \n
    \n {Object.values(Section).map((section) => {\n return (\n \n );\n })}\n
    \n\n {\n setIsBooksSelectorOpen(open);\n if (!open) {\n setInputValue(''); // Reset search when closing\n }\n }}\n >\n \n \n {selectedBookIds.length > 0\n ? `${booksSelectedText}: ${selectedBookIds.length}`\n : selectBooksText}\n \n \n \n \n {\n if (e.key === 'Enter') {\n // Store shift state in a ref that will be used by onSelect\n lastKeyEventShiftKey.current = e.shiftKey;\n }\n }}\n >\n \n
    \n \n \n
    \n \n {noBookFoundText}\n {Object.values(Section).map((section, index) => {\n const sectionBooks = filteredBooksBySection[section];\n\n if (sectionBooks.length === 0) return undefined;\n\n return (\n \n \n {sectionBooks.map((bookId) => (\n handleKeyboardSelect(bookId)}\n onMouseDown={(event) => handleMouseDown(event, bookId)}\n section={getSectionForBook(bookId)}\n showCheck\n localizedBookNames={localizedBookNames}\n commandValue={generateCommandValue(bookId, localizedBookNames)}\n className=\"tw-flex tw-items-center\"\n />\n ))}\n \n {index < Object.values(Section).length - 1 && }\n \n );\n })}\n \n \n
    \n \n\n {selectedBookIds.length > 0 && (\n
    \n {selectedBookIds\n .slice(\n 0,\n selectedBookIds.length === MAX_VISIBLE_BADGES\n ? MAX_VISIBLE_BADGES\n : VISIBLE_BADGES_COUNT,\n )\n .map((bookId) => (\n \n {getLocalizedBookName(bookId, localizedBookNames)}\n \n ))}\n {selectedBookIds.length > MAX_VISIBLE_BADGES && (\n {`+${selectedBookIds.length - VISIBLE_BADGES_COUNT} ${moreText}`}\n )}\n
    \n )}\n
    \n );\n}\n","import { BookSelector } from '@/components/advanced/scope-selector/book-selector.component';\nimport { Label } from '@/components/shadcn-ui/label';\nimport { RadioGroup, RadioGroupItem } from '@/components/shadcn-ui/radio-group';\nimport { Scope } from '@/components/utils/scripture.util';\nimport { LocalizedStringValue } from 'platform-bible-utils';\n\n/**\n * Object containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const SCOPE_SELECTOR_STRING_KEYS = Object.freeze([\n '%webView_scope_selector_selected_text%',\n '%webView_scope_selector_current_verse%',\n '%webView_scope_selector_current_chapter%',\n '%webView_scope_selector_current_book%',\n '%webView_scope_selector_choose_books%',\n '%webView_scope_selector_scope%',\n '%webView_scope_selector_select_books%',\n '%webView_book_selector_books_selected%',\n '%webView_book_selector_select_books%',\n '%webView_book_selector_search_books%',\n '%webView_book_selector_select_all%',\n '%webView_book_selector_clear_all%',\n '%webView_book_selector_no_book_found%',\n '%webView_book_selector_more%',\n '%scripture_section_ot_long%',\n '%scripture_section_ot_short%',\n '%scripture_section_nt_long%',\n '%scripture_section_nt_short%',\n '%scripture_section_dc_long%',\n '%scripture_section_dc_short%',\n '%scripture_section_extra_long%',\n '%scripture_section_extra_short%',\n] as const);\n\n/** Type definition for the localized strings used in this component */\nexport type ScopeSelectorLocalizedStrings = {\n [localizedInventoryKey in (typeof SCOPE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: ScopeSelectorLocalizedStrings,\n key: keyof ScopeSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\n/** Props for configuring the ScopeSelector component */\ninterface ScopeSelectorProps {\n /** The current scope selection */\n scope: Scope;\n\n /**\n * Optional array of scopes that should be available in the selector. If not provided, all scopes\n * will be shown as defined in the Scope type\n */\n availableScopes?: Scope[];\n\n /** Callback function that is executed when the user changes the scope selection */\n onScopeChange: (scope: Scope) => void;\n\n /**\n * Information about available books, formatted as a 123 character long string as defined in a\n * projects BooksPresent setting\n */\n availableBookInfo: string;\n\n /** Array of currently selected book IDs */\n selectedBookIds: string[];\n\n /** Callback function that is executed when the user changes the book selection */\n onSelectedBookIdsChange: (books: string[]) => void;\n\n /**\n * Object with all localized strings that the component needs to work well across multiple\n * languages. When using this component with Platform.Bible, you can import\n * `SCOPE_SELECTOR_STRING_KEYS` from this library, pass it in to the Platform's localization hook,\n * and pass the localized keys that are returned by the hook into this prop.\n */\n localizedStrings: ScopeSelectorLocalizedStrings;\n /**\n * Optional map of localized book IDs/short names and full names. Key is the (English) book ID,\n * value contains localized versions of the ID and full book name\n */\n localizedBookNames?: Map;\n /** Optional ID that is applied to the root element of this component */\n id?: string;\n}\n\n/**\n * A component that allows users to select the scope of their search or operation. Available scopes\n * are defined in the Scope type. When 'selectedBooks' is chosen as the scope, a BookSelector\n * component is displayed to allow users to choose specific books.\n */\nexport function ScopeSelector({\n scope,\n availableScopes,\n onScopeChange,\n availableBookInfo,\n selectedBookIds,\n onSelectedBookIdsChange,\n localizedStrings,\n localizedBookNames,\n id,\n}: ScopeSelectorProps) {\n const selectedTextText = localizeString(\n localizedStrings,\n '%webView_scope_selector_selected_text%',\n );\n const currentVerseText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_verse%',\n );\n const currentChapterText = localizeString(\n localizedStrings,\n '%webView_scope_selector_current_chapter%',\n );\n const currentBookText = localizeString(localizedStrings, '%webView_scope_selector_current_book%');\n const chooseBooksText = localizeString(localizedStrings, '%webView_scope_selector_choose_books%');\n const scopeText = localizeString(localizedStrings, '%webView_scope_selector_scope%');\n const selectBooksText = localizeString(localizedStrings, '%webView_scope_selector_select_books%');\n\n const SCOPE_OPTIONS: Array<{ value: Scope; label: string; id: string }> = [\n { value: 'selectedText', label: selectedTextText, id: 'scope-selected-text' },\n { value: 'verse', label: currentVerseText, id: 'scope-verse' },\n { value: 'chapter', label: currentChapterText, id: 'scope-chapter' },\n { value: 'book', label: currentBookText, id: 'scope-book' },\n { value: 'selectedBooks', label: chooseBooksText, id: 'scope-selected' },\n ];\n\n const displayedScopes = availableScopes\n ? SCOPE_OPTIONS.filter((option) => availableScopes.includes(option.value))\n : SCOPE_OPTIONS;\n\n return (\n
    \n
    \n \n \n {displayedScopes.map(({ value, label, id: scopeId }) => (\n
    \n \n \n
    \n ))}\n \n
    \n\n {scope === 'selectedBooks' && (\n
    \n \n \n
    \n )}\n
    \n );\n}\n\nexport default ScopeSelector;\n","import {\n getLocalizeKeyForScrollGroupId,\n LanguageStrings,\n ScrollGroupId,\n} from 'platform-bible-utils';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/shadcn-ui/select';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\n\nconst DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS = {\n [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n [getLocalizeKeyForScrollGroupId(0)]: 'A',\n [getLocalizeKeyForScrollGroupId(1)]: 'B',\n [getLocalizeKeyForScrollGroupId(2)]: 'C',\n [getLocalizeKeyForScrollGroupId(3)]: 'D',\n [getLocalizeKeyForScrollGroupId(4)]: 'E',\n [getLocalizeKeyForScrollGroupId(5)]: 'F',\n [getLocalizeKeyForScrollGroupId(6)]: 'G',\n [getLocalizeKeyForScrollGroupId(7)]: 'H',\n [getLocalizeKeyForScrollGroupId(8)]: 'I',\n [getLocalizeKeyForScrollGroupId(9)]: 'J',\n [getLocalizeKeyForScrollGroupId(10)]: 'K',\n [getLocalizeKeyForScrollGroupId(11)]: 'L',\n [getLocalizeKeyForScrollGroupId(12)]: 'M',\n [getLocalizeKeyForScrollGroupId(13)]: 'N',\n [getLocalizeKeyForScrollGroupId(14)]: 'O',\n [getLocalizeKeyForScrollGroupId(15)]: 'P',\n [getLocalizeKeyForScrollGroupId(16)]: 'Q',\n [getLocalizeKeyForScrollGroupId(17)]: 'R',\n [getLocalizeKeyForScrollGroupId(18)]: 'S',\n [getLocalizeKeyForScrollGroupId(19)]: 'T',\n [getLocalizeKeyForScrollGroupId(20)]: 'U',\n [getLocalizeKeyForScrollGroupId(21)]: 'V',\n [getLocalizeKeyForScrollGroupId(22)]: 'W',\n [getLocalizeKeyForScrollGroupId(23)]: 'X',\n [getLocalizeKeyForScrollGroupId(24)]: 'Y',\n [getLocalizeKeyForScrollGroupId(25)]: 'Z',\n};\n\nexport type ScrollGroupSelectorProps = {\n /**\n * List of scroll group ids to show to the user. Either a `ScrollGroupId` or `undefined` for no\n * scroll group\n */\n availableScrollGroupIds: (ScrollGroupId | undefined)[];\n /** Currently selected scroll group id. `undefined` for no scroll group */\n scrollGroupId: ScrollGroupId | undefined;\n /** Callback function run when the user tries to change the scroll group id */\n onChangeScrollGroupId: (newScrollGroupId: ScrollGroupId | undefined) => void;\n /**\n * Localized strings to use for displaying scroll group ids. Must be an object whose keys are\n * `getLocalizeKeyForScrollGroupId(scrollGroupId)` for all scroll group ids (and `undefined` if\n * included) in {@link ScrollGroupSelectorProps.availableScrollGroupIds} and whose values are the\n * localized strings to use for those scroll group ids.\n *\n * Defaults to English localizations of English alphabet for scroll groups 0-25 (e.g. 0 is A) and\n * ร˜ for `undefined`. Will fill in any that are not provided with these English localizations.\n * Also, if any values match the keys, the English localization will be used. This is useful in\n * case you want to pass in a temporary version of the localized strings while your localized\n * strings load.\n *\n * @example\n *\n * ```typescript\n * const myScrollGroupIdLocalizedStrings = {\n * [getLocalizeKeyForScrollGroupId('undefined')]: 'ร˜',\n * [getLocalizeKeyForScrollGroupId(0)]: 'A',\n * [getLocalizeKeyForScrollGroupId(1)]: 'B',\n * [getLocalizeKeyForScrollGroupId(2)]: 'C',\n * [getLocalizeKeyForScrollGroupId(3)]: 'D',\n * [getLocalizeKeyForScrollGroupId(4)]: 'E',\n * };\n * ```\n *\n * @example\n *\n * ```tsx\n * const availableScrollGroupIds = [undefined, 0, 1, 2, 3, 4];\n *\n * const localizeKeys = getLocalizeKeysForScrollGroupIds();\n *\n * const [localizedStrings] = useLocalizedStrings(localizeKeys);\n *\n * ...\n *\n * \n * ```\n */\n localizedStrings?: LanguageStrings;\n\n /** Size of the scroll group dropdown button. Defaults to 'sm' */\n size?: 'default' | 'sm' | 'lg' | 'icon';\n\n /** Additional css classes to help with unique styling */\n className?: string;\n\n /** Optional id for the select element */\n id?: string;\n};\n\n/** Selector component for choosing a scroll group */\nexport function ScrollGroupSelector({\n availableScrollGroupIds,\n scrollGroupId,\n onChangeScrollGroupId,\n localizedStrings = {},\n size = 'sm',\n className,\n id,\n}: ScrollGroupSelectorProps) {\n const localizedStringsDefaulted = {\n ...DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS,\n ...Object.fromEntries(\n Object.entries(localizedStrings).map(\n ([localizedStringKey, localizedStringValue]: [string, string]) => [\n localizedStringKey,\n localizedStringKey === localizedStringValue &&\n localizedStringKey in DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS\n ? DEFAULT_SCROLL_GROUP_LOCALIZED_STRINGS[localizedStringKey]\n : localizedStringValue,\n ],\n ),\n ),\n };\n\n const dir: Direction = readDirection();\n\n return (\n \n onChangeScrollGroupId(\n newScrollGroupString === 'undefined' ? undefined : parseInt(newScrollGroupString, 10),\n )\n }\n >\n \n \n \n \n {availableScrollGroupIds.map((scrollGroupOptionId) => (\n \n {localizedStringsDefaulted[getLocalizeKeyForScrollGroupId(scrollGroupOptionId)]}\n \n ))}\n \n \n );\n}\n\nexport default ScrollGroupSelector;\n","import { PropsWithChildren } from 'react';\nimport { Separator } from '@/components/shadcn-ui/separator';\n\n/** Props for the SettingsList component, currently just children */\ntype SettingsListProps = PropsWithChildren;\n\n/**\n * SettingsList component is a wrapper for list items. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param children To populate the list with\n * @returns Formatted div encompassing the children\n */\nexport function SettingsList({ children }: SettingsListProps) {\n return
    {children}
    ;\n}\n\n/** Props for SettingsListItem component */\ntype SettingsListItemProps = PropsWithChildren & {\n /** Primary text of the list item */\n primary: string;\n\n /** Optional text of the list item */\n secondary?: string | undefined;\n\n /** Optional boolean to display a message if the children aren't loaded yet. Defaults to false */\n isLoading?: boolean;\n\n /** Optional message to display if isLoading */\n loadingMessage?: string;\n};\n\n/**\n * SettingsListItem component is a common list item. Rendered with a formatted div\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListItemProps\n * @returns Formatted div encompassing the list item content\n */\nexport function SettingsListItem({\n primary,\n secondary,\n children,\n isLoading = false,\n loadingMessage,\n}: SettingsListItemProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    \n {secondary}\n

    \n
    \n\n {isLoading ? (\n

    {loadingMessage}

    \n ) : (\n
    {children}
    \n )}\n
    \n );\n}\n\n/** Props for SettingsListHeader component */\ntype SettingsListHeaderProps = {\n /** The primary text of the list header */\n primary: string;\n\n /** Optional secondary text of the list header */\n secondary?: string | undefined;\n\n /** Optional boolean to include a separator underneath the secondary text. Defaults to false */\n includeSeparator?: boolean;\n};\n\n/**\n * SettingsListHeader component displays text above the list\n *\n * @deprecated Jul 18 2025. This component is no longer supported or tested. Use of this component\n * is discouraged and it may be removed in the future.\n * @param SettingsListHeaderProps\n * @returns Formatted div with list header content\n */\nexport function SettingsListHeader({\n primary,\n secondary,\n includeSeparator = false,\n}: SettingsListHeaderProps) {\n return (\n
    \n
    \n

    {primary}

    \n

    {secondary}

    \n
    \n {includeSeparator ? : ''}\n
    \n );\n}\n","import { GroupsInMultiColumnMenu, Localized } from 'platform-bible-utils';\n\n/**\n * Function that looks up the key of a sub-menu group using the value of it's `menuItem` property.\n *\n * @example\n *\n * ```ts\n * const groups = {\n * 'platform.subMenu': { menuItem: 'platform.subMenuId', order: 1 },\n * 'platform.subSubMenu': { menuItem: 'platform.subSubMenuId', order: 2 },\n * };\n * const id = 'platform.subMenuId';\n * const groupKey = getSubMenuGroupKeyForMenuItemId(groups, id);\n * console.log(groupKey); // Output: 'platform.subMenu'\n * ```\n *\n * @param groups The JSON Object containing the group definitions\n * @param id The value of the `menuItem` property of the group to look up\n * @returns The key of the group that has the `menuItem` property with the value of `id` or\n * `undefined` if no such group exists.\n */\nexport function getSubMenuGroupKeyForMenuItemId(\n groups: Localized,\n id: string,\n): string | undefined {\n return Object.entries(groups).find(\n ([, value]) => 'menuItem' in value && value.menuItem === id,\n )?.[0];\n}\n","import { cn } from '@/utils/shadcn-ui.util';\n\ntype MenuItemIconProps = {\n /** The icon to display */\n icon: string;\n /** The label of the menu item */\n menuLabel: string;\n /** Whether the icon is leading or trailing */\n leading?: boolean;\n};\n\nfunction MenuItemIcon({ icon, menuLabel, leading }: MenuItemIconProps) {\n return icon ? (\n \n ) : undefined;\n}\n\nexport default MenuItemIcon;\n","import {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuPortal,\n DropdownMenuSeparator,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from '@/components/shadcn-ui/dropdown-menu';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport { MenuIcon } from 'lucide-react';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { Fragment, ReactNode } from 'react';\nimport { Button } from '@/components/shadcn-ui/button';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport { SelectMenuItemHandler } from './platform-menubar.component';\nimport MenuItemIcon from './menu-icon.component';\n\nconst getGroupContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey]) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n\n \n \n {getGroupContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n return groupItems;\n });\n};\n\nexport type TabDropdownMenuProps = {\n /** The handler to use for menu commands */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /** The menu data to show on the dropdown menu */\n menuData: Localized;\n\n /** Defines a string value that labels the current element */\n tabLabel: string;\n\n /** Optional icon for the dropdown menu trigger. Defaults to hamburger icon. */\n icon?: ReactNode;\n\n /** Additional css class(es) to help with unique styling of the tab dropdown menu */\n className?: string;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n\n buttonVariant?: 'default' | 'ghost' | 'outline' | 'secondary';\n\n /** Optional unique identifier */\n id?: string;\n};\n\n/**\n * Dropdown menu designed to be used with Platform.Bible menu data. Column headers are ignored.\n * Column data is separated by a horizontal divider, so groups are not distinguishable. Tooltips are\n * displayed on hovering over menu items, if a tooltip is defined for them.\n *\n * A child component can be passed in to show as an icon on the menu trigger button.\n */\nexport default function TabDropdownMenu({\n onSelectMenuItem,\n menuData,\n tabLabel,\n icon,\n className,\n variant,\n buttonVariant = 'ghost',\n id,\n}: TabDropdownMenuProps) {\n return (\n \n \n \n \n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey], index, array) => (\n \n \n \n {getGroupContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n\n {index < array.length - 1 && }\n \n ))}\n \n \n );\n}\n","import { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport React, { PropsWithChildren, ReactNode } from 'react';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\n\nexport type TabToolbarCommonProps = {\n /**\n * The handler to use for toolbar item commands related to the project menu. Here is a basic\n * example of how to create this:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectProjectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component for the project menu. In an extension,\n * the menu data comes from menus.json in the contributions folder. To access that info, use\n * useMemo to get the WebViewMenu.\n */\n projectMenuData?: Localized;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n\n /** Icon that will be displayed on the Menu Button. Defaults to the hamburger menu icon. */\n menuButtonIcon?: ReactNode;\n};\n\nexport type TabToolbarContainerProps = PropsWithChildren<{\n /** Optional unique identifier */\n id?: string;\n /** Additional css classes to help with unique styling of the extensible toolbar */\n className?: string;\n}>;\n\n/** Wrapper that allows consistent styling for both TabToolbar and TabFloatingMenu. */\nexport const TabToolbarContainer = React.forwardRef(\n ({ id, className, children }, ref) => (\n \n {children}\n \n ),\n);\n\nexport default TabToolbarContainer;\n","import { ReactNode } from 'react';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { Menu, EllipsisVertical } from 'lucide-react';\nimport TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { SelectMenuItemHandler } from '../menus/platform-menubar.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\nexport type TabToolbarProps = TabToolbarCommonProps & {\n /**\n * The handler to use for toolbar item commands related to the tab view menu. Here is a basic\n * example of how to create this from the hello-rock3 extension:\n *\n * @example\n *\n * ```tsx\n * const projectMenuCommandHandler: SelectMenuItemHandler = async (selectedMenuItem) => {\n * const commandName = selectedMenuItem.command;\n * try {\n * // Assert the more specific type. Assert the more specific type. The menu data should\n * // specify a valid command name here. If not, the error will be caught.\n * // eslint-disable-next-line no-type-assertion/no-type-assertion\n * await papi.commands.sendCommand(commandName as CommandNames);\n * } catch (e) {\n * throw new Error(\n * `handleMenuCommand error: command: ${commandName}. ${JSON.stringify(e)}`,\n * );\n * }\n * };\n * ```\n */\n onSelectViewInfoMenuItem: SelectMenuItemHandler;\n\n /** Menu data that is used to populate the Menubar component for the view info menu */\n tabViewMenuData?: Localized;\n\n /**\n * Toolbar children to be put at the start of the the toolbar after the project menu icon (left\n * side in ltr, right side in rtl). Recommended for inner navigation.\n */\n startAreaChildren?: ReactNode;\n\n /** Toolbar children to be put in the center area of the the toolbar. Recommended for tools. */\n centerAreaChildren?: ReactNode;\n\n /**\n * Toolbar children to be put at the end of the the toolbar before the tab view menu icon (right\n * side in ltr, left side in rtl). Recommended for secondary tools and view options.\n */\n endAreaChildren?: ReactNode;\n};\n\n/**\n * Toolbar that holds the project menu icon on one side followed by three different areas/categories\n * for toolbar icons followed by an optional view info menu icon. See the Tab Floating Menu Button\n * component for a menu component that takes up less screen real estate yet is always visible.\n */\nexport function TabToolbar({\n onSelectProjectMenuItem,\n onSelectViewInfoMenuItem,\n projectMenuData,\n tabViewMenuData,\n id,\n className,\n startAreaChildren,\n centerAreaChildren,\n endAreaChildren,\n menuButtonIcon,\n}: TabToolbarProps) {\n return (\n \n {projectMenuData && (\n }\n buttonVariant=\"ghost\"\n />\n )}\n {startAreaChildren && (\n
    \n {startAreaChildren}\n
    \n )}\n {centerAreaChildren && (\n
    \n {centerAreaChildren}\n
    \n )}\n
    \n {tabViewMenuData && (\n }\n className=\"tw-h-full\"\n />\n )}\n {endAreaChildren}\n
    \n
    \n );\n}\n\nexport default TabToolbar;\n","import TabDropdownMenu from '../menus/tab-dropdown-menu.component';\nimport { TabToolbarCommonProps, TabToolbarContainer } from './tab-toolbar-container.component';\n\n/**\n * Renders a TabDropdownMenu with a trigger button that looks like the menuButtonIcon or like the\n * default of three stacked horizontal lines (aka the hamburger). The menu \"floats\" over the content\n * so it is always visible. When clicked, it displays a dropdown menu with the projectMenuData.\n */\nexport function TabFloatingMenu({\n onSelectProjectMenuItem,\n projectMenuData,\n id,\n className,\n menuButtonIcon,\n}: TabToolbarCommonProps) {\n return (\n \n {projectMenuData && (\n \n )}\n \n );\n}\n\nexport default TabFloatingMenu;\n","// adapted from: https://github.com/shadcn-ui/ui/discussions/752\n\n'use client';\n\nimport { TabsContentProps, TabsListProps, TabsTriggerProps } from '@/components/shadcn-ui/tabs';\nimport { Direction, readDirection } from '@/utils/dir-helper.util';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport React from 'react';\n\nexport type VerticalTabsProps = React.ComponentPropsWithoutRef & {\n className?: string;\n};\n\nexport type LeftTabsTriggerProps = TabsTriggerProps & {\n value: string;\n ref?: React.Ref;\n};\n\n/**\n * Tabs components provide a set of layered sections of contentโ€”known as tab panelsโ€“that are\n * displayed one at a time. These components are built on Radix UI primitives and styled with Shadcn\n * UI. See Shadcn UI Documentation: https://ui.shadcn.com/docs/components/tabs See Radix UI\n * Documentation: https://www.radix-ui.com/primitives/docs/components/tabs\n */\nexport const VerticalTabs = React.forwardRef<\n React.ElementRef,\n VerticalTabsProps\n>(({ className, ...props }, ref) => {\n const dir: Direction = readDirection();\n return (\n \n );\n});\n\nVerticalTabs.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsList = React.forwardRef<\n React.ElementRef,\n TabsListProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsList.displayName = TabsPrimitive.List.displayName;\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsTrigger = React.forwardRef<\n React.ElementRef,\n LeftTabsTriggerProps\n>(({ className, ...props }, ref) => (\n \n));\n\n/** @inheritdoc VerticalTabs */\nexport const VerticalTabsContent = React.forwardRef<\n React.ElementRef,\n TabsContentProps\n>(({ className, ...props }, ref) => (\n \n));\nVerticalTabsContent.displayName = TabsPrimitive.Content.displayName;\n","import { SearchBar } from '@/components/basics/search-bar.component';\nimport {\n VerticalTabs,\n VerticalTabsContent,\n VerticalTabsList,\n VerticalTabsTrigger,\n} from '@/components/basics/tabs-vertical';\nimport { ReactNode } from 'react';\n\nexport type TabKeyValueContent = {\n key: string;\n value: string;\n content: ReactNode;\n};\n\nexport type TabNavigationContentSearchProps = {\n /** List of values and keys for each tab this component should provide */\n tabList: TabKeyValueContent[];\n\n /** The search query in the search bar */\n searchValue: string;\n\n /** Handler to run when the value of the search bar changes */\n onSearch: (searchQuery: string) => void;\n\n /** Optional placeholder for the search bar */\n searchPlaceholder?: string;\n\n /** Optional title to include in the header */\n headerTitle?: string;\n\n /** Optional className to modify the search input */\n searchClassName?: string;\n\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * TabNavigationContentSearch component provides a vertical tab navigation interface with a search\n * bar at the top. This component allows users to filter content within tabs based on a search\n * query.\n *\n * @param {TabNavigationContentSearchProps} props\n * @param {TabKeyValueContent[]} props.tabList - List of objects containing keys, values, and\n * content for each tab to be displayed.\n * @param {string} props.searchValue - The current value of the search input.\n * @param {function} props.onSearch - Callback function called when the search input changes;\n * receives the new search query as an argument.\n * @param {string} [props.searchPlaceholder] - Optional placeholder text for the search input.\n * @param {string} [props.headerTitle] - Optional title to display above the search input.\n * @param {string} [props.searchClassName] - Optional CSS class name to apply custom styles to the\n * search input.\n * @param {string} [props.id] - Optional id for the root element.\n */\nexport function TabNavigationContentSearch({\n tabList,\n searchValue,\n onSearch,\n searchPlaceholder,\n headerTitle,\n searchClassName,\n id,\n}: TabNavigationContentSearchProps) {\n return (\n
    \n
    \n {headerTitle ?

    {headerTitle}

    : ''}\n \n
    \n \n \n {tabList.map((tab) => (\n \n {tab.value}\n \n ))}\n \n {tabList.map((tab) => (\n \n {tab.content}\n \n ))}\n \n
    \n );\n}\n\nexport default TabNavigationContentSearch;\n","import {\n MenuContext,\n MenuContextProps,\n menuVariants,\n useMenuContext,\n} from '@/context/menu.context';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport * as MenubarPrimitive from '@radix-ui/react-menubar';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\nimport React from 'react';\n\nfunction MenubarMenu({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarPortal({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarRadioGroup({ ...props }: React.ComponentProps) {\n return ;\n}\n\nfunction MenubarSub({ ...props }: React.ComponentProps) {\n return ;\n}\n\nconst Menubar = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n variant?: MenuContextProps['variant'];\n }\n>(({ className, variant = 'default', ...props }, ref) => {\n /* #region CUSTOM provide context to add variants */\n const contextValue = React.useMemo(\n () => ({\n variant,\n }),\n [variant],\n );\n return (\n \n {/* #endregion CUSTOM */}\n \n \n );\n});\nMenubar.displayName = MenubarPrimitive.Root.displayName;\n\nconst MenubarTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;\n\nconst MenubarSubTrigger = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n {children}\n \n \n );\n});\nMenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;\n\nconst MenubarSubContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;\n\nconst MenubarContent = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n );\n});\nMenubarContent.displayName = MenubarPrimitive.Content.displayName;\n\nconst MenubarItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n );\n});\nMenubarItem.displayName = MenubarPrimitive.Item.displayName;\n\nconst MenubarCheckboxItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, checked, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;\n\nconst MenubarRadioItem = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => {\n const context = useMenuContext(); // CUSTOM use context to add variants\n return (\n \n \n \n \n \n \n {children}\n \n );\n});\nMenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;\n\nconst MenubarLabel = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef & {\n inset?: boolean;\n }\n>(({ className, inset, ...props }, ref) => (\n \n));\nMenubarLabel.displayName = MenubarPrimitive.Label.displayName;\n\nconst MenubarSeparator = React.forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nMenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;\n\nfunction MenubarShortcut({ className, ...props }: React.HTMLAttributes) {\n return (\n \n );\n}\nMenubarShortcut.displayname = 'MenubarShortcut';\n\nexport {\n Menubar,\n MenubarCheckboxItem,\n MenubarContent,\n MenubarGroup,\n MenubarItem,\n MenubarLabel,\n MenubarMenu,\n MenubarPortal,\n MenubarRadioGroup,\n MenubarRadioItem,\n MenubarSeparator,\n MenubarShortcut,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n};\n","import {\n Menubar,\n MenubarContent,\n MenubarItem,\n MenubarMenu,\n MenubarSeparator,\n MenubarSub,\n MenubarSubContent,\n MenubarSubTrigger,\n MenubarTrigger,\n} from '@/components/shadcn-ui/menubar';\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from '@/components/shadcn-ui/tooltip';\nimport {\n GroupsInMultiColumnMenu,\n Localized,\n MenuItemContainingCommand,\n MenuItemContainingSubmenu,\n MultiColumnMenu,\n} from 'platform-bible-utils';\nimport { RefObject, useEffect, useRef } from 'react';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { getSubMenuGroupKeyForMenuItemId } from './menu.util';\nimport MenuItemIcon from './menu-icon.component';\n\n/**\n * Callback function that is invoked when a user selects a menu item. Receives the full\n * `MenuItemContainingCommand` object as an argument.\n */\nexport interface SelectMenuItemHandler {\n (selectedMenuItem: MenuItemContainingCommand): void;\n}\n\nconst simulateKeyPress = (ref: RefObject, keys: KeyboardEventInit[]) => {\n setTimeout(() => {\n keys.forEach((key) => {\n ref.current?.dispatchEvent(new KeyboardEvent('keydown', key));\n });\n }, 0);\n};\n\nconst getMenubarContent = (\n groups: Localized,\n items: Localized<(MenuItemContainingCommand | MenuItemContainingSubmenu)[]>,\n columnOrSubMenuKey: string | undefined,\n onSelectMenuItem: SelectMenuItemHandler,\n) => {\n if (!columnOrSubMenuKey) return undefined;\n\n const sortedGroupsForColumn = Object.entries(groups)\n .filter(\n ([key, group]) =>\n ('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKey,\n )\n .sort(([, a], [, b]) => a.order - b.order);\n\n return sortedGroupsForColumn.flatMap(([groupKey], index) => {\n const groupItems = items\n .filter((item) => item.group === groupKey)\n .sort((a, b) => a.order - b.order)\n .map((item: Localized) => {\n return (\n \n \n {'command' in item ? (\n {\n // Since the item has a command, we know it is a MenuItemContainingCommand.\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n onSelectMenuItem(item as MenuItemContainingCommand);\n }}\n >\n {item.iconPathBefore && (\n \n )}\n {item.label}\n {item.iconPathAfter && (\n \n )}\n \n ) : (\n \n {item.label}\n \n {getMenubarContent(\n groups,\n items,\n getSubMenuGroupKeyForMenuItemId(groups, item.id),\n onSelectMenuItem,\n )}\n \n \n )}\n \n {item.tooltip && {item.tooltip}}\n \n );\n });\n\n const itemsWithSeparator = [...groupItems];\n if (groupItems.length > 0 && index < sortedGroupsForColumn.length - 1) {\n itemsWithSeparator.push();\n }\n\n return itemsWithSeparator;\n });\n};\n\ntype PlatformMenubarProps = {\n /** Menu data that is used to populate the Menubar component. */\n menuData: Localized;\n\n /** The handler to use for menu commands. */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Optional callback function that is executed whenever a menu on the Menubar is opened or closed.\n * Helpful for handling updates to the menu, as changing menu data when the menu is opened is not\n * desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Style variant for the app menubar component. */\n variant?: 'default' | 'muted';\n};\n\n/** Menubar component tailored to work with Platform.Bible menu data */\nexport function PlatformMenubar({\n menuData,\n onSelectMenuItem,\n onOpenChange,\n variant,\n}: PlatformMenubarProps) {\n // These refs will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const menubarRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const projectMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const windowMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const layoutMenuRef = useRef(undefined!);\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const helpMenuRef = useRef(undefined!);\n\n const getRefForColumn = (columnKey: string) => {\n switch (columnKey) {\n case 'platform.app':\n return projectMenuRef;\n case 'platform.window':\n return windowMenuRef;\n case 'platform.layout':\n return layoutMenuRef;\n case 'platform.help':\n return helpMenuRef;\n default:\n return undefined;\n }\n };\n\n // This is a quick and dirty way to implement some shortcuts by simulating key presses\n useHotkeys(['alt', 'alt+p', 'alt+l', 'alt+n', 'alt+h'], (event, handler) => {\n event.preventDefault();\n\n const escKey: KeyboardEventInit = { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true };\n const spaceKey: KeyboardEventInit = { key: ' ', code: 'Space', keyCode: 32, bubbles: true };\n\n switch (handler.hotkey) {\n case 'alt':\n simulateKeyPress(projectMenuRef, [escKey]);\n break;\n case 'alt+p':\n projectMenuRef.current?.focus();\n simulateKeyPress(projectMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+l':\n windowMenuRef.current?.focus();\n simulateKeyPress(windowMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+n':\n layoutMenuRef.current?.focus();\n simulateKeyPress(layoutMenuRef, [escKey, spaceKey]);\n break;\n case 'alt+h':\n helpMenuRef.current?.focus();\n simulateKeyPress(helpMenuRef, [escKey, spaceKey]);\n break;\n default:\n break;\n }\n });\n\n useEffect(() => {\n if (!onOpenChange || !menubarRef.current) return;\n\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.attributeName === 'data-state' && mutation.target instanceof HTMLElement) {\n const state = mutation.target.getAttribute('data-state');\n\n if (state === 'open') {\n onOpenChange(true);\n } else {\n onOpenChange(false);\n }\n }\n });\n });\n\n const menubarElement = menubarRef.current;\n const dataStateAttributes = menubarElement.querySelectorAll('[data-state]');\n\n dataStateAttributes.forEach((element) => {\n observer.observe(element, { attributes: true });\n });\n\n return () => observer.disconnect();\n }, [onOpenChange]);\n\n if (!menuData) return undefined;\n\n return (\n \n {Object.entries(menuData.columns)\n .filter(([, column]) => typeof column === 'object')\n .sort(([, a], [, b]) => {\n if (typeof a === 'boolean' || typeof b === 'boolean') return 0;\n return a.order - b.order;\n })\n .map(([columnKey, column]) => (\n \n \n {typeof column === 'object' && 'label' in column && column.label}\n \n \n \n {getMenubarContent(menuData.groups, menuData.items, columnKey, onSelectMenuItem)}\n \n \n \n ))}\n \n );\n}\n\nexport default PlatformMenubar;\n","import {\n SelectMenuItemHandler,\n PlatformMenubar,\n} from '@/components/advanced/menus/platform-menubar.component';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Localized, MultiColumnMenu } from 'platform-bible-utils';\nimport { PropsWithChildren, ReactNode, useRef } from 'react';\n\nexport type ToolbarProps = PropsWithChildren<{\n /** The handler to use for menu commands (and eventually toolbar commands). */\n onSelectMenuItem: SelectMenuItemHandler;\n\n /**\n * Menu data that is used to populate the Menubar component. If empty object, no menus will be\n * shown on the App Menubar\n */\n menuData?: Localized;\n\n /**\n * Optional callback function that is executed whenever a menu on the App Menubar is opened or\n * closed. Helpful for handling updates to the menu, as changing menu data when the menu is opened\n * is not desirable.\n */\n onOpenChange?: (isOpen: boolean) => void;\n\n /** Optional unique identifier */\n id?: string;\n\n /** Additional css classes to help with unique styling of the toolbar */\n className?: string;\n\n /**\n * Whether the toolbar should be used as a draggable area for moving the application. This will\n * add an electron specific style `WebkitAppRegion: 'drag'` to the toolbar in order to make it\n * draggable. See:\n * https://www.electronjs.org/docs/latest/tutorial/custom-title-bar#create-a-custom-title-bar\n */\n shouldUseAsAppDragArea?: boolean;\n\n /** Toolbar children to be put at the start of the toolbar (left side in ltr, right side in rtl) */\n appMenuAreaChildren?: ReactNode;\n\n /** Toolbar children to be put at the end of the toolbar (right side in ltr, left side in rtl) */\n configAreaChildren?: ReactNode;\n\n /** Variant of the menubar */\n menubarVariant?: 'default' | 'muted';\n}>;\n\n/**\n * Get tailwind class for reserved space for the window controls / macos \"traffic lights\". Passing\n * 'darwin' will reserve the necessary space for macos traffic lights at the start, otherwise a\n * different amount of space at the end for the window controls.\n *\n * Apply to the toolbar like: `` or ``\n *\n * @param operatingSystem The os platform: 'darwin' (macos) | anything else\n * @returns The class name to apply to the toolbar if os specific space should be reserved\n */\nexport function getToolbarOSReservedSpaceClassName(\n operatingSystem: string | undefined,\n): string | undefined {\n switch (operatingSystem) {\n case undefined:\n return undefined;\n case 'darwin':\n return 'tw-ps-[85px]';\n default:\n return 'tw-pe-[calc(138px+1rem)]';\n }\n}\n\n/**\n * A customizable toolbar component with a menubar, content area, and configure area.\n *\n * This component is designed to be used in the window title bar of an electron application.\n *\n * @param {ToolbarProps} props - The props for the component.\n */\nexport function Toolbar({\n menuData,\n onOpenChange,\n onSelectMenuItem,\n className,\n id,\n children,\n appMenuAreaChildren,\n configAreaChildren,\n shouldUseAsAppDragArea,\n menubarVariant = 'default',\n}: ToolbarProps) {\n // This ref will always be defined\n // eslint-disable-next-line no-type-assertion/no-type-assertion\n const containerRef = useRef(undefined!);\n\n return (\n \n \n {/* App Menu area */}\n
    \n \n {appMenuAreaChildren}\n\n {menuData && (\n \n )}\n
    \n \n\n {/* Content area */}\n \n {children}\n \n\n {/* Configure area */}\n
    \n \n {configAreaChildren}\n
    \n \n \n \n );\n}\n\nexport default Toolbar;\n","import { useState } from 'react';\nimport { LocalizedStringValue, formatReplacementString } from 'platform-bible-utils';\nimport { cn } from '@/utils/shadcn-ui.util';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../shadcn-ui/select';\nimport { Label } from '../shadcn-ui/label';\n\n/**\n * Immutable array containing all keys used for localization in this component. If you're using this\n * component in an extension, you can pass it into the useLocalizedStrings hook to easily obtain the\n * localized strings and pass them into the localizedStrings prop of this component\n */\nexport const UI_LANGUAGE_SELECTOR_STRING_KEYS = Object.freeze([\n '%settings_uiLanguageSelector_fallbackLanguages%',\n] as const);\n\nexport type UiLanguageSelectorLocalizedStrings = {\n [localizedUiLanguageSelectorKey in (typeof UI_LANGUAGE_SELECTOR_STRING_KEYS)[number]]?: LocalizedStringValue;\n};\n\n/**\n * Gets the localized value for the provided key\n *\n * @param strings Object containing localized string\n * @param key Key for a localized string\n * @returns The localized value for the provided key, if available. Returns the key if no localized\n * value is available\n */\nconst localizeString = (\n strings: UiLanguageSelectorLocalizedStrings,\n key: keyof UiLanguageSelectorLocalizedStrings,\n) => {\n return strings[key] ?? key;\n};\n\nexport type LanguageInfo = {\n /** The name of the language to be displayed (in its native script) */\n autonym: string;\n /**\n * The name of the language in other languages, so that the language can also be displayed in the\n * current UI language, if known.\n */\n uiNames?: Record;\n /**\n * Other known names of the language (for searching). This can include pejorative names and should\n * never be displayed unless typed by the user.\n */\n otherNames?: string[];\n};\n\nexport type UiLanguageSelectorProps = {\n /** Full set of known languages to display. The keys are valid BCP-47 tags. */\n knownUiLanguages: Record;\n /** IETF BCP-47 language tag of the current primary UI language. `undefined` => 'en' */\n primaryLanguage: string;\n /**\n * Ordered list of fallback language tags to use if the localization key can't be found in the\n * current primary UI language. This list never contains English ('en') because it is the ultimate\n * fallback.\n */\n fallbackLanguages: string[] | undefined;\n /**\n * Handler for when either the primary or the fallback languages change (or both). For this\n * handler, the primary UI language is the first one in the array, followed by the fallback\n * languages in order of decreasing preference.\n */\n onLanguagesChange?: (newUiLanguages: string[]) => void;\n /** Handler for the primary language changes. */\n onPrimaryLanguageChange?: (newPrimaryUiLanguage: string) => void;\n /**\n * Handler for when the fallback languages change. The array contains the fallback languages in\n * order of decreasing preference.\n */\n onFallbackLanguagesChange?: (newFallbackLanguages: string[]) => void;\n /**\n * Map whose keys are localized string keys as contained in UI_LANGUAGE_SELECTOR_STRING_KEYS and\n * whose values are the localized strings (in the current UI language).\n */\n localizedStrings: UiLanguageSelectorLocalizedStrings;\n /** Additional css classes to help with unique styling of the control */\n className?: string;\n /** Optional id for the root element */\n id?: string;\n};\n\n/**\n * A component for selecting the user interface language and managing fallback languages. Allows\n * users to choose a primary UI language and optionally select fallback languages.\n *\n * @param {UiLanguageSelectorProps} props - The props for the component.\n */\nexport function UiLanguageSelector({\n knownUiLanguages,\n primaryLanguage = 'en',\n fallbackLanguages = [],\n onLanguagesChange,\n onPrimaryLanguageChange,\n onFallbackLanguagesChange,\n localizedStrings,\n className,\n id,\n}: UiLanguageSelectorProps) {\n const fallbackLanguagesText = localizeString(\n localizedStrings,\n '%settings_uiLanguageSelector_fallbackLanguages%',\n );\n const [isOpen, setIsOpen] = useState(false);\n\n const handleLanguageChange = (code: string) => {\n if (onPrimaryLanguageChange) onPrimaryLanguageChange(code);\n // REVIEW: Should fallback languages be preserved when primary language changes?\n if (onLanguagesChange)\n onLanguagesChange([code, ...fallbackLanguages.filter((lang) => lang !== code)]);\n if (onFallbackLanguagesChange && fallbackLanguages.find((l) => l === code))\n onFallbackLanguagesChange([...fallbackLanguages.filter((lang) => lang !== code)]);\n setIsOpen(false); // Close the dropdown when a selection is made\n };\n\n /**\n * Gets the display name for the given language. This will typically include the autonym (in the\n * native script), along with the name of the language in the current UI locale if known, with a\n * fallback to the English name (if known).\n *\n * @param {string} lang - The BCP-47 code of the language whose display name is being requested.\n * @param {string} uiLang - The BCP-47 code of the current user-interface language used used to\n * try to look up the name of the language in a form that is likely to be helpful to the user if\n * they do not recognize the autonym.\n * @returns {string} The display name of the language.\n */\n const getLanguageDisplayName = (lang: string, uiLang: string) => {\n const altName =\n uiLang !== lang\n ? (knownUiLanguages[lang]?.uiNames?.[uiLang] ?? knownUiLanguages[lang]?.uiNames?.en)\n : undefined;\n\n return altName\n ? `${knownUiLanguages[lang]?.autonym} (${altName})`\n : knownUiLanguages[lang]?.autonym;\n };\n\n return (\n
    \n {/* Language Selector */}\n setIsOpen(open)}\n >\n \n \n \n \n {Object.keys(knownUiLanguages).map((key) => {\n return (\n \n {getLanguageDisplayName(key, primaryLanguage)}\n \n );\n })}\n \n \n\n {/* Fallback Language Button */}\n {primaryLanguage !== 'en' && (\n
    \n \n
    \n )}\n
    \n );\n}\n\nexport default UiLanguageSelector;\n","import { Label } from '@/components/shadcn-ui/label';\nimport { ReactNode } from 'react';\n\ntype SmartLabelProps = {\n item: string;\n createLabel?: (item: string) => string;\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Create labels with text, react elements (e.g. links), or text + react elements */\nfunction SmartLabel({ item, createLabel, createComplexLabel }: SmartLabelProps): ReactNode {\n if (createLabel) {\n return ;\n }\n if (createComplexLabel) {\n return ;\n }\n return ;\n}\n\nexport default SmartLabel;\n","import { Checkbox } from '@/components/shadcn-ui/checkbox';\nimport { ReactNode } from 'react';\nimport SmartLabel from './smart-label.component';\n\nexport type ChecklistProps = {\n /** Optional string representing the id attribute of the Checklist */\n id?: string;\n /** Optional string representing CSS class name(s) for styling */\n className?: string;\n /** Array of strings representing the checkable items */\n listItems: string[];\n /** Array of strings representing the checked items */\n selectedListItems: string[];\n /**\n * Function that is called when a checkbox item is selected or deselected\n *\n * @param item The string description for this item\n * @param selected True if selected, false if not selected\n */\n handleSelectListItem: (item: string, selected: boolean) => void;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created\n * @returns A string representing the label text for the checkbox associated with that item\n */\n createLabel?: (item: string) => string;\n\n /**\n * Optional function creates a label for a provided checkable item\n *\n * @param item The item for which a label is to be created, including text and any additional\n * elements (e.g. links)\n * @returns A react node representing the label text and any additional elements (e.g. links) for\n * the checkbox associated with that item\n */\n createComplexLabel?: (item: string) => ReactNode;\n};\n\n/** Renders a list of checkboxes. Each checkbox corresponds to an item from the `listItems` array. */\nexport function Checklist({\n id,\n className,\n listItems,\n selectedListItems,\n handleSelectListItem,\n createLabel,\n createComplexLabel,\n}: ChecklistProps) {\n return (\n
    \n {listItems.map((item) => (\n
    \n handleSelectListItem(item, value)}\n />\n \n
    \n ))}\n
    \n );\n}\n\nexport default Checklist;\n","import { cn } from '@/utils/shadcn-ui.util';\nimport { MoreHorizontal } from 'lucide-react';\nimport React, { ReactNode } from 'react';\nimport { Button } from '../shadcn-ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../shadcn-ui/dropdown-menu';\n\n/** Props interface for the ResultsCard base component */\nexport interface ResultsCardProps {\n /** Unique key for the card */\n cardKey: string;\n /** Whether this card is currently selected/focused */\n isSelected: boolean;\n /** Callback function called when the card is clicked */\n onSelect: () => void;\n /** Whether the content of this card are in a denied state */\n isDenied?: boolean;\n /** Whether the card should be hidden */\n isHidden?: boolean;\n /** Additional CSS classes to apply to the card */\n className?: string;\n /** Main content to display on the card */\n children: ReactNode;\n /** Content to show in the dropdown menu when selected */\n dropdownContent?: ReactNode;\n /** Additional content to show below the main content when selected */\n additionalSelectedContent?: ReactNode;\n /** Color to use for the card's accent border */\n accentColor?: string;\n}\n\n/**\n * ResultsCard is a base component for displaying scripture-related results in a card format, even\n * though it is not based on the Card component. It provides common functionality like selection\n * state, dropdown menus, and expandable content.\n */\nexport function ResultsCard({\n cardKey,\n isSelected,\n onSelect,\n isDenied,\n isHidden = false,\n className,\n children,\n dropdownContent,\n additionalSelectedContent,\n accentColor,\n}: ResultsCardProps) {\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n onSelect();\n }\n };\n\n return (\n