@assistant-ui/react 0.15.18 → 0.15.19
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/README.md +7 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +17 -12
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +7 -1
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -8
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/message/MessageParts.js +2 -14
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
- package/dist/primitives/message/MessagePartsGrouped.js +2 -2
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +13 -36
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +3 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +22 -4
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +24 -2
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -5
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +16 -15
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
- package/dist/unstable/webmcp/convertTools.js +54 -3
- package/dist/unstable/webmcp/convertTools.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.d.ts +14 -6
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js +25 -6
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +17 -3
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/package.json +11 -11
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp-apps/app-frame.test.tsx +189 -0
- package/src/mcp-apps/app-frame.tsx +31 -22
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +7 -1
- package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
- package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
- package/src/primitives/composer/ComposerInput.test.tsx +22 -0
- package/src/primitives/composer/ComposerInput.tsx +5 -9
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
- package/src/primitives/message/MessageParts.tsx +1 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
- package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
- package/src/primitives/messagePart/MessagePartText.tsx +22 -9
- package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
- package/src/primitives/reasoning/useScrollLock.ts +15 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
- package/src/sandbox-host/SandboxHost.test.tsx +40 -0
- package/src/sandbox-host/SandboxHost.tsx +24 -4
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +47 -1
- package/src/unstable/useMentionAdapter.ts +2 -2
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/webmcp/convertTools.test.ts +181 -21
- package/src/unstable/webmcp/convertTools.ts +87 -7
- package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
- package/src/utils/Primitive.test.tsx +46 -1
- package/src/utils/Primitive.tsx +37 -7
- package/src/utils/getSelectionMessageId.test.ts +176 -1
- package/src/utils/getSelectionMessageId.ts +21 -3
|
@@ -118,6 +118,68 @@ const isStandardSchema = (schema: unknown): schema is StandardSchemaLike =>
|
|
|
118
118
|
"~standard" in schema &&
|
|
119
119
|
(schema as StandardSchemaLike)["~standard"].version === 1;
|
|
120
120
|
|
|
121
|
+
const TOOL_ABORTED = Symbol("assistant-ui.webmcp-tool-aborted");
|
|
122
|
+
|
|
123
|
+
const isThenable = <T>(value: T | PromiseLike<T>): value is PromiseLike<T> =>
|
|
124
|
+
typeof (value as PromiseLike<T> | null | undefined)?.then === "function";
|
|
125
|
+
|
|
126
|
+
const raceWithAbort = async <T>(
|
|
127
|
+
value: PromiseLike<T>,
|
|
128
|
+
abortSignal: AbortSignal,
|
|
129
|
+
): Promise<T | typeof TOOL_ABORTED> => {
|
|
130
|
+
let onAbort!: () => void;
|
|
131
|
+
const abortPromise = new Promise<typeof TOOL_ABORTED>((resolve) => {
|
|
132
|
+
onAbort = () => resolve(TOOL_ABORTED);
|
|
133
|
+
if (abortSignal.aborted) {
|
|
134
|
+
onAbort();
|
|
135
|
+
} else {
|
|
136
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// Unlike assistant-stream's helper, cancellation wins when validation aborts and rejects synchronously.
|
|
142
|
+
return await Promise.race([abortPromise, value]);
|
|
143
|
+
} finally {
|
|
144
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// AbortSignal.any sits above the browserslist floor and rejects any input that
|
|
149
|
+
// is not a native AbortSignal, which a navigator.modelContext polyfill's signal
|
|
150
|
+
// is not. The merged signal tracks its inputs only until cleanup runs, where
|
|
151
|
+
// the single-signal path hands the caller the lifecycle signal itself.
|
|
152
|
+
const combineAbortSignals = (
|
|
153
|
+
callerSignal: AbortSignal,
|
|
154
|
+
lifecycleSignal: AbortSignal,
|
|
155
|
+
): { signal: AbortSignal; cleanup: () => void } => {
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const teardown: (() => void)[] = [];
|
|
158
|
+
const cleanup = () => {
|
|
159
|
+
while (teardown.length) teardown.pop()!();
|
|
160
|
+
};
|
|
161
|
+
const abort = (reason: unknown) => {
|
|
162
|
+
cleanup();
|
|
163
|
+
controller.abort(reason);
|
|
164
|
+
};
|
|
165
|
+
const listen = (signal: AbortSignal) => {
|
|
166
|
+
const onAbort = () => abort(signal.reason);
|
|
167
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
168
|
+
teardown.push(() => signal.removeEventListener("abort", onAbort));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (callerSignal.aborted) {
|
|
172
|
+
abort(callerSignal.reason);
|
|
173
|
+
} else if (lifecycleSignal.aborted) {
|
|
174
|
+
abort(lifecycleSignal.reason);
|
|
175
|
+
} else {
|
|
176
|
+
listen(callerSignal);
|
|
177
|
+
listen(lifecycleSignal);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { signal: controller.signal, cleanup };
|
|
181
|
+
};
|
|
182
|
+
|
|
121
183
|
export const toWebMcpTool = (
|
|
122
184
|
name: string,
|
|
123
185
|
getTool: () => Tool<any, any>,
|
|
@@ -133,17 +195,33 @@ export const toWebMcpTool = (
|
|
|
133
195
|
const tool = getTool();
|
|
134
196
|
const args = (rawArgs ?? {}) as Record<string, unknown>;
|
|
135
197
|
const toolCallId = generateId();
|
|
198
|
+
let cleanup: (() => void) | undefined;
|
|
136
199
|
try {
|
|
137
200
|
const callerSignal = context?.signal;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
201
|
+
let abortSignal: AbortSignal | undefined;
|
|
202
|
+
if (callerSignal && lifecycleSignal) {
|
|
203
|
+
const combined = combineAbortSignals(callerSignal, lifecycleSignal);
|
|
204
|
+
abortSignal = combined.signal;
|
|
205
|
+
cleanup = combined.cleanup;
|
|
206
|
+
} else {
|
|
207
|
+
abortSignal = callerSignal ?? lifecycleSignal;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (abortSignal?.aborted) {
|
|
211
|
+
return errorResult("Tool execution was cancelled.");
|
|
212
|
+
}
|
|
213
|
+
|
|
143
214
|
let executeFn = tool.execute;
|
|
144
215
|
if (isStandardSchema(tool.parameters)) {
|
|
145
|
-
|
|
146
|
-
validation =
|
|
216
|
+
const result = tool.parameters["~standard"].validate(args);
|
|
217
|
+
const validation = isThenable(result)
|
|
218
|
+
? abortSignal
|
|
219
|
+
? await raceWithAbort(result, abortSignal)
|
|
220
|
+
: await result
|
|
221
|
+
: result;
|
|
222
|
+
if (validation === TOOL_ABORTED) {
|
|
223
|
+
return errorResult("Tool execution was cancelled.");
|
|
224
|
+
}
|
|
147
225
|
if (validation.issues) {
|
|
148
226
|
const issues = validation.issues;
|
|
149
227
|
executeFn =
|
|
@@ -175,6 +253,8 @@ export const toWebMcpTool = (
|
|
|
175
253
|
return await toMcpContent(result, { tool, toolCallId, args });
|
|
176
254
|
} catch (e) {
|
|
177
255
|
return errorResult(e instanceof Error ? e.message : String(e));
|
|
256
|
+
} finally {
|
|
257
|
+
cleanup?.();
|
|
178
258
|
}
|
|
179
259
|
},
|
|
180
260
|
});
|
|
@@ -7,6 +7,7 @@ import type { Tool } from "assistant-stream";
|
|
|
7
7
|
import { getDefaultWebMcpHost, type WebMcpHost } from "./webmcp-host";
|
|
8
8
|
import { defaultWebMcpFilter, toWebMcpInputSchema } from "./convertTools";
|
|
9
9
|
import { WebMcpRegistrationResource } from "./WebMcpRegistrationResource";
|
|
10
|
+
import { shallowEqual } from "@assistant-ui/store/internal";
|
|
10
11
|
import {
|
|
11
12
|
useModelContextSnapshot,
|
|
12
13
|
type ModelContextSnapshotSource,
|
|
@@ -64,10 +65,7 @@ const useStableNames = (names: readonly (string | null)[]) => {
|
|
|
64
65
|
const [cell] = useState(() => ({ names: EMPTY_NAMES }));
|
|
65
66
|
const next = names.filter((name): name is string => name !== null).sort();
|
|
66
67
|
const previous = cell.names;
|
|
67
|
-
if (
|
|
68
|
-
previous.length === next.length &&
|
|
69
|
-
previous.every((name, index) => name === next[index])
|
|
70
|
-
) {
|
|
68
|
+
if (shallowEqual(previous, next)) {
|
|
71
69
|
return previous;
|
|
72
70
|
}
|
|
73
71
|
cell.names = next;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
-
import { Primitive, withRenderProp } from "./Primitive";
|
|
3
|
+
import { Primitive, renderSlot, withRenderProp } from "./Primitive";
|
|
4
4
|
|
|
5
5
|
const ALL_NODES = [
|
|
6
6
|
"a",
|
|
@@ -103,3 +103,48 @@ describe("Primitive", () => {
|
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("renderSlot", () => {
|
|
108
|
+
it("lets supplied children replace the render element's own", () => {
|
|
109
|
+
expect(
|
|
110
|
+
renderToStaticMarkup(renderSlot(<em>Original</em>, "Override", {})),
|
|
111
|
+
).toBe("<em>Override</em>");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("falls back to the render element's own children", () => {
|
|
115
|
+
expect(
|
|
116
|
+
renderToStaticMarkup(renderSlot(<em>Fallback</em>, undefined, {})),
|
|
117
|
+
).toBe("<em>Fallback</em>");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("treats null children as supplied rather than absent", () => {
|
|
121
|
+
expect(renderToStaticMarkup(renderSlot(<em>Fallback</em>, null, {}))).toBe(
|
|
122
|
+
"<em></em>",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("merges call-site props into the render element", () => {
|
|
127
|
+
const html = renderToStaticMarkup(
|
|
128
|
+
renderSlot(<em className="child" id="kept" />, "text", {
|
|
129
|
+
className: "parent",
|
|
130
|
+
id: "dropped",
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(html).toContain('id="kept"');
|
|
135
|
+
expect(html).toContain("parent");
|
|
136
|
+
expect(html).toContain("child");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("applies the same precedence as withRenderProp", () => {
|
|
140
|
+
expect(
|
|
141
|
+
renderToStaticMarkup(
|
|
142
|
+
renderSlot(<em>Fallback</em>, undefined, { className: "parent" }),
|
|
143
|
+
),
|
|
144
|
+
).toBe(
|
|
145
|
+
renderToStaticMarkup(
|
|
146
|
+
<Primitive.span className="parent" render={<em>Fallback</em>} />,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/utils/Primitive.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ComponentProps,
|
|
2
3
|
type ComponentPropsWithoutRef,
|
|
3
4
|
type ComponentRef,
|
|
4
5
|
type ElementType,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
isValidElement,
|
|
13
14
|
} from "react";
|
|
14
15
|
import { Primitive as RadixPrimitive } from "radix-ui/internal";
|
|
16
|
+
import { Slot } from "radix-ui";
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Thin wrapper around Radix `Primitive` that adds `render` prop support.
|
|
@@ -64,6 +66,39 @@ type PrimitiveRef<E extends PrimitiveNode> = ComponentRef<
|
|
|
64
66
|
(typeof RadixPrimitive)[E]
|
|
65
67
|
>;
|
|
66
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Composes the children of a `render` element. Outer children win when supplied;
|
|
71
|
+
* the render element's own children are the fallback.
|
|
72
|
+
*/
|
|
73
|
+
function composeRenderElement(
|
|
74
|
+
render: ReactElement,
|
|
75
|
+
children: ReactNode,
|
|
76
|
+
): ReactElement {
|
|
77
|
+
return cloneElement(
|
|
78
|
+
render,
|
|
79
|
+
undefined,
|
|
80
|
+
children !== undefined
|
|
81
|
+
? children
|
|
82
|
+
: (render.props as { children?: ReactNode }).children,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Composes a `render` element at a call site that has already computed its props.
|
|
88
|
+
*
|
|
89
|
+
* `withRenderProp` wraps a component; this covers the case where the props are
|
|
90
|
+
* already in hand and the composition happens inline.
|
|
91
|
+
*/
|
|
92
|
+
function renderSlot(
|
|
93
|
+
render: ReactElement,
|
|
94
|
+
children: ReactNode,
|
|
95
|
+
props: ComponentProps<typeof Slot.Root>,
|
|
96
|
+
): ReactElement {
|
|
97
|
+
return (
|
|
98
|
+
<Slot.Root {...props}>{composeRenderElement(render, children)}</Slot.Root>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
function withRenderProp<T extends ElementType>(Component: T) {
|
|
68
103
|
const Wrapped = forwardRef<ComponentRef<T>, WithRenderPropRuntimeProps<T>>(
|
|
69
104
|
(
|
|
@@ -78,14 +113,9 @@ function withRenderProp<T extends ElementType>(Component: T) {
|
|
|
78
113
|
const Comp = Component as any;
|
|
79
114
|
|
|
80
115
|
if (render && isValidElement(render)) {
|
|
81
|
-
const renderChildren =
|
|
82
|
-
children !== undefined
|
|
83
|
-
? children
|
|
84
|
-
: ((render.props as Record<string, unknown>).children as ReactNode);
|
|
85
|
-
|
|
86
116
|
return (
|
|
87
117
|
<Comp {...(rest as any)} asChild ref={ref}>
|
|
88
|
-
{
|
|
118
|
+
{composeRenderElement(render, children)}
|
|
89
119
|
</Comp>
|
|
90
120
|
);
|
|
91
121
|
}
|
|
@@ -129,5 +159,5 @@ const Primitive = NODES.reduce(
|
|
|
129
159
|
},
|
|
130
160
|
);
|
|
131
161
|
|
|
132
|
-
export { Primitive, withRenderProp };
|
|
162
|
+
export { Primitive, renderSlot, withRenderProp };
|
|
133
163
|
export type { PrimitiveProps, WithRenderPropProps };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @vitest-environment jsdom
|
|
3
3
|
*/
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, assert, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { getSelectionMessageId } from "./getSelectionMessageId";
|
|
6
6
|
|
|
7
7
|
const selectText = (start: Text, end = start) => {
|
|
@@ -24,6 +24,7 @@ const textNode = (selector: string) => {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
27
28
|
window.getSelection()?.removeAllRanges();
|
|
28
29
|
document.body.replaceChildren();
|
|
29
30
|
});
|
|
@@ -116,6 +117,180 @@ describe("getSelectionMessageId", () => {
|
|
|
116
117
|
).toBeNull();
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
it.each(["", "data-aui-quote-selectable"])(
|
|
121
|
+
"rejects selections spanning an excluded subtree with root %s",
|
|
122
|
+
(marker) => {
|
|
123
|
+
document.body.innerHTML = `
|
|
124
|
+
<div data-message-id="message-1" ${marker}>
|
|
125
|
+
<span id="before">before</span>
|
|
126
|
+
<span data-aui-quote-selectable="false">excluded</span>
|
|
127
|
+
<span id="after">after</span>
|
|
128
|
+
</div>
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
expect(getSelectionMessageId(selectText(textNode("#before")))).toBe(
|
|
132
|
+
"message-1",
|
|
133
|
+
);
|
|
134
|
+
expect(
|
|
135
|
+
getSelectionMessageId(
|
|
136
|
+
selectText(textNode("#before"), textNode("#after")),
|
|
137
|
+
),
|
|
138
|
+
).toBeNull();
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("rejects a paragraph selection that spans an inline exclusion", () => {
|
|
143
|
+
document.body.innerHTML = `
|
|
144
|
+
<div data-message-id="message-1">
|
|
145
|
+
<p id="text" data-aui-quote-selectable>before <span data-aui-quote-selectable="false">[1]</span> after</p>
|
|
146
|
+
</div>
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const paragraph = document.querySelector("#text");
|
|
150
|
+
assert(paragraph);
|
|
151
|
+
const selection = selectText(textNode("#text"));
|
|
152
|
+
selection.getRangeAt(0).selectNodeContents(paragraph);
|
|
153
|
+
|
|
154
|
+
expect(getSelectionMessageId(selection)).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("accepts selections that stop or start at an excluded node boundary", () => {
|
|
158
|
+
document.body.innerHTML = `
|
|
159
|
+
<div data-message-id="message-1" data-aui-quote-selectable>
|
|
160
|
+
<span id="before">before</span><span id="chip" data-aui-quote-selectable="false">[1]</span><span id="after">after</span>
|
|
161
|
+
</div>
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const chip = document.querySelector("#chip");
|
|
165
|
+
assert(chip);
|
|
166
|
+
const before = selectText(textNode("#before"));
|
|
167
|
+
before.getRangeAt(0).setEndBefore(chip);
|
|
168
|
+
expect(getSelectionMessageId(before)).toBe("message-1");
|
|
169
|
+
|
|
170
|
+
const after = selectText(textNode("#after"));
|
|
171
|
+
after.getRangeAt(0).setStartAfter(chip);
|
|
172
|
+
expect(getSelectionMessageId(after)).toBe("message-1");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("preserves a nested quote region inside an excluded subtree", () => {
|
|
176
|
+
document.body.innerHTML = `
|
|
177
|
+
<div data-message-id="message-1">
|
|
178
|
+
<div data-aui-quote-selectable="false">
|
|
179
|
+
<p data-aui-quote-selectable>
|
|
180
|
+
<span id="before">before</span>
|
|
181
|
+
<span data-aui-quote-selectable="false">[1]</span>
|
|
182
|
+
<span id="after">after</span>
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
expect(getSelectionMessageId(selectText(textNode("#before")))).toBe(
|
|
189
|
+
"message-1",
|
|
190
|
+
);
|
|
191
|
+
expect(
|
|
192
|
+
getSelectionMessageId(
|
|
193
|
+
selectText(textNode("#before"), textNode("#after")),
|
|
194
|
+
),
|
|
195
|
+
).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("checks another range outside the active quote region, including its excluded ancestor", () => {
|
|
199
|
+
document.body.innerHTML = `
|
|
200
|
+
<div data-message-id="message-1">
|
|
201
|
+
<p id="other">before <span id="chip" data-aui-quote-selectable="false">[1]</span> after</p>
|
|
202
|
+
<p id="active" data-aui-quote-selectable>active text</p>
|
|
203
|
+
</div>
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const other = document.querySelector("#other");
|
|
207
|
+
assert(other);
|
|
208
|
+
const range = document.createRange();
|
|
209
|
+
range.selectNodeContents(other);
|
|
210
|
+
const selection = selectText(textNode("#active"));
|
|
211
|
+
const active = selection.getRangeAt(0);
|
|
212
|
+
vi.spyOn(selection, "rangeCount", "get").mockReturnValue(2);
|
|
213
|
+
vi.spyOn(selection, "getRangeAt").mockImplementation((index) => {
|
|
214
|
+
if (index === 0) return range;
|
|
215
|
+
if (index === 1) return active;
|
|
216
|
+
throw new DOMException("Range index out of bounds", "IndexSizeError");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(getSelectionMessageId(selection)).toBeNull();
|
|
220
|
+
|
|
221
|
+
range.selectNodeContents(textNode("#chip"));
|
|
222
|
+
expect(getSelectionMessageId(selection)).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("rejects another range inside the excluded ancestor of the active quote region", () => {
|
|
226
|
+
document.body.innerHTML = `
|
|
227
|
+
<div data-message-id="message-1">
|
|
228
|
+
<div data-aui-quote-selectable="false">
|
|
229
|
+
<span id="sibling">excluded prose</span>
|
|
230
|
+
<p id="active" data-aui-quote-selectable>active text</p>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
const range = document.createRange();
|
|
236
|
+
range.selectNodeContents(textNode("#sibling"));
|
|
237
|
+
const selection = selectText(textNode("#active"));
|
|
238
|
+
const active = selection.getRangeAt(0);
|
|
239
|
+
vi.spyOn(selection, "rangeCount", "get").mockReturnValue(2);
|
|
240
|
+
vi.spyOn(selection, "getRangeAt").mockImplementation((index) => {
|
|
241
|
+
if (index === 0) return range;
|
|
242
|
+
if (index === 1) return active;
|
|
243
|
+
throw new DOMException("Range index out of bounds", "IndexSizeError");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(getSelectionMessageId(selection)).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("rejects another range in a different message", () => {
|
|
250
|
+
document.body.innerHTML = `
|
|
251
|
+
<div data-message-id="message-1"><p id="active">active text</p></div>
|
|
252
|
+
<div data-message-id="message-2">
|
|
253
|
+
<p id="other" data-aui-quote-selectable="false">excluded prose</p>
|
|
254
|
+
</div>
|
|
255
|
+
`;
|
|
256
|
+
|
|
257
|
+
const range = document.createRange();
|
|
258
|
+
range.selectNodeContents(textNode("#other"));
|
|
259
|
+
const selection = selectText(textNode("#active"));
|
|
260
|
+
const active = selection.getRangeAt(0);
|
|
261
|
+
vi.spyOn(selection, "rangeCount", "get").mockReturnValue(2);
|
|
262
|
+
vi.spyOn(selection, "getRangeAt").mockImplementation((index) => {
|
|
263
|
+
if (index === 0) return range;
|
|
264
|
+
if (index === 1) return active;
|
|
265
|
+
throw new DOMException("Range index out of bounds", "IndexSizeError");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(getSelectionMessageId(selection)).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("accepts disjoint ranges on either side of an excluded gap", () => {
|
|
272
|
+
document.body.innerHTML = `
|
|
273
|
+
<div data-message-id="message-1" data-aui-quote-selectable>
|
|
274
|
+
<p id="before">before</p>
|
|
275
|
+
<p data-aui-quote-selectable="false">excluded</p>
|
|
276
|
+
<p id="after">after</p>
|
|
277
|
+
</div>
|
|
278
|
+
`;
|
|
279
|
+
|
|
280
|
+
const before = document.createRange();
|
|
281
|
+
before.selectNodeContents(textNode("#before"));
|
|
282
|
+
const selection = selectText(textNode("#after"));
|
|
283
|
+
const after = selection.getRangeAt(0);
|
|
284
|
+
vi.spyOn(selection, "rangeCount", "get").mockReturnValue(2);
|
|
285
|
+
vi.spyOn(selection, "getRangeAt").mockImplementation((index) => {
|
|
286
|
+
if (index === 0) return before;
|
|
287
|
+
if (index === 1) return after;
|
|
288
|
+
throw new DOMException("Range index out of bounds", "IndexSizeError");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(getSelectionMessageId(selection)).toBe("message-1");
|
|
292
|
+
});
|
|
293
|
+
|
|
119
294
|
it("rejects selections outside quote-selectable regions when a message opts in", () => {
|
|
120
295
|
document.body.innerHTML = `
|
|
121
296
|
<div data-message-id="message-1">
|
|
@@ -43,6 +43,17 @@ const findQuoteMarker = (
|
|
|
43
43
|
return marker;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
const intersectsExcluded = (scope: Element, selection: Selection): boolean => {
|
|
47
|
+
const ranges = Array.from({ length: selection.rangeCount }, (_, i) =>
|
|
48
|
+
selection.getRangeAt(i),
|
|
49
|
+
);
|
|
50
|
+
for (const marker of scope.querySelectorAll(QUOTE_SELECTABLE_SELECTOR)) {
|
|
51
|
+
if (!isExcluded(marker)) continue;
|
|
52
|
+
if (ranges.some((range) => range.intersectsNode(marker))) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
|
|
46
57
|
export const getSelectionMessageId = (selection: Selection): string | null => {
|
|
47
58
|
const { anchorNode, focusNode } = selection;
|
|
48
59
|
if (!anchorNode || !focusNode) return null;
|
|
@@ -63,9 +74,16 @@ export const getSelectionMessageId = (selection: Selection): string | null => {
|
|
|
63
74
|
if (anchorMarker && isExcluded(anchorMarker)) return null;
|
|
64
75
|
if (focusMarker && isExcluded(focusMarker)) return null;
|
|
65
76
|
|
|
66
|
-
if (
|
|
77
|
+
if (hasQuoteSelectableRegion(anchorMessageElement)) {
|
|
78
|
+
if (!anchorMarker || anchorMarker !== focusMarker) return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const scope = anchorMarker ?? anchorMessageElement;
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
for (let i = 0; i < selection.rangeCount; i++) {
|
|
84
|
+
const { commonAncestorContainer } = selection.getRangeAt(i);
|
|
85
|
+
if (!scope.contains(commonAncestorContainer)) return null;
|
|
86
|
+
}
|
|
69
87
|
|
|
70
|
-
return messageId;
|
|
88
|
+
return intersectsExcluded(scope, selection) ? null : messageId;
|
|
71
89
|
};
|