Adds a text-selection comment flow that opens New Task with source context. - Add a reusable selection comment hook and popover for editor/document text selections. - Thread selected-file and line-range context into the New Task modal. - Enable comment-on-selection entry points in file editor, browser, documents, and memory surfaces. - Cover the selection flow with focused component and hook tests, plus docs and copy updates. Files changed: docs/dashboard-guide.md | 6 +- packages/dashboard/app/App.tsx | 7 +- packages/dashboard/app/components/AppModals.tsx | 2 + .../dashboard/app/components/DocumentsView.tsx | 25 ++- .../dashboard/app/components/FileBrowserModal.tsx | 3 + packages/dashboard/app/components/FileEditor.tsx | 35 +++- packages/dashboard/app/components/MemoryView.tsx | 5 +- packages/dashboard/app/components/NewTaskModal.tsx | 15 +- .../app/components/SelectionCommentPopover.css | 73 +++++++++ .../app/components/SelectionCommentPopover.tsx | 181 +++++++++++++++++++++ .../components/__tests__/DocumentsView.test.tsx | 59 +++++++ .../components/__tests__/FileBrowserModal.test.tsx | 79 +++++++++ .../app/components/__tests__/FileEditor.test.tsx | 60 +++++++ .../app/components/__tests__/MemoryView.test.tsx | 48 +++++- .../app/components/__tests__/NewTaskModal.test.tsx | 17 ++ .../__tests__/SelectionCommentPopover.test.tsx | 75 +++++++++ .../app/hooks/__tests__/useModalManager.test.ts | 29 ++++ .../hooks/__tests__/useSelectionComment.test.ts | 140 ++++++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 19 ++- .../dashboard/app/hooks/useSelectionComment.ts | 95 +++++++++++ packages/i18n/locales/en/app.json | 11 ++ 21 files changed, 973 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6528 Fusion-Task-Lineage: ef79e450-e69f-497b-97cd-f82d3c832fdf
96 lines
3.0 KiB
TypeScript
96 lines
3.0 KiB
TypeScript
import { useCallback, useEffect, useState, type RefObject } from "react";
|
|
|
|
export interface SelectionCommentLineRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
export interface SelectionCommentState {
|
|
selectedText: string;
|
|
anchorRect: DOMRect;
|
|
lineRange?: SelectionCommentLineRange;
|
|
}
|
|
|
|
interface UseSelectionCommentOptions {
|
|
getLineRange?: (selection: Selection) => SelectionCommentLineRange | undefined;
|
|
locked?: boolean;
|
|
}
|
|
|
|
function isNodeInside(container: HTMLElement, node: Node | null): boolean {
|
|
if (!node) return false;
|
|
return container === node || container.contains(node);
|
|
}
|
|
|
|
function getRangeAnchorRect(range: Range): DOMRect | null {
|
|
const rect = range.getBoundingClientRect();
|
|
if (rect.width > 0 || rect.height > 0) {
|
|
return rect;
|
|
}
|
|
const firstRect = range.getClientRects()[0];
|
|
return firstRect ?? null;
|
|
}
|
|
|
|
/**
|
|
* FNXC:SelectionComment 2026-06-16-23:49:
|
|
* File content surfaces need a shared selection detector so editor, markdown preview, and read-only preview containers can offer the same comment-to-New-Task affordance without mutating the file or disrupting copy selection.
|
|
*/
|
|
export function useSelectionComment(
|
|
containerRef: RefObject<HTMLElement | null>,
|
|
options: UseSelectionCommentOptions = {},
|
|
): SelectionCommentState | null {
|
|
const { getLineRange, locked = false } = options;
|
|
const [selectionState, setSelectionState] = useState<SelectionCommentState | null>(null);
|
|
|
|
const refreshSelection = useCallback(() => {
|
|
if (locked) {
|
|
return;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
const selection = document.getSelection();
|
|
if (!container || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
setSelectionState(null);
|
|
return;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const selectedText = selection.toString().trim();
|
|
if (!selectedText) {
|
|
setSelectionState(null);
|
|
return;
|
|
}
|
|
|
|
if (!isNodeInside(container, range.commonAncestorContainer) || !isNodeInside(container, selection.anchorNode) || !isNodeInside(container, selection.focusNode)) {
|
|
setSelectionState(null);
|
|
return;
|
|
}
|
|
|
|
const anchorRect = getRangeAnchorRect(range);
|
|
if (!anchorRect) {
|
|
setSelectionState(null);
|
|
return;
|
|
}
|
|
|
|
setSelectionState({
|
|
selectedText,
|
|
anchorRect,
|
|
lineRange: getLineRange?.(selection),
|
|
});
|
|
}, [containerRef, getLineRange, locked]);
|
|
|
|
useEffect(() => {
|
|
document.addEventListener("selectionchange", refreshSelection);
|
|
document.addEventListener("mouseup", refreshSelection);
|
|
document.addEventListener("touchend", refreshSelection);
|
|
document.addEventListener("keyup", refreshSelection);
|
|
return () => {
|
|
document.removeEventListener("selectionchange", refreshSelection);
|
|
document.removeEventListener("mouseup", refreshSelection);
|
|
document.removeEventListener("touchend", refreshSelection);
|
|
document.removeEventListener("keyup", refreshSelection);
|
|
};
|
|
}, [refreshSelection]);
|
|
|
|
return selectionState;
|
|
}
|