lib/ask-numo-context.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
// Shortcut “@” (MIN-105): opens Numo with, in context, the element under the
// pointer — a ticket card, a yard line, a return line — or
// multiple selection when there is one. It is the keyboard counterpart of
// the “Ask Numo” action of the selection pill (see
// components/bulk-issue-actions.tsx): same `onAskNumo`, same pill
// contexte au-dessus du composer.
//
// Keyboard listening is UNIQUE and lives here, on the surface; the elements are
// just register. One listener per card (like the shortcuts of
// field) PLUS a global listener for the “selection without hover” case
// would walk on it: two openings for a single strike.
//
//Which element is under the pointer is read in the DOM AS YOU TYPE
// (`innermostHovered`), never in a state stored on hover: this one
// delays a passive effect on the pointer and still designates the element
// previous (MIN-158).
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import { eventKey } from "@/lib/keyboard/event-key";
import { isTypingTarget } from "@/lib/keyboard/keyboard-context";
import { innermostHovered } from "@/lib/keyboard/hover-keys";
import type { Issue } from "@/lib/types";
/**
* “@” does not live on the same key depending on the layout: Shift+2 in QWERTY
* US, bare key on a French Apple keyboard, AltGr+0 under Windows FR — and
* AltGr raises both `ctrlKey` and `altKey`. We therefore read the produced CHARACTER
* rather than the physical key, and we only discard the real combinations
* ⌘/Ctrl. `eventKey` protects synthetic keydowns without `key`.
*/
function isAtSign(e: KeyboardEvent): boolean {
if (eventKey(e) !== "@") return false;
if (e.metaKey) return false;
return !(e.ctrlKey && !e.altKey);
}
/** Nothing selected — a constant rather than a new `[]` each time it is rendered. */
const NOTHING: readonly never[] = [];
/**
* Creates the provider + registration pair for ONE type of thing that we can
* talk to Numo about. Provider and hook are born together because they share the same React context; calling it twice gives two waterproof registers.
*
* Two surfaces mounted at the same time do not interfere with each other, even if each one places
* its listener: the one which has neither selection nor hovered element leaves without anything
* consume (no `preventDefault`), and hovering cannot designate only one
* register at a time.
*/
function createAskNumo<T>() {
const Registry = createContext<{
register: (el: Element, get: () => T) => () => void;
} | null>(null);
function AskProvider({
selection,
onAsk,
children,
}: {
/** What is selected, and which takes precedence over hover. Empty if surface
* does not have multi-select. */
selection: readonly T[];
onAsk: (targets: T[]) => void;
children: ReactNode;
}) {
// The assembled elements, each knowing how to make their own fresh version of the thing
// (a modified line under the pointer therefore never sends an outdated title).
const targetsRef = useRef(new Map<Element, () => T>());
// Mirror refs: the listener is placed only once and always reads the status
// current, without resubscribing each time the selection changes.
const selectionRef = useRef(selection);
selectionRef.current = selection;
const askRef = useRef(onAsk);
askRef.current = onAsk;
const register = useCallback((el: Element, get: () => T) => {
const targets = targetsRef.current;
targets.set(el, get);
return () => {
targets.delete(el);
};
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!isAtSign(e)) return;
// Never during an entry: “@” is a character (a mention, a
// adresse e-mail), pas un raccourci.
if (isTypingTarget(e.target)) return;
const selected = selectionRef.current;
const hovered = innermostHovered(targetsRef.current)?.();
const targets =
selected.length > 0 ? [...selected] : hovered ? [hovered] : [];
if (targets.length === 0) return;
e.preventDefault();
e.stopImmediatePropagation();
askRef.current(targets);
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, []);
const value = useMemo(() => ({ register }), [register]);
return <Registry.Provider value={value}>{children}</Registry.Provider>;
}
/**
* Declares an element as a possible target of "@". Returns a callback ref to
* placed on it (merged with its other refs, without returning anything from the
* merge: this is the callback with `null` which unsubscribes on disassembly — filter
* view, drag). Outside of the surface (no provider), it's a no-op: the shortcut
* simply does not exist there.
*/
function useAskTarget(target: T): (el: Element | null) => void {
const register = useContext(Registry)?.register;
const targetRef = useRef(target);
targetRef.current = target;
const unregister = useRef<(() => void) | null>(null);
return useCallback(
(el: Element | null) => {
unregister.current?.();
unregister.current =
el && register ? register(el, () => targetRef.current) : null;
},
[register]
);
}
return { AskProvider, useAskTarget };
}
// ── Tickets: project board, global board, sorting column ───────────────
const issueAsk = createAskNumo<Issue>();
/**
* Set up by a board (or the sorting column): arbitrates what “@” sends to
* Numo. Selection takes precedence over hover — when the pill is there, it
* is the current mode.
*/
export function AskNumoProvider({
selectedIssues,
onAskNumo,
children,
}: {
selectedIssues: Issue[];
onAskNumo: (issues: Issue[]) => void;
children: ReactNode;
}) {
return (
<issueAsk.AskProvider selection={selectedIssues} onAsk={onAskNumo}>
{children}
</issueAsk.AskProvider>
);
}
/** Declares a ticket card as a possible target of "@". */
export const useAskNumoTarget = issueAsk.useAskTarget;
// ── Returns: column in the team tab ─────────────────────────────────────
/**
* The minimum that a return line must be able to say about itself for Numo
* to receive it in context. Deliberately structural rather than
* `TeamFeedbackListItem`: this file does not have to know the form of a return.
*/
export interface AskNumoFeedback {
id: string;
/** The canonical title — the one carried by the composer's context pill. */
title: string;
}
const feedbackAsk = createAskNumo<AskNumoFeedback>();
/**
* Set up by the returns team tab: "@" on hovering over a line opens
* Numo with THIS feedback in context, without having to open it first. With
* a selection (MIN-873), "@" sends the selection, as on the boards.
*/
export function AskNumoFeedbackProvider({
selectedPosts = NOTHING,
onAskNumo,
children,
}: {
selectedPosts?: readonly AskNumoFeedback[];
onAskNumo: (posts: AskNumoFeedback[]) => void;
children: ReactNode;
}) {
return (
<feedbackAsk.AskProvider selection={selectedPosts} onAsk={onAskNumo}>
{children}
</feedbackAsk.AskProvider>
);
}
/** Declares a return line as a possible target of "@". */
export const useAskNumoFeedbackTarget = feedbackAsk.useAskTarget;