diff --git a/packages/scribe/package.json b/packages/scribe/package.json index 8dfb4b53..7c1254eb 100644 --- a/packages/scribe/package.json +++ b/packages/scribe/package.json @@ -1,6 +1,6 @@ { "name": "@biblionexus-foundation/scribe-editor", - "version": "0.1.0", + "version": "0.1.1", "description": "Scripture editor used in Scribe", "license": "MIT", "homepage": "https://github.com/BiblioNexus-Foundation/scripture-editors/tree/main/packages/scribe#readme", @@ -30,10 +30,12 @@ }, "dependencies": { "@biblionexus-foundation/scripture-utilities": "workspace:~", + "@floating-ui/dom": "^1.6.13", "@lexical/mark": "^0.24.0", "@lexical/react": "^0.24.0", "@lexical/selection": "^0.24.0", "@lexical/utils": "^0.24.0", + "autoprefixer": "^10.4.20", "fast-equals": "^5.2.2", "lexical": "^0.24.0" }, diff --git a/packages/scribe/src/App.tsx b/packages/scribe/src/App.tsx index f271acef..7f577d60 100644 --- a/packages/scribe/src/App.tsx +++ b/packages/scribe/src/App.tsx @@ -7,7 +7,8 @@ import { formattedViewMode as defaultViewMode } from "./plugins/view-mode.model" import { ScriptureReference } from "shared/utils/get-marker-action.model"; import { UsjNodeOptions } from "shared-react/nodes/scripture/usj/usj-node-options.model"; import { immutableNoteCallerNodeName } from "shared-react/nodes/scripture/usj/ImmutableNoteCallerNode"; -import { Usj2Usfm } from "./hooks/usj2Usfm"; +// import { Usj2Usfm } from "./hooks/usj2Usfm"; +import "shared/styles/nodes-menu.css"; const defaultUsj: Usj = { type: USJ_TYPE, @@ -37,11 +38,8 @@ function App() { }, }; const viewOptions = useMemo(() => getViewOptions(viewMode), [viewMode]); - // const noteViewOptions = useMemo(() => getViewOptions(noteViewMode), [noteViewMode]); const onChange = async (usj: Usj) => { - // console.log({ usj }); - const usfm = await Usj2Usfm(usj); - console.log(usfm); + console.log(usj); }; useEffect(() => { console.log({ scrRef }); diff --git a/packages/scribe/src/adaptors/usj-marker-action.utils.ts b/packages/scribe/src/adaptors/usj-marker-action.utils.ts index 3c7096f9..c4ce888a 100644 --- a/packages/scribe/src/adaptors/usj-marker-action.utils.ts +++ b/packages/scribe/src/adaptors/usj-marker-action.utils.ts @@ -27,13 +27,18 @@ const markerActions: { action?: (currentEditor: { reference: ScriptureReference; editor: LexicalEditor; + autoNumbering?: boolean; + newVerseRChapterNum?: number; + noteText?: string; }) => MarkerContent[]; }; } = { c: { action: (currentEditor) => { const { book, chapterNum } = currentEditor.reference; - const nextChapter = chapterNum + 1; + const nextChapter = currentEditor.autoNumbering + ? chapterNum + 1 + : currentEditor.newVerseRChapterNum; const content: MarkerContent = { type: "chapter", marker: "c", @@ -46,7 +51,9 @@ const markerActions: { v: { action: (currentEditor) => { const { book, chapterNum, verseNum, verse } = currentEditor.reference; - const nextVerse = getNextVerse(verseNum, verse); + const nextVerse = currentEditor.autoNumbering + ? getNextVerse(verseNum, verse) + : currentEditor.newVerseRChapterNum; const content: MarkerContent = { type: "verse", marker: "v", @@ -68,7 +75,7 @@ const markerActions: { { type: "char", marker: "ft", - content: [" "], + content: [currentEditor.noteText ?? " "], }, ], }; @@ -87,7 +94,7 @@ const markerActions: { { type: "char", marker: "xt", - content: [" "], + content: [currentEditor.noteText ?? " "], }, ], }; @@ -103,9 +110,17 @@ export function getUsjMarkerAction( viewOptions?: ViewOptions, ): MarkerAction { const markerAction = getMarkerAction(marker); - const action = (currentEditor: { reference: ScriptureReference; editor: LexicalEditor }) => { + const action = (currentEditor: { + reference: ScriptureReference; + editor: LexicalEditor; + autoNumbering?: boolean; + newVerseRChapterNum?: number; + noteText?: string; + }) => { currentEditor.editor.update(() => { - const content = markerAction?.action?.(currentEditor); + const content = currentEditor.autoNumbering + ? markerAction?.action?.(currentEditor) + : markerAction?.action?.(currentEditor); if (!content) return; const serializedLexicalNode = createLexicalUsjNode(content, usjEditorAdaptor, viewOptions); diff --git a/packages/scribe/src/components/Editor.tsx b/packages/scribe/src/components/Editor.tsx index 7c45f141..152ed78a 100644 --- a/packages/scribe/src/components/Editor.tsx +++ b/packages/scribe/src/components/Editor.tsx @@ -22,12 +22,14 @@ import UpdateStatePlugin from "shared-react/plugins/UpdateStatePlugin"; import editorUsjAdaptor from "../adaptors/editor-usj.adaptor"; import { getViewClassList, ViewOptions } from "../adaptors/view-options.utils"; import usjEditorAdaptor from "../adaptors/usj-editor.adaptor"; +import UsjNodesMenuPlugin from "../plugins/UsjNodesMenuPlugin"; import useDeferredState from "../hooks/use-deferred-state.hook"; import { ScriptureReferencePlugin } from "../plugins/ScriptureReferencePlugin"; import editorTheme from "../themes/editor-theme"; import LoadingSpinner from "./LoadingSpinner"; import { blackListedChangeTags } from "shared/nodes/scripture/usj/node-constants"; import { deepEqual } from "fast-equals"; +import { getUsjMarkerAction } from "../adaptors/usj-marker-action.utils"; /** Forward reference for the editor. */ export type EditorRef = { @@ -73,7 +75,7 @@ const Editor = forwardRef(function Editor( const [usj, setUsj] = useState(usjInput); const [loadedUsj, , setEditedUsj] = useDeferredState(usj); useDefaultNodeOptions(nodeOptions); - + const autoNumbering = false; const initialConfig = { namespace: "ScribeEditor", editable: true, @@ -127,19 +129,21 @@ const Editor = forwardRef(function Editor( placeholder={} ErrorBoundary={LexicalErrorBoundary} /> - {/* getUsjMarkerAction(marker, markerData, viewOptions) } - /> */} + autoNumbering={autoNumbering} + /> + )} diff --git a/packages/scribe/src/index.css b/packages/scribe/src/index.css index de0cfb3f..e20fa4ad 100644 --- a/packages/scribe/src/index.css +++ b/packages/scribe/src/index.css @@ -2433,3 +2433,69 @@ span.read img { background-repeat: no-repeat; background-position: center; } +/* NodesMenu.css */ + +.user-input-container { + border-radius: 0.5rem; + border: 1px solid rgba(229, 231, 235, 1); + background-color: rgba(252, 252, 252, 1); + padding: 1rem; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.input-header { + margin-bottom: 0.5rem; + font-size: 1.125rem; + font-weight: 500; + color: rgba(25, 25, 25, 1); +} + +.user-input-container input { + width: 100%; + border-radius: 0.25rem; + border: 1px solid rgba(209, 213, 219, 1); + padding: 0.5rem; + margin-bottom: 0.75rem; + background-color: white; + color: rgba(25, 25, 25, 1); +} + +.user-input-container input:focus { + outline: none; + border-color: rgba(18, 82, 179, 1); + box-shadow: 0 0 0 3px rgba(18, 82, 179, 0.3); +} + +.input-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.cancel-button { + border-radius: 0.25rem; + background-color: rgba(229, 231, 235, 1); + padding: 0.25rem 0.75rem; + color: rgba(25, 25, 25, 1); + border: none; + cursor: pointer; +} + +.cancel-button:hover { + background-color: rgba(209, 213, 219, 1); +} + +.apply-button { + border-radius: 0.25rem; + background-color: rgba(18, 82, 179, 1); + color: white; + padding: 0.25rem 0.75rem; + border: none; + cursor: pointer; +} + +.apply-button:hover { + background-color: rgba(15, 65, 143, 1); +} diff --git a/packages/scribe/src/plugins/FloatingBox/FloatingBox.tsx b/packages/scribe/src/plugins/FloatingBox/FloatingBox.tsx new file mode 100644 index 00000000..49c555f7 --- /dev/null +++ b/packages/scribe/src/plugins/FloatingBox/FloatingBox.tsx @@ -0,0 +1,33 @@ +import { forwardRef, ReactNode } from "react"; + +export type FloatingBoxCoords = { x: number; y: number } | undefined; + +type FloatingBoxProps = { + coords: FloatingBoxCoords; + children?: ReactNode; +} & React.HTMLAttributes; + +export const FloatingBox = forwardRef((props, ref) => { + const { coords, children, style, ...extraProps } = props; + const shouldShow = coords !== undefined; + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/scribe/src/plugins/FloatingBox/FloatingBoxAtCursor.tsx b/packages/scribe/src/plugins/FloatingBox/FloatingBoxAtCursor.tsx new file mode 100644 index 00000000..3a7e21b2 --- /dev/null +++ b/packages/scribe/src/plugins/FloatingBox/FloatingBoxAtCursor.tsx @@ -0,0 +1,43 @@ +import { memo, ReactNode, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; +import { FloatingBox } from "./FloatingBox"; +import useCursorCoords from "./useCursorCoords"; +import { Placement } from "@floating-ui/dom"; + +const DOM_ELEMENT = document.body; + +const MemoizedFloatingBox = memo(FloatingBox); + +export type FloatingMenuCoords = { x: number; y: number } | undefined; + +type CursorFloatingBox = { + isOpen?: boolean; + children: + | ReactNode + | ((props: { isOpen: boolean | undefined; placement?: Placement }) => ReactNode); +}; + +/** + * FloatingBoxAtCursor component is responsible for rendering a floating menu + * at the cursor position when the isOpen prop is true + */ +export default function FloatingBoxAtCursor({ isOpen = false, children }: CursorFloatingBox) { + const floatingBoxRef = useRef(null); + const { coords, placement } = useCursorCoords({ isOpen, floatingBoxRef }); + + const renderChildren = useMemo( + () => (coords ? (typeof children === "function" ? children : () => children) : () => null), + [children, coords], + ); + + return createPortal( + + {renderChildren({ isOpen, placement })} + , + DOM_ELEMENT, + ); +} diff --git a/packages/scribe/src/plugins/FloatingBox/useCursorCoords.tsx b/packages/scribe/src/plugins/FloatingBox/useCursorCoords.tsx new file mode 100644 index 00000000..e4b89e0e --- /dev/null +++ b/packages/scribe/src/plugins/FloatingBox/useCursorCoords.tsx @@ -0,0 +1,27 @@ +import React, { useEffect } from "react"; +import { useFloatingPosition } from "./useFloatingPosition"; + +export default function useCursorCoords({ + isOpen, + floatingBoxRef, +}: { + isOpen: boolean; + floatingBoxRef: React.RefObject; +}) { + const { coords, updatePosition, cleanup, placement } = useFloatingPosition(); + + useEffect(() => { + if (!isOpen || !floatingBoxRef.current) { + cleanup(); + return; + } + + const domRange = window.getSelection()?.getRangeAt(0); + if (domRange) { + updatePosition(domRange, floatingBoxRef.current); + return cleanup; + } + }, [isOpen, updatePosition, cleanup]); + + return { coords, placement }; +} diff --git a/packages/scribe/src/plugins/FloatingBox/useFloatingPosition.tsx b/packages/scribe/src/plugins/FloatingBox/useFloatingPosition.tsx new file mode 100644 index 00000000..c2574a31 --- /dev/null +++ b/packages/scribe/src/plugins/FloatingBox/useFloatingPosition.tsx @@ -0,0 +1,50 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { autoUpdate, computePosition, shift, flip, Placement } from "@floating-ui/dom"; + +export function useFloatingPosition() { + const [coords, setCoords] = useState<{ x: number; y: number } | undefined>(undefined); + const [placement, setPlacement] = useState(); + const cleanupRef = useRef<(() => void) | null>(null); + + const updatePosition = useCallback((domRange: Range, anchorElement: HTMLElement) => { + if (cleanupRef.current) { + cleanupRef.current(); + } + const referenceElement = + domRange.commonAncestorContainer.nodeType === domRange.commonAncestorContainer.TEXT_NODE + ? domRange + : (domRange.commonAncestorContainer as HTMLElement); + + cleanupRef.current = autoUpdate(referenceElement, anchorElement, () => { + computePosition(referenceElement, anchorElement, { + placement: "bottom-start", + middleware: [shift(), flip()], + }) + .then((pos) => { + setPlacement(pos.placement); + setCoords((prevCoords) => + prevCoords?.x === pos.x && prevCoords?.y === pos.y + ? prevCoords + : { x: pos.x, y: pos.y }, + ); + }) + .catch(() => { + setCoords(undefined); + }); + }); + }, []); + + const cleanup = useCallback(() => { + if (cleanupRef.current) { + setCoords(undefined); + cleanupRef.current(); + cleanupRef.current = null; + } + }, []); + + useEffect(() => { + return cleanup; + }, [cleanup]); + + return { coords, placement, updatePosition, cleanup }; +} diff --git a/packages/scribe/src/plugins/FloatingBox/usePointerInteractions.tsx b/packages/scribe/src/plugins/FloatingBox/usePointerInteractions.tsx new file mode 100644 index 00000000..70901a0e --- /dev/null +++ b/packages/scribe/src/plugins/FloatingBox/usePointerInteractions.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; + +export const usePointerInteractions = () => { + const [isPointerDown, setIsPointerDown] = useState(false); + const [isPointerReleased, setIsPointerReleased] = useState(false); + + const handlePointerDown = () => { + setIsPointerDown(true); + setIsPointerReleased(false); + }; + + const handlePointerUp = () => { + setIsPointerDown(false); + setIsPointerReleased(true); + }; + + useEffect(() => { + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("pointerup", handlePointerUp); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("pointerup", handlePointerUp); + }; + }, []); + + return { isPointerDown, isPointerReleased }; +}; diff --git a/packages/scribe/src/plugins/KeyboardShortcutPlugin.tsx b/packages/scribe/src/plugins/KeyboardShortcutPlugin.tsx new file mode 100644 index 00000000..ca1ae1c7 --- /dev/null +++ b/packages/scribe/src/plugins/KeyboardShortcutPlugin.tsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { IS_APPLE } from "@lexical/utils"; +import { UNDO_COMMAND, REDO_COMMAND } from "lexical"; + +export default function ClipboardPlugin(): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const { key, shiftKey, metaKey, ctrlKey, altKey } = event; + if (!(IS_APPLE ? metaKey : ctrlKey) || altKey) return; + if (key.toLowerCase() === "z" && shiftKey) { + event.preventDefault(); + editor.dispatchCommand(REDO_COMMAND, undefined); + } else if (key.toLowerCase() === "z") { + console.log("undo"); + event.preventDefault(); + editor.dispatchCommand(UNDO_COMMAND, undefined); + } + }; + + return editor.registerRootListener( + (rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => { + if (prevRootElement !== null) { + prevRootElement.removeEventListener("keydown", onKeyDown); + } + if (rootElement !== null) { + rootElement.addEventListener("keydown", onKeyDown); + } + }, + ); + }, [editor]); + + return null; +} diff --git a/packages/scribe/src/plugins/NodesMenu/LexicalMenuNavigation.tsx b/packages/scribe/src/plugins/NodesMenu/LexicalMenuNavigation.tsx new file mode 100644 index 00000000..c25f8bfc --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/LexicalMenuNavigation.tsx @@ -0,0 +1,6 @@ +import { useLexicalMenuNavigation } from "./Menu/useLexicalMenuNavigation"; + +export default function LexicalMenuNavigation() { + useLexicalMenuNavigation(); + return null; +} diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/MenuContext.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/MenuContext.tsx new file mode 100644 index 00000000..22afb797 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/MenuContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; +import { useMenuCore } from "./useMenuCore"; + +type MenuContextType = ReturnType; + +const MenuContext = createContext(undefined); + +export function useMenuContext() { + const context = useContext(MenuContext); + if (!context) { + throw new Error("useMenuContext must be used within a MenuProvider"); + } + return context; +} + +export { MenuContext }; diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/Option.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/Option.tsx new file mode 100644 index 00000000..0b8de8df --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/Option.tsx @@ -0,0 +1,50 @@ +import React, { useCallback, forwardRef } from "react"; +import { useMenuContext } from "./MenuContext"; + +type OptionProps = { + index: number; + children: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + onMouseEnter?: (event: React.MouseEvent) => void; +} & React.ButtonHTMLAttributes; + +export const MenuOption = forwardRef( + ({ index, children, onMouseEnter, onClick, ...props }, ref) => { + const { + state: { activeIndex }, + setActiveIndex, + setSelectedIndex, + select, + } = useMenuContext(); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + select(); + setSelectedIndex(-1); + onClick?.(event); + }, + [index, setSelectedIndex, onClick], + ); + + const handleMouseEnter = useCallback( + (event: React.MouseEvent) => { + setActiveIndex(index); + onMouseEnter?.(event); + }, + [index, setActiveIndex, onMouseEnter], + ); + return ( + + ); + }, +); diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/Options.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/Options.tsx new file mode 100644 index 00000000..089828c7 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/Options.tsx @@ -0,0 +1,73 @@ +import React, { + useEffect, + useMemo, + Children, + cloneElement, + isValidElement, + ReactElement, + useRef, +} from "react"; +import { useMenuContext } from "./MenuContext"; +import { MenuOption } from "./Option"; +import { OptionItem } from "./types"; + +type OptionElement = ReactElement, typeof MenuOption>; + +type MenuOptionsProps = Omit, "children"> & { + children: + | OptionElement + | OptionElement[] + | ((menuItems: OptionItem[]) => OptionElement | OptionElement[]); + autoIndex?: boolean; +}; + +export function MenuOptions({ children, autoIndex = true, ...divProps }: MenuOptionsProps) { + const menuRef = useRef(null); + const { + state: { activeIndex, menuItems }, + } = useMenuContext(); + + const renderChildren = useMemo( + () => (menuItems ? (typeof children === "function" ? children : () => children) : () => null), + [children, menuItems], + ); + + const mappedChildren = useMemo(() => { + const children = renderChildren(menuItems); + if (!autoIndex) return children; + + return Children.map(children, (child, index) => { + if ( + isValidElement>(child) && + child.type === MenuOption && + child.props.index === undefined + ) { + return cloneElement(child, { index }); + } + return child; + }); + }, [renderChildren, autoIndex, menuItems]); + + useEffect(() => { + if (menuRef.current) { + const menuElement = menuRef.current; + const selectedElement = menuElement.children[activeIndex] as HTMLElement; + if (selectedElement) { + const menuRect = menuElement.getBoundingClientRect(); + const selectedRect = selectedElement.getBoundingClientRect(); + + if (selectedRect.bottom > menuRect.bottom) { + menuElement.scrollTop += selectedRect.bottom - menuRect.bottom; + } else if (selectedRect.top < menuRect.top) { + menuElement.scrollTop -= menuRect.top - selectedRect.top; + } + } + } + }, [activeIndex]); + + return ( +
+ {mappedChildren} +
+ ); +} diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/Root.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/Root.tsx new file mode 100644 index 00000000..b1477295 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/Root.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { MenuContext } from "./MenuContext"; +import { useMenuCore } from "./useMenuCore"; +import { OptionItem } from "./types"; + +type MenuRootProps = { + children: React.ReactNode; + menuItems?: OptionItem[]; + onSelectOption?: (option: OptionItem) => void; + autoIndex?: boolean; +} & React.HTMLAttributes; + +export function MenuRoot({ children, menuItems, onSelectOption, ...divProps }: MenuRootProps) { + const menuContext = useMenuCore(menuItems, onSelectOption); + return ( + +
{children}
+
+ ); +} diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/filterAndRankItems.ts b/packages/scribe/src/plugins/NodesMenu/Menu/filterAndRankItems.ts new file mode 100644 index 00000000..cb30c003 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/filterAndRankItems.ts @@ -0,0 +1,117 @@ +export type Item = { + [key: string]: unknown; +}; + +// Default filter function +const defaultFilter = (item: T, query: string, filterBy: string): boolean => { + return getSafeValue(item, filterBy).toLowerCase().includes(query.toLowerCase()); +}; + +// Helper function to get the first string key of an object +const getFirstStringKey = (obj: T): string => { + return Object.keys(obj).find((key) => typeof obj[key] === "string") || ""; +}; + +// Helper function to safely get a string value from an item +const getSafeValue = (item: T, key: string): string => { + const value = item[key]; + return typeof value === "string" ? value : String(value); +}; + +export interface SortingOptions { + caseSensitive?: boolean; + priorityOrder?: ("exact" | "startsWith" | "contains")[]; +} + +export interface FilterAndRankItems { + query: string; + items: T[]; + filterBy?: keyof Pick; + filter?: (item: T, query: string) => boolean; + sortBy?: keyof Pick; + sortingOptions?: SortingOptions; +} + +export function filterAndRankItems( + options: Omit, "filter"> & { filterBy: keyof Pick }, +): T[]; +export function filterAndRankItems( + options: Omit, "filterBy"> & { + filter: (item: T, query: string) => boolean; + }, +): T[]; +export function filterAndRankItems(options: FilterAndRankItems): T[]; + +export function filterAndRankItems({ + query, + items, + filterBy, + filter, + sortBy, + sortingOptions, +}: FilterAndRankItems): T[] { + const { caseSensitive = false, priorityOrder = ["exact", "startsWith", "contains"] } = + sortingOptions || {}; + + const compareQuery = caseSensitive ? query : query.toLowerCase(); + + let actualFilterBy: string; + let actualFilter: (item: T, query: string) => boolean; + + if (filter) { + actualFilter = filter; + actualFilterBy = items.length > 0 ? getFirstStringKey(items[0]) : ""; + } else { + actualFilterBy = filterBy || (items.length > 0 ? getFirstStringKey(items[0]) : ""); + actualFilter = (item: T, query: string) => defaultFilter(item, query, actualFilterBy); + } + + const actualSortBy = sortBy || actualFilterBy; + + // Create a Map to cache lowercase versions of sortBy values + const sortByCache = new Map(); + + return items + .filter((item) => { + try { + return actualFilter(item, query); + } catch (error) { + console.warn(`Error filtering item:`, item, error); + return false; + } + }) + .sort((a, b) => { + const getTextLower = (item: T) => { + if (!sortByCache.has(item)) { + sortByCache.set(item, getSafeValue(item, actualSortBy).toLowerCase()); + } + return sortByCache.get(item) ?? ""; + }; + + const textA = caseSensitive ? getSafeValue(a, actualSortBy) : getTextLower(a); + const textB = caseSensitive ? getSafeValue(b, actualSortBy) : getTextLower(b); + + for (const priority of priorityOrder) { + switch (priority) { + case "exact": + if (textA === compareQuery && textB !== compareQuery) return -1; + if (textB === compareQuery && textA !== compareQuery) return 1; + break; + case "startsWith": + if (textA.startsWith(compareQuery) && !textB.startsWith(compareQuery)) return -1; + if (textB.startsWith(compareQuery) && !textA.startsWith(compareQuery)) return 1; + break; + case "contains": { + const indexA = textA.indexOf(compareQuery); + const indexB = textB.indexOf(compareQuery); + if (indexA !== -1 && indexB === -1) return -1; + if (indexB !== -1 && indexA === -1) return 1; + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + break; + } + } + } + + return textA.localeCompare(textB); + }); +} diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/index.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/index.tsx new file mode 100644 index 00000000..f85ecbe1 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/index.tsx @@ -0,0 +1,11 @@ +import { MenuRoot } from "./Root"; +import { MenuOptions } from "./Options"; +import { MenuOption } from "./Option"; + +export type { OptionItem } from "./types"; + +export default { + Root: MenuRoot, + Options: MenuOptions, + Option: MenuOption, +}; diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/types.d.ts b/packages/scribe/src/plugins/NodesMenu/Menu/types.d.ts new file mode 100644 index 00000000..a713f767 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/types.d.ts @@ -0,0 +1,15 @@ +export type OptionItem = { + name: string; + label: string; + description: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + action: ({ + editor, + newVerseRChapterNum, + noteText, + }: { + editor: LexicalEditor; + newVerseRChapterNum?: number; + noteText?: string; + }) => void; +}; diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/useFilteredItems.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/useFilteredItems.tsx new file mode 100644 index 00000000..2750d456 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/useFilteredItems.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { filterAndRankItems, Item, FilterAndRankItems } from "./filterAndRankItems"; + +// Overload 1: Using filterBy as a string +export function useFilteredItems( + props: Omit, "filter"> & { filterBy: keyof Pick }, +): T[]; + +// Overload 2: Using filter as a function +export function useFilteredItems( + props: Omit, "filterBy"> & { + filter: (item: T, query: string) => boolean; + }, +): T[]; + +// Implementation +export function useFilteredItems({ + query, + items, + filterBy, + filter, + sortBy, + sortingOptions, +}: FilterAndRankItems) { + const filteredItems = useMemo(() => { + return filterAndRankItems({ + query, + items, + filterBy, + filter, + sortBy, + sortingOptions, + }); + }, [query, items, filterBy, filter, sortBy, sortingOptions]); + + return filteredItems; +} diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/useLexicalMenuNavigation.ts b/packages/scribe/src/plugins/NodesMenu/Menu/useLexicalMenuNavigation.ts new file mode 100644 index 00000000..fd3fd328 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/useLexicalMenuNavigation.ts @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useMenuActions } from "./useMenuActions"; +import { COMMAND_PRIORITY_HIGH, KEY_DOWN_COMMAND } from "lexical"; + +export const useLexicalMenuNavigation = () => { + const menu = useMenuActions(); + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const handleEvent = (event: KeyboardEvent) => { + const actions: { [key: string]: () => void } = { + ArrowDown: () => menu?.moveDown(), + ArrowUp: () => menu?.moveUp(), + Enter: () => menu?.select(), + Tab: () => menu?.select(), + }; + + const action = actions[event.key]; + if (action) { + action(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + return false; + }; + + return editor.registerCommand(KEY_DOWN_COMMAND, handleEvent, COMMAND_PRIORITY_HIGH); + }, [editor, menu]); +}; diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/useMenuActions.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/useMenuActions.tsx new file mode 100644 index 00000000..542852ee --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/useMenuActions.tsx @@ -0,0 +1,15 @@ +import { useMemo } from "react"; +import { useMenuContext } from "./MenuContext"; + +export function useMenuActions() { + const { moveUp, moveDown, select } = useMenuContext(); + + return useMemo( + () => ({ + moveUp, + moveDown, + select, + }), + [moveUp, moveDown, select], + ); +} diff --git a/packages/scribe/src/plugins/NodesMenu/Menu/useMenuCore.tsx b/packages/scribe/src/plugins/NodesMenu/Menu/useMenuCore.tsx new file mode 100644 index 00000000..0855cefe --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/Menu/useMenuCore.tsx @@ -0,0 +1,57 @@ +import { useState, useCallback, useMemo } from "react"; +import { OptionItem } from "./types"; + +type State = { + menuItems: OptionItem[]; + activeIndex: number; + selectedIndex: number; + onSelectOption: (option: OptionItem) => void; +}; + +export function useMenuCore( + initialMenuItems?: OptionItem[], + onSelectOption?: (option: OptionItem) => void, +) { + const [activeIndex, setActiveIndex] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(-1); + const menuItems = useMemo(() => initialMenuItems ?? [], [initialMenuItems]); + + const state: State = { + menuItems, + activeIndex, + selectedIndex, + onSelectOption: onSelectOption ?? (() => undefined), + }; + + const moveUp = useCallback(() => { + setActiveIndex((prev) => { + const optionsCount = menuItems.length; + return optionsCount ? (prev - 1 + optionsCount) % optionsCount : 0; + }); + }, [menuItems.length]); + + const moveDown = useCallback(() => { + setActiveIndex((prev) => { + const optionsCount = menuItems.length; + return optionsCount ? (prev + 1) % optionsCount : 0; + }); + }, [menuItems.length]); + + const select = useCallback(() => { + const optionsCount = menuItems.length; + if (activeIndex >= 0 && activeIndex < optionsCount) { + const selectedOption = menuItems[activeIndex]; + onSelectOption?.(selectedOption); + setSelectedIndex(activeIndex); + } + }, [activeIndex, menuItems, onSelectOption]); + + return { + state, + moveUp, + moveDown, + select, + setActiveIndex, + setSelectedIndex, + }; +} diff --git a/packages/scribe/src/plugins/NodesMenu/NodeSelectionMenu.tsx b/packages/scribe/src/plugins/NodesMenu/NodeSelectionMenu.tsx new file mode 100644 index 00000000..7dcdf349 --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/NodeSelectionMenu.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import Menu from "./Menu"; +import { useFilteredItems } from "./Menu/useFilteredItems"; +import { COMMAND_PRIORITY_HIGH, KEY_DOWN_COMMAND } from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import LexicalMenuNavigation from "./LexicalMenuNavigation"; +import { OptionItem } from "./Menu"; + +interface NodeSelectionMenuProps { + options: Array; + onSelectOption?: (option: OptionItem) => void; + onClose?: () => void; + inverse?: boolean; + query?: string; +} + +export function NodeSelectionMenu({ + options, + onSelectOption, + onClose, + inverse, + query: controlledQuery, +}: NodeSelectionMenuProps) { + const [editor] = useLexicalComposerContext(); + const isControlled = controlledQuery !== undefined; + const [query, setQuery] = useState(""); + const localQuery = isControlled ? controlledQuery : query; + + const filteredOptions = useFilteredItems({ query: localQuery, items: options, filterBy: "name" }); + + const handleOptionSelection = (option: OptionItem) => { + onClose?.(); + if (onSelectOption) onSelectOption(option); + else option.action({ editor }); + }; + + useEffect(() => { + return editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => { + if (isControlled) return false; + const actions: { [key: string]: () => void } = { + Escape: () => onClose?.(), + Backspace: () => { + if (localQuery.length === 0) { + onClose?.(); + } else { + setQuery((prev) => prev.slice(0, -1)); + } + }, + }; + const action = actions[event.key]; + if (action) { + event.stopPropagation(); + event.preventDefault(); + action(); + return true; + } else { + if (event.key.length === 1) { + event.stopPropagation(); + event.preventDefault(); + setQuery((prev) => prev + event.key); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onClose, localQuery]); + + return ( + handleOptionSelection(item)} + > + {!isControlled && } + + + {(options) => { + const mappedOptions = options.map((option, index) => ( + + {option.label ?? option.name} + {option.description} + + )); + return mappedOptions; + }} + + + ); +} diff --git a/packages/scribe/src/plugins/NodesMenu/index.tsx b/packages/scribe/src/plugins/NodesMenu/index.tsx new file mode 100644 index 00000000..e8c24b6d --- /dev/null +++ b/packages/scribe/src/plugins/NodesMenu/index.tsx @@ -0,0 +1,193 @@ +import { $getSelection, $isRangeSelection } from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useCallback, useEffect, useState } from "react"; +import FloatingBoxAtCursor from "../FloatingBox/FloatingBoxAtCursor"; +import { NodeSelectionMenu } from "./NodeSelectionMenu"; +import { OptionItem } from "./Menu"; + +export default function NodesMenu({ + trigger, + items, + autoNumbering = true, +}: { + trigger: string; + items?: OptionItem[]; + autoNumbering?: boolean; +}) { + const [editor] = useLexicalComposerContext(); + const [isOpen, setIsOpen] = useState(false); + const [userInputValue, setUserInputValue] = useState(""); + const [selectedOption, setSelectedOption] = useState(null); + const [isRequestingInput, setIsRequestingInput] = useState(false); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + console.log("KeyDown:", e.key, "isOpen:", isOpen, "isRequestingInput:", isRequestingInput); + + if (e.key === "Escape" && (isOpen || isRequestingInput)) { + setIsOpen(false); + setIsRequestingInput(false); + setUserInputValue(""); + editor.focus(); + } else if (e.key === trigger && !isOpen && !isRequestingInput) { + e.preventDefault(); + setIsOpen(true); + } else if (e.key === "Enter") { + if (isRequestingInput) { + e.preventDefault(); + handleInputSubmit(); + } + } + }, + [editor, trigger, isOpen, isRequestingInput], + ); + + useEffect(() => { + return editor.registerRootListener((root) => { + if (!root) return; + root.addEventListener("keydown", handleKeyDown); + return () => { + root.removeEventListener("keydown", handleKeyDown); + }; + }); + }, [editor, handleKeyDown]); + + useEffect(() => { + return editor.registerUpdateListener(({ prevEditorState, editorState }) => { + const prevSelection = prevEditorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + return selection; + }); + editorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || prevSelection?.is(selection)) return; + setIsOpen(false); + setIsRequestingInput(false); + }); + }); + }, [editor]); + + const handleOptionSelection = useCallback( + (option: OptionItem) => { + const needsUserInput = + (!autoNumbering && (option.name === "c" || option.name === "v")) || + option.name === "f" || + option.name === "x"; + console.log({ needsUserInput }, !autoNumbering, option.name); + if (needsUserInput) { + setSelectedOption(option); + setIsRequestingInput(true); + setIsOpen(false); + + // Prevent any default actions from executing immediately + setTimeout(() => { + // Focus on input when it appears + const inputElement = document.querySelector(".user-input-container input"); + if (inputElement) { + (inputElement as HTMLInputElement).focus(); + } + }, 0); + } else { + option.action({ editor }); + setIsOpen(false); + } + }, + [editor], + ); + + const handleInputSubmit = () => { + if (selectedOption && userInputValue.trim()) { + try { + if (selectedOption.name === "c" || selectedOption.name === "v") { + const newVerseRChapterNum = parseInt(userInputValue); + if (isNaN(newVerseRChapterNum)) { + console.error("Invalid number input"); + return; + } + selectedOption.action({ editor, newVerseRChapterNum }); + } else if (selectedOption.name === "f" || selectedOption.name === "x") { + selectedOption.action({ editor, noteText: userInputValue }); + } else { + selectedOption.action({ editor }); + } + + console.log("Submitted: ", selectedOption.name, userInputValue); + } catch (error) { + console.error("Error processing input:", error); + } + + setIsRequestingInput(false); + setUserInputValue(""); + setSelectedOption(null); + editor.focus(); + } + }; + + return ( + <> + {items && isOpen && ( + + {({ placement }) => ( + setIsOpen(false)} + inverse={placement === "top-start"} + /> + )} + + )} + + {/* User Input Dialog - shown when input is needed */} + {isRequestingInput && selectedOption && ( + + {() => ( +
+
+ {selectedOption.name === "c" + ? "Enter chapter number:" + : selectedOption.name === "v" + ? "Enter verse number:" + : selectedOption.name === "f" + ? "Enter footnote text:" + : selectedOption.name === "x" + ? "Enter cross-reference:" + : "Enter text:"} +
+ setUserInputValue(e.target.value)} + autoFocus + className="mb-3 w-full rounded border border-gray-300 p-2" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleInputSubmit(); + } + }} + /> +
+ + +
+
+ )} +
+ )} + + ); +} diff --git a/packages/scribe/src/plugins/PerfNodesItems/useUsfmMarkersForMenu.ts b/packages/scribe/src/plugins/PerfNodesItems/useUsfmMarkersForMenu.ts new file mode 100644 index 00000000..71a1829f --- /dev/null +++ b/packages/scribe/src/plugins/PerfNodesItems/useUsfmMarkersForMenu.ts @@ -0,0 +1,58 @@ +import { LexicalEditor } from "lexical"; +import { useMemo } from "react"; +import { GetMarkerAction, ScriptureReference } from "shared/utils/get-marker-action.model"; +import getMarker from "shared/utils/usfm/getMarker"; + +// getMarker() takes a marker string and gets its data from a usfm markers map object that is merged with overwrites that fit the PERF editor context. +// getMarkerAction() returns a function to generate a LexicalNode and insert it in the editor, this lexical node is a custom node made for the PERF editor +//NOTE: You can create your own typeahead plugin by creating your own getMarker() and getMarkerAction() functions adapted to your editor needs. +export default function useUsfmMakersForMenu({ + editor, + scriptureReference, + contextMarker, + getMarkerAction, + autoNumbering, +}: { + editor: LexicalEditor; + scriptureReference: ScriptureReference; + contextMarker: string | undefined; + getMarkerAction: GetMarkerAction; + autoNumbering?: boolean; +}) { + const markersMenuItems = useMemo(() => { + if (!contextMarker || !scriptureReference) return; + const marker = getMarker(contextMarker); + if (!marker?.children) return; + + return Object.values(marker.children).flatMap((markers) => + markers.map((marker) => { + const markerData = getMarker(marker); + const { action } = getMarkerAction(marker, markerData); + return { + name: marker, + label: marker, + description: markerData?.description ?? "", + action: ({ + editor, + newVerseRChapterNum, + noteText, + }: { + editor: LexicalEditor; + newVerseRChapterNum?: number | undefined; + noteText?: string | undefined; + }) => { + action({ + editor, + reference: scriptureReference, + autoNumbering, + newVerseRChapterNum, + noteText, + }); + }, + }; + }), + ); + }, [editor, contextMarker, scriptureReference]); + + return { markersMenuItems }; +} diff --git a/packages/scribe/src/plugins/UsfmNodesMenuPlugin.tsx b/packages/scribe/src/plugins/UsfmNodesMenuPlugin.tsx new file mode 100644 index 00000000..dfdc7601 --- /dev/null +++ b/packages/scribe/src/plugins/UsfmNodesMenuPlugin.tsx @@ -0,0 +1,29 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { GetMarkerAction, ScriptureReference } from "shared/utils/get-marker-action.model"; +import useUsfmMakersForMenu from "./PerfNodesItems/useUsfmMarkersForMenu"; +import NodesMenu from "./NodesMenu"; + +export default function UsfmNodesMenuPlugin({ + trigger, + scriptureReference, + contextMarker, + getMarkerAction, + autoNumbering, +}: { + trigger: string; + scriptureReference: ScriptureReference; + contextMarker: string | undefined; + getMarkerAction: GetMarkerAction; + autoNumbering?: boolean; +}) { + const [editor] = useLexicalComposerContext(); + const { markersMenuItems } = useUsfmMakersForMenu({ + editor, + scriptureReference, + contextMarker, + getMarkerAction, + autoNumbering, + }); + + return ; +} diff --git a/packages/scribe/src/plugins/UsjNodesMenuPlugin.tsx b/packages/scribe/src/plugins/UsjNodesMenuPlugin.tsx new file mode 100644 index 00000000..267cf776 --- /dev/null +++ b/packages/scribe/src/plugins/UsjNodesMenuPlugin.tsx @@ -0,0 +1,193 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $dfs, DFSNode, mergeRegister } from "@lexical/utils"; +import { + $getNodeByKey, + $getRoot, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from "lexical"; +import { useEffect, useMemo, useState } from "react"; +import { + $findNextChapter, + $findThisChapter, + getNextVerse, + removeNodesBeforeNode, +} from "shared/nodes/scripture/usj/node.utils"; +import { $isVerseNode, VerseNode } from "shared/nodes/scripture/usj/VerseNode"; +import { GetMarkerAction, ScriptureReference } from "shared/utils/get-marker-action.model"; +import { + $isImmutableVerseNode, + ImmutableVerseNode, +} from "shared-react/nodes/scripture/usj/ImmutableVerseNode"; +import { $isReactNodeWithMarker } from "shared-react/nodes/scripture/usj/node-react.utils"; +import UsfmNodesMenuPlugin from "./UsfmNodesMenuPlugin"; + +type DfsVerseNode = Omit & { node: VerseNode | ImmutableVerseNode }; + +export default function UsjNodesMenuPlugin({ + trigger, + scrRef, + getMarkerAction, + autoNumbering = true, +}: { + trigger: string; + scrRef: ScriptureReference; + getMarkerAction: GetMarkerAction; + autoNumbering: boolean; +}) { + const { book, chapterNum, verseNum, verse } = scrRef; + const scriptureReference = useMemo(() => scrRef, [book, chapterNum, verseNum, verse]); + + const [editor] = useLexicalComposerContext(); + const [contextMarker] = useContextMarker(editor); + + // Only register verse renumbering if autoNumbering is enabled + useVerseCreated(editor, autoNumbering); + + return ( + + ); +} + +function useContextMarker(editor: LexicalEditor) { + const [contextMarker, setContextMarker] = useState(); + useEffect( + () => + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + setContextMarker(undefined); + return; + } + + const startNode = $getNodeByKey(selection.anchor.key); + const endNode = $getNodeByKey(selection.focus.key); + if (!startNode || !endNode) { + setContextMarker(undefined); + return; + } + + const contextNode = startNode.getCommonAncestor(endNode); + if (!contextNode || !$isReactNodeWithMarker(contextNode)) { + setContextMarker(undefined); + return; + } + + setContextMarker(contextNode.getMarker()); + }); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + [editor], + ); + return [contextMarker]; +} + +/** + * Extracts the verse range and/or segments from a given verse string. + * + * The verse string can be in the format: + * - "1" (single verse) + * - "1a" (single verse segment) + * - "1-2" (verse range) + * - "1a-2b" (verse range with segments) + * + * @param verse - The verse string to extract the range from. + * @returns An array containing the matched groups or null if no match is found. + */ +function getVerseRangeSegment(verse: string) { + return RegExp(/(\d+)([a-zA-Z]+)?(-(\d+)([a-zA-Z]+)?)?/).exec(verse); +} + +/** + * Renumber all the verse numbers after the inserted verse to keep them sequential. + * @param insertedNode - Inserted verse node. + */ +function $renumberAllVerses(insertedNode: VerseNode | ImmutableVerseNode) { + const children = $getRoot().getChildren(); + const chapterNode = $findThisChapter(insertedNode); + const nodesInChapter = removeNodesBeforeNode(children, chapterNode); + const nextChapterNode = $findNextChapter(nodesInChapter, !!chapterNode); + const allVerseNodes = $dfs(chapterNode, nextChapterNode).filter( + (dfsNode): dfsNode is DfsVerseNode => + $isImmutableVerseNode(dfsNode.node) || $isVerseNode(dfsNode.node), + ); + // find the index of the inserted node in the DFS result + const insertedNodeKey = insertedNode.getKey(); + const nodeIndex = allVerseNodes.findIndex(({ node }) => node.getKey() === insertedNodeKey); + // all verse nodes that require renumbering + const verseNodes = allVerseNodes.slice(nodeIndex + 1); + + // renumber for each verse + let verseNum = parseInt(insertedNode.getNumber()); + verseNodes.forEach(({ node }) => { + const nodeVerse = node.getNumber(); + const nodeVerseNum = parseInt(nodeVerse); + if (nodeVerseNum > verseNum) return; + + const startVerse = getNextVerse(nodeVerseNum, undefined); + const nodeVerseSegment = getVerseRangeSegment(nodeVerse); + const isRange = !!nodeVerseSegment?.[3]; + const startVerseSegmentChar = nodeVerseSegment?.[2] ?? ""; + const endVerseSegmentChar = nodeVerseSegment?.[5] ?? ""; + const endVerse = isRange ? getNextVerse(parseInt(nodeVerseSegment[4]), undefined) : ""; + let tail = `${startVerseSegmentChar}`; + tail += isRange ? `-${endVerse}${endVerseSegmentChar}` : ""; + node.setNumber(`${startVerse}${tail}`); + verseNum = parseInt(isRange ? endVerse : startVerse); + }); +} + +function useVerseCreated(editor: LexicalEditor, autoNumbering: boolean) { + useEffect(() => { + if (!editor.hasNodes([VerseNode, ImmutableVerseNode])) { + throw new Error( + "UsjNodesMenuPlugin: VerseNode or ImmutableVerseNode not registered on editor!", + ); + } + + // Only register renumbering if autoNumbering is enabled + if (!autoNumbering) { + return; + } + + // Re-generate all verse numbers when a verse is added. + return mergeRegister( + editor.registerMutationListener(ImmutableVerseNode, (nodeMutations) => { + editor.update( + () => { + for (const [nodeKey, mutation] of nodeMutations) { + const node = $getNodeByKey(nodeKey); + if (mutation === "created" && $isImmutableVerseNode(node)) $renumberAllVerses(node); + } + }, + { tag: "history-merge" }, + ); + }), + editor.registerMutationListener(VerseNode, (nodeMutations) => { + editor.update( + () => { + for (const [nodeKey, mutation] of nodeMutations) { + const node = $getNodeByKey(nodeKey); + if (mutation === "created" && $isVerseNode(node)) $renumberAllVerses(node); + } + }, + { tag: "history-merge" }, + ); + }), + ); + }, [editor, autoNumbering]); +} diff --git a/packages/scribe/src/plugins/useModifiedMarkersForMenu.ts b/packages/scribe/src/plugins/useModifiedMarkersForMenu.ts new file mode 100644 index 00000000..ffe68ad9 --- /dev/null +++ b/packages/scribe/src/plugins/useModifiedMarkersForMenu.ts @@ -0,0 +1,66 @@ +// import { LexicalEditor } from "lexical"; +// import { useMemo } from "react"; +// import { GetMarkerAction, ScriptureReference } from "shared/utils/get-marker-action.model"; +// import getMarker from "shared/utils/usfm/getMarker"; + +// export default function useModifiedMarkersForMenu({ +// editor, +// scriptureReference, +// contextMarker, +// getMarkerAction, +// autoNumbering, +// }: { +// editor: LexicalEditor; +// scriptureReference: ScriptureReference; +// contextMarker: string | undefined; +// getMarkerAction: GetMarkerAction; +// autoNumbering: boolean; +// }) { +// const markersMenuItems = useMemo(() => { +// if (!contextMarker || !scriptureReference) return; +// const marker = getMarker(contextMarker); +// if (!marker?.children) return; + +// return Object.values(marker.children).flatMap((markers) => +// markers.map((marker) => { +// const markerData = getMarker(marker); +// const { action } = getMarkerAction(marker, markerData); + +// // Special handling for chapter and verse markers when autoNumbering is disabled +// const requiresInput = !autoNumbering && (marker === "c" || marker === "v"); + +// // Base menu item +// const menuItem = { +// name: marker, +// label: marker, +// description: markerData?.description ?? "", +// action: (editor: LexicalEditor, value: number) => { +// action({ editor, reference: scriptureReference }); +// }, +// // Add a custom action method to handle user input when manual numbering is used +// customActionWithValue: (editor: LexicalEditor, value: number) => { +// // Create a modified reference with the user-provided value +// const modifiedReference = { ...scriptureReference }; + +// if (marker === "c") { +// modifiedReference.chapterNum = value; +// } else if (marker === "v") { +// modifiedReference.verseNum = value; +// // modifiedReference.verse = value; +// } + +// // Call the action with the modified reference +// action({ +// editor, +// reference: modifiedReference, +// }); +// }, +// }; + +// return menuItem; +// }), +// ); +// }, [editor, contextMarker, scriptureReference, autoNumbering]); + +// return { markersMenuItems }; +// } diff --git a/packages/shared-react/tsconfig.json b/packages/shared-react/tsconfig.json index 14214fad..bb4233f5 100644 --- a/packages/shared-react/tsconfig.json +++ b/packages/shared-react/tsconfig.json @@ -4,6 +4,12 @@ "jsx": "react-jsx", "moduleResolution": "Node" }, - "include": ["."], - "exclude": ["node_modules", "jest.config.ts"] -} + "include": [ + ".", + "../scribe/src/plugins/useModifiedMarkersForMenu.ts" + ], + "exclude": [ + "node_modules", + "jest.config.ts" + ] +} \ No newline at end of file diff --git a/packages/shared/utils/get-marker-action.model.ts b/packages/shared/utils/get-marker-action.model.ts index 66dc0874..15cb1345 100644 --- a/packages/shared/utils/get-marker-action.model.ts +++ b/packages/shared/utils/get-marker-action.model.ts @@ -5,7 +5,13 @@ import { Marker } from "./usfm/usfmTypes"; export type ScriptureReference = SerializedVerseRef; export type MarkerAction = { - action: (currentEditor: { editor: LexicalEditor; reference: ScriptureReference }) => void; + action: (currentEditor: { + editor: LexicalEditor; + reference: ScriptureReference; + autoNumbering?: boolean; + newVerseRChapterNum?: number; + noteText?: string; + }) => void; label: string | undefined; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e806bc1..f78bb597 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: '@biblionexus-foundation/scripture-utilities': specifier: workspace:~ version: link:../utilities + '@floating-ui/dom': + specifier: ^1.6.13 + version: 1.6.13 '@lexical/mark': specifier: ^0.24.0 version: 0.24.0 @@ -314,6 +317,9 @@ importers: '@lexical/utils': specifier: ^0.24.0 version: 0.24.0 + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.1) fast-equals: specifier: ^5.2.2 version: 5.2.2 @@ -3917,11 +3923,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.23.3: - resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.24.2: resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4003,9 +4004,6 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001655: - resolution: {integrity: sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==} - caniuse-lite@1.0.30001683: resolution: {integrity: sha512-iqmNnThZ0n70mNwvxpEC2nBJ037ZHZUoBI5Gorh1Mw6IlEAZujEoU1tXA628iZfzm7R9FvFzxbfdgml82a3k8Q==} @@ -4550,9 +4548,6 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.13: - resolution: {integrity: sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==} - electron-to-chromium@1.5.64: resolution: {integrity: sha512-IXEuxU+5ClW2IGEYFC2T7szbyVgehupCWQe5GNh+H065CD6U6IFN0s4KeAMFGNmQolRU4IV7zGBWSYMmZ8uuqQ==} @@ -7737,12 +7732,6 @@ packages: resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} engines: {node: '>=4'} - update-browserslist-db@1.1.0: - resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -12910,14 +12899,24 @@ snapshots: autoprefixer@10.4.20(postcss@8.4.49): dependencies: - browserslist: 4.23.3 - caniuse-lite: 1.0.30001655 + browserslist: 4.24.2 + caniuse-lite: 1.0.30001683 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 postcss: 8.4.49 postcss-value-parser: 4.2.0 + autoprefixer@10.4.20(postcss@8.5.1): + dependencies: + browserslist: 4.24.2 + caniuse-lite: 1.0.30001683 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.1 + postcss-value-parser: 4.2.0 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -13135,13 +13134,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.23.3: - dependencies: - caniuse-lite: 1.0.30001655 - electron-to-chromium: 1.5.13 - node-releases: 2.0.18 - update-browserslist-db: 1.1.0(browserslist@4.23.3) - browserslist@4.24.2: dependencies: caniuse-lite: 1.0.30001683 @@ -13227,8 +13219,6 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001655: {} - caniuse-lite@1.0.30001683: {} chai@5.1.2: @@ -13772,8 +13762,6 @@ snapshots: dependencies: jake: 10.9.2 - electron-to-chromium@1.5.13: {} - electron-to-chromium@1.5.64: {} emitter-component@1.1.2: {} @@ -17826,12 +17814,6 @@ snapshots: upath@2.0.1: {} - update-browserslist-db@1.1.0(browserslist@4.23.3): - dependencies: - browserslist: 4.23.3 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.1.1(browserslist@4.24.2): dependencies: browserslist: 4.24.2