Files
fusion/packages/dashboard/app/hooks/useSelectionComment.ts
gsxdsm 4bfb6b46ad FN-6528: add selection comments for new tasks
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
2026-06-17 03:29:39 -07:00

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;
}