@object-ui/plugin-detail 3.0.3 → 3.1.0
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/.turbo/turbo-build.log +45 -8
- package/dist/AddressField-C07oUOY6.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-VThNABzo.js +82 -0
- package/dist/BooleanField-CGHKBzAi.js +37 -0
- package/dist/CodeField-Co_muhRR.js +21 -0
- package/dist/ColorField-DLid_tFz.js +42 -0
- package/dist/CurrencyField-Bw-LqANM.js +43 -0
- package/dist/DateField-BNHAzMB2.js +21 -0
- package/dist/DateTimeField-DjAyn_DQ.js +28 -0
- package/dist/EmailField-xoNcSppb.js +31 -0
- package/dist/FileField-DbNJwjU2.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-C1AnS6VV.js +123 -0
- package/dist/GridField-DATAHIKf.js +30 -0
- package/dist/ImageField-CEKJpyJp.js +90 -0
- package/dist/LocationField-jDWXjlpx.js +31 -0
- package/dist/LookupField-DQ08L9UQ.js +96 -0
- package/dist/MasterDetailField-Dbk529Ea.js +108 -0
- package/dist/NumberField-BVroN9aV.js +26 -0
- package/dist/ObjectField-CT3l_IHW.js +48 -0
- package/dist/PasswordField-DweVLEE0.js +38 -0
- package/dist/PercentField-ZpWUK97K.js +63 -0
- package/dist/PhoneField-mw-9fqZ_.js +31 -0
- package/dist/QRCodeField-Cbb9ck59.js +77 -0
- package/dist/RatingField-CSqgLS6t.js +47 -0
- package/dist/RichTextField-BpfBOd99.js +38 -0
- package/dist/SelectField-B9Ei-5jl.js +26 -0
- package/dist/SignatureField-DgGpHnQ8.js +85 -0
- package/dist/SliderField-C6HvOHd8.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-BK3RgzY3.js +39 -0
- package/dist/TextField-Bvzx3atT.js +32 -0
- package/dist/TimeField-Cuz9-Uai.js +21 -0
- package/dist/UrlField-B6XHTV73.js +33 -0
- package/dist/UserField-ooTul2d6.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-CnlyRfY_.js +59461 -0
- package/dist/index.js +30 -55026
- package/dist/index.umd.cjs +41 -30
- package/dist/plugin-detail.css +1 -1
- package/dist/src/ActivityTimeline.d.ts +20 -0
- package/dist/src/ActivityTimeline.d.ts.map +1 -0
- package/dist/src/CommentAttachment.d.ts +25 -0
- package/dist/src/CommentAttachment.d.ts.map +1 -0
- package/dist/src/CommentInput.d.ts +24 -0
- package/dist/src/CommentInput.d.ts.map +1 -0
- package/dist/src/DetailSection.d.ts +6 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts +4 -0
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +8 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -1
- package/dist/src/DiffView.d.ts +24 -0
- package/dist/src/DiffView.d.ts.map +1 -0
- package/dist/src/FieldChangeItem.d.ts +21 -0
- package/dist/src/FieldChangeItem.d.ts.map +1 -0
- package/dist/src/InlineCreateRelated.d.ts +32 -0
- package/dist/src/InlineCreateRelated.d.ts.map +1 -0
- package/dist/src/MentionAutocomplete.d.ts +43 -0
- package/dist/src/MentionAutocomplete.d.ts.map +1 -0
- package/dist/src/PointInTimeRestore.d.ts +28 -0
- package/dist/src/PointInTimeRestore.d.ts.map +1 -0
- package/dist/src/ReactionPicker.d.ts +25 -0
- package/dist/src/ReactionPicker.d.ts.map +1 -0
- package/dist/src/RecordActivityTimeline.d.ts +49 -0
- package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
- package/dist/src/RecordChatterPanel.d.ts +48 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -0
- package/dist/src/RecordComments.d.ts +20 -0
- package/dist/src/RecordComments.d.ts.map +1 -0
- package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
- package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +4 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/RelationshipGraph.d.ts +23 -0
- package/dist/src/RelationshipGraph.d.ts.map +1 -0
- package/dist/src/RichTextCommentInput.d.ts +24 -0
- package/dist/src/RichTextCommentInput.d.ts.map +1 -0
- package/dist/src/SubscriptionToggle.d.ts +22 -0
- package/dist/src/SubscriptionToggle.d.ts.map +1 -0
- package/dist/src/ThreadedReplies.d.ts +26 -0
- package/dist/src/ThreadedReplies.d.ts.map +1 -0
- package/dist/src/autoLayout.d.ts +34 -0
- package/dist/src/autoLayout.d.ts.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts +34 -0
- package/dist/src/useDetailTranslation.d.ts.map +1 -0
- package/package.json +8 -7
- package/src/ActivityTimeline.tsx +184 -0
- package/src/CommentAttachment.tsx +192 -0
- package/src/CommentInput.tsx +81 -0
- package/src/DetailSection.tsx +74 -9
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +270 -27
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -0
- package/src/InlineCreateRelated.tsx +291 -0
- package/src/MentionAutocomplete.tsx +123 -0
- package/src/PointInTimeRestore.tsx +261 -0
- package/src/ReactionPicker.tsx +106 -0
- package/src/RecordActivityTimeline.tsx +429 -0
- package/src/RecordChatterPanel.tsx +202 -0
- package/src/RecordComments.tsx +215 -0
- package/src/RecordNavigationEnhanced.tsx +211 -0
- package/src/RelatedList.tsx +37 -8
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -0
- package/src/SubscriptionToggle.tsx +60 -0
- package/src/ThreadedReplies.tsx +161 -0
- package/src/__tests__/ActivityTimeline.test.tsx +119 -0
- package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
- package/src/__tests__/CommentInput.test.tsx +57 -0
- package/src/__tests__/DetailSection.test.tsx +320 -0
- package/src/__tests__/DetailView.test.tsx +415 -1
- package/src/__tests__/FieldChangeItem.test.tsx +119 -0
- package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
- package/src/__tests__/ReactionPicker.test.tsx +113 -0
- package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
- package/src/__tests__/RecordComments.test.tsx +96 -0
- package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
- package/src/__tests__/RelatedList.test.tsx +66 -0
- package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
- package/src/__tests__/ThreadedReplies.test.tsx +212 -0
- package/src/__tests__/autoLayout.test.ts +184 -0
- package/src/__tests__/phase12-features.test.tsx +583 -0
- package/src/autoLayout.ts +111 -0
- package/src/index.tsx +46 -0
- package/src/useDetailTranslation.ts +103 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { cn, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
|
|
11
|
+
import { Network } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
export interface GraphNode {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
relatedRecords?: GraphNode[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RelationshipGraphProps {
|
|
21
|
+
record: GraphNode;
|
|
22
|
+
relatedRecords: GraphNode[];
|
|
23
|
+
levels?: number;
|
|
24
|
+
onNodeClick?: (nodeId: string) => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LayoutNode {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
level: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface LayoutEdge {
|
|
38
|
+
fromId: string;
|
|
39
|
+
toId: string;
|
|
40
|
+
fromX: number;
|
|
41
|
+
fromY: number;
|
|
42
|
+
toX: number;
|
|
43
|
+
toY: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NODE_RADIUS = 28;
|
|
47
|
+
const LEVEL_COLORS = [
|
|
48
|
+
'fill-primary stroke-primary',
|
|
49
|
+
'fill-blue-500 stroke-blue-500',
|
|
50
|
+
'fill-emerald-500 stroke-emerald-500',
|
|
51
|
+
'fill-amber-500 stroke-amber-500',
|
|
52
|
+
];
|
|
53
|
+
const LEVEL_TEXT_COLORS = [
|
|
54
|
+
'fill-primary-foreground',
|
|
55
|
+
'fill-white',
|
|
56
|
+
'fill-white',
|
|
57
|
+
'fill-white',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Compute layout positions for nodes in concentric rings. */
|
|
61
|
+
function computeLayout(
|
|
62
|
+
center: GraphNode,
|
|
63
|
+
relatedRecords: GraphNode[],
|
|
64
|
+
levels: number,
|
|
65
|
+
width: number,
|
|
66
|
+
height: number,
|
|
67
|
+
): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
|
|
68
|
+
const nodes: LayoutNode[] = [];
|
|
69
|
+
const edges: LayoutEdge[] = [];
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
|
|
72
|
+
const cx = width / 2;
|
|
73
|
+
const cy = height / 2;
|
|
74
|
+
|
|
75
|
+
// Center node
|
|
76
|
+
nodes.push({ id: center.id, label: center.label, type: center.type, x: cx, y: cy, level: 0 });
|
|
77
|
+
seen.add(center.id);
|
|
78
|
+
|
|
79
|
+
// Level 1: direct relations
|
|
80
|
+
const ringRadius1 = Math.min(width, height) * 0.32;
|
|
81
|
+
const level1Nodes = relatedRecords.filter((r) => !seen.has(r.id));
|
|
82
|
+
|
|
83
|
+
level1Nodes.forEach((node, i) => {
|
|
84
|
+
const angle = (2 * Math.PI * i) / level1Nodes.length - Math.PI / 2;
|
|
85
|
+
const x = cx + ringRadius1 * Math.cos(angle);
|
|
86
|
+
const y = cy + ringRadius1 * Math.sin(angle);
|
|
87
|
+
nodes.push({ id: node.id, label: node.label, type: node.type, x, y, level: 1 });
|
|
88
|
+
edges.push({ fromId: center.id, toId: node.id, fromX: cx, fromY: cy, toX: x, toY: y });
|
|
89
|
+
seen.add(node.id);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Level 2+: related records of related records
|
|
93
|
+
if (levels >= 2) {
|
|
94
|
+
const ringRadius2 = Math.min(width, height) * 0.46;
|
|
95
|
+
const level2Nodes: { node: GraphNode; parentX: number; parentY: number; parentId: string }[] = [];
|
|
96
|
+
|
|
97
|
+
level1Nodes.forEach((parentNode) => {
|
|
98
|
+
const parentLayoutNode = nodes.find((n) => n.id === parentNode.id);
|
|
99
|
+
if (!parentLayoutNode) return;
|
|
100
|
+
const children = (parentNode.relatedRecords || []).filter((r) => !seen.has(r.id));
|
|
101
|
+
children.forEach((child) => {
|
|
102
|
+
level2Nodes.push({
|
|
103
|
+
node: child,
|
|
104
|
+
parentX: parentLayoutNode.x,
|
|
105
|
+
parentY: parentLayoutNode.y,
|
|
106
|
+
parentId: parentNode.id,
|
|
107
|
+
});
|
|
108
|
+
seen.add(child.id);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
level2Nodes.forEach((item, i) => {
|
|
113
|
+
const angle = (2 * Math.PI * i) / Math.max(level2Nodes.length, 1) - Math.PI / 2;
|
|
114
|
+
const x = cx + ringRadius2 * Math.cos(angle);
|
|
115
|
+
const y = cy + ringRadius2 * Math.sin(angle);
|
|
116
|
+
nodes.push({
|
|
117
|
+
id: item.node.id,
|
|
118
|
+
label: item.node.label,
|
|
119
|
+
type: item.node.type,
|
|
120
|
+
x,
|
|
121
|
+
y,
|
|
122
|
+
level: 2,
|
|
123
|
+
});
|
|
124
|
+
edges.push({
|
|
125
|
+
fromId: item.parentId,
|
|
126
|
+
toId: item.node.id,
|
|
127
|
+
fromX: item.parentX,
|
|
128
|
+
fromY: item.parentY,
|
|
129
|
+
toX: x,
|
|
130
|
+
toY: y,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { nodes, edges };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Truncate label to fit inside a node circle. */
|
|
139
|
+
function truncateLabel(label: string, maxLen: number = 6): string {
|
|
140
|
+
if (label.length <= maxLen) return label;
|
|
141
|
+
return label.slice(0, maxLen - 1) + '…';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const RelationshipGraph: React.FC<RelationshipGraphProps> = ({
|
|
145
|
+
record,
|
|
146
|
+
relatedRecords,
|
|
147
|
+
levels = 1,
|
|
148
|
+
onNodeClick,
|
|
149
|
+
className,
|
|
150
|
+
}) => {
|
|
151
|
+
const svgRef = React.useRef<SVGSVGElement>(null);
|
|
152
|
+
const [dimensions, setDimensions] = React.useState({ width: 500, height: 400 });
|
|
153
|
+
const [hoveredNode, setHoveredNode] = React.useState<string | null>(null);
|
|
154
|
+
|
|
155
|
+
// Observe container size
|
|
156
|
+
React.useEffect(() => {
|
|
157
|
+
const svg = svgRef.current;
|
|
158
|
+
if (!svg) return;
|
|
159
|
+
const parent = svg.parentElement;
|
|
160
|
+
if (!parent) return;
|
|
161
|
+
|
|
162
|
+
const observer = new ResizeObserver((entries) => {
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const { width } = entry.contentRect;
|
|
165
|
+
if (width > 0) {
|
|
166
|
+
setDimensions({ width, height: Math.max(300, width * 0.7) });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
observer.observe(parent);
|
|
171
|
+
return () => observer.disconnect();
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
const { nodes, edges } = React.useMemo(
|
|
175
|
+
() => computeLayout(record, relatedRecords, levels, dimensions.width, dimensions.height),
|
|
176
|
+
[record, relatedRecords, levels, dimensions],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Card className={cn('overflow-hidden', className)}>
|
|
181
|
+
<CardHeader className="pb-2">
|
|
182
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
183
|
+
<Network className="h-4 w-4" />
|
|
184
|
+
Relationships
|
|
185
|
+
<span className="text-sm font-normal text-muted-foreground">
|
|
186
|
+
({relatedRecords.length} related)
|
|
187
|
+
</span>
|
|
188
|
+
</CardTitle>
|
|
189
|
+
</CardHeader>
|
|
190
|
+
<CardContent className="p-0">
|
|
191
|
+
<svg
|
|
192
|
+
ref={svgRef}
|
|
193
|
+
width="100%"
|
|
194
|
+
height={dimensions.height}
|
|
195
|
+
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
|
196
|
+
className="select-none"
|
|
197
|
+
>
|
|
198
|
+
{/* Edges */}
|
|
199
|
+
{edges.map((edge, i) => (
|
|
200
|
+
<line
|
|
201
|
+
key={`edge-${i}`}
|
|
202
|
+
x1={edge.fromX}
|
|
203
|
+
y1={edge.fromY}
|
|
204
|
+
x2={edge.toX}
|
|
205
|
+
y2={edge.toY}
|
|
206
|
+
className="stroke-border"
|
|
207
|
+
strokeWidth={1.5}
|
|
208
|
+
strokeOpacity={0.5}
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
|
|
212
|
+
{/* Nodes */}
|
|
213
|
+
{nodes.map((node) => {
|
|
214
|
+
const isHovered = hoveredNode === node.id;
|
|
215
|
+
const levelColor = LEVEL_COLORS[Math.min(node.level, LEVEL_COLORS.length - 1)];
|
|
216
|
+
const textColor = LEVEL_TEXT_COLORS[Math.min(node.level, LEVEL_TEXT_COLORS.length - 1)];
|
|
217
|
+
const radius = node.level === 0 ? NODE_RADIUS + 6 : NODE_RADIUS;
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<g
|
|
221
|
+
key={node.id}
|
|
222
|
+
className={cn('cursor-pointer transition-transform', onNodeClick && 'hover:opacity-80')}
|
|
223
|
+
onClick={() => onNodeClick?.(node.id)}
|
|
224
|
+
onMouseEnter={() => setHoveredNode(node.id)}
|
|
225
|
+
onMouseLeave={() => setHoveredNode(null)}
|
|
226
|
+
>
|
|
227
|
+
<circle
|
|
228
|
+
cx={node.x}
|
|
229
|
+
cy={node.y}
|
|
230
|
+
r={isHovered ? radius + 3 : radius}
|
|
231
|
+
className={levelColor}
|
|
232
|
+
fillOpacity={node.level === 0 ? 1 : 0.85}
|
|
233
|
+
strokeWidth={2}
|
|
234
|
+
strokeOpacity={0.3}
|
|
235
|
+
/>
|
|
236
|
+
<text
|
|
237
|
+
x={node.x}
|
|
238
|
+
y={node.y}
|
|
239
|
+
textAnchor="middle"
|
|
240
|
+
dominantBaseline="central"
|
|
241
|
+
className={cn('text-[10px] font-medium pointer-events-none', textColor)}
|
|
242
|
+
>
|
|
243
|
+
{truncateLabel(node.label)}
|
|
244
|
+
</text>
|
|
245
|
+
{/* Type label below */}
|
|
246
|
+
{node.type && (
|
|
247
|
+
<text
|
|
248
|
+
x={node.x}
|
|
249
|
+
y={node.y + radius + 12}
|
|
250
|
+
textAnchor="middle"
|
|
251
|
+
className="fill-muted-foreground text-[9px] pointer-events-none"
|
|
252
|
+
>
|
|
253
|
+
{node.type}
|
|
254
|
+
</text>
|
|
255
|
+
)}
|
|
256
|
+
{/* Tooltip on hover */}
|
|
257
|
+
{isHovered && (
|
|
258
|
+
<>
|
|
259
|
+
<rect
|
|
260
|
+
x={node.x - 50}
|
|
261
|
+
y={node.y - radius - 28}
|
|
262
|
+
width={100}
|
|
263
|
+
height={20}
|
|
264
|
+
rx={4}
|
|
265
|
+
className="fill-popover stroke-border"
|
|
266
|
+
strokeWidth={1}
|
|
267
|
+
/>
|
|
268
|
+
<text
|
|
269
|
+
x={node.x}
|
|
270
|
+
y={node.y - radius - 16}
|
|
271
|
+
textAnchor="middle"
|
|
272
|
+
dominantBaseline="central"
|
|
273
|
+
className="fill-popover-foreground text-[10px] pointer-events-none"
|
|
274
|
+
>
|
|
275
|
+
{node.label}
|
|
276
|
+
</text>
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
</g>
|
|
280
|
+
);
|
|
281
|
+
})}
|
|
282
|
+
</svg>
|
|
283
|
+
</CardContent>
|
|
284
|
+
</Card>
|
|
285
|
+
);
|
|
286
|
+
};
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { cn, Button } from '@object-ui/components';
|
|
11
|
+
import {
|
|
12
|
+
Bold,
|
|
13
|
+
Italic,
|
|
14
|
+
List,
|
|
15
|
+
Code,
|
|
16
|
+
AtSign,
|
|
17
|
+
Eye,
|
|
18
|
+
Edit,
|
|
19
|
+
Send,
|
|
20
|
+
} from 'lucide-react';
|
|
21
|
+
|
|
22
|
+
export interface MentionSuggestion {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
avatarUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RichTextCommentInputProps {
|
|
29
|
+
value: string;
|
|
30
|
+
onChange: (value: string) => void;
|
|
31
|
+
onSubmit?: () => void | Promise<void>;
|
|
32
|
+
mentionSuggestions?: MentionSuggestion[];
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
className?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render minimal markdown to HTML for preview. */
|
|
39
|
+
function renderMarkdown(text: string): string {
|
|
40
|
+
let html = text
|
|
41
|
+
// Escape HTML
|
|
42
|
+
.replace(/&/g, '&')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>')
|
|
45
|
+
// Code blocks (```)
|
|
46
|
+
.replace(/```([\s\S]*?)```/g, '<pre class="bg-muted rounded p-2 text-xs font-mono my-1 overflow-x-auto">$1</pre>')
|
|
47
|
+
// Inline code
|
|
48
|
+
.replace(/`([^`]+)`/g, '<code class="bg-muted rounded px-1 py-0.5 text-xs font-mono">$1</code>')
|
|
49
|
+
// Bold
|
|
50
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
51
|
+
// Italic
|
|
52
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
53
|
+
// @mentions
|
|
54
|
+
.replace(/@(\w+)/g, '<span class="text-primary font-medium">@$1</span>')
|
|
55
|
+
// Unordered lists
|
|
56
|
+
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
|
57
|
+
// Line breaks
|
|
58
|
+
.replace(/\n/g, '<br/>');
|
|
59
|
+
|
|
60
|
+
// Wrap consecutive <li> in <ul>
|
|
61
|
+
html = html.replace(
|
|
62
|
+
/(<li[^>]*>.*?<\/li>(?:<br\/>)?)+/g,
|
|
63
|
+
(match) => `<ul class="my-1">${match.replace(/<br\/>/g, '')}</ul>`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return html;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const RichTextCommentInput: React.FC<RichTextCommentInputProps> = ({
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
onSubmit,
|
|
73
|
+
mentionSuggestions = [],
|
|
74
|
+
placeholder = 'Write a comment…',
|
|
75
|
+
className,
|
|
76
|
+
disabled = false,
|
|
77
|
+
}) => {
|
|
78
|
+
const [isPreview, setIsPreview] = React.useState(false);
|
|
79
|
+
const [showMentions, setShowMentions] = React.useState(false);
|
|
80
|
+
const [mentionQuery, setMentionQuery] = React.useState('');
|
|
81
|
+
const [mentionIndex, setMentionIndex] = React.useState(0);
|
|
82
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
83
|
+
|
|
84
|
+
const filteredMentions = React.useMemo(() => {
|
|
85
|
+
if (!mentionQuery) return mentionSuggestions;
|
|
86
|
+
const query = mentionQuery.toLowerCase();
|
|
87
|
+
return mentionSuggestions.filter((s) =>
|
|
88
|
+
s.label.toLowerCase().includes(query),
|
|
89
|
+
);
|
|
90
|
+
}, [mentionQuery, mentionSuggestions]);
|
|
91
|
+
|
|
92
|
+
const insertAtCursor = React.useCallback(
|
|
93
|
+
(before: string, after: string = '') => {
|
|
94
|
+
const textarea = textareaRef.current;
|
|
95
|
+
if (!textarea) return;
|
|
96
|
+
const start = textarea.selectionStart;
|
|
97
|
+
const end = textarea.selectionEnd;
|
|
98
|
+
const selected = value.slice(start, end);
|
|
99
|
+
const newValue =
|
|
100
|
+
value.slice(0, start) + before + selected + after + value.slice(end);
|
|
101
|
+
onChange(newValue);
|
|
102
|
+
// Restore cursor after the insertion
|
|
103
|
+
requestAnimationFrame(() => {
|
|
104
|
+
textarea.focus();
|
|
105
|
+
const cursorPos = start + before.length + selected.length;
|
|
106
|
+
textarea.setSelectionRange(cursorPos, cursorPos);
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
[value, onChange],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const handleBold = React.useCallback(() => insertAtCursor('**', '**'), [insertAtCursor]);
|
|
113
|
+
const handleItalic = React.useCallback(() => insertAtCursor('*', '*'), [insertAtCursor]);
|
|
114
|
+
const handleList = React.useCallback(() => insertAtCursor('\n- ', ''), [insertAtCursor]);
|
|
115
|
+
const handleCode = React.useCallback(() => insertAtCursor('`', '`'), [insertAtCursor]);
|
|
116
|
+
|
|
117
|
+
const handleMentionTrigger = React.useCallback(() => {
|
|
118
|
+
insertAtCursor('@', '');
|
|
119
|
+
setShowMentions(true);
|
|
120
|
+
setMentionQuery('');
|
|
121
|
+
setMentionIndex(0);
|
|
122
|
+
}, [insertAtCursor]);
|
|
123
|
+
|
|
124
|
+
const handleSelectMention = React.useCallback(
|
|
125
|
+
(suggestion: MentionSuggestion) => {
|
|
126
|
+
const textarea = textareaRef.current;
|
|
127
|
+
if (!textarea) return;
|
|
128
|
+
const cursorPos = textarea.selectionStart;
|
|
129
|
+
// Find the last '@' before cursor to replace the partial query
|
|
130
|
+
const textBefore = value.slice(0, cursorPos);
|
|
131
|
+
const atIndex = textBefore.lastIndexOf('@');
|
|
132
|
+
if (atIndex !== -1) {
|
|
133
|
+
const newValue =
|
|
134
|
+
value.slice(0, atIndex) + `@${suggestion.label} ` + value.slice(cursorPos);
|
|
135
|
+
onChange(newValue);
|
|
136
|
+
}
|
|
137
|
+
setShowMentions(false);
|
|
138
|
+
setMentionQuery('');
|
|
139
|
+
requestAnimationFrame(() => textarea.focus());
|
|
140
|
+
},
|
|
141
|
+
[value, onChange],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const handleTextChange = React.useCallback(
|
|
145
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
146
|
+
const newValue = e.target.value;
|
|
147
|
+
onChange(newValue);
|
|
148
|
+
|
|
149
|
+
// Detect @mention trigger
|
|
150
|
+
const cursorPos = e.target.selectionStart;
|
|
151
|
+
const textBefore = newValue.slice(0, cursorPos);
|
|
152
|
+
const lastAtIndex = textBefore.lastIndexOf('@');
|
|
153
|
+
|
|
154
|
+
if (lastAtIndex !== -1) {
|
|
155
|
+
const textAfterAt = textBefore.slice(lastAtIndex + 1);
|
|
156
|
+
// Only show if @ is at start or preceded by whitespace and no space in query
|
|
157
|
+
const charBeforeAt = lastAtIndex > 0 ? textBefore[lastAtIndex - 1] : ' ';
|
|
158
|
+
if (/\s/.test(charBeforeAt) && !/\s/.test(textAfterAt)) {
|
|
159
|
+
setShowMentions(true);
|
|
160
|
+
setMentionQuery(textAfterAt);
|
|
161
|
+
setMentionIndex(0);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
setShowMentions(false);
|
|
166
|
+
},
|
|
167
|
+
[onChange],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const handleKeyDown = React.useCallback(
|
|
171
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
172
|
+
if (showMentions && filteredMentions.length > 0) {
|
|
173
|
+
if (e.key === 'ArrowDown') {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
setMentionIndex((prev) =>
|
|
176
|
+
prev < filteredMentions.length - 1 ? prev + 1 : 0,
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (e.key === 'ArrowUp') {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
setMentionIndex((prev) =>
|
|
183
|
+
prev > 0 ? prev - 1 : filteredMentions.length - 1,
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
handleSelectMention(filteredMentions[mentionIndex]);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (e.key === 'Escape') {
|
|
193
|
+
setShowMentions(false);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Ctrl+Enter to submit
|
|
199
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
onSubmit?.();
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[showMentions, filteredMentions, mentionIndex, handleSelectMention, onSubmit],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className={cn('border rounded-md', className)}>
|
|
209
|
+
{/* Toolbar */}
|
|
210
|
+
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b bg-muted/30">
|
|
211
|
+
<Button
|
|
212
|
+
variant="ghost"
|
|
213
|
+
size="icon"
|
|
214
|
+
className="h-7 w-7"
|
|
215
|
+
onClick={handleBold}
|
|
216
|
+
disabled={disabled || isPreview}
|
|
217
|
+
title="Bold (Ctrl+B)"
|
|
218
|
+
>
|
|
219
|
+
<Bold className="h-3.5 w-3.5" />
|
|
220
|
+
</Button>
|
|
221
|
+
<Button
|
|
222
|
+
variant="ghost"
|
|
223
|
+
size="icon"
|
|
224
|
+
className="h-7 w-7"
|
|
225
|
+
onClick={handleItalic}
|
|
226
|
+
disabled={disabled || isPreview}
|
|
227
|
+
title="Italic (Ctrl+I)"
|
|
228
|
+
>
|
|
229
|
+
<Italic className="h-3.5 w-3.5" />
|
|
230
|
+
</Button>
|
|
231
|
+
<Button
|
|
232
|
+
variant="ghost"
|
|
233
|
+
size="icon"
|
|
234
|
+
className="h-7 w-7"
|
|
235
|
+
onClick={handleList}
|
|
236
|
+
disabled={disabled || isPreview}
|
|
237
|
+
title="List"
|
|
238
|
+
>
|
|
239
|
+
<List className="h-3.5 w-3.5" />
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
variant="ghost"
|
|
243
|
+
size="icon"
|
|
244
|
+
className="h-7 w-7"
|
|
245
|
+
onClick={handleCode}
|
|
246
|
+
disabled={disabled || isPreview}
|
|
247
|
+
title="Inline code"
|
|
248
|
+
>
|
|
249
|
+
<Code className="h-3.5 w-3.5" />
|
|
250
|
+
</Button>
|
|
251
|
+
<Button
|
|
252
|
+
variant="ghost"
|
|
253
|
+
size="icon"
|
|
254
|
+
className="h-7 w-7"
|
|
255
|
+
onClick={handleMentionTrigger}
|
|
256
|
+
disabled={disabled || isPreview}
|
|
257
|
+
title="Mention someone"
|
|
258
|
+
>
|
|
259
|
+
<AtSign className="h-3.5 w-3.5" />
|
|
260
|
+
</Button>
|
|
261
|
+
|
|
262
|
+
<div className="flex-1" />
|
|
263
|
+
|
|
264
|
+
<Button
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="icon"
|
|
267
|
+
className="h-7 w-7"
|
|
268
|
+
onClick={() => setIsPreview(!isPreview)}
|
|
269
|
+
title={isPreview ? 'Edit' : 'Preview'}
|
|
270
|
+
>
|
|
271
|
+
{isPreview ? (
|
|
272
|
+
<Edit className="h-3.5 w-3.5" />
|
|
273
|
+
) : (
|
|
274
|
+
<Eye className="h-3.5 w-3.5" />
|
|
275
|
+
)}
|
|
276
|
+
</Button>
|
|
277
|
+
|
|
278
|
+
{onSubmit && (
|
|
279
|
+
<Button
|
|
280
|
+
variant="ghost"
|
|
281
|
+
size="icon"
|
|
282
|
+
className="h-7 w-7"
|
|
283
|
+
onClick={onSubmit}
|
|
284
|
+
disabled={disabled || !value.trim()}
|
|
285
|
+
title="Submit (Ctrl+Enter)"
|
|
286
|
+
>
|
|
287
|
+
<Send className="h-3.5 w-3.5" />
|
|
288
|
+
</Button>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* Editor / Preview */}
|
|
293
|
+
<div className="relative">
|
|
294
|
+
{isPreview ? (
|
|
295
|
+
<div
|
|
296
|
+
className="min-h-[80px] px-3 py-2 text-sm prose prose-sm max-w-none"
|
|
297
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(value) }}
|
|
298
|
+
/>
|
|
299
|
+
) : (
|
|
300
|
+
<>
|
|
301
|
+
<textarea
|
|
302
|
+
ref={textareaRef}
|
|
303
|
+
className="w-full min-h-[80px] px-3 py-2 text-sm bg-transparent resize-none focus:outline-none placeholder:text-muted-foreground"
|
|
304
|
+
placeholder={placeholder}
|
|
305
|
+
value={value}
|
|
306
|
+
onChange={handleTextChange}
|
|
307
|
+
onKeyDown={handleKeyDown}
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
/>
|
|
310
|
+
|
|
311
|
+
{/* @mention dropdown */}
|
|
312
|
+
{showMentions && filteredMentions.length > 0 && (
|
|
313
|
+
<div className="absolute left-2 bottom-full mb-1 w-56 bg-popover border rounded-md shadow-md z-50 max-h-40 overflow-y-auto">
|
|
314
|
+
{filteredMentions.map((suggestion, index) => (
|
|
315
|
+
<button
|
|
316
|
+
key={suggestion.id}
|
|
317
|
+
type="button"
|
|
318
|
+
className={cn(
|
|
319
|
+
'w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 hover:bg-accent transition-colors',
|
|
320
|
+
index === mentionIndex && 'bg-accent',
|
|
321
|
+
)}
|
|
322
|
+
onMouseDown={(e) => {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
handleSelectMention(suggestion);
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{suggestion.avatarUrl ? (
|
|
328
|
+
<img
|
|
329
|
+
src={suggestion.avatarUrl}
|
|
330
|
+
alt={suggestion.label}
|
|
331
|
+
className="h-5 w-5 rounded-full object-cover"
|
|
332
|
+
/>
|
|
333
|
+
) : (
|
|
334
|
+
<div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium">
|
|
335
|
+
{suggestion.label.charAt(0).toUpperCase()}
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
<span>{suggestion.label}</span>
|
|
339
|
+
</button>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { cn, Button } from '@object-ui/components';
|
|
11
|
+
import { Bell, BellOff } from 'lucide-react';
|
|
12
|
+
import type { RecordSubscription } from '@object-ui/types';
|
|
13
|
+
|
|
14
|
+
export interface SubscriptionToggleProps {
|
|
15
|
+
/** Current subscription state */
|
|
16
|
+
subscription: RecordSubscription;
|
|
17
|
+
/** Called when user toggles subscription */
|
|
18
|
+
onToggle?: (subscribed: boolean) => void | Promise<void>;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* SubscriptionToggle — Bell icon toggle for record notification subscriptions.
|
|
24
|
+
* Aligned with @objectstack/spec RecordSubscriptionSchema.
|
|
25
|
+
*/
|
|
26
|
+
export const SubscriptionToggle: React.FC<SubscriptionToggleProps> = ({
|
|
27
|
+
subscription,
|
|
28
|
+
onToggle,
|
|
29
|
+
className,
|
|
30
|
+
}) => {
|
|
31
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
32
|
+
|
|
33
|
+
const handleToggle = React.useCallback(async () => {
|
|
34
|
+
if (!onToggle) return;
|
|
35
|
+
setIsLoading(true);
|
|
36
|
+
try {
|
|
37
|
+
await onToggle(!subscription.subscribed);
|
|
38
|
+
} finally {
|
|
39
|
+
setIsLoading(false);
|
|
40
|
+
}
|
|
41
|
+
}, [onToggle, subscription.subscribed]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Button
|
|
45
|
+
variant="ghost"
|
|
46
|
+
size="icon"
|
|
47
|
+
className={cn('h-8 w-8', className)}
|
|
48
|
+
onClick={handleToggle}
|
|
49
|
+
disabled={isLoading || !onToggle}
|
|
50
|
+
aria-label={subscription.subscribed ? 'Unsubscribe from notifications' : 'Subscribe to notifications'}
|
|
51
|
+
title={subscription.subscribed ? 'Subscribed — click to unsubscribe' : 'Subscribe to notifications'}
|
|
52
|
+
>
|
|
53
|
+
{subscription.subscribed ? (
|
|
54
|
+
<Bell className="h-4 w-4 text-primary" />
|
|
55
|
+
) : (
|
|
56
|
+
<BellOff className="h-4 w-4 text-muted-foreground" />
|
|
57
|
+
)}
|
|
58
|
+
</Button>
|
|
59
|
+
);
|
|
60
|
+
};
|