@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,273 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { matchesMentionQuery, mentionQueryAt, mentionToken } from "./mentions.js";
|
|
3
|
+
export const EMPTY_COMPOSER = { text: "", mentions: [] };
|
|
4
|
+
const label = (mention) => `@${mention.name}`;
|
|
5
|
+
/** Keeps only the ranges that still read exactly `@Name`, in order. */
|
|
6
|
+
const valid = (text, mentions) => mentions
|
|
7
|
+
.filter((range) => range.start >= 0 && text.slice(range.start, range.end) === label(range.mention))
|
|
8
|
+
.sort((a, b) => a.start - b.start);
|
|
9
|
+
/**
|
|
10
|
+
* The text after an edit, with its mentions moved. `caret` is where the caret is after the edit:
|
|
11
|
+
* what follows it is taken as unchanged, which places a typed or pasted run, or a deletion, exactly
|
|
12
|
+
* (a plain diff can't tell which of two equal letters was typed).
|
|
13
|
+
*/
|
|
14
|
+
export const applyEdit = (state, text, caret = text.length) => {
|
|
15
|
+
const before = state.text;
|
|
16
|
+
const delta = text.length - before.length;
|
|
17
|
+
// What follows the caret is unchanged: the edited span ends there in the new text. When it isn't
|
|
18
|
+
// (an undo can leave the caret elsewhere), the longest common ending stands in.
|
|
19
|
+
const afterCaret = Math.max(0, Math.min(text.length - caret, before.length));
|
|
20
|
+
let suffix = afterCaret;
|
|
21
|
+
if (!before.endsWith(text.slice(text.length - afterCaret))) {
|
|
22
|
+
suffix = 0;
|
|
23
|
+
const limit = Math.min(before.length, text.length);
|
|
24
|
+
while (suffix < limit && before[before.length - 1 - suffix] === text[text.length - 1 - suffix]) {
|
|
25
|
+
suffix += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let prefix = 0;
|
|
29
|
+
const prefixLimit = Math.min(before.length - suffix, text.length - suffix);
|
|
30
|
+
while (prefix < prefixLimit && before[prefix] === text[prefix]) {
|
|
31
|
+
prefix += 1;
|
|
32
|
+
}
|
|
33
|
+
const oldEnd = before.length - suffix;
|
|
34
|
+
const mentions = state.mentions.flatMap((range) => {
|
|
35
|
+
if (range.end <= prefix) {
|
|
36
|
+
return [range];
|
|
37
|
+
}
|
|
38
|
+
if (range.start >= oldEnd && oldEnd >= prefix) {
|
|
39
|
+
return [{ ...range, start: range.start + delta, end: range.end + delta }];
|
|
40
|
+
}
|
|
41
|
+
// The edit touched it: it's plain text now.
|
|
42
|
+
return [];
|
|
43
|
+
});
|
|
44
|
+
return { text, mentions: valid(text, mentions) };
|
|
45
|
+
};
|
|
46
|
+
/** Replaces `[from, to)` with `insert`, moving the mentions after it. Mentions inside go. */
|
|
47
|
+
const replaceRange = (state, from, to, insert) => {
|
|
48
|
+
const delta = insert.length - (to - from);
|
|
49
|
+
const text = state.text.slice(0, from) + insert + state.text.slice(to);
|
|
50
|
+
const mentions = state.mentions.flatMap((range) => {
|
|
51
|
+
if (range.end <= from) {
|
|
52
|
+
return [range];
|
|
53
|
+
}
|
|
54
|
+
if (range.start >= to) {
|
|
55
|
+
return [{ ...range, start: range.start + delta, end: range.end + delta }];
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
});
|
|
59
|
+
return { text, mentions: valid(text, mentions) };
|
|
60
|
+
};
|
|
61
|
+
/** Picks a mention for the `@query` typed at `[queryStart, caret)`: `@Name ` in its place. */
|
|
62
|
+
export const insertMention = (state, queryStart, caret, mention) => {
|
|
63
|
+
const inserted = `${label(mention)} `;
|
|
64
|
+
const next = replaceRange(state, queryStart, caret, inserted);
|
|
65
|
+
return {
|
|
66
|
+
state: {
|
|
67
|
+
text: next.text,
|
|
68
|
+
mentions: valid(next.text, [
|
|
69
|
+
...next.mentions,
|
|
70
|
+
{ start: queryStart, end: queryStart + label(mention).length, mention },
|
|
71
|
+
]),
|
|
72
|
+
},
|
|
73
|
+
caret: queryStart + inserted.length,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/** Backspace with the caret right after a mention: the whole mention goes. Null when it isn't one. */
|
|
77
|
+
export const deleteMentionBefore = (state, caret) => {
|
|
78
|
+
const range = state.mentions.find((candidate) => candidate.end === caret);
|
|
79
|
+
if (!range) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return { state: replaceRange(state, range.start, range.end, ""), caret: range.start };
|
|
83
|
+
};
|
|
84
|
+
const sameAgent = (mention, agentId) => mention.kind === "agent" && mention.agentId === agentId;
|
|
85
|
+
/** Whether the text mentions this agent. */
|
|
86
|
+
export const mentionsAgent = (state, agentId) => state.mentions.some((range) => sameAgent(range.mention, agentId));
|
|
87
|
+
/** Mentions an agent at the start of the text (the "Send to" line), unless the text already does. */
|
|
88
|
+
export const addAgentMention = (state, agent) => {
|
|
89
|
+
if (mentionsAgent(state, agent.agentId)) {
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
const mention = { kind: "agent", agentId: agent.agentId, name: agent.name };
|
|
93
|
+
const next = replaceRange(state, 0, 0, `${label(mention)} `);
|
|
94
|
+
return {
|
|
95
|
+
text: next.text,
|
|
96
|
+
mentions: valid(next.text, [...next.mentions, { start: 0, end: label(mention).length, mention }]),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
/** Removes every mention of an agent, with the space after each. */
|
|
100
|
+
export const removeAgentMention = (state, agentId) => [...state.mentions]
|
|
101
|
+
.reverse()
|
|
102
|
+
.filter((range) => sameAgent(range.mention, agentId))
|
|
103
|
+
.reduce((current, range) => {
|
|
104
|
+
const end = current.text[range.end] === " " ? range.end + 1 : range.end;
|
|
105
|
+
return replaceRange(current, range.start, end, "");
|
|
106
|
+
}, state);
|
|
107
|
+
/** The text in runs — plain text, and mentions — for the highlight drawn behind the text. */
|
|
108
|
+
export const composerSegments = (state) => {
|
|
109
|
+
const segments = [];
|
|
110
|
+
let last = 0;
|
|
111
|
+
for (const range of state.mentions) {
|
|
112
|
+
if (range.start > last) {
|
|
113
|
+
segments.push({ kind: "text", text: state.text.slice(last, range.start) });
|
|
114
|
+
}
|
|
115
|
+
segments.push({ kind: "mention", text: state.text.slice(range.start, range.end), mention: range.mention });
|
|
116
|
+
last = range.end;
|
|
117
|
+
}
|
|
118
|
+
if (last < state.text.length) {
|
|
119
|
+
segments.push({ kind: "text", text: state.text.slice(last) });
|
|
120
|
+
}
|
|
121
|
+
return segments;
|
|
122
|
+
};
|
|
123
|
+
/** The body to store: each mention as its token (`@[42]`, `@[agent:Name]`), the rest as typed. */
|
|
124
|
+
export const composerBody = (state) => composerSegments(state)
|
|
125
|
+
.map((segment) => (segment.kind === "mention" ? mentionToken(segment.mention) : segment.text))
|
|
126
|
+
.join("")
|
|
127
|
+
.trim();
|
|
128
|
+
/**
|
|
129
|
+
* How well a name matches what was typed after `@`: 0 when the name starts with it, 1 when one of
|
|
130
|
+
* its words does, 2 for any other match (inside a word, an email). The menu lists better matches
|
|
131
|
+
* first, so "@ol" puts Olive before Marina Sokolov.
|
|
132
|
+
*/
|
|
133
|
+
export const mentionRank = (name, query) => {
|
|
134
|
+
const needle = query.trim().toLowerCase();
|
|
135
|
+
const haystack = name.toLowerCase();
|
|
136
|
+
if (!needle || haystack.startsWith(needle)) {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
return haystack.split(/\s+/).some((word) => word.startsWith(needle)) ? 1 : 2;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* The `@` menu for what was typed: the app's agents first, then people (never yourself), each group
|
|
143
|
+
* ranked by `mentionRank` and capped (3 agents, 5 people). `separatorBefore` is the index of the
|
|
144
|
+
* first person when both groups have matches, where the menu draws its divider.
|
|
145
|
+
*/
|
|
146
|
+
export const mentionOptions = ({ people, agents, currentUserId, query, }) => {
|
|
147
|
+
const ranked = (items, cap) => items
|
|
148
|
+
.map((item, index) => ({ item, index, rank: mentionRank(item.name, query) }))
|
|
149
|
+
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
|
150
|
+
.slice(0, cap)
|
|
151
|
+
.map(({ item }) => item);
|
|
152
|
+
const matchingAgents = ranked(agents.filter((agent) => matchesMentionQuery({ name: agent.name }, query)), 3).map((agent) => ({ kind: "agent", agentId: agent.id, name: agent.name, photoUrl: agent.photoUrl }));
|
|
153
|
+
const matchingPeople = ranked(people.filter((person) => person.userId !== currentUserId && matchesMentionQuery(person, query)), 5).map((person) => ({
|
|
154
|
+
kind: "user",
|
|
155
|
+
userId: person.userId,
|
|
156
|
+
name: person.name,
|
|
157
|
+
photoUrl: person.profileImageUrl,
|
|
158
|
+
}));
|
|
159
|
+
return {
|
|
160
|
+
options: [...matchingAgents, ...matchingPeople],
|
|
161
|
+
separatorBefore: matchingAgents.length > 0 && matchingPeople.length > 0 ? matchingAgents.length : null,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
/** The composer's state and actions; see the module comment. */
|
|
165
|
+
export const useMentionComposer = ({ people, agents, currentUserId, textareaRef, }) => {
|
|
166
|
+
const [state, setState] = useState(EMPTY_COMPOSER);
|
|
167
|
+
const [query, setQuery] = useState(null);
|
|
168
|
+
const [highlighted, setHighlighted] = useState(0);
|
|
169
|
+
const [chosenAgentId, setChosenAgentId] = useState(null);
|
|
170
|
+
const latest = useRef(state);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
latest.current = state;
|
|
173
|
+
});
|
|
174
|
+
// After a change the composer made itself (a pick, a whole-mention delete), the caret goes where
|
|
175
|
+
// that change ends, once the textarea shows the new text.
|
|
176
|
+
const placeCaret = useCallback((caret) => requestAnimationFrame(() => {
|
|
177
|
+
const textarea = textareaRef.current;
|
|
178
|
+
if (textarea) {
|
|
179
|
+
textarea.focus();
|
|
180
|
+
textarea.setSelectionRange(caret, caret);
|
|
181
|
+
}
|
|
182
|
+
}), [textareaRef]);
|
|
183
|
+
const { options, separatorBefore } = useMemo(() => query
|
|
184
|
+
? mentionOptions({ people, agents, currentUserId, query: query.query })
|
|
185
|
+
: { options: [], separatorBefore: null }, [agents, currentUserId, people, query]);
|
|
186
|
+
const onChange = useCallback((text, caret) => {
|
|
187
|
+
setState((current) => applyEdit(current, text, caret));
|
|
188
|
+
setQuery(mentionQueryAt(text, caret));
|
|
189
|
+
setHighlighted(0);
|
|
190
|
+
}, []);
|
|
191
|
+
const pick = useCallback((option) => {
|
|
192
|
+
if (!query) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const caret = textareaRef.current?.selectionStart ?? latest.current.text.length;
|
|
196
|
+
const mention = option.kind === "user"
|
|
197
|
+
? { kind: "user", userId: option.userId, name: option.name }
|
|
198
|
+
: { kind: "agent", agentId: option.agentId, name: option.name };
|
|
199
|
+
const next = insertMention(latest.current, query.start, Math.max(caret, query.start), mention);
|
|
200
|
+
setState(next.state);
|
|
201
|
+
setQuery(null);
|
|
202
|
+
placeCaret(next.caret);
|
|
203
|
+
}, [placeCaret, query, textareaRef]);
|
|
204
|
+
const onBackspace = useCallback((selectionStart, selectionEnd) => {
|
|
205
|
+
const textarea = textareaRef.current;
|
|
206
|
+
const current = latest.current;
|
|
207
|
+
const range = current.mentions.find((candidate) => candidate.end === selectionStart);
|
|
208
|
+
if (!textarea || selectionStart !== selectionEnd || !range) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Selects the whole mention (with the space after it, when that would leave two) and lets
|
|
212
|
+
// the key delete it: the browser's own edit, so Undo brings it back.
|
|
213
|
+
const before = current.text[range.start - 1];
|
|
214
|
+
const end = current.text[range.end] === " " && (range.start === 0 || before === " " || before === "\n")
|
|
215
|
+
? range.end + 1
|
|
216
|
+
: range.end;
|
|
217
|
+
textarea.setSelectionRange(range.start, end);
|
|
218
|
+
}, [textareaRef]);
|
|
219
|
+
const firstMentionedAgent = state.mentions.find((range) => range.mention.kind === "agent")?.mention;
|
|
220
|
+
const sendToAgent = (firstMentionedAgent?.kind === "agent"
|
|
221
|
+
? agents.find((agent) => agent.id === firstMentionedAgent.agentId)
|
|
222
|
+
: undefined) ??
|
|
223
|
+
agents.find((agent) => agent.id === chosenAgentId) ??
|
|
224
|
+
agents[0] ??
|
|
225
|
+
null;
|
|
226
|
+
const isSendingToAgent = !!sendToAgent && mentionsAgent(state, sendToAgent.id);
|
|
227
|
+
const setSendingToAgent = useCallback((isOn) => {
|
|
228
|
+
if (!sendToAgent) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const current = latest.current;
|
|
232
|
+
const next = isOn
|
|
233
|
+
? addAgentMention(current, { agentId: sendToAgent.id, name: sendToAgent.name })
|
|
234
|
+
: removeAgentMention(current, sendToAgent.id);
|
|
235
|
+
setState(next);
|
|
236
|
+
// Back to typing: right after the mention and its space when it was added, else the end.
|
|
237
|
+
placeCaret(isOn ? sendToAgent.name.length + 2 : next.text.length);
|
|
238
|
+
}, [placeCaret, sendToAgent]);
|
|
239
|
+
const chooseSendToAgent = useCallback((agentId) => {
|
|
240
|
+
const next = agents.find((agent) => agent.id === agentId);
|
|
241
|
+
if (!next) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
setChosenAgentId(agentId);
|
|
245
|
+
if (sendToAgent && isSendingToAgent && sendToAgent.id !== agentId) {
|
|
246
|
+
setState((current) => addAgentMention(removeAgentMention(current, sendToAgent.id), { agentId: next.id, name: next.name }));
|
|
247
|
+
}
|
|
248
|
+
}, [agents, isSendingToAgent, sendToAgent]);
|
|
249
|
+
const reset = useCallback(() => {
|
|
250
|
+
setState(EMPTY_COMPOSER);
|
|
251
|
+
setQuery(null);
|
|
252
|
+
}, []);
|
|
253
|
+
return {
|
|
254
|
+
text: state.text,
|
|
255
|
+
segments: composerSegments(state),
|
|
256
|
+
body: composerBody(state),
|
|
257
|
+
options,
|
|
258
|
+
highlighted,
|
|
259
|
+
setHighlighted,
|
|
260
|
+
moveHighlight: (step) => setHighlighted((current) => (options.length ? (current + step + options.length) % options.length : 0)),
|
|
261
|
+
pick,
|
|
262
|
+
closeMenu: () => setQuery(null),
|
|
263
|
+
separatorBefore,
|
|
264
|
+
isMenuOpen: options.length > 0,
|
|
265
|
+
onChange,
|
|
266
|
+
onBackspace,
|
|
267
|
+
sendToAgent,
|
|
268
|
+
isSendingToAgent,
|
|
269
|
+
setSendingToAgent,
|
|
270
|
+
chooseSendToAgent,
|
|
271
|
+
reset,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { ITEM_ATTRIBUTE, FIELD_ATTRIBUTE, PICK_IGNORE_ATTRIBUTE, anchorQuote, anchorComponentName, anchorSubject, type AnchorElementKind, type AnchorState, type AnchorSubject, } from "./anchor.js";
|
|
3
|
+
export { initials, type Mention, type MentionNames } from "./mentions.js";
|
|
4
|
+
export { toRect, type Rect } from "./layout.js";
|
|
5
|
+
export { COMMENTS_LAYER_ATTRIBUTE, pinPoint, pickPlacement, groupPins, useCommentPins, type PinPoint, type PinGroup, type ThreadLayout, } from "./pins.js";
|
|
6
|
+
export { participantKey, pinParticipants, pinWidth, panelPosition, cornerPoint, panelSide, type PanelSide, type PanelPositionOptions, type PlacementViewport, } from "./pin-view.js";
|
|
7
|
+
export { menuKeyTarget, type MenuPosition } from "./menu.js";
|
|
8
|
+
export { COMMENT_MENU_ATTRIBUTE, useCommentMenu, type CommentMenuState, type UseCommentMenuOptions, } from "./use-comment-menu.js";
|
|
9
|
+
export { isOnScreen, openCommentResponse, type OpenCommentResponse } from "./open-comment.js";
|
|
10
|
+
export { useHeld } from "./use-held.js";
|
|
11
|
+
export { useThreadScroll } from "./use-thread-scroll.js";
|
|
12
|
+
export { usePagePick, type PagePick, type PagePickHover, type UsePagePickOptions } from "./page-pick.js";
|
|
13
|
+
export { createCommentsApi, type CommentsApi, type CreateCommentsApiOptions } from "./api.js";
|
|
14
|
+
export { setCommentMode, onCommentModeExit, setCommentOptions, onCommentModeRequest, openCommentThread, canOpenCommentThread, useCanOpenCommentThread, useCommentsSurface, type CommentThreadOpenRequest, type CommentOptions, type UseCommentsSurfaceOptions, } from "./comment-mode.js";
|
|
15
|
+
export { commentPingsFor, useCommentThreads, type CommentThreadsStore, type CommentThreadsSnapshot, type CommentThreadLoadState, type SubscribeToCommentPings, } from "./threads-store.js";
|
|
16
|
+
export { useCommentAgents, useRegisterCommentAgents, type CommentAgent, type CommentAgentsSnapshot, type OpenCommentAgentThread, } from "./comment-agents.js";
|
|
17
|
+
export { pagePickComposerMeta, parsePagePickPart, pagePickOf } from "./pick-message.js";
|
|
18
|
+
export { useMentionComposer, type ComposerAgent, type ComposerOption, type ComposerSegment, type MentionComposer, type UseMentionComposerOptions, } from "./composer.js";
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/comments/index.ts"],"names":[],"mappings":"AAMA,cAAc,SAAS,CAAC;AACxB,OAAO,EACL,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,WAAW,EACX,mBAAmB,EACnB,aAAa,EACb,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,aAAa,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,QAAQ,EAAE,KAAK,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AACvE,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,EACL,wBAAwB,EACxB,QAAQ,EACR,aAAa,EACb,SAAS,EACT,cAAc,EACd,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,YAAY,GAClB,MAAM,QAAQ,CAAC;AAChB,OAAO,EACL,cAAc,EACd,eAAe,EACf,QAAQ,EACR,aAAa,EACb,WAAW,EACX,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,GACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,EACL,sBAAsB,EACtB,cAAc,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtG,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,KAAK,wBAAwB,EAAE,MAAM,OAAO,CAAC;AAC3F,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,kBAAkB,EAClB,KAAK,wBAAwB,EAC7B,KAAK,cAAc,EACnB,KAAK,yBAAyB,GAC/B,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,GAC7B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,gBAAgB,EAChB,wBAAwB,EACxB,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACrF,OAAO,EACL,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,yBAAyB,GAC/B,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// In-app comments, headless: what the registry `comments` item and the agent chat's pointer are
|
|
2
|
+
// built on. Registry files are copied into each app and never update on their own, while this
|
|
3
|
+
// module is shared at runtime (a UMD external app-render provides), so its exports only grow:
|
|
4
|
+
// never remove, rename or change the meaning of one. It exports what the registry items and
|
|
5
|
+
// app-render use (and the types their signatures show); add an export when an item needs one.
|
|
6
|
+
export * from "./types.js";
|
|
7
|
+
export { ITEM_ATTRIBUTE, FIELD_ATTRIBUTE, PICK_IGNORE_ATTRIBUTE, anchorQuote, anchorComponentName, anchorSubject, } from "./anchor.js";
|
|
8
|
+
export { initials } from "./mentions.js";
|
|
9
|
+
export { toRect } from "./layout.js";
|
|
10
|
+
export { COMMENTS_LAYER_ATTRIBUTE, pinPoint, pickPlacement, groupPins, useCommentPins, } from "./pins.js";
|
|
11
|
+
export { participantKey, pinParticipants, pinWidth, panelPosition, cornerPoint, panelSide, } from "./pin-view.js";
|
|
12
|
+
export { menuKeyTarget } from "./menu.js";
|
|
13
|
+
export { COMMENT_MENU_ATTRIBUTE, useCommentMenu, } from "./use-comment-menu.js";
|
|
14
|
+
export { isOnScreen, openCommentResponse } from "./open-comment.js";
|
|
15
|
+
export { useHeld } from "./use-held.js";
|
|
16
|
+
export { useThreadScroll } from "./use-thread-scroll.js";
|
|
17
|
+
export { usePagePick } from "./page-pick.js";
|
|
18
|
+
export { createCommentsApi } from "./api.js";
|
|
19
|
+
export { setCommentMode, onCommentModeExit, setCommentOptions, onCommentModeRequest, openCommentThread, canOpenCommentThread, useCanOpenCommentThread, useCommentsSurface, } from "./comment-mode.js";
|
|
20
|
+
export { commentPingsFor, useCommentThreads, } from "./threads-store.js";
|
|
21
|
+
export { useCommentAgents, useRegisterCommentAgents, } from "./comment-agents.js";
|
|
22
|
+
export { pagePickComposerMeta, parsePagePickPart, pagePickOf } from "./pick-message.js";
|
|
23
|
+
export { useMentionComposer, } from "./composer.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A counter that moves at most once per animation frame whenever the page may have moved: a
|
|
3
|
+
* scroll anywhere (capture phase, so scroll containers count), a window resize, a change to the
|
|
4
|
+
* app's DOM, or a reflow of the body (a panel opening beside the app moves everything with no DOM
|
|
5
|
+
* change and no window resize). Changes inside the comments' own UI don't count, so drawing pins
|
|
6
|
+
* never feeds itself.
|
|
7
|
+
*
|
|
8
|
+
* Positions are read from the DOM, which is not a React input: pass the tick to whatever reads them
|
|
9
|
+
* so it recomputes.
|
|
10
|
+
*/
|
|
11
|
+
export declare const usePageLayoutTick: (isActive: boolean) => number;
|
|
12
|
+
export interface Rect {
|
|
13
|
+
top: number;
|
|
14
|
+
left: number;
|
|
15
|
+
right: number;
|
|
16
|
+
bottom: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const toRect: (rect: Pick<DOMRect, "top" | "left" | "right" | "bottom">) => Rect;
|
|
19
|
+
/** The overlap of two rects, or null when they don't overlap. */
|
|
20
|
+
export declare const intersectRects: (a: Rect, b: Rect) => Rect | null;
|
|
21
|
+
/** The part of `rect` inside every clip (the viewport, each scrolling ancestor), or null. */
|
|
22
|
+
export declare const visiblePart: (rect: Rect, clips: readonly Rect[]) => Rect | null;
|
|
23
|
+
/** The boxes that clip `element`: the viewport and every ancestor that clips its overflow. */
|
|
24
|
+
export declare const clipRectsOf: (element: Element) => Rect[];
|
|
25
|
+
//# sourceMappingURL=layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../src/comments/layout.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,OAAO,KAAG,MAkCrD,CAAC;AAEF,MAAM,WAAW,IAAI;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,MAAM,GAAI,MAAM,IAAI,CAAC,OAAO,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC,KAAG,IAKhF,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,GAAI,GAAG,IAAI,EAAE,GAAG,IAAI,KAAG,IAAI,GAAG,IAMxD,CAAC;AAEF,6FAA6F;AAC7F,eAAO,MAAM,WAAW,GAAI,MAAM,IAAI,EAAE,OAAO,SAAS,IAAI,EAAE,KAAG,IAAI,GAAG,IASvE,CAAC;AAKF,8FAA8F;AAC9F,eAAO,MAAM,WAAW,GAAI,SAAS,OAAO,KAAG,IAAI,EAYlD,CAAC"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { isPickIgnored } from "./anchor.js";
|
|
3
|
+
/**
|
|
4
|
+
* A counter that moves at most once per animation frame whenever the page may have moved: a
|
|
5
|
+
* scroll anywhere (capture phase, so scroll containers count), a window resize, a change to the
|
|
6
|
+
* app's DOM, or a reflow of the body (a panel opening beside the app moves everything with no DOM
|
|
7
|
+
* change and no window resize). Changes inside the comments' own UI don't count, so drawing pins
|
|
8
|
+
* never feeds itself.
|
|
9
|
+
*
|
|
10
|
+
* Positions are read from the DOM, which is not a React input: pass the tick to whatever reads them
|
|
11
|
+
* so it recomputes.
|
|
12
|
+
*/
|
|
13
|
+
export const usePageLayoutTick = (isActive) => {
|
|
14
|
+
const [tick, setTick] = useState(0);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!isActive || typeof window === "undefined") {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let frame = 0;
|
|
20
|
+
const schedule = () => {
|
|
21
|
+
if (!frame) {
|
|
22
|
+
frame = window.requestAnimationFrame(() => {
|
|
23
|
+
frame = 0;
|
|
24
|
+
setTick((value) => value + 1);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const mutations = new MutationObserver((records) => {
|
|
29
|
+
if (records.some((record) => !isPickIgnored(record.target))) {
|
|
30
|
+
schedule();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
mutations.observe(document.body, { subtree: true, childList: true, characterData: true });
|
|
34
|
+
const resizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
|
|
35
|
+
resizes?.observe(document.body);
|
|
36
|
+
document.addEventListener("scroll", schedule, true);
|
|
37
|
+
window.addEventListener("resize", schedule);
|
|
38
|
+
return () => {
|
|
39
|
+
window.cancelAnimationFrame(frame);
|
|
40
|
+
mutations.disconnect();
|
|
41
|
+
resizes?.disconnect();
|
|
42
|
+
document.removeEventListener("scroll", schedule, true);
|
|
43
|
+
window.removeEventListener("resize", schedule);
|
|
44
|
+
};
|
|
45
|
+
}, [isActive]);
|
|
46
|
+
return tick;
|
|
47
|
+
};
|
|
48
|
+
export const toRect = (rect) => ({
|
|
49
|
+
top: rect.top,
|
|
50
|
+
left: rect.left,
|
|
51
|
+
right: rect.right,
|
|
52
|
+
bottom: rect.bottom,
|
|
53
|
+
});
|
|
54
|
+
/** The overlap of two rects, or null when they don't overlap. */
|
|
55
|
+
export const intersectRects = (a, b) => {
|
|
56
|
+
const top = Math.max(a.top, b.top);
|
|
57
|
+
const left = Math.max(a.left, b.left);
|
|
58
|
+
const right = Math.min(a.right, b.right);
|
|
59
|
+
const bottom = Math.min(a.bottom, b.bottom);
|
|
60
|
+
return right > left && bottom > top ? { top, left, right, bottom } : null;
|
|
61
|
+
};
|
|
62
|
+
/** The part of `rect` inside every clip (the viewport, each scrolling ancestor), or null. */
|
|
63
|
+
export const visiblePart = (rect, clips) => {
|
|
64
|
+
let visible = rect.right > rect.left && rect.bottom > rect.top ? rect : null;
|
|
65
|
+
for (const clip of clips) {
|
|
66
|
+
if (!visible) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
visible = intersectRects(visible, clip);
|
|
70
|
+
}
|
|
71
|
+
return visible;
|
|
72
|
+
};
|
|
73
|
+
const clipsOverflow = (style) => /(auto|scroll|hidden|clip)/.test(`${style.overflow}${style.overflowX}${style.overflowY}`);
|
|
74
|
+
/** The boxes that clip `element`: the viewport and every ancestor that clips its overflow. */
|
|
75
|
+
export const clipRectsOf = (element) => {
|
|
76
|
+
const clips = [{ top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight }];
|
|
77
|
+
for (let ancestor = element.parentElement; ancestor && ancestor !== document.body; ancestor = ancestor.parentElement) {
|
|
78
|
+
if (clipsOverflow(window.getComputedStyle(ancestor))) {
|
|
79
|
+
clips.push(toRect(ancestor.getBoundingClientRect()));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return clips;
|
|
83
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mentions in a comment body. Stored, a person is `@[42]` and an agent `@[agent:SupportAgent]`;
|
|
3
|
+
* in the composer both read as `@Name`. The server parses the stored tokens and never trusts a list
|
|
4
|
+
* the client sends.
|
|
5
|
+
*/
|
|
6
|
+
export type Mention = {
|
|
7
|
+
kind: "user";
|
|
8
|
+
userId: number;
|
|
9
|
+
name: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "agent";
|
|
12
|
+
agentId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
/** Names for the ids a body mentions: the thread's people, the app's agents. */
|
|
16
|
+
export interface MentionNames {
|
|
17
|
+
users: ReadonlyMap<number, string>;
|
|
18
|
+
agents: ReadonlyMap<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export declare const mentionToken: (mention: Mention) => string;
|
|
21
|
+
/** The `@query` being typed at the caret, if any: an `@` at a word start, no line break after it. */
|
|
22
|
+
export declare const mentionQueryAt: (text: string, caret: number) => {
|
|
23
|
+
start: number;
|
|
24
|
+
query: string;
|
|
25
|
+
} | null;
|
|
26
|
+
/** Whether a mention candidate matches what was typed after `@`: its name contains it, or its email starts with it. */
|
|
27
|
+
export declare const matchesMentionQuery: (candidate: {
|
|
28
|
+
name: string;
|
|
29
|
+
email?: string | null;
|
|
30
|
+
}, query: string) => boolean;
|
|
31
|
+
export declare const initials: (name: string) => string;
|
|
32
|
+
//# sourceMappingURL=mentions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mentions.d.ts","sourceRoot":"","sources":["../../src/comments/mentions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAExH,gFAAgF;AAChF,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,eAAO,MAAM,YAAY,GAAI,SAAS,OAAO,KAAG,MACkC,CAAC;AAEnF,qGAAqG;AACrG,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,EAAE,OAAO,MAAM,KAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAW/F,CAAC;AAEF,uHAAuH;AACvH,eAAO,MAAM,mBAAmB,GAAI,WAAW;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EAAE,OAAO,MAAM,KAAG,OAKvG,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,MAAM,MAAM,KAAG,MAMpB,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mentions in a comment body. Stored, a person is `@[42]` and an agent `@[agent:SupportAgent]`;
|
|
3
|
+
* in the composer both read as `@Name`. The server parses the stored tokens and never trusts a list
|
|
4
|
+
* the client sends.
|
|
5
|
+
*/
|
|
6
|
+
export const mentionToken = (mention) => mention.kind === "user" ? `@[${mention.userId}]` : `@[agent:${mention.agentId}]`;
|
|
7
|
+
/** The `@query` being typed at the caret, if any: an `@` at a word start, no line break after it. */
|
|
8
|
+
export const mentionQueryAt = (text, caret) => {
|
|
9
|
+
const before = text.slice(0, caret);
|
|
10
|
+
const at = before.lastIndexOf("@");
|
|
11
|
+
if (at < 0 || (at > 0 && !/\s/.test(before[at - 1]))) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const query = before.slice(at + 1);
|
|
15
|
+
if (query.length > 30 || /[\n@]/.test(query) || /\s{2}/.test(query)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return { start: at, query };
|
|
19
|
+
};
|
|
20
|
+
/** Whether a mention candidate matches what was typed after `@`: its name contains it, or its email starts with it. */
|
|
21
|
+
export const matchesMentionQuery = (candidate, query) => {
|
|
22
|
+
const needle = query.trim().toLowerCase();
|
|
23
|
+
return (!needle || candidate.name.toLowerCase().includes(needle) || (candidate.email ?? "").toLowerCase().startsWith(needle));
|
|
24
|
+
};
|
|
25
|
+
export const initials = (name) => name
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.slice(0, 2)
|
|
29
|
+
.map((part) => part[0].toUpperCase())
|
|
30
|
+
.join("") || "?";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Rect } from "./layout.js";
|
|
2
|
+
/**
|
|
3
|
+
* The item a key moves focus to in a menu of `count` items whose item `index` has focus (-1: none):
|
|
4
|
+
* ↓ and ↑ the next and the previous, wrapping round, Home and End the first and the last; null for
|
|
5
|
+
* any other key, which the menu leaves alone.
|
|
6
|
+
*/
|
|
7
|
+
export declare const menuKeyTarget: (key: string, index: number, count: number) => number | null;
|
|
8
|
+
/** Where a menu fixed in the window goes: one of `left`/`right`, one of `top`/`bottom`, and its tallest. */
|
|
9
|
+
export interface MenuPosition {
|
|
10
|
+
side: "bottom" | "top";
|
|
11
|
+
/** From the window's top (`bottom`) or its bottom (`top`). */
|
|
12
|
+
top?: number;
|
|
13
|
+
bottom?: number;
|
|
14
|
+
left?: number;
|
|
15
|
+
right?: number;
|
|
16
|
+
/** The room it has on that side: taller than this, it scrolls. */
|
|
17
|
+
maxHeight: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where a menu `height` tall opens beside its trigger (in window coordinates): below it when it
|
|
21
|
+
* fits there, else above when it fits there or there is more room above (a composer at the bottom
|
|
22
|
+
* of the window, a sheet on a phone), else below; never taller than the room on its side. Its start
|
|
23
|
+
* edge lines up with the trigger's start (a picker) or its end edge with the trigger's end (a ⋯),
|
|
24
|
+
* as the page reads (`isRtl`), from the window's own edges.
|
|
25
|
+
*/
|
|
26
|
+
export declare const menuPosition: (trigger: Rect, { height, align, isRtl, viewportWidth, viewportHeight, }: {
|
|
27
|
+
height: number;
|
|
28
|
+
align: "start" | "end";
|
|
29
|
+
isRtl: boolean;
|
|
30
|
+
viewportWidth: number;
|
|
31
|
+
viewportHeight: number;
|
|
32
|
+
}) => MenuPosition;
|
|
33
|
+
//# sourceMappingURL=menu.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../src/comments/menu.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAQrC;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,EAAE,OAAO,MAAM,EAAE,OAAO,MAAM,KAAG,MAAM,GAAG,IAgBlF,CAAC;AAMF,4GAA4G;AAC5G,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,QAAQ,GAAG,KAAK,CAAC;IACvB,8DAA8D;IAC9D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS,IAAI,EACb,0DAMG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,GAAG,KAAK,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,KAC3G,YAUF,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What the comments' own menus share: which item a key moves to (a comment's ⋯, the composer's
|
|
3
|
+
* agent picker, a grouped pin's chooser), and where a menu button's menu opens beside its trigger
|
|
4
|
+
* (the ⋯ and the picker). Pure: the caller passes the window. `useCommentMenu` puts them to work.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The item a key moves focus to in a menu of `count` items whose item `index` has focus (-1: none):
|
|
8
|
+
* ↓ and ↑ the next and the previous, wrapping round, Home and End the first and the last; null for
|
|
9
|
+
* any other key, which the menu leaves alone.
|
|
10
|
+
*/
|
|
11
|
+
export const menuKeyTarget = (key, index, count) => {
|
|
12
|
+
if (count <= 0) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
switch (key) {
|
|
16
|
+
case "ArrowDown":
|
|
17
|
+
return (index + 1) % count;
|
|
18
|
+
case "ArrowUp":
|
|
19
|
+
return index < 0 ? count - 1 : (index - 1 + count) % count;
|
|
20
|
+
case "Home":
|
|
21
|
+
return 0;
|
|
22
|
+
case "End":
|
|
23
|
+
return count - 1;
|
|
24
|
+
default:
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** The gap between a menu and its trigger, and how far a menu keeps from the window's edge. */
|
|
29
|
+
const MENU_GAP = 4;
|
|
30
|
+
const MENU_EDGE = 8;
|
|
31
|
+
/**
|
|
32
|
+
* Where a menu `height` tall opens beside its trigger (in window coordinates): below it when it
|
|
33
|
+
* fits there, else above when it fits there or there is more room above (a composer at the bottom
|
|
34
|
+
* of the window, a sheet on a phone), else below; never taller than the room on its side. Its start
|
|
35
|
+
* edge lines up with the trigger's start (a picker) or its end edge with the trigger's end (a ⋯),
|
|
36
|
+
* as the page reads (`isRtl`), from the window's own edges.
|
|
37
|
+
*/
|
|
38
|
+
export const menuPosition = (trigger, { height, align, isRtl, viewportWidth, viewportHeight, }) => {
|
|
39
|
+
const roomBelow = viewportHeight - trigger.bottom - MENU_GAP;
|
|
40
|
+
const roomAbove = trigger.top - MENU_GAP;
|
|
41
|
+
const vertical = height < roomBelow || (height > roomAbove && roomBelow >= roomAbove)
|
|
42
|
+
? { side: "bottom", top: trigger.bottom + MENU_GAP, maxHeight: Math.max(0, roomBelow - MENU_EDGE) }
|
|
43
|
+
: { side: "top", bottom: viewportHeight - trigger.top + MENU_GAP, maxHeight: Math.max(0, roomAbove - MENU_EDGE) };
|
|
44
|
+
return (align === "start") !== isRtl
|
|
45
|
+
? { ...vertical, left: trigger.left }
|
|
46
|
+
: { ...vertical, right: viewportWidth - trigger.right };
|
|
47
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Rect } from "./layout.js";
|
|
2
|
+
/**
|
|
3
|
+
* - `close`: close it.
|
|
4
|
+
* - `refocus`: keep it, with focus back in its composer (a click while it holds something unsent).
|
|
5
|
+
* - `return`: take the page back to it and focus its composer; it is out of sight with something
|
|
6
|
+
* unsent. Tried once: when a return has had its chance (its scroll has ended) and the panel is
|
|
7
|
+
* still out of sight (its element has no box, or scrolling can't reveal it), the click or Esc
|
|
8
|
+
* closes it instead, as Esc does in sight, so nobody is held by a panel they can't see. While the
|
|
9
|
+
* return is still under way, another click or Esc answers `return` again: nothing is lost.
|
|
10
|
+
* - `pass`: it is out of sight with nothing unsent: the click or Esc acts as if nothing were open
|
|
11
|
+
* (a click starts a new comment, Esc ends comment mode), and it closes with that.
|
|
12
|
+
*/
|
|
13
|
+
export type OpenCommentResponse = "close" | "refocus" | "return" | "pass";
|
|
14
|
+
export declare const openCommentResponse: ({ input, isOnScreen, hasUnsent, hasReturnFailed, }: {
|
|
15
|
+
input: "click" | "escape";
|
|
16
|
+
/** Whether any of its panel is in the window and drawn (`isOnScreen`). */
|
|
17
|
+
isOnScreen: boolean;
|
|
18
|
+
/** Whether its composer holds something that would be sent. */
|
|
19
|
+
hasUnsent: boolean;
|
|
20
|
+
/** A `return` was answered, its scroll has had time to end, and the panel hasn't been in sight since. */
|
|
21
|
+
hasReturnFailed?: boolean;
|
|
22
|
+
}) => OpenCommentResponse;
|
|
23
|
+
/** Whether any of `rect` (null: not drawn) is in the window; an empty box is not. */
|
|
24
|
+
export declare const isOnScreen: (rect: Rect | null, viewport: {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}) => boolean;
|
|
28
|
+
//# sourceMappingURL=open-comment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open-comment.d.ts","sourceRoot":"","sources":["../../src/comments/open-comment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,IAAI,EAAE,MAAM,UAAU,CAAC;AASrD;;;;;;;;;;GAUG;AACH,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE1E,eAAO,MAAM,mBAAmB,GAAI,oDAKjC;IACD,KAAK,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC1B,0EAA0E;IAC1E,UAAU,EAAE,OAAO,CAAC;IACpB,+DAA+D;IAC/D,SAAS,EAAE,OAAO,CAAC;IACnB,yGAAyG;IACzG,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,KAAG,mBAKH,CAAC;AAEF,qFAAqF;AACrF,eAAO,MAAM,UAAU,GAAI,MAAM,IAAI,GAAG,IAAI,EAAE,UAAU;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,KAAG,OACW,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { intersectRects } from "./layout.js";
|
|
2
|
+
export const openCommentResponse = ({ input, isOnScreen, hasUnsent, hasReturnFailed = false, }) => {
|
|
3
|
+
if (!isOnScreen) {
|
|
4
|
+
return !hasUnsent ? "pass" : hasReturnFailed ? "close" : "return";
|
|
5
|
+
}
|
|
6
|
+
return input === "click" && hasUnsent ? "refocus" : "close";
|
|
7
|
+
};
|
|
8
|
+
/** Whether any of `rect` (null: not drawn) is in the window; an empty box is not. */
|
|
9
|
+
export const isOnScreen = (rect, viewport) => !!rect && !!intersectRects(rect, { top: 0, left: 0, right: viewport.width, bottom: viewport.height });
|