@blocksdiy/react-common 1.70.2 → 1.71.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/comments/anchor.d.ts +96 -0
- package/dist/comments/anchor.d.ts.map +1 -0
- package/dist/comments/anchor.js +346 -0
- package/dist/comments/api.d.ts +38 -0
- package/dist/comments/api.d.ts.map +1 -0
- package/dist/comments/api.js +62 -0
- package/dist/comments/comment-agents.d.ts +28 -0
- package/dist/comments/comment-agents.d.ts.map +1 -0
- package/dist/comments/comment-agents.js +32 -0
- package/dist/comments/comment-mode.d.ts +115 -0
- package/dist/comments/comment-mode.d.ts.map +1 -0
- package/dist/comments/comment-mode.js +176 -0
- package/dist/comments/composer.d.ts +133 -0
- package/dist/comments/composer.d.ts.map +1 -0
- package/dist/comments/composer.js +273 -0
- package/dist/comments/index.d.ts +19 -0
- package/dist/comments/index.d.ts.map +1 -0
- package/dist/comments/index.js +23 -0
- package/dist/comments/layout.d.ts +25 -0
- package/dist/comments/layout.d.ts.map +1 -0
- package/dist/comments/layout.js +83 -0
- package/dist/comments/mentions.d.ts +32 -0
- package/dist/comments/mentions.d.ts.map +1 -0
- package/dist/comments/mentions.js +30 -0
- package/dist/comments/menu.d.ts +33 -0
- package/dist/comments/menu.d.ts.map +1 -0
- package/dist/comments/menu.js +47 -0
- package/dist/comments/open-comment.d.ts +28 -0
- package/dist/comments/open-comment.d.ts.map +1 -0
- package/dist/comments/open-comment.js +9 -0
- package/dist/comments/page-pick.d.ts +76 -0
- package/dist/comments/page-pick.d.ts.map +1 -0
- package/dist/comments/page-pick.js +283 -0
- package/dist/comments/pick-message.d.ts +11 -0
- package/dist/comments/pick-message.d.ts.map +1 -0
- package/dist/comments/pick-message.js +33 -0
- package/dist/comments/pin-view.d.ts +107 -0
- package/dist/comments/pin-view.d.ts.map +1 -0
- package/dist/comments/pin-view.js +248 -0
- package/dist/comments/pins.d.ts +126 -0
- package/dist/comments/pins.d.ts.map +1 -0
- package/dist/comments/pins.js +218 -0
- package/dist/comments/text-pick.d.ts +69 -0
- package/dist/comments/text-pick.d.ts.map +1 -0
- package/dist/comments/text-pick.js +307 -0
- package/dist/comments/threads-store.d.ts +119 -0
- package/dist/comments/threads-store.d.ts.map +1 -0
- package/dist/comments/threads-store.js +286 -0
- package/dist/comments/types.d.ts +235 -0
- package/dist/comments/types.d.ts.map +1 -0
- package/dist/comments/types.js +109 -0
- package/dist/comments/use-comment-menu.d.ts +69 -0
- package/dist/comments/use-comment-menu.d.ts.map +1 -0
- package/dist/comments/use-comment-menu.js +140 -0
- package/dist/comments/use-held.d.ts +3 -0
- package/dist/comments/use-held.d.ts.map +1 -0
- package/dist/comments/use-held.js +16 -0
- package/dist/comments/use-thread-scroll.d.ts +23 -0
- package/dist/comments/use-thread-scroll.d.ts.map +1 -0
- package/dist/comments/use-thread-scroll.js +89 -0
- package/dist/hooks/agent-conversation-context.d.ts +12 -1
- package/dist/hooks/agent-conversation-context.d.ts.map +1 -1
- package/dist/hooks/agent-conversation-context.js +12 -5
- package/dist/hooks/agent-thread-live-update.d.ts +5 -2
- package/dist/hooks/agent-thread-live-update.d.ts.map +1 -1
- package/dist/hooks/agent-thread-live-update.js +14 -0
- package/dist/hooks/use-agent-conversation.d.ts +8 -0
- package/dist/hooks/use-agent-conversation.d.ts.map +1 -1
- package/dist/hooks/use-agent-conversation.js +10 -0
- package/dist/hooks/use-agent-threads.d.ts +1 -0
- package/dist/hooks/use-agent-threads.d.ts.map +1 -1
- package/dist/hooks/use-agent-threads.js +4 -1
- package/dist/iframe-communication/types.d.ts +25 -0
- package/dist/iframe-communication/types.d.ts.map +1 -1
- package/package.json +5 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type CommentAnchor, type CommentAnchorItem } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a comment (or a pick from the chat) points, and finding that place again on the page as it
|
|
4
|
+
* is now. An anchor keeps several signals — the row, the source location, its position among the
|
|
5
|
+
* same source's elements, a DOM path and the text itself — because none of them alone survives an
|
|
6
|
+
* edit to the app or a re-sorted list.
|
|
7
|
+
*/
|
|
8
|
+
/** An app marks the element that shows one row with `data-blocks-item="<table>:<rowId>"`. */
|
|
9
|
+
export declare const ITEM_ATTRIBUTE = "data-blocks-item";
|
|
10
|
+
/** What `fieldAnchorProps` (blocks-client-sdk) tags a column's element with. */
|
|
11
|
+
export declare const FIELD_ATTRIBUTE = "data-blocks-field";
|
|
12
|
+
/**
|
|
13
|
+
* Inside an element with this attribute nothing is picked, and the picker blocks no click: the
|
|
14
|
+
* comments' own UI, the agent chat (the registry's `pagePickIgnoreProps` writes it).
|
|
15
|
+
*/
|
|
16
|
+
export declare const PICK_IGNORE_ATTRIBUTE = "data-blocks-pick-ignore";
|
|
17
|
+
/** An element's text as it reads, whitespace collapsed. */
|
|
18
|
+
export declare const textOf: (element: HTMLElement) => string;
|
|
19
|
+
/**
|
|
20
|
+
* `"<table>:<rowId>"` as a row. Split on the last colon: the table part is what the app's code
|
|
21
|
+
* calls the table (generated code uses its title), and row ids are digits.
|
|
22
|
+
*/
|
|
23
|
+
export declare const parseItemAttribute: (value: string | null | undefined) => CommentAnchorItem | undefined;
|
|
24
|
+
/** True when `node` is inside UI the picker leaves alone. */
|
|
25
|
+
export declare const isPickIgnored: (node: Node | null) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The element a click or selection is about: the nearest one the app's own markup tagged, else the
|
|
28
|
+
* element itself. Null inside ignored UI.
|
|
29
|
+
*/
|
|
30
|
+
export declare const pickTarget: (node: Node | null) => HTMLElement | null;
|
|
31
|
+
/**
|
|
32
|
+
* Describes where a comment points, from the element picked and, when text was highlighted inside
|
|
33
|
+
* it, the selection. `clientX`/`clientY` (the click) keep where in the element it was dropped.
|
|
34
|
+
*/
|
|
35
|
+
export declare const captureAnchor: (options: {
|
|
36
|
+
routePath: string;
|
|
37
|
+
element: HTMLElement;
|
|
38
|
+
range?: Range | null;
|
|
39
|
+
clientX?: number;
|
|
40
|
+
clientY?: number;
|
|
41
|
+
}) => CommentAnchor;
|
|
42
|
+
export type AnchorState = "anchored" | "outdated" | "missing";
|
|
43
|
+
export interface ResolvedAnchor {
|
|
44
|
+
state: AnchorState;
|
|
45
|
+
element: HTMLElement | null;
|
|
46
|
+
/** The highlighted text, found again: where a quote's pin and highlight go. */
|
|
47
|
+
range: Range | null;
|
|
48
|
+
/** What the anchored text says now, when it changed. */
|
|
49
|
+
currentText?: string;
|
|
50
|
+
}
|
|
51
|
+
/** The row's element an anchor names, or null when it has none or the row is not on the page. */
|
|
52
|
+
export declare const findAnchorItemElement: (anchor: CommentAnchor, root?: ParentNode) => Element | null;
|
|
53
|
+
/**
|
|
54
|
+
* Finds an anchor on the page as it is now. `outdated`: the element is there but the text the
|
|
55
|
+
* comment was about changed, so the thread shows what it said.
|
|
56
|
+
*/
|
|
57
|
+
export declare const resolveAnchor: (anchor: CommentAnchor) => ResolvedAnchor;
|
|
58
|
+
/** Whether `element` still says what the anchor was about. */
|
|
59
|
+
export declare const checkText: (anchor: CommentAnchor, element: HTMLElement) => ResolvedAnchor;
|
|
60
|
+
/** The words the thread is about: the highlighted text, else the element's text when short. */
|
|
61
|
+
export declare const anchorQuote: (anchor: CommentAnchor) => string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* A component's name as the app's code gives it (`TaskCard`): a `devName` that starts with a
|
|
64
|
+
* capital. A plain element's `devName` is its tag (`div`), jargon to the app's people: none then.
|
|
65
|
+
*/
|
|
66
|
+
export declare const componentNameOf: (devName: string | null | undefined) => string | undefined;
|
|
67
|
+
/** The component an anchor's element is (`componentNameOf`), when it has a name. */
|
|
68
|
+
export declare const anchorComponentName: (anchor: CommentAnchor) => string | undefined;
|
|
69
|
+
/** The kinds of text-less element a thread can name in plain words (the words are the UI's). */
|
|
70
|
+
export type AnchorElementKind = "image" | "icon" | "video" | "field" | "button" | "link";
|
|
71
|
+
/** What a thread is about, as its header names it; see `anchorSubject`. */
|
|
72
|
+
export type AnchorSubject = {
|
|
73
|
+
kind: "text";
|
|
74
|
+
text: string;
|
|
75
|
+
} | {
|
|
76
|
+
kind: "component";
|
|
77
|
+
name: string;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "element";
|
|
80
|
+
element: AnchorElementKind;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "page";
|
|
83
|
+
routePath: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* What an anchor is about, never a tag: its words (the highlighted text, else the element's text,
|
|
87
|
+
* however long: the UI cuts it), else its component's name, else the kind of a common text-less
|
|
88
|
+
* element (an image, an icon, a video, a field, a button, a link), else its page.
|
|
89
|
+
*/
|
|
90
|
+
export declare const anchorSubject: (anchor: CommentAnchor) => AnchorSubject;
|
|
91
|
+
/**
|
|
92
|
+
* A short name for what an anchor points at, never a tag: its quote, else its element's text,
|
|
93
|
+
* clipped, else its component's name, else its page (`anchorSubject`, an element's kind aside).
|
|
94
|
+
*/
|
|
95
|
+
export declare const anchorLabel: (anchor: CommentAnchor) => string;
|
|
96
|
+
//# sourceMappingURL=anchor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anchor.d.ts","sourceRoot":"","sources":["../../src/comments/anchor.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAElB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAC;AAEjB;;;;;GAKG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,cAAc,qBAAqB,CAAC;AACjD,gFAAgF;AAChF,eAAO,MAAM,eAAe,sBAAsB,CAAC;AAEnD;;;GAGG;AACH,eAAO,MAAM,qBAAqB,4BAA4B,CAAC;AAQ/D,2DAA2D;AAC3D,eAAO,MAAM,MAAM,GAAI,SAAS,WAAW,WAA8D,CAAC;AAO1G;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,GAAG,IAAI,GAAG,SAAS,KAAG,iBAAiB,GAAG,SAMzF,CAAC;AAyBF,6DAA6D;AAC7D,eAAO,MAAM,aAAa,GAAI,MAAM,IAAI,GAAG,IAAI,KAAG,OAGjD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,GAAI,MAAM,IAAI,GAAG,IAAI,KAAG,WAAW,GAAG,IAS5D,CAAC;AAmCF;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,SAAS;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,WAAW,CAAC;IACrB,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,KAAG,aAgDH,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAAC;AAE9D,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IAC5B,+EAA+E;IAC/E,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA6ED,iGAAiG;AACjG,eAAO,MAAM,qBAAqB,GAAI,QAAQ,aAAa,EAAE,OAAM,UAAqB,KAAG,OAAO,GAAG,IACjC,CAAC;AAErE;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,QAAQ,aAAa,KAAG,cA2CrD,CAAC;AAEF,8DAA8D;AAC9D,eAAO,MAAM,SAAS,GAAI,QAAQ,aAAa,EAAE,SAAS,WAAW,KAAG,cAavE,CAAC;AAEF,+FAA+F;AAC/F,eAAO,MAAM,WAAW,GAAI,QAAQ,aAAa,KAAG,MAAM,GAAG,SAM5D,CAAC;AAIF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,GAAG,IAAI,GAAG,SAAS,KAAG,MAAM,GAAG,SACrB,CAAC;AAE1D,oFAAoF;AACpF,eAAO,MAAM,mBAAmB,GAAI,QAAQ,aAAa,KAAG,MAAM,GAAG,SAC3B,CAAC;AAE3C,gGAAgG;AAChG,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAgBzF,2EAA2E;AAC3E,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,iBAAiB,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,QAAQ,aAAa,KAAG,aAYrD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,aAAa,KAAG,MAOnD,CAAC"}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { COMMENT_ANCHOR_CONTEXT_MAX_LENGTH, COMMENT_ANCHOR_FIELD_MAX_LENGTH, COMMENT_ANCHOR_TEXT_MAX_LENGTH, } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a comment (or a pick from the chat) points, and finding that place again on the page as it
|
|
4
|
+
* is now. An anchor keeps several signals — the row, the source location, its position among the
|
|
5
|
+
* same source's elements, a DOM path and the text itself — because none of them alone survives an
|
|
6
|
+
* edit to the app or a re-sorted list.
|
|
7
|
+
*/
|
|
8
|
+
/** An app marks the element that shows one row with `data-blocks-item="<table>:<rowId>"`. */
|
|
9
|
+
export const ITEM_ATTRIBUTE = "data-blocks-item";
|
|
10
|
+
/** What `fieldAnchorProps` (blocks-client-sdk) tags a column's element with. */
|
|
11
|
+
export const FIELD_ATTRIBUTE = "data-blocks-field";
|
|
12
|
+
/**
|
|
13
|
+
* Inside an element with this attribute nothing is picked, and the picker blocks no click: the
|
|
14
|
+
* comments' own UI, the agent chat (the registry's `pagePickIgnoreProps` writes it).
|
|
15
|
+
*/
|
|
16
|
+
export const PICK_IGNORE_ATTRIBUTE = "data-blocks-pick-ignore";
|
|
17
|
+
/** An element comment shows "was …" when its text changed, if the text was this short. */
|
|
18
|
+
const SHORT_TEXT_MAX = 200;
|
|
19
|
+
const DOM_PATH_MAX_DEPTH = 12;
|
|
20
|
+
const normalize = (text) => text.replace(/\s+/g, " ").trim();
|
|
21
|
+
/** An element's text as it reads, whitespace collapsed. */
|
|
22
|
+
export const textOf = (element) => normalize(element.innerText ?? element.textContent ?? "");
|
|
23
|
+
const escape = (value) => typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
24
|
+
? CSS.escape(value)
|
|
25
|
+
: value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
26
|
+
/**
|
|
27
|
+
* `"<table>:<rowId>"` as a row. Split on the last colon: the table part is what the app's code
|
|
28
|
+
* calls the table (generated code uses its title), and row ids are digits.
|
|
29
|
+
*/
|
|
30
|
+
export const parseItemAttribute = (value) => {
|
|
31
|
+
const separator = value?.lastIndexOf(":") ?? -1;
|
|
32
|
+
if (!value || separator <= 0 || separator === value.length - 1) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return { tableBlockId: value.slice(0, separator), itemId: value.slice(separator + 1) };
|
|
36
|
+
};
|
|
37
|
+
const itemSelector = (item) => `[${ITEM_ATTRIBUTE}="${escape(`${item.tableBlockId}:${item.itemId}`)}"]`;
|
|
38
|
+
const sameSourceSelector = (devPath, devName) => `[data-dev-path="${escape(devPath)}"][data-dev-name="${escape(devName)}"]`;
|
|
39
|
+
/** `:scope > div:nth-of-type(2) > span:nth-of-type(1)` from `root` down to `element`. */
|
|
40
|
+
const domPathFrom = (root, element) => {
|
|
41
|
+
const parts = [];
|
|
42
|
+
let current = element;
|
|
43
|
+
while (current && current !== root && parts.length < DOM_PATH_MAX_DEPTH) {
|
|
44
|
+
const parent = current.parentElement;
|
|
45
|
+
if (!parent) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const tag = current.tagName;
|
|
49
|
+
const sameTag = Array.from(parent.children).filter((child) => child.tagName === tag);
|
|
50
|
+
parts.unshift(`${tag.toLowerCase()}:nth-of-type(${sameTag.indexOf(current) + 1})`);
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
return current === root && parts.length > 0 ? `:scope > ${parts.join(" > ")}` : undefined;
|
|
54
|
+
};
|
|
55
|
+
/** True when `node` is inside UI the picker leaves alone. */
|
|
56
|
+
export const isPickIgnored = (node) => {
|
|
57
|
+
const element = node instanceof Element ? node : (node?.parentElement ?? null);
|
|
58
|
+
return !!element?.closest(`[${PICK_IGNORE_ATTRIBUTE}]`);
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The element a click or selection is about: the nearest one the app's own markup tagged, else the
|
|
62
|
+
* element itself. Null inside ignored UI.
|
|
63
|
+
*/
|
|
64
|
+
export const pickTarget = (node) => {
|
|
65
|
+
if (!node || isPickIgnored(node)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const element = node instanceof HTMLElement ? node : node.parentElement;
|
|
69
|
+
if (!element) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return element.closest("[data-dev-id]") ?? element;
|
|
73
|
+
};
|
|
74
|
+
/** The text of a selection inside `element`, with context on both sides; undefined when none. */
|
|
75
|
+
const selectedText = (element, range) => {
|
|
76
|
+
if (!range || range.collapsed || !element.contains(range.commonAncestorContainer)) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const selected = range.toString();
|
|
80
|
+
const trimmed = selected.trim();
|
|
81
|
+
if (!trimmed) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
// A double or triple click selects the space or line break around the words too: that whitespace
|
|
85
|
+
// goes to the context, so prefix + exact + suffix is still the element's text.
|
|
86
|
+
const leading = selected.slice(0, selected.length - selected.trimStart().length);
|
|
87
|
+
const exact = trimmed.slice(0, COMMENT_ANCHOR_TEXT_MAX_LENGTH);
|
|
88
|
+
const trailing = trimmed.slice(exact.length) + selected.slice(selected.trimEnd().length);
|
|
89
|
+
const before = document.createRange();
|
|
90
|
+
before.selectNodeContents(element);
|
|
91
|
+
before.setEnd(range.startContainer, range.startOffset);
|
|
92
|
+
const after = document.createRange();
|
|
93
|
+
after.selectNodeContents(element);
|
|
94
|
+
after.setStart(range.endContainer, range.endOffset);
|
|
95
|
+
return {
|
|
96
|
+
exact,
|
|
97
|
+
prefix: (before.toString() + leading).slice(-COMMENT_ANCHOR_CONTEXT_MAX_LENGTH),
|
|
98
|
+
suffix: (trailing + after.toString()).slice(0, COMMENT_ANCHOR_CONTEXT_MAX_LENGTH),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
|
|
102
|
+
const stripUndefined = (value) => Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
103
|
+
/**
|
|
104
|
+
* Describes where a comment points, from the element picked and, when text was highlighted inside
|
|
105
|
+
* it, the selection. `clientX`/`clientY` (the click) keep where in the element it was dropped.
|
|
106
|
+
*/
|
|
107
|
+
export const captureAnchor = (options) => {
|
|
108
|
+
const { element } = options;
|
|
109
|
+
const itemElement = element.closest(`[${ITEM_ATTRIBUTE}]`);
|
|
110
|
+
const item = parseItemAttribute(itemElement?.getAttribute(ITEM_ATTRIBUTE));
|
|
111
|
+
const root = (item && itemElement) || document.body;
|
|
112
|
+
const devPath = element.getAttribute("data-dev-path") ?? undefined;
|
|
113
|
+
const devName = element.getAttribute("data-dev-name") ?? undefined;
|
|
114
|
+
const anchorElement = {
|
|
115
|
+
devId: element.getAttribute("data-dev-id") ?? undefined,
|
|
116
|
+
devPath,
|
|
117
|
+
devName,
|
|
118
|
+
tagName: element.tagName,
|
|
119
|
+
domPath: domPathFrom(root, element),
|
|
120
|
+
};
|
|
121
|
+
if (devPath && devName) {
|
|
122
|
+
const ordinal = Array.from(root.querySelectorAll(sameSourceSelector(devPath, devName))).indexOf(element);
|
|
123
|
+
if (ordinal >= 0) {
|
|
124
|
+
anchorElement.ordinal = ordinal;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const text = textOf(element);
|
|
128
|
+
if (text) {
|
|
129
|
+
anchorElement.textSnapshot = text.slice(0, COMMENT_ANCHOR_TEXT_MAX_LENGTH);
|
|
130
|
+
}
|
|
131
|
+
const anchor = { v: 1, routePath: options.routePath, element: stripUndefined(anchorElement) };
|
|
132
|
+
if (item) {
|
|
133
|
+
// The column the element shows: the nearest tag at or above it, inside the row's element.
|
|
134
|
+
const fieldElement = element.closest(`[${FIELD_ATTRIBUTE}]`);
|
|
135
|
+
const field = fieldElement && itemElement?.contains(fieldElement) ? fieldElement.getAttribute(FIELD_ATTRIBUTE) : null;
|
|
136
|
+
anchor.item = field ? { ...item, field: field.slice(0, COMMENT_ANCHOR_FIELD_MAX_LENGTH) } : item;
|
|
137
|
+
}
|
|
138
|
+
const quote = selectedText(element, options.range);
|
|
139
|
+
if (quote) {
|
|
140
|
+
anchor.text = quote;
|
|
141
|
+
}
|
|
142
|
+
if (options.clientX !== undefined && options.clientY !== undefined) {
|
|
143
|
+
const rect = element.getBoundingClientRect();
|
|
144
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
145
|
+
anchor.point = {
|
|
146
|
+
x: clamp01((options.clientX - rect.left) / rect.width),
|
|
147
|
+
y: clamp01((options.clientY - rect.top) / rect.height),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return anchor;
|
|
152
|
+
};
|
|
153
|
+
const MISSING = { state: "missing", element: null, range: null };
|
|
154
|
+
const findElement = (anchor, root) => {
|
|
155
|
+
const element = anchor.element;
|
|
156
|
+
if (!element) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (element.devPath && element.devName && element.ordinal !== undefined) {
|
|
160
|
+
// No ordinal: the element was the row's own element, which its descendants never match.
|
|
161
|
+
const byOrdinal = root.querySelectorAll(sameSourceSelector(element.devPath, element.devName))[element.ordinal];
|
|
162
|
+
if (byOrdinal) {
|
|
163
|
+
return byOrdinal;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (element.devId) {
|
|
167
|
+
const byId = root.querySelector(`[data-dev-id="${escape(element.devId)}"]`);
|
|
168
|
+
if (byId) {
|
|
169
|
+
return byId;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (element.domPath) {
|
|
173
|
+
try {
|
|
174
|
+
const byPath = root.querySelector(element.domPath);
|
|
175
|
+
if (byPath && (!element.tagName || byPath.tagName === element.tagName)) {
|
|
176
|
+
return byPath;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// A path the current DOM can't parse: no match.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
};
|
|
185
|
+
/** A Range over the first occurrence of `exact` in `element`'s text, preferring one in context. */
|
|
186
|
+
const findTextRange = (element, text) => {
|
|
187
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
188
|
+
const nodes = [];
|
|
189
|
+
let content = "";
|
|
190
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
191
|
+
nodes.push(node);
|
|
192
|
+
content += node.data;
|
|
193
|
+
}
|
|
194
|
+
const inContext = content.indexOf(text.prefix + text.exact + text.suffix);
|
|
195
|
+
const start = inContext >= 0 ? inContext + text.prefix.length : content.indexOf(text.exact);
|
|
196
|
+
if (start < 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const end = start + text.exact.length;
|
|
200
|
+
const range = document.createRange();
|
|
201
|
+
let offset = 0;
|
|
202
|
+
let startSet = false;
|
|
203
|
+
for (const node of nodes) {
|
|
204
|
+
const next = offset + node.data.length;
|
|
205
|
+
if (!startSet && start < next) {
|
|
206
|
+
range.setStart(node, start - offset);
|
|
207
|
+
startSet = true;
|
|
208
|
+
}
|
|
209
|
+
if (startSet && end <= next) {
|
|
210
|
+
range.setEnd(node, end - offset);
|
|
211
|
+
return range;
|
|
212
|
+
}
|
|
213
|
+
offset = next;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
};
|
|
217
|
+
/** Whether `element` is inside the row's tag of a column other than `field` (untagged: unknown, so no). */
|
|
218
|
+
const showsAnotherField = (element, row, field) => {
|
|
219
|
+
const tagged = element.closest(`[${FIELD_ATTRIBUTE}]`);
|
|
220
|
+
const shown = tagged && row.contains(tagged) ? tagged.getAttribute(FIELD_ATTRIBUTE) : null;
|
|
221
|
+
return !!shown && shown.slice(0, COMMENT_ANCHOR_FIELD_MAX_LENGTH) !== field;
|
|
222
|
+
};
|
|
223
|
+
/** The row's element an anchor names, or null when it has none or the row is not on the page. */
|
|
224
|
+
export const findAnchorItemElement = (anchor, root = document) => anchor.item ? root.querySelector(itemSelector(anchor.item)) : null;
|
|
225
|
+
/**
|
|
226
|
+
* Finds an anchor on the page as it is now. `outdated`: the element is there but the text the
|
|
227
|
+
* comment was about changed, so the thread shows what it said.
|
|
228
|
+
*/
|
|
229
|
+
export const resolveAnchor = (anchor) => {
|
|
230
|
+
let root = document.body;
|
|
231
|
+
if (anchor.item) {
|
|
232
|
+
const itemElement = findAnchorItemElement(anchor);
|
|
233
|
+
if (!itemElement) {
|
|
234
|
+
return MISSING;
|
|
235
|
+
}
|
|
236
|
+
root = itemElement;
|
|
237
|
+
}
|
|
238
|
+
const found = findElement(anchor, root);
|
|
239
|
+
// Another view of the row can have an element from the same source at the same place that
|
|
240
|
+
// shows another column: not the one the comment was on.
|
|
241
|
+
const element = found && anchor.item?.field && showsAnotherField(found, root, anchor.item.field) ? null : found;
|
|
242
|
+
if (element) {
|
|
243
|
+
return checkText(anchor, element);
|
|
244
|
+
}
|
|
245
|
+
if (!anchor.item) {
|
|
246
|
+
return MISSING;
|
|
247
|
+
}
|
|
248
|
+
// The row is on the page without the element the comment was on: the row's own element, or
|
|
249
|
+
// another view of the same rows (a board's card, a list's line). The thread stays with its row.
|
|
250
|
+
const row = root;
|
|
251
|
+
const inner = anchor.element;
|
|
252
|
+
if (inner?.ordinal === undefined && !inner?.domPath) {
|
|
253
|
+
return checkText(anchor, row);
|
|
254
|
+
}
|
|
255
|
+
// On the column it was about, when this view tags it; else on the part of the row that says
|
|
256
|
+
// what the comment was about (the innermost such element); else on the row, out of date.
|
|
257
|
+
const field = anchor.item.field
|
|
258
|
+
? row.querySelector(`[${FIELD_ATTRIBUTE}="${escape(anchor.item.field)}"]`)
|
|
259
|
+
: null;
|
|
260
|
+
if (field) {
|
|
261
|
+
return checkText(anchor, field);
|
|
262
|
+
}
|
|
263
|
+
const snapshot = inner?.textSnapshot;
|
|
264
|
+
if (!anchor.text && snapshot) {
|
|
265
|
+
const saying = Array.from(row.querySelectorAll("*")).filter((part) => textOf(part) === snapshot);
|
|
266
|
+
const innermost = saying[saying.length - 1];
|
|
267
|
+
if (innermost) {
|
|
268
|
+
return { state: "anchored", element: innermost, range: null };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return checkText(anchor, row);
|
|
272
|
+
};
|
|
273
|
+
/** Whether `element` still says what the anchor was about. */
|
|
274
|
+
export const checkText = (anchor, element) => {
|
|
275
|
+
const current = textOf(element);
|
|
276
|
+
if (anchor.text) {
|
|
277
|
+
const range = findTextRange(element, anchor.text);
|
|
278
|
+
return range
|
|
279
|
+
? { state: "anchored", element, range }
|
|
280
|
+
: { state: "outdated", element, range: null, currentText: current.slice(0, SHORT_TEXT_MAX) };
|
|
281
|
+
}
|
|
282
|
+
const snapshot = anchor.element?.textSnapshot;
|
|
283
|
+
if (snapshot && snapshot.length <= SHORT_TEXT_MAX && current !== snapshot) {
|
|
284
|
+
return { state: "outdated", element, range: null, currentText: current.slice(0, SHORT_TEXT_MAX) };
|
|
285
|
+
}
|
|
286
|
+
return { state: "anchored", element, range: null };
|
|
287
|
+
};
|
|
288
|
+
/** The words the thread is about: the highlighted text, else the element's text when short. */
|
|
289
|
+
export const anchorQuote = (anchor) => {
|
|
290
|
+
if (anchor.text) {
|
|
291
|
+
return anchor.text.exact;
|
|
292
|
+
}
|
|
293
|
+
const snapshot = anchor.element?.textSnapshot;
|
|
294
|
+
return snapshot && snapshot.length <= SHORT_TEXT_MAX ? snapshot : undefined;
|
|
295
|
+
};
|
|
296
|
+
const LABEL_MAX = 40;
|
|
297
|
+
/**
|
|
298
|
+
* A component's name as the app's code gives it (`TaskCard`): a `devName` that starts with a
|
|
299
|
+
* capital. A plain element's `devName` is its tag (`div`), jargon to the app's people: none then.
|
|
300
|
+
*/
|
|
301
|
+
export const componentNameOf = (devName) => devName && /^[A-Z]/.test(devName) ? devName : undefined;
|
|
302
|
+
/** The component an anchor's element is (`componentNameOf`), when it has a name. */
|
|
303
|
+
export const anchorComponentName = (anchor) => componentNameOf(anchor.element?.devName);
|
|
304
|
+
/** Tags (HTML ones stored in capitals, SVG ones as written, so compared in capitals) by kind. */
|
|
305
|
+
const ELEMENT_KINDS = new Map([
|
|
306
|
+
["IMG", "image"],
|
|
307
|
+
["PICTURE", "image"],
|
|
308
|
+
["CANVAS", "image"],
|
|
309
|
+
["SVG", "icon"],
|
|
310
|
+
["VIDEO", "video"],
|
|
311
|
+
["INPUT", "field"],
|
|
312
|
+
["TEXTAREA", "field"],
|
|
313
|
+
["SELECT", "field"],
|
|
314
|
+
["BUTTON", "button"],
|
|
315
|
+
["A", "link"],
|
|
316
|
+
]);
|
|
317
|
+
/**
|
|
318
|
+
* What an anchor is about, never a tag: its words (the highlighted text, else the element's text,
|
|
319
|
+
* however long: the UI cuts it), else its component's name, else the kind of a common text-less
|
|
320
|
+
* element (an image, an icon, a video, a field, a button, a link), else its page.
|
|
321
|
+
*/
|
|
322
|
+
export const anchorSubject = (anchor) => {
|
|
323
|
+
const text = anchorQuote(anchor) ?? anchor.element?.textSnapshot;
|
|
324
|
+
if (text) {
|
|
325
|
+
return { kind: "text", text };
|
|
326
|
+
}
|
|
327
|
+
const name = anchorComponentName(anchor);
|
|
328
|
+
if (name) {
|
|
329
|
+
return { kind: "component", name };
|
|
330
|
+
}
|
|
331
|
+
const tag = anchor.element?.tagName?.toUpperCase();
|
|
332
|
+
const element = tag ? ELEMENT_KINDS.get(tag) : undefined;
|
|
333
|
+
return element ? { kind: "element", element } : { kind: "page", routePath: anchor.routePath };
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* A short name for what an anchor points at, never a tag: its quote, else its element's text,
|
|
337
|
+
* clipped, else its component's name, else its page (`anchorSubject`, an element's kind aside).
|
|
338
|
+
*/
|
|
339
|
+
export const anchorLabel = (anchor) => {
|
|
340
|
+
const subject = anchorSubject(anchor);
|
|
341
|
+
if (subject.kind === "text") {
|
|
342
|
+
const { text } = subject;
|
|
343
|
+
return text.length > LABEL_MAX ? `${text.slice(0, LABEL_MAX - 1)}…` : text;
|
|
344
|
+
}
|
|
345
|
+
return subject.kind === "component" ? subject.name : anchor.routePath;
|
|
346
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CommentAnchor, CommentThreadDto, CommentThreadsPageDto, MentionableUsersDto } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The in-app comments API, through the platform's proxy (`/data-api/apps/:appId/comments/**`), as
|
|
4
|
+
* the signed-in app user. The token is read at call time, so a refreshed token needs no new client.
|
|
5
|
+
*/
|
|
6
|
+
/** A non-2xx answer, with its status: a 404 means "not there, or not yours to see". */
|
|
7
|
+
export declare class CommentsRequestError extends Error {
|
|
8
|
+
readonly status: number;
|
|
9
|
+
constructor(status: number);
|
|
10
|
+
}
|
|
11
|
+
export interface CommentsApi {
|
|
12
|
+
/** One page of a page's threads, newest activity first. */
|
|
13
|
+
listThreads(routePath: string, options?: {
|
|
14
|
+
cursor?: string;
|
|
15
|
+
}): Promise<CommentThreadsPageDto>;
|
|
16
|
+
getThread(threadId: number): Promise<CommentThreadDto>;
|
|
17
|
+
createThread(request: {
|
|
18
|
+
anchor: CommentAnchor;
|
|
19
|
+
body: string;
|
|
20
|
+
}): Promise<CommentThreadDto>;
|
|
21
|
+
addComment(threadId: number, body: string): Promise<CommentThreadDto>;
|
|
22
|
+
updateComment(commentId: number, body: string): Promise<CommentThreadDto>;
|
|
23
|
+
/** The thread without the comment; null when it was the thread's last: the thread is gone. */
|
|
24
|
+
deleteComment(commentId: number): Promise<CommentThreadDto | null>;
|
|
25
|
+
resolveThread(threadId: number): Promise<CommentThreadDto>;
|
|
26
|
+
reopenThread(threadId: number): Promise<CommentThreadDto>;
|
|
27
|
+
mentionableUsers(): Promise<MentionableUsersDto>;
|
|
28
|
+
}
|
|
29
|
+
export interface CreateCommentsApiOptions {
|
|
30
|
+
appId: string;
|
|
31
|
+
/** The app user's token, read on every call. */
|
|
32
|
+
getToken: () => string | undefined;
|
|
33
|
+
/** Defaults to the platform host (`getApiHost`). */
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
}
|
|
37
|
+
export declare const createCommentsApi: ({ appId, getToken, baseUrl, fetch: fetchImpl, }: CreateCommentsApiOptions) => CommentsApi;
|
|
38
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/comments/api.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAE3G;;;GAGG;AAEH,uFAAuF;AACvF,qBAAa,oBAAqB,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM;gBAAd,MAAM,EAAE,MAAM;CAIpC;AAED,MAAM,WAAW,WAAW;IAC1B,2DAA2D;IAC3D,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC9F,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACvD,YAAY,CAAC,OAAO,EAAE;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC1F,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACtE,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC1E,8FAA8F;IAC9F,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACnE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC3D,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC1D,gBAAgB,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,QAAQ,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IACnC,oDAAoD;IACpD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,eAAO,MAAM,iBAAiB,GAAI,iDAK/B,wBAAwB,KAAG,WAiD7B,CAAC"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getBuilderContextHeaders } from "@blocksdiy/blocks-client-api";
|
|
2
|
+
import { getApiHost } from "@blocksdiy/blocks-client-api/envService";
|
|
3
|
+
import { websocketsService } from "@blocksdiy/blocks-client-api/websocketService";
|
|
4
|
+
/**
|
|
5
|
+
* The in-app comments API, through the platform's proxy (`/data-api/apps/:appId/comments/**`), as
|
|
6
|
+
* the signed-in app user. The token is read at call time, so a refreshed token needs no new client.
|
|
7
|
+
*/
|
|
8
|
+
/** A non-2xx answer, with its status: a 404 means "not there, or not yours to see". */
|
|
9
|
+
export class CommentsRequestError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(status) {
|
|
12
|
+
super(`Comments request failed with status ${status}`);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.name = "CommentsRequestError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export const createCommentsApi = ({ appId, getToken, baseUrl, fetch: fetchImpl = (...args) => fetch(...args), }) => {
|
|
18
|
+
const request = async (path, init = {}) => {
|
|
19
|
+
const token = getToken();
|
|
20
|
+
const socketId = websocketsService.getSocketId();
|
|
21
|
+
const response = await fetchImpl(`${baseUrl ?? getApiHost()}/data-api/apps/${encodeURIComponent(appId)}/comments/${path}`, {
|
|
22
|
+
method: init.method ?? "GET",
|
|
23
|
+
headers: {
|
|
24
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
25
|
+
...(init.body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
26
|
+
// A person's change pings every tab but theirs: their write returned it. An agent's
|
|
27
|
+
// events (picking a mention up, its progress, its answer) reach every tab.
|
|
28
|
+
...(socketId ? { "x-socket-id": socketId } : {}),
|
|
29
|
+
"x-app-id": appId,
|
|
30
|
+
...getBuilderContextHeaders(appId),
|
|
31
|
+
},
|
|
32
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new CommentsRequestError(response.status);
|
|
36
|
+
}
|
|
37
|
+
// No content: what was asked for is gone (a thread whose last comment was deleted).
|
|
38
|
+
if (response.status === 204) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return (await response.json());
|
|
42
|
+
};
|
|
43
|
+
const post = (path, body = {}) => request(path, { method: "POST", body });
|
|
44
|
+
return {
|
|
45
|
+
listThreads: (routePath, options = {}) => {
|
|
46
|
+
const query = new URLSearchParams({ routePath });
|
|
47
|
+
if (options.cursor) {
|
|
48
|
+
query.set("cursor", options.cursor);
|
|
49
|
+
}
|
|
50
|
+
return request(`threads?${query}`);
|
|
51
|
+
},
|
|
52
|
+
getThread: (threadId) => request(`threads/${threadId}`),
|
|
53
|
+
createThread: (body) => post("threads", body),
|
|
54
|
+
addComment: (threadId, body) => post(`threads/${threadId}/comments`, { body }),
|
|
55
|
+
// POST only: the platform answers an app cross-origin, and its CORS allows GET and POST.
|
|
56
|
+
updateComment: (commentId, body) => post(`${commentId}/edit`, { body }),
|
|
57
|
+
deleteComment: (commentId) => post(`${commentId}/delete`),
|
|
58
|
+
resolveThread: (threadId) => post(`threads/${threadId}/resolve`),
|
|
59
|
+
reopenThread: (threadId) => post(`threads/${threadId}/reopen`),
|
|
60
|
+
mentionableUsers: () => request("mentionable-users"),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agents a comment can mention: the app's floating-chat agents (one per agent mini-app), as
|
|
3
|
+
* the floating chat's provider registers them while it is mounted, with the way to open one's
|
|
4
|
+
* conversation in its panel. The comments surface reads them; it never imports the chat itself. An
|
|
5
|
+
* app without the floating chat offers people only.
|
|
6
|
+
*/
|
|
7
|
+
export interface CommentAgent {
|
|
8
|
+
/** The agent block's name, what a mention stores (`@[agent:<id>]`). */
|
|
9
|
+
id: string;
|
|
10
|
+
/** The persona's name. */
|
|
11
|
+
title: string;
|
|
12
|
+
photoUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Opens the floating chat on an agent's conversation (`chatThreadId`). */
|
|
15
|
+
export type OpenCommentAgentThread = (agentId: string, chatThreadId: string) => void;
|
|
16
|
+
export interface CommentAgentsSnapshot {
|
|
17
|
+
agents: readonly CommentAgent[];
|
|
18
|
+
openThread: OpenCommentAgentThread | null;
|
|
19
|
+
}
|
|
20
|
+
/** Registers the app's agents; returns the unregister. The latest registration wins. */
|
|
21
|
+
export declare const registerCommentAgents: (agents: readonly CommentAgent[], openThread: OpenCommentAgentThread) => (() => void);
|
|
22
|
+
/** The agents registered now (what `useCommentAgents` reads). */
|
|
23
|
+
export declare const readCommentAgents: () => CommentAgentsSnapshot;
|
|
24
|
+
/** The agents comments can mention, and how to open one's conversation. */
|
|
25
|
+
export declare const useCommentAgents: () => CommentAgentsSnapshot;
|
|
26
|
+
/** Registers `agents` while the calling component is mounted (the floating chat's provider). */
|
|
27
|
+
export declare const useRegisterCommentAgents: (agents: readonly CommentAgent[], openThread: OpenCommentAgentThread) => void;
|
|
28
|
+
//# sourceMappingURL=comment-agents.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comment-agents.d.ts","sourceRoot":"","sources":["../../src/comments/comment-agents.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,2EAA2E;AAC3E,MAAM,MAAM,sBAAsB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;AAErF,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,SAAS,YAAY,EAAE,CAAC;IAChC,UAAU,EAAE,sBAAsB,GAAG,IAAI,CAAC;CAC3C;AASD,wFAAwF;AACxF,eAAO,MAAM,qBAAqB,GAChC,QAAQ,SAAS,YAAY,EAAE,EAC/B,YAAY,sBAAsB,KACjC,CAAC,MAAM,IAAI,CAUb,CAAC;AAQF,iEAAiE;AACjE,eAAO,MAAM,iBAAiB,QAAO,qBAAgC,CAAC;AAEtE,2EAA2E;AAC3E,eAAO,MAAM,gBAAgB,QAAO,qBACmC,CAAC;AAExE,gGAAgG;AAChG,eAAO,MAAM,wBAAwB,GAAI,QAAQ,SAAS,YAAY,EAAE,EAAE,YAAY,sBAAsB,KAAG,IAE9G,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
const NONE = { agents: [], openThread: null };
|
|
3
|
+
let current = NONE;
|
|
4
|
+
let registration = 0;
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
const emit = () => listeners.forEach((listener) => listener());
|
|
7
|
+
/** Registers the app's agents; returns the unregister. The latest registration wins. */
|
|
8
|
+
export const registerCommentAgents = (agents, openThread) => {
|
|
9
|
+
const id = ++registration;
|
|
10
|
+
current = { agents, openThread };
|
|
11
|
+
emit();
|
|
12
|
+
return () => {
|
|
13
|
+
if (registration === id) {
|
|
14
|
+
current = NONE;
|
|
15
|
+
emit();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const subscribe = (listener) => {
|
|
20
|
+
listeners.add(listener);
|
|
21
|
+
return () => {
|
|
22
|
+
listeners.delete(listener);
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** The agents registered now (what `useCommentAgents` reads). */
|
|
26
|
+
export const readCommentAgents = () => current;
|
|
27
|
+
/** The agents comments can mention, and how to open one's conversation. */
|
|
28
|
+
export const useCommentAgents = () => useSyncExternalStore(subscribe, readCommentAgents, readCommentAgents);
|
|
29
|
+
/** Registers `agents` while the calling component is mounted (the floating chat's provider). */
|
|
30
|
+
export const useRegisterCommentAgents = (agents, openThread) => {
|
|
31
|
+
useEffect(() => registerCommentAgents(agents, openThread), [agents, openThread]);
|
|
32
|
+
};
|