@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.161.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/README.md +435 -133
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +19008 -0
- package/dist/base-skills/notis-apps/SKILL.md +70 -0
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +62 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +140 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +1626 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +2990 -0
- package/package.json +18 -7
- package/skills/notis-apps/cli.md +313 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1470 -202
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +729 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +84 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +86 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +632 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2633 -210
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +697 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +110 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +143 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +430 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +28 -3
- package/template/app/layout.tsx +6 -3
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +3642 -0
- package/template/package.json +19 -16
- package/template/packages/{notis-sdk → sdk}/package.json +14 -4
- package/template/packages/sdk/src/agentContext.ts +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +257 -0
- package/template/packages/sdk/src/documents.ts +256 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useTool.ts +65 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +161 -0
- package/template/packages/sdk/src/interactions/actions.ts +59 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +45 -0
- package/template/packages/sdk/src/provider.tsx +44 -0
- package/template/packages/sdk/src/queryCache.ts +170 -0
- package/template/packages/sdk/src/runtime.ts +451 -0
- package/template/packages/sdk/src/styles.css +247 -0
- package/template/packages/sdk/src/tailwind.ts +66 -0
- package/template/packages/sdk/src/vite.ts +73 -0
- package/template/packages/{notis-sdk → sdk}/tsconfig.json +1 -0
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import type { ResolvedCollectionAction } from '../interactions/actions';
|
|
12
|
+
import { activateShortcutCollection, shortcutDisplay, useShortcuts, type ShortcutDefinition } from '../interactions/shortcuts';
|
|
13
|
+
import { isInteractionElementVisible } from '../interactions/visibility';
|
|
14
|
+
import type { ShortcutScope } from '../interactions/shortcuts';
|
|
15
|
+
|
|
16
|
+
export type MultiSelectAction = ResolvedCollectionAction;
|
|
17
|
+
|
|
18
|
+
/** The Portal owns global launcher positioning; app code only reports its own bar. */
|
|
19
|
+
export const BULK_ACTION_LAYOUT_EVENT = 'notis:bulk-actions-layout';
|
|
20
|
+
export interface BulkActionLayoutDetail {
|
|
21
|
+
bar: HTMLElement;
|
|
22
|
+
active: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MultiSelectActionBarProps {
|
|
26
|
+
selectedCount: number;
|
|
27
|
+
actions: MultiSelectAction[];
|
|
28
|
+
/** Override the "{count} selected" label units. Default: "selected" with no item word. */
|
|
29
|
+
itemLabel?: { singular: string; plural: string };
|
|
30
|
+
/** Optional extra class on the outer container (composed alongside the inline styles). */
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Optional override for the bar's positioning style. Defaults to bottom-center floating. */
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
/** Disable action key bindings while leaving the visible toolbar mounted. */
|
|
35
|
+
shortcutsEnabled?: boolean;
|
|
36
|
+
/** Override the default collection shortcut scope. */
|
|
37
|
+
shortcutScope?: ShortcutScope;
|
|
38
|
+
collectionOwnerId?: string;
|
|
39
|
+
isAvailable?: () => boolean;
|
|
40
|
+
onClearSelection?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const containerBaseStyle: CSSProperties = {
|
|
44
|
+
position: 'fixed',
|
|
45
|
+
bottom: 'calc(var(--notis-viewport-bottom, 0px) + max(1rem, var(--notis-safe-area-bottom, env(safe-area-inset-bottom, 0px))))',
|
|
46
|
+
left: '50%',
|
|
47
|
+
transform: 'translateX(-50%)',
|
|
48
|
+
zIndex: 60,
|
|
49
|
+
display: 'flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
gap: '0.25rem',
|
|
52
|
+
padding: '0.375rem 0.5rem',
|
|
53
|
+
borderRadius: '0.5rem',
|
|
54
|
+
background: 'color-mix(in srgb, hsl(var(--foreground)) 95%, transparent)',
|
|
55
|
+
color: 'hsl(var(--background))',
|
|
56
|
+
boxShadow: '0 10px 25px -10px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.08)',
|
|
57
|
+
backdropFilter: 'blur(6px)',
|
|
58
|
+
WebkitBackdropFilter: 'blur(6px)',
|
|
59
|
+
pointerEvents: 'auto',
|
|
60
|
+
fontSize: '13px',
|
|
61
|
+
lineHeight: 1.2,
|
|
62
|
+
width: 'max-content',
|
|
63
|
+
maxWidth: 'calc(100% - 2rem)',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const countStyle: CSSProperties = {
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
padding: '0 0.5rem',
|
|
69
|
+
fontSize: '12px',
|
|
70
|
+
fontWeight: 500,
|
|
71
|
+
fontVariantNumeric: 'tabular-nums',
|
|
72
|
+
whiteSpace: 'nowrap',
|
|
73
|
+
color: 'color-mix(in srgb, hsl(var(--background)) 70%, transparent)',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const dividerStyle: CSSProperties = {
|
|
77
|
+
width: '1px',
|
|
78
|
+
height: '1rem',
|
|
79
|
+
margin: '0 0.125rem',
|
|
80
|
+
background: 'color-mix(in srgb, hsl(var(--background)) 20%, transparent)',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const baseButtonStyle: CSSProperties = {
|
|
84
|
+
flexShrink: 0,
|
|
85
|
+
display: 'inline-flex',
|
|
86
|
+
alignItems: 'center',
|
|
87
|
+
gap: '0.375rem',
|
|
88
|
+
padding: '0.25rem 0.5rem',
|
|
89
|
+
border: 0,
|
|
90
|
+
background: 'transparent',
|
|
91
|
+
color: 'color-mix(in srgb, hsl(var(--background)) 90%, transparent)',
|
|
92
|
+
borderRadius: '0.375rem',
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
fontSize: '13px',
|
|
95
|
+
fontFamily: 'inherit',
|
|
96
|
+
whiteSpace: 'nowrap',
|
|
97
|
+
transition: 'background-color 120ms ease, color 120ms ease',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const keycapStyle: CSSProperties = {
|
|
101
|
+
display: 'inline-flex',
|
|
102
|
+
alignItems: 'center',
|
|
103
|
+
justifyContent: 'center',
|
|
104
|
+
minWidth: '20px',
|
|
105
|
+
height: '20px',
|
|
106
|
+
padding: '0 4px',
|
|
107
|
+
borderRadius: '4px',
|
|
108
|
+
border: '1px solid color-mix(in srgb, hsl(var(--background)) 20%, transparent)',
|
|
109
|
+
background: 'color-mix(in srgb, hsl(var(--background)) 15%, transparent)',
|
|
110
|
+
color: 'inherit',
|
|
111
|
+
fontSize: '11px',
|
|
112
|
+
fontWeight: 500,
|
|
113
|
+
lineHeight: 1,
|
|
114
|
+
fontFamily: 'inherit',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const iconSlotStyle: CSSProperties = {
|
|
118
|
+
display: 'inline-flex',
|
|
119
|
+
alignItems: 'center',
|
|
120
|
+
justifyContent: 'center',
|
|
121
|
+
color: 'inherit',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function MultiSelectActionBar({
|
|
125
|
+
selectedCount,
|
|
126
|
+
actions,
|
|
127
|
+
itemLabel,
|
|
128
|
+
className,
|
|
129
|
+
style,
|
|
130
|
+
shortcutsEnabled = true,
|
|
131
|
+
shortcutScope = 'collection',
|
|
132
|
+
collectionOwnerId,
|
|
133
|
+
isAvailable,
|
|
134
|
+
onClearSelection,
|
|
135
|
+
}: MultiSelectActionBarProps): ReactElement | null {
|
|
136
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
137
|
+
const visible = selectedCount > 0;
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const bar = barRef.current;
|
|
140
|
+
if (!visible || !bar) return;
|
|
141
|
+
// Notify the owning document, including from a shadow root or after unmount.
|
|
142
|
+
// Only the Portal host may measure this bar and change global layout styles.
|
|
143
|
+
const report = (active: boolean) => {
|
|
144
|
+
const owner = bar.ownerDocument;
|
|
145
|
+
const LayoutEvent = owner.defaultView?.CustomEvent;
|
|
146
|
+
if (LayoutEvent) {
|
|
147
|
+
owner.dispatchEvent(new LayoutEvent<BulkActionLayoutDetail>(BULK_ACTION_LAYOUT_EVENT, {
|
|
148
|
+
detail: { bar, active },
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const update = () => report(isAvailable?.() !== false && isInteractionElementVisible(bar));
|
|
153
|
+
update();
|
|
154
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
|
155
|
+
observer?.observe(bar);
|
|
156
|
+
const VisibilityObserver = bar.ownerDocument.defaultView?.MutationObserver;
|
|
157
|
+
const visibilityObserver = VisibilityObserver ? new VisibilityObserver(update) : null;
|
|
158
|
+
let element: Element | null = bar;
|
|
159
|
+
while (element) {
|
|
160
|
+
visibilityObserver?.observe(element, { attributes: true, attributeFilter: element === bar.ownerDocument.documentElement ? ['hidden', 'inert', 'aria-hidden', 'class'] : ['hidden', 'inert', 'aria-hidden', 'style', 'class'] });
|
|
161
|
+
const root: Node = element.getRootNode();
|
|
162
|
+
element = element.parentElement ?? ('host' in root ? (root as ShadowRoot).host : null);
|
|
163
|
+
}
|
|
164
|
+
return () => {
|
|
165
|
+
observer?.disconnect();
|
|
166
|
+
visibilityObserver?.disconnect();
|
|
167
|
+
report(false);
|
|
168
|
+
};
|
|
169
|
+
}, [isAvailable, visible]);
|
|
170
|
+
|
|
171
|
+
const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
|
|
172
|
+
return actions.flatMap((action): ShortcutDefinition[] => {
|
|
173
|
+
if (!action.shortcut) return [];
|
|
174
|
+
return [{
|
|
175
|
+
id: `collection.action.${action.id}`,
|
|
176
|
+
keys: action.shortcut,
|
|
177
|
+
label: action.label,
|
|
178
|
+
allowRepeat: Boolean(action.disabled || action.pending),
|
|
179
|
+
// Keep advertised keys owned while disabled/pending; never fall through to another action.
|
|
180
|
+
onTrigger: () => { if (!action.disabled && !action.pending) action.onRun(); },
|
|
181
|
+
}];
|
|
182
|
+
});
|
|
183
|
+
}, [actions]);
|
|
184
|
+
useShortcuts(actionShortcuts, {
|
|
185
|
+
enabled: shortcutsEnabled && selectedCount > 0,
|
|
186
|
+
scope: shortcutScope,
|
|
187
|
+
priority: 25,
|
|
188
|
+
collectionOwnerId,
|
|
189
|
+
isAvailable: () => isAvailable?.() !== false && isInteractionElementVisible(barRef.current),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (selectedCount === 0) return null;
|
|
193
|
+
|
|
194
|
+
const countWord = itemLabel
|
|
195
|
+
? selectedCount === 1
|
|
196
|
+
? itemLabel.singular
|
|
197
|
+
: itemLabel.plural
|
|
198
|
+
: 'selected';
|
|
199
|
+
const countLabel = itemLabel
|
|
200
|
+
? `${selectedCount} ${countWord} selected`
|
|
201
|
+
: `${selectedCount} selected`;
|
|
202
|
+
|
|
203
|
+
const composedStyle: CSSProperties = { ...containerBaseStyle, ...(style || {}) };
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={barRef}
|
|
208
|
+
data-notis-bulk-actions
|
|
209
|
+
onFocusCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
|
|
210
|
+
onMouseDownCapture={() => { if (collectionOwnerId) activateShortcutCollection(collectionOwnerId); }}
|
|
211
|
+
onKeyDown={(event) => {
|
|
212
|
+
if (event.key !== 'Escape' || !shortcutsEnabled || !onClearSelection || isAvailable?.() === false) return;
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
event.stopPropagation();
|
|
215
|
+
onClearSelection();
|
|
216
|
+
}}
|
|
217
|
+
role="toolbar"
|
|
218
|
+
aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
|
|
219
|
+
className={className}
|
|
220
|
+
style={composedStyle}
|
|
221
|
+
>
|
|
222
|
+
<style>{`
|
|
223
|
+
[data-notis-bulk-action-icon][data-has-shortcut] { display: none !important; }
|
|
224
|
+
@media (max-width: 639px) {
|
|
225
|
+
[data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
|
|
226
|
+
[data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
|
|
227
|
+
[data-notis-bulk-divider] { display: none; }
|
|
228
|
+
[data-notis-bulk-action-list] { width: 100%; }
|
|
229
|
+
[data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
|
|
230
|
+
}
|
|
231
|
+
@media (hover: none) and (pointer: coarse) {
|
|
232
|
+
[data-notis-bulk-actions] kbd { display: none !important; }
|
|
233
|
+
[data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
|
|
234
|
+
}
|
|
235
|
+
`}</style>
|
|
236
|
+
<span data-notis-bulk-count style={countStyle}>{countLabel}</span>
|
|
237
|
+
{actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
|
|
238
|
+
<div data-notis-bulk-action-list style={{ display: 'flex', minWidth: 0, overflowX: 'auto', overscrollBehaviorX: 'contain', gap: '0.25rem' }}>
|
|
239
|
+
{actions.map((action) => (
|
|
240
|
+
<ActionButton key={action.id} action={action} />
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ActionButton({ action }: { action: MultiSelectAction }) {
|
|
248
|
+
const [hover, setHover] = useState(false);
|
|
249
|
+
const display = action.shortcut ? shortcutDisplay(action.shortcut) : null;
|
|
250
|
+
const buttonStyle: CSSProperties = {
|
|
251
|
+
...baseButtonStyle,
|
|
252
|
+
background: hover
|
|
253
|
+
? 'color-mix(in srgb, hsl(var(--background)) 12%, transparent)'
|
|
254
|
+
: 'transparent',
|
|
255
|
+
color: hover
|
|
256
|
+
? 'hsl(var(--background))'
|
|
257
|
+
: baseButtonStyle.color,
|
|
258
|
+
opacity: action.disabled ? 0.45 : 1,
|
|
259
|
+
cursor: action.disabled || action.pending ? 'not-allowed' : 'pointer',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => {
|
|
266
|
+
if (!action.disabled && !action.pending) void action.onRun();
|
|
267
|
+
}}
|
|
268
|
+
disabled={action.disabled || action.pending}
|
|
269
|
+
aria-busy={action.pending || undefined}
|
|
270
|
+
aria-keyshortcuts={action.shortcut || undefined}
|
|
271
|
+
data-destructive={action.destructive ? 'true' : 'false'}
|
|
272
|
+
onMouseEnter={() => setHover(true)}
|
|
273
|
+
onMouseLeave={() => setHover(false)}
|
|
274
|
+
onFocus={() => setHover(true)}
|
|
275
|
+
onBlur={() => setHover(false)}
|
|
276
|
+
style={buttonStyle}
|
|
277
|
+
>
|
|
278
|
+
{action.icon ? (
|
|
279
|
+
<span data-notis-bulk-action-icon data-has-shortcut={action.shortcut ? '' : undefined} aria-hidden style={iconSlotStyle}>{action.icon}</span>
|
|
280
|
+
) : null}
|
|
281
|
+
{display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
|
|
282
|
+
<span>{action.pending ? `${action.label}…` : action.label}</span>
|
|
283
|
+
</button>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactElement } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface MultiSelectCheckboxProps {
|
|
6
|
+
isSelected: boolean;
|
|
7
|
+
onClick: (event: ReactMouseEvent) => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
/** When true, the checkbox is always visible. Default false (hover/focus reveal via the parent's :hover state). */
|
|
10
|
+
alwaysVisible?: boolean;
|
|
11
|
+
/** Optional aria-label override. Defaults to "Select item" / "Deselect item". */
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
/** Optional inline-style override merged with the defaults. */
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const baseStyle: CSSProperties = {
|
|
19
|
+
flexShrink: 0,
|
|
20
|
+
display: 'inline-flex',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
justifyContent: 'center',
|
|
23
|
+
width: '16px',
|
|
24
|
+
height: '16px',
|
|
25
|
+
borderRadius: '4px',
|
|
26
|
+
borderWidth: '1px',
|
|
27
|
+
borderStyle: 'solid',
|
|
28
|
+
borderColor: 'hsl(var(--border))',
|
|
29
|
+
background: 'transparent',
|
|
30
|
+
color: 'hsl(var(--primary-foreground))',
|
|
31
|
+
cursor: 'pointer',
|
|
32
|
+
padding: 0,
|
|
33
|
+
transition: 'background-color 120ms ease, border-color 120ms ease, opacity 150ms ease',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const selectedStyle: CSSProperties = {
|
|
37
|
+
background: 'hsl(var(--primary))',
|
|
38
|
+
borderColor: 'hsl(var(--primary))',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const checkPath = 'M3.5 7.5l2.5 2.5 6.5-6.5';
|
|
42
|
+
|
|
43
|
+
export function MultiSelectCheckbox({
|
|
44
|
+
isSelected,
|
|
45
|
+
onClick,
|
|
46
|
+
disabled = false,
|
|
47
|
+
alwaysVisible = false,
|
|
48
|
+
ariaLabel,
|
|
49
|
+
className,
|
|
50
|
+
style,
|
|
51
|
+
}: MultiSelectCheckboxProps): ReactElement {
|
|
52
|
+
const merged: CSSProperties = {
|
|
53
|
+
...baseStyle,
|
|
54
|
+
...(isSelected ? selectedStyle : null),
|
|
55
|
+
...(alwaysVisible || isSelected ? { opacity: 1 } : null),
|
|
56
|
+
...(disabled ? { opacity: 0.45, cursor: 'not-allowed' } : null),
|
|
57
|
+
...(style || null),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// When alwaysVisible is false and the row isn't selected, defer the
|
|
61
|
+
// hover-reveal to the parent — consumers wrap the checkbox in a `.group`
|
|
62
|
+
// hover scope and pass `data-notis-hover-reveal` styles via className.
|
|
63
|
+
// Out of the box, we render the checkbox at full opacity if selected,
|
|
64
|
+
// otherwise inherit the parent's reveal state.
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
role="checkbox"
|
|
69
|
+
aria-checked={isSelected}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
aria-label={ariaLabel ?? (isSelected ? 'Deselect item' : 'Select item')}
|
|
72
|
+
onClick={onClick}
|
|
73
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
74
|
+
data-notis-multiselect-checkbox=""
|
|
75
|
+
data-selected={isSelected ? 'true' : 'false'}
|
|
76
|
+
className={className}
|
|
77
|
+
style={merged}
|
|
78
|
+
>
|
|
79
|
+
{isSelected ? (
|
|
80
|
+
<svg
|
|
81
|
+
width="10"
|
|
82
|
+
height="10"
|
|
83
|
+
viewBox="0 0 16 16"
|
|
84
|
+
fill="none"
|
|
85
|
+
stroke="currentColor"
|
|
86
|
+
strokeWidth={3}
|
|
87
|
+
strokeLinecap="round"
|
|
88
|
+
strokeLinejoin="round"
|
|
89
|
+
aria-hidden
|
|
90
|
+
focusable="false"
|
|
91
|
+
>
|
|
92
|
+
<path d={checkPath} />
|
|
93
|
+
</svg>
|
|
94
|
+
) : null}
|
|
95
|
+
</button>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { type CSSProperties, type ReactElement } from 'react';
|
|
4
|
+
import type { DragRect } from '../hooks/useMultiSelect';
|
|
5
|
+
|
|
6
|
+
export interface MultiSelectDragOverlayProps {
|
|
7
|
+
rect: DragRect | null;
|
|
8
|
+
className?: string;
|
|
9
|
+
style?: CSSProperties;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const overlayBaseStyle: CSSProperties = {
|
|
13
|
+
position: 'fixed',
|
|
14
|
+
borderRadius: '3px',
|
|
15
|
+
border: '1px solid rgba(35, 131, 226, 0.3)',
|
|
16
|
+
background: 'rgba(35, 131, 226, 0.08)',
|
|
17
|
+
pointerEvents: 'none',
|
|
18
|
+
zIndex: 50,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function MultiSelectDragOverlay({
|
|
22
|
+
rect,
|
|
23
|
+
className,
|
|
24
|
+
style,
|
|
25
|
+
}: MultiSelectDragOverlayProps): ReactElement | null {
|
|
26
|
+
if (!rect) return null;
|
|
27
|
+
if (rect.width === 0 && rect.height === 0) return null;
|
|
28
|
+
|
|
29
|
+
const merged: CSSProperties = {
|
|
30
|
+
...overlayBaseStyle,
|
|
31
|
+
left: rect.left,
|
|
32
|
+
top: rect.top,
|
|
33
|
+
width: rect.width,
|
|
34
|
+
height: rect.height,
|
|
35
|
+
...(style || null),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return <div aria-hidden className={className} style={merged} />;
|
|
39
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
3
|
+
import type { ContextResource } from '../runtime';
|
|
4
|
+
import { NotisSelectionBoundary } from './NotisSelectionBoundary';
|
|
5
|
+
import { useAgentContext } from '../hooks/useAgentContext';
|
|
6
|
+
|
|
7
|
+
export interface NotisCommentBoxProps {
|
|
8
|
+
quote?: string;
|
|
9
|
+
value: string;
|
|
10
|
+
onChange(value: string): void;
|
|
11
|
+
onSubmit(): void;
|
|
12
|
+
onCancel(): void;
|
|
13
|
+
pending?: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compact, chrome-themed presentation. Apps retain ownership of comment state. */
|
|
19
|
+
export function NotisCommentBox({ quote, value, onChange, onSubmit, onCancel, pending, error, className = '' }: NotisCommentBoxProps) {
|
|
20
|
+
return <div data-notis-comment-ui role="dialog" aria-label="Comment on selection"
|
|
21
|
+
style={{ colorScheme: 'dark', background: 'hsl(var(--sidebar-background, 0 0% 7.5%))', color: 'hsl(var(--sidebar-foreground, 240 4.8% 95.9%))', borderColor: 'hsl(var(--sidebar-border, 0 0% 23.5%))' }}
|
|
22
|
+
className={`flex min-w-0 flex-col gap-2 rounded-xl border p-2.5 text-sm shadow-lg ${className}`}>
|
|
23
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
24
|
+
{quote && <blockquote title={quote} className="min-w-0 flex-1 truncate border-l-2 border-current/40 pl-2 opacity-70">{quote}</blockquote>}
|
|
25
|
+
<button type="button" aria-label="Cancel comment" onClick={onCancel} disabled={pending} className="ml-auto flex size-7 shrink-0 items-center justify-center rounded-lg opacity-70 hover:bg-white/10 hover:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:opacity-40 [@media(pointer:coarse)]:size-9">
|
|
26
|
+
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="m6 6 12 12M18 6 6 18" /></svg>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
<textarea autoFocus rows={2} name="selection-comment" aria-label="Your comment" placeholder="Add a comment…" value={value} onChange={event => onChange(event.target.value)} disabled={pending}
|
|
30
|
+
onKeyDown={event => { if (event.nativeEvent.isComposing || pending) return; if (event.key === 'Escape') { event.preventDefault(); onCancel(); } if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); if (value.trim()) onSubmit(); } }}
|
|
31
|
+
className="w-full min-w-0 resize-none rounded bg-transparent px-0.5 py-1 text-base text-inherit outline-none placeholder:text-current placeholder:opacity-60 sm:text-sm" />
|
|
32
|
+
{error && <p role="alert" className="text-sm text-red-300">{error}</p>}
|
|
33
|
+
<div className="flex items-center justify-between gap-3">
|
|
34
|
+
<span className="text-xs opacity-60">⌘ / Ctrl ↵ to add</span>
|
|
35
|
+
<button type="button" disabled={pending || !value.trim()} onClick={onSubmit}
|
|
36
|
+
style={{ background: 'hsl(var(--sidebar-foreground, 240 4.8% 95.9%))', color: 'hsl(var(--sidebar-background, 0 0% 7.5%))' }}
|
|
37
|
+
className="min-h-8 shrink-0 rounded-lg px-2.5 text-sm font-medium hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:opacity-40 [@media(pointer:coarse)]:min-h-9">{pending ? 'Adding…' : 'Add to chat'}</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NotisCommentBoundaryProps {
|
|
43
|
+
resource?: ContextResource | null;
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
className?: string;
|
|
46
|
+
commentClassName?: string;
|
|
47
|
+
/** Replace the optional standard editor while retaining selection/context behavior. */
|
|
48
|
+
renderComment?: (props: NotisCommentBoxProps) => ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type SelectionRect = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>;
|
|
52
|
+
|
|
53
|
+
/** Place the tile after the final selected line, otherwise below (or above near the viewport bottom). */
|
|
54
|
+
export function getCommentActionPosition(rects: SelectionRect[], viewport: { width: number; height: number }, rightBoundary = viewport.width, trailing: SelectionRect[] = []) {
|
|
55
|
+
const last = rects[rects.length - 1];
|
|
56
|
+
const size = 32, gap = 8;
|
|
57
|
+
const clampX = (x: number) => Math.max(gap, Math.min(viewport.width - size - gap, x));
|
|
58
|
+
const clampY = (y: number) => Math.max(gap, Math.min(viewport.height - size - gap, y));
|
|
59
|
+
const beside = { left: last.right + gap, top: clampY((last.top + last.bottom - size) / 2) };
|
|
60
|
+
const overlapsText = trailing.some(rect => rect.left < beside.left + size && rect.right > beside.left && rect.top < beside.top + size && rect.bottom > beside.top);
|
|
61
|
+
if (beside.left + size <= Math.min(viewport.width, rightBoundary) - gap && !overlapsText) return beside;
|
|
62
|
+
return { left: clampX(last.right - size), top: clampY(last.bottom + gap + size <= viewport.height - gap ? last.bottom + gap : last.top - size - gap) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Browser paragraph selection may include an adjacent block's empty starting boundary. */
|
|
66
|
+
export function getOwnedCommentRange(selected: Range, content: Element): Range | null {
|
|
67
|
+
const owned = content.ownerDocument.createRange();
|
|
68
|
+
owned.selectNodeContents(content);
|
|
69
|
+
const range = selected.cloneRange();
|
|
70
|
+
// Only clip whitespace outside the resource; actual neighboring content must
|
|
71
|
+
// never be silently attached to this resource's comment.
|
|
72
|
+
if (range.compareBoundaryPoints(Range.START_TO_START, owned) < 0) {
|
|
73
|
+
const before = range.cloneRange();
|
|
74
|
+
before.setEnd(owned.startContainer, owned.startOffset);
|
|
75
|
+
if (before.toString().trim()) return null;
|
|
76
|
+
range.setStart(owned.startContainer, owned.startOffset);
|
|
77
|
+
}
|
|
78
|
+
if (range.compareBoundaryPoints(Range.END_TO_END, owned) > 0) {
|
|
79
|
+
const after = range.cloneRange();
|
|
80
|
+
after.setStart(owned.endContainer, owned.endOffset);
|
|
81
|
+
if (after.toString().trim()) return null;
|
|
82
|
+
range.setEnd(owned.endContainer, owned.endOffset);
|
|
83
|
+
}
|
|
84
|
+
if (range.collapsed || !content.contains(range.startContainer) || !content.contains(range.endContainer)) return null;
|
|
85
|
+
return range;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Selection-to-chat convenience. No annotation database, polling or background attachment. */
|
|
89
|
+
export function NotisCommentBoundary({ resource, children, className, commentClassName, renderComment }: NotisCommentBoundaryProps) {
|
|
90
|
+
const context = useAgentContext();
|
|
91
|
+
const root = useRef<HTMLDivElement>(null);
|
|
92
|
+
const [selection, setSelection] = useState<{ id: string; text: string; resource?: ContextResource | null; top: number; left: number } | null>(null);
|
|
93
|
+
const [editing, setEditing] = useState(false);
|
|
94
|
+
const editingRef = useRef(false);
|
|
95
|
+
const [comment, setComment] = useState('');
|
|
96
|
+
const [pending, setPending] = useState(false);
|
|
97
|
+
const [error, setError] = useState('');
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const node = root.current;
|
|
100
|
+
if (!node) return;
|
|
101
|
+
const document = node.ownerDocument;
|
|
102
|
+
const scope = node.getRootNode() as ShadowRoot & { getSelection?: () => Selection | null };
|
|
103
|
+
const clearInvalidSelection = () => {
|
|
104
|
+
// Opening the editor focuses its input and collapses the browser range.
|
|
105
|
+
// Keep the captured quote/editor; only the unopened action follows live selection.
|
|
106
|
+
if (editingRef.current) return;
|
|
107
|
+
const selected = scope.getSelection?.() || document.defaultView?.getSelection();
|
|
108
|
+
const content = node.firstElementChild;
|
|
109
|
+
if (!selected?.rangeCount || selected.isCollapsed || !selected.toString().trim()
|
|
110
|
+
|| !content || !getOwnedCommentRange(selected.getRangeAt(0), content)) setSelection(null);
|
|
111
|
+
};
|
|
112
|
+
document.addEventListener('selectionchange', clearInvalidSelection);
|
|
113
|
+
if (scope !== (document as unknown as ShadowRoot)) scope.addEventListener('selectionchange', clearInvalidSelection);
|
|
114
|
+
return () => {
|
|
115
|
+
document.removeEventListener('selectionchange', clearInvalidSelection);
|
|
116
|
+
if (scope !== (document as unknown as ShadowRoot)) scope.removeEventListener('selectionchange', clearInvalidSelection);
|
|
117
|
+
};
|
|
118
|
+
}, []);
|
|
119
|
+
const open = () => { editingRef.current = true; setEditing(true); };
|
|
120
|
+
const capture = (target: EventTarget | null) => {
|
|
121
|
+
// Clicking the action/editor must not unmount it before its click runs.
|
|
122
|
+
if (editing || (target instanceof Element && target.closest('[data-notis-comment-ui]'))) return;
|
|
123
|
+
const node = root.current;
|
|
124
|
+
const scope = node?.getRootNode() as ShadowRoot & { getSelection?: () => Selection | null };
|
|
125
|
+
const selected = scope?.getSelection?.() || window.getSelection();
|
|
126
|
+
if (!node || !selected?.rangeCount || selected.isCollapsed) { setSelection(null); return; }
|
|
127
|
+
const content = node.firstElementChild;
|
|
128
|
+
const range = content ? getOwnedCommentRange(selected.getRangeAt(0), content) : null;
|
|
129
|
+
if (!range) { setSelection(null); return; }
|
|
130
|
+
const start = range.startContainer.nodeType === Node.ELEMENT_NODE ? range.startContainer as Element : range.startContainer.parentElement;
|
|
131
|
+
const end = range.endContainer.nodeType === Node.ELEMENT_NODE ? range.endContainer as Element : range.endContainer.parentElement;
|
|
132
|
+
if (!node.contains(range.startContainer) || !node.contains(range.endContainer)
|
|
133
|
+
|| start?.closest('input,textarea,button,[contenteditable],[data-notis-comment-ui]')
|
|
134
|
+
|| end?.closest('input,textarea,button,[contenteditable],[data-notis-comment-ui]')) { setSelection(null); return; }
|
|
135
|
+
const rect = range.getBoundingClientRect();
|
|
136
|
+
const lines = Array.from(range.getClientRects?.() || []).filter(line => line.width > 0 && line.height > 0);
|
|
137
|
+
const boundary = node.firstElementChild?.getBoundingClientRect();
|
|
138
|
+
const trailing: DOMRect[] = [];
|
|
139
|
+
const endBlock = end?.closest('p,li,h1,h2,h3,h4,h5,h6,td,th,div');
|
|
140
|
+
if (endBlock && node.contains(endBlock)) {
|
|
141
|
+
const remainder = range.cloneRange();
|
|
142
|
+
remainder.selectNodeContents(endBlock);
|
|
143
|
+
remainder.setStart(range.endContainer, range.endOffset);
|
|
144
|
+
trailing.push(...Array.from(remainder.getClientRects?.() || []).filter(line => line.width > 0 && line.height > 0));
|
|
145
|
+
}
|
|
146
|
+
const position = getCommentActionPosition(lines.length ? lines : [rect], { width: window.innerWidth, height: window.innerHeight }, boundary?.width ? boundary.right : window.innerWidth, trailing);
|
|
147
|
+
setSelection({ id: crypto.randomUUID(), text: selected.toString().trim(), resource: resource ? JSON.parse(JSON.stringify(resource)) : null, ...position });
|
|
148
|
+
};
|
|
149
|
+
const close = () => { editingRef.current = false; setEditing(false); setSelection(null); setComment(''); setError(''); };
|
|
150
|
+
const submit = async () => {
|
|
151
|
+
if (!selection || !comment.trim() || pending) return;
|
|
152
|
+
setPending(true); setError('');
|
|
153
|
+
try {
|
|
154
|
+
const added = await context.add({ id: selection.id, kind: 'comment', title: selection.resource?.label || 'Comment', icon: 'phosphor:chat-text', text: selection.text, comment, resource: selection.resource });
|
|
155
|
+
if (!added) throw new Error('The comment could not be added to chat.');
|
|
156
|
+
close();
|
|
157
|
+
} catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not add this comment.'); }
|
|
158
|
+
finally { setPending(false); }
|
|
159
|
+
};
|
|
160
|
+
const editorProps: NotisCommentBoxProps = { quote: selection?.text, value: comment, onChange: setComment, onSubmit: () => void submit(), onCancel: close, pending, error, className: commentClassName };
|
|
161
|
+
return <div ref={root} className="contents" onMouseUp={event => capture(event.target)} onDoubleClick={event => capture(event.target)} onKeyUp={event => { if (!editing && (event.shiftKey || event.key === 'Shift' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a'))) capture(event.target); }}>
|
|
162
|
+
<NotisSelectionBoundary resource={resource} className={className} style={className ? undefined : { display: 'contents' }}>{children}</NotisSelectionBoundary>
|
|
163
|
+
{selection && <div data-notis-comment-ui className="fixed z-50 max-h-[calc(100dvh-1rem)]" style={{ overflow: editing ? 'auto' : 'visible', top: editing ? Math.min(selection.top, Math.max(8, window.innerHeight - 320)) : selection.top, left: `clamp(8px, ${selection.left}px, calc(100vw - ${editing ? 'min(20rem, calc(100vw - 1rem))' : '2rem'} - 8px))`, width: editing ? 'min(20rem, calc(100vw - 1rem))' : undefined } as CSSProperties}>
|
|
164
|
+
{editing ? (renderComment ? renderComment(editorProps) : <NotisCommentBox {...editorProps} />) : <button type="button" aria-label="Comment" title="Comment" onMouseDown={event => event.preventDefault()} onClick={open}
|
|
165
|
+
style={{ background: 'hsl(var(--sidebar-background, 0 0% 7.5%))', color: 'hsl(var(--sidebar-foreground, 240 4.8% 95.9%))' }}
|
|
166
|
+
className="relative flex size-8 items-center justify-center rounded-lg ring-1 ring-white/15 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current">
|
|
167
|
+
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 256 256" fill="currentColor"><path d="M216,48H40A16,16,0,0,0,24,64V224a15.85,15.85,0,0,0,9.24,14.5A16.13,16.13,0,0,0,40,240a15.89,15.89,0,0,0,10.25-3.78l.09-.07L83,208H216a16,16,0,0,0,16-16V64A16,16,0,0,0,216,48ZM40,224h0ZM216,192H80a8,8,0,0,0-5.23,1.95L40,224V64H216ZM88,112a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H96A8,8,0,0,1,88,112Zm0,32a8,8,0,0,1,8-8h64a8,8,0,1,1,0,16H96A8,8,0,0,1,88,144Z" /></svg>
|
|
168
|
+
<span aria-hidden="true" className="absolute left-1/2 top-1/2 hidden size-12 -translate-x-1/2 -translate-y-1/2 [@media(pointer:coarse)]:block" />
|
|
169
|
+
</button>}
|
|
170
|
+
</div>}
|
|
171
|
+
</div>;
|
|
172
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CSSProperties, ClipboardEvent, ReactNode } from 'react';
|
|
2
|
+
import React, { useCallback } from 'react';
|
|
3
|
+
import { useNotisRuntime } from '../provider';
|
|
4
|
+
import type { ContextResource, ContextSelection } from '../runtime';
|
|
5
|
+
|
|
6
|
+
export const NOTIS_CONTEXT_CLIPBOARD_TYPE = 'application/x-notis-context+json';
|
|
7
|
+
|
|
8
|
+
export interface NotisSelectionBoundaryProps {
|
|
9
|
+
resource?: ContextResource | null;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
style?: CSSProperties;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function selectionId(): string {
|
|
16
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
17
|
+
? crypto.randomUUID()
|
|
18
|
+
: `selection-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Adds structured provenance to ordinary copied text. Pasting still works in
|
|
23
|
+
* every app because `text/plain` is preserved; Notis chat additionally reads
|
|
24
|
+
* the custom MIME payload and renders a separate quote pill.
|
|
25
|
+
*/
|
|
26
|
+
export function NotisSelectionBoundary({
|
|
27
|
+
resource,
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
style,
|
|
31
|
+
}: NotisSelectionBoundaryProps) {
|
|
32
|
+
const runtime = useNotisRuntime();
|
|
33
|
+
const onCopy = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
|
34
|
+
const target = event.target as Element | null;
|
|
35
|
+
if (target?.closest?.('input,textarea,[contenteditable="true"]')) return;
|
|
36
|
+
const scope = event.currentTarget.getRootNode() as ShadowRoot & { getSelection?: () => Selection | null };
|
|
37
|
+
const selection = scope?.getSelection?.() || window.getSelection();
|
|
38
|
+
const text = selection?.toString() ?? '';
|
|
39
|
+
if (
|
|
40
|
+
!text.trim()
|
|
41
|
+
|| !selection
|
|
42
|
+
|| !event.currentTarget.contains(selection.anchorNode)
|
|
43
|
+
|| !event.currentTarget.contains(selection.focusNode)
|
|
44
|
+
) return;
|
|
45
|
+
|
|
46
|
+
const payload: ContextSelection = {
|
|
47
|
+
id: selectionId(),
|
|
48
|
+
text,
|
|
49
|
+
...(runtime?.contextSource ? { source: runtime.contextSource } : {}),
|
|
50
|
+
...(resource ? { resource: { ...resource, snapshot: undefined } } : {}),
|
|
51
|
+
};
|
|
52
|
+
event.clipboardData.setData('text/plain', text);
|
|
53
|
+
event.clipboardData.setData(NOTIS_CONTEXT_CLIPBOARD_TYPE, JSON.stringify(payload));
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
runtime?.captureContextSelection?.(payload);
|
|
56
|
+
}, [resource, runtime]);
|
|
57
|
+
|
|
58
|
+
return <div className={className} style={style} onCopy={onCopy}>{children}</div>;
|
|
59
|
+
}
|