@qoretechnologies/reqraft 0.10.20 → 0.10.26
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/.claude/CLAUDE.md +11 -8
- package/dist/components/composer/Composer.d.ts +108 -0
- package/dist/components/composer/Composer.d.ts.map +1 -0
- package/dist/components/composer/Composer.js +238 -0
- package/dist/components/composer/Composer.js.map +1 -0
- package/dist/components/composer/helpers.d.ts +21 -0
- package/dist/components/composer/helpers.d.ts.map +1 -0
- package/dist/components/composer/helpers.js +75 -0
- package/dist/components/composer/helpers.js.map +1 -0
- package/dist/components/composer/index.d.ts +3 -0
- package/dist/components/composer/index.d.ts.map +1 -0
- package/dist/components/composer/index.js +19 -0
- package/dist/components/composer/index.js.map +1 -0
- package/dist/components/form/engine/FormEngine.d.ts +1 -1
- package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
- package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
- package/dist/components/imageLightbox/ImageLightbox.js +169 -0
- package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
- package/dist/components/imageLightbox/index.d.ts +2 -0
- package/dist/components/imageLightbox/index.d.ts.map +1 -0
- package/dist/components/imageLightbox/index.js +18 -0
- package/dist/components/imageLightbox/index.js.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
- package/dist/components/searchFilterBar/index.d.ts +2 -0
- package/dist/components/searchFilterBar/index.d.ts.map +1 -0
- package/dist/components/searchFilterBar/index.js +18 -0
- package/dist/components/searchFilterBar/index.js.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.js +122 -0
- package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
- package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
- package/dist/components/supportTicket/ReferencePicker.js +295 -0
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
- package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
- package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketHeader.js +149 -0
- package/dist/components/supportTicket/TicketHeader.js.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.js +21 -0
- package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.js +115 -0
- package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.js +132 -0
- package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
- package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
- package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
- package/dist/components/supportTicket/fileToAttachment.js +28 -0
- package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
- package/dist/components/supportTicket/index.d.ts +11 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -0
- package/dist/components/supportTicket/index.js +27 -0
- package/dist/components/supportTicket/index.js.map +1 -0
- package/dist/components/supportTicket/meta.d.ts +71 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -0
- package/dist/components/supportTicket/meta.js +115 -0
- package/dist/components/supportTicket/meta.js.map +1 -0
- package/dist/components/supportTicket/ticketModel.d.ts +102 -0
- package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
- package/dist/components/supportTicket/ticketModel.js +3 -0
- package/dist/components/supportTicket/ticketModel.js.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.js +492 -0
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
- package/dist/components/ticketReferences/index.d.ts +3 -0
- package/dist/components/ticketReferences/index.d.ts.map +1 -0
- package/dist/components/ticketReferences/index.js +6 -0
- package/dist/components/ticketReferences/index.js.map +1 -0
- package/dist/components/ticketThread/TicketThread.d.ts +57 -0
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
- package/dist/components/ticketThread/TicketThread.js +299 -0
- package/dist/components/ticketThread/TicketThread.js.map +1 -0
- package/dist/components/ticketThread/index.d.ts +3 -0
- package/dist/components/ticketThread/index.d.ts.map +1 -0
- package/dist/components/ticketThread/index.js +7 -0
- package/dist/components/ticketThread/index.js.map +1 -0
- package/dist/helpers/common.d.ts +5 -0
- package/dist/helpers/common.d.ts.map +1 -1
- package/dist/helpers/common.js +20 -1
- package/dist/helpers/common.js.map +1 -1
- package/dist/helpers/scrollbar.d.ts +2 -0
- package/dist/helpers/scrollbar.d.ts.map +1 -0
- package/dist/helpers/scrollbar.js +25 -0
- package/dist/helpers/scrollbar.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/composer/Composer.stories.tsx +238 -0
- package/src/components/composer/Composer.tsx +545 -0
- package/src/components/composer/helpers.ts +74 -0
- package/src/components/composer/index.ts +2 -0
- package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
- package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
- package/src/components/imageLightbox/index.ts +1 -0
- package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
- package/src/components/searchFilterBar/index.ts +1 -0
- package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
- package/src/components/supportTicket/AttachmentChips.tsx +147 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
- package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
- package/src/components/supportTicket/ReferencePicker.tsx +563 -0
- package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
- package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
- package/src/components/supportTicket/TicketHeader.tsx +247 -0
- package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
- package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
- package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
- package/src/components/supportTicket/attachmentAccess.ts +97 -0
- package/src/components/supportTicket/fileToAttachment.ts +24 -0
- package/src/components/supportTicket/index.ts +10 -0
- package/src/components/supportTicket/meta.ts +185 -0
- package/src/components/supportTicket/ticketModel.ts +131 -0
- package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
- package/src/components/ticketReferences/TicketReferences.tsx +886 -0
- package/src/components/ticketReferences/index.ts +2 -0
- package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
- package/src/components/ticketThread/TicketThread.tsx +704 -0
- package/src/components/ticketThread/index.ts +6 -0
- package/src/helpers/common.ts +19 -0
- package/src/helpers/scrollbar.ts +32 -0
- package/src/index.tsx +8 -1
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// Copyright 2026 Qore Technologies, s.r.o.
|
|
2
|
+
// <Composer> — the shared Qorus message composer, a faithful lift of qorus-ide's
|
|
3
|
+
// Qonsole chat input: the same gradient-bordered `StyledEffect` bar, transparent
|
|
4
|
+
// editor, attachment preview strip, and control row (attach + toolbar on the left,
|
|
5
|
+
// actions + send on the right). Everything Qonsole-specific (slash completions,
|
|
6
|
+
// command history, design-mode, sendCommand) is replaced by slots + props, so the
|
|
7
|
+
// exact same input can back the Qonsole, the helpdesk reply box, and anything else.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ReqoreButton,
|
|
11
|
+
ReqoreControlGroup,
|
|
12
|
+
ReqoreDropdown,
|
|
13
|
+
ReqoreIcon,
|
|
14
|
+
ReqoreMessage,
|
|
15
|
+
ReqoreRichTextEditor,
|
|
16
|
+
ReqoreSpan,
|
|
17
|
+
ReqoreTextarea,
|
|
18
|
+
useReqoreTheme,
|
|
19
|
+
} from '@qoretechnologies/reqore';
|
|
20
|
+
import { StyledEffect } from '@qoretechnologies/reqore/dist/components/Effect';
|
|
21
|
+
import type { IReqoreRichTextEditorProps } from '@qoretechnologies/reqore/dist/components/RichTextEditor';
|
|
22
|
+
import type { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
23
|
+
import {
|
|
24
|
+
ChangeEvent as ReactChangeEvent,
|
|
25
|
+
ClipboardEvent as ReactClipboardEvent,
|
|
26
|
+
ComponentProps,
|
|
27
|
+
forwardRef,
|
|
28
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
29
|
+
ReactNode,
|
|
30
|
+
useEffect,
|
|
31
|
+
useImperativeHandle,
|
|
32
|
+
useRef,
|
|
33
|
+
useState,
|
|
34
|
+
} from 'react';
|
|
35
|
+
import styled from 'styled-components';
|
|
36
|
+
import {
|
|
37
|
+
clipboardImages,
|
|
38
|
+
EMPTY_RICH_VALUE,
|
|
39
|
+
isPlainSubmitEnter,
|
|
40
|
+
richValueFromString,
|
|
41
|
+
richValueToString,
|
|
42
|
+
} from './helpers';
|
|
43
|
+
|
|
44
|
+
/** Reqore's intent / effect / theme unions, without guessing import paths. */
|
|
45
|
+
type TIntent = ComponentProps<typeof ReqoreButton>['intent'];
|
|
46
|
+
type TEffect = ComponentProps<typeof StyledEffect>['effect'];
|
|
47
|
+
type TCustomTheme = ComponentProps<typeof ReqoreButton>['customTheme'];
|
|
48
|
+
/** The exact `items` shape ReqoreDropdown accepts. */
|
|
49
|
+
type TDropdownItems = NonNullable<ComponentProps<typeof ReqoreDropdown>['items']>;
|
|
50
|
+
|
|
51
|
+
/** Which editor the composer renders. */
|
|
52
|
+
export type TComposerEditor = 'rich' | 'plain';
|
|
53
|
+
|
|
54
|
+
/** One send action. With more than one, the send button splits into a primary
|
|
55
|
+
* button (the first action) + a caret dropdown listing them all. `id` is echoed
|
|
56
|
+
* back to `onSend` so the caller knows which action fired. */
|
|
57
|
+
export interface IComposerSendAction {
|
|
58
|
+
id?: string;
|
|
59
|
+
/** button label; omit for an icon-only button (like the Qonsole send). */
|
|
60
|
+
label?: string;
|
|
61
|
+
icon?: IReqoreIconName;
|
|
62
|
+
intent?: TIntent;
|
|
63
|
+
/** per-button Reqore theme (e.g. `{ main: 'custom1' }` for the Qonsole accent). */
|
|
64
|
+
customTheme?: TCustomTheme;
|
|
65
|
+
/** render the button minimal (subtle, blends into the bar — the Qonsole style). */
|
|
66
|
+
minimal?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Imperative handle for consumers that drive the editor externally (e.g. the
|
|
70
|
+
* Qonsole input inserting a completion or navigating history). */
|
|
71
|
+
export interface IComposerHandle {
|
|
72
|
+
focus: () => void;
|
|
73
|
+
clear: () => void;
|
|
74
|
+
setText: (text: string) => void;
|
|
75
|
+
getText: () => string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IComposerProps {
|
|
79
|
+
/** Editor flavour. `rich` = ReqoreRichTextEditor (default), `plain` = ReqoreTextarea. */
|
|
80
|
+
editor?: TComposerEditor;
|
|
81
|
+
/** Initial draft text (also seeds the editor for a restored draft / a story). */
|
|
82
|
+
defaultText?: string;
|
|
83
|
+
/** Initial staged files (a restored draft's attachments, or a demo). */
|
|
84
|
+
defaultFiles?: File[];
|
|
85
|
+
placeholder?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Submit the composed message. `text` is the plain-string body; `files` are the
|
|
88
|
+
* staged attachments (raw `File`s — the caller converts to its own upload shape);
|
|
89
|
+
* `action` is the chosen send action's `id` (undefined for the default button).
|
|
90
|
+
*/
|
|
91
|
+
onSend: (text: string, options: { files: File[]; action?: string }) => void | Promise<void>;
|
|
92
|
+
/** Single-button label / icon / intent (ignored when `sendActions` is set). */
|
|
93
|
+
sendLabel?: string;
|
|
94
|
+
sendIcon?: IReqoreIconName;
|
|
95
|
+
sendIntent?: TIntent;
|
|
96
|
+
/** Default send button: minimal (subtle, Qonsole-style). Default `true`. */
|
|
97
|
+
sendMinimal?: boolean;
|
|
98
|
+
/** Default send button accent (Reqore `customTheme`). Default `{ main: 'custom1' }`
|
|
99
|
+
* — the Qorus accent, which each app's theme colours (gray in a bare reqore theme). */
|
|
100
|
+
sendCustomTheme?: TCustomTheme;
|
|
101
|
+
/** Multiple send actions → a split send button. The first is the primary. */
|
|
102
|
+
sendActions?: IComposerSendAction[];
|
|
103
|
+
/** className on the split-send caret dropdown (keeps consumer test hooks, e.g.
|
|
104
|
+
* the helpdesk `ticket-send-more`). */
|
|
105
|
+
sendMenuClassName?: string;
|
|
106
|
+
/** the send round-trip is in flight */
|
|
107
|
+
sending?: boolean;
|
|
108
|
+
/** disabled → renders a muted notice with `disabledReason` instead of the composer */
|
|
109
|
+
disabled?: boolean;
|
|
110
|
+
disabledReason?: string;
|
|
111
|
+
/** submit on plain Enter (Shift/Ctrl/Alt/Meta+Enter insert a newline). Default true. */
|
|
112
|
+
submitOnEnter?: boolean;
|
|
113
|
+
/** clear the editor + staged files after a resolved send. Default true. */
|
|
114
|
+
clearOnSend?: boolean;
|
|
115
|
+
/** allow sending with empty text (e.g. attachments only). Default false. */
|
|
116
|
+
allowEmptySend?: boolean;
|
|
117
|
+
/** Extra attach-menu entries beyond "Upload files" (receives `addFiles`). With any
|
|
118
|
+
* entry the attach control becomes a dropdown; otherwise a plain button. */
|
|
119
|
+
attachMenuItems?: (addFiles: (files: File[]) => void) => TDropdownItems;
|
|
120
|
+
/** the attach control icon (Qonsole uses `AddLine`). */
|
|
121
|
+
attachIcon?: IReqoreIconName;
|
|
122
|
+
/** hide the attach control entirely. */
|
|
123
|
+
hideAttach?: boolean;
|
|
124
|
+
/** Slots: `toolbarStart` sits after attach on the left (e.g. the Qonsole slash
|
|
125
|
+
* dropdown), `footerActions` after it (e.g. a reference toggle), `rightActions`
|
|
126
|
+
* left of send (e.g. the Qonsole design button), `aboveInput` on top of the
|
|
127
|
+
* editor inside the bar (e.g. reference chips), and `belowInput` under the bar. */
|
|
128
|
+
toolbarStart?: ReactNode;
|
|
129
|
+
footerActions?: ReactNode;
|
|
130
|
+
rightActions?: ReactNode;
|
|
131
|
+
aboveInput?: ReactNode;
|
|
132
|
+
belowInput?: ReactNode;
|
|
133
|
+
/** Hooks for consumers layering behaviour on the editor (completions, history). */
|
|
134
|
+
onChange?: (text: string) => void;
|
|
135
|
+
onEditorKeyDown?: (e: ReactKeyboardEvent) => void;
|
|
136
|
+
onFocus?: () => void;
|
|
137
|
+
onBlur?: () => void;
|
|
138
|
+
/** editor sizing */
|
|
139
|
+
size?: ComponentProps<typeof ReqoreTextarea>['size'];
|
|
140
|
+
/** drop the composer's outer margin (for tight embeds). */
|
|
141
|
+
minimal?: boolean;
|
|
142
|
+
/** override the bar's gradient / effect (qorus-ide passes its `custom1` accent to
|
|
143
|
+
* match the Qonsole floating bar exactly). */
|
|
144
|
+
effect?: TEffect;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The Qonsole input chrome, lifted verbatim from qorus-ide's StyledInputWrapper.
|
|
148
|
+
// The border is 1px *transparent* — it only reserves the border-box so the
|
|
149
|
+
// `effect` gradient can paint the edge (that's how the Qonsole bar's accent glow
|
|
150
|
+
// is drawn — a gradient border, not a border-color). All colour comes from
|
|
151
|
+
// `effect` (+ the ambient theme), so the bar takes on whatever accent the app /
|
|
152
|
+
// story supplies.
|
|
153
|
+
const StyledComposer = styled(StyledEffect)<{ $minimal?: boolean }>`
|
|
154
|
+
margin: ${({ $minimal }) => ($minimal ? '0' : '2px')};
|
|
155
|
+
padding: 4px;
|
|
156
|
+
border: 1px solid transparent;
|
|
157
|
+
border-radius: 6px;
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
// Layout-neutral ref holder: `display: contents` generates no box, so it gives the
|
|
161
|
+
// composer a wrapper element to target paste/drop against without perturbing flow.
|
|
162
|
+
const StyledContentsWrapper = styled.div`
|
|
163
|
+
display: contents;
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const StyledAttachmentList = styled.div`
|
|
167
|
+
display: flex;
|
|
168
|
+
flex-wrap: wrap;
|
|
169
|
+
gap: 8px;
|
|
170
|
+
// 8px left inset (as in the IDE) so it lines up with the editor text; the 10px
|
|
171
|
+
// bottom gives space before whatever follows (references / editor).
|
|
172
|
+
padding: 8px 8px 10px;
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
// Content shown on top of the editor (e.g. reference chips) gets the same 8px left
|
|
176
|
+
// inset as the attachment strip so both line up with the editor text, not the bar edge.
|
|
177
|
+
const StyledAboveInput = styled.div`
|
|
178
|
+
padding-left: 8px;
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
const StyledAttachment = styled.div`
|
|
182
|
+
position: relative;
|
|
183
|
+
display: inline-block;
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
const StyledAttachmentImage = styled.img`
|
|
187
|
+
display: block;
|
|
188
|
+
max-height: 80px;
|
|
189
|
+
border-radius: 4px;
|
|
190
|
+
border: 1px solid rgba(123, 104, 238, 0.3);
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
const StyledAttachmentFile = styled.div`
|
|
194
|
+
display: flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
gap: 6px;
|
|
197
|
+
height: 40px;
|
|
198
|
+
padding: 0 12px;
|
|
199
|
+
border-radius: 4px;
|
|
200
|
+
border: 1px solid rgba(123, 104, 238, 0.3);
|
|
201
|
+
background: rgba(123, 104, 238, 0.08);
|
|
202
|
+
max-width: 220px;
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
const StyledAttachmentFilename = styled(ReqoreSpan)`
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
text-overflow: ellipsis;
|
|
208
|
+
white-space: nowrap;
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
const StyledAttachmentRemoveWrapper = styled.div`
|
|
212
|
+
position: absolute;
|
|
213
|
+
top: -8px;
|
|
214
|
+
right: -8px;
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
/** The Qonsole bar effect — a `custom1` gradient border over a transparent fill,
|
|
218
|
+
* exactly like qorus-ide's QonsoleInput. `custom1` resolves per app theme (Qorus
|
|
219
|
+
* purple in qorus-ide / admin-portal, gray in a bare reqore theme). Override via
|
|
220
|
+
* the `effect` prop. */
|
|
221
|
+
const DEFAULT_EFFECT: TEffect = {
|
|
222
|
+
gradient: { direction: 'to bottom right', colors: 'custom1:darken:1:0.9' },
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const Composer = forwardRef<IComposerHandle, IComposerProps>(function Composer(
|
|
226
|
+
{
|
|
227
|
+
editor = 'rich',
|
|
228
|
+
defaultText,
|
|
229
|
+
defaultFiles,
|
|
230
|
+
placeholder = 'Write a message…',
|
|
231
|
+
onSend,
|
|
232
|
+
sendLabel = 'Send',
|
|
233
|
+
sendIcon = 'SendPlane2Line',
|
|
234
|
+
sendIntent,
|
|
235
|
+
sendMinimal = true,
|
|
236
|
+
sendCustomTheme = { main: 'custom1' },
|
|
237
|
+
sendActions,
|
|
238
|
+
sendMenuClassName,
|
|
239
|
+
sending,
|
|
240
|
+
disabled,
|
|
241
|
+
disabledReason = '',
|
|
242
|
+
submitOnEnter = true,
|
|
243
|
+
clearOnSend = true,
|
|
244
|
+
allowEmptySend = false,
|
|
245
|
+
attachMenuItems,
|
|
246
|
+
attachIcon = 'AddLine',
|
|
247
|
+
hideAttach,
|
|
248
|
+
toolbarStart,
|
|
249
|
+
footerActions,
|
|
250
|
+
rightActions,
|
|
251
|
+
aboveInput,
|
|
252
|
+
belowInput,
|
|
253
|
+
onChange,
|
|
254
|
+
onEditorKeyDown,
|
|
255
|
+
onFocus,
|
|
256
|
+
onBlur,
|
|
257
|
+
size = 'small',
|
|
258
|
+
minimal,
|
|
259
|
+
effect = DEFAULT_EFFECT,
|
|
260
|
+
},
|
|
261
|
+
ref
|
|
262
|
+
) {
|
|
263
|
+
const theme = useReqoreTheme();
|
|
264
|
+
const [richValue, setRichValue] = useState<IReqoreRichTextEditorProps['value']>(() =>
|
|
265
|
+
defaultText ? richValueFromString(defaultText) : EMPTY_RICH_VALUE
|
|
266
|
+
);
|
|
267
|
+
const [plainValue, setPlainValue] = useState(defaultText ?? '');
|
|
268
|
+
const [files, setFiles] = useState<File[]>(() => defaultFiles ?? []);
|
|
269
|
+
const [previews, setPreviews] = useState<string[]>([]);
|
|
270
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
271
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
272
|
+
|
|
273
|
+
// Image thumbnails for the preview strip; object URLs revoked on change/unmount.
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
const urls = files.map((f) => (f.type.startsWith('image/') ? URL.createObjectURL(f) : ''));
|
|
276
|
+
setPreviews(urls);
|
|
277
|
+
return () => urls.forEach((u) => u && URL.revokeObjectURL(u));
|
|
278
|
+
}, [files]);
|
|
279
|
+
|
|
280
|
+
const getText = (): string =>
|
|
281
|
+
editor === 'rich' ? richValueToString(richValue) : plainValue;
|
|
282
|
+
const setText = (text: string): void => {
|
|
283
|
+
if (editor === 'rich') {
|
|
284
|
+
setRichValue(richValueFromString(text));
|
|
285
|
+
} else {
|
|
286
|
+
setPlainValue(text);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
useImperativeHandle(
|
|
291
|
+
ref,
|
|
292
|
+
(): IComposerHandle => ({
|
|
293
|
+
focus: () =>
|
|
294
|
+
wrapperRef.current
|
|
295
|
+
?.querySelector<HTMLElement>('[contenteditable="true"], textarea')
|
|
296
|
+
?.focus(),
|
|
297
|
+
clear: () => setText(''),
|
|
298
|
+
setText,
|
|
299
|
+
getText,
|
|
300
|
+
}),
|
|
301
|
+
[editor, richValue, plainValue]
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// Every send action inherits the composer's send style (the Qonsole look by
|
|
305
|
+
// default) so a split-send stays visually of a piece with the single button —
|
|
306
|
+
// unless the action picks its own. The accent is only defaulted when the action
|
|
307
|
+
// hasn't chosen an `intent`, since in Reqore an intent owns the colour and would
|
|
308
|
+
// otherwise fight the `customTheme`.
|
|
309
|
+
const withSendStyle = (action: IComposerSendAction): IComposerSendAction => ({
|
|
310
|
+
minimal: sendMinimal,
|
|
311
|
+
...(action.intent ? undefined : { customTheme: sendCustomTheme }),
|
|
312
|
+
...action,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const actions: IComposerSendAction[] = sendActions?.length
|
|
316
|
+
? sendActions.map(withSendStyle)
|
|
317
|
+
: [withSendStyle({ id: undefined, label: sendLabel, icon: sendIcon, intent: sendIntent })];
|
|
318
|
+
const primary = actions[0];
|
|
319
|
+
const isSplit = actions.length > 1;
|
|
320
|
+
|
|
321
|
+
const addFiles = (incoming: File[]): void => {
|
|
322
|
+
if (incoming.length) {
|
|
323
|
+
setFiles((prev) => [...prev, ...incoming]);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const removeFile = (index: number): void =>
|
|
327
|
+
setFiles((prev) => prev.filter((_, j) => j !== index));
|
|
328
|
+
|
|
329
|
+
const handlePaste = (event: ReactClipboardEvent): void => {
|
|
330
|
+
const images = clipboardImages(event);
|
|
331
|
+
if (images.length) {
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
addFiles(images);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const text = getText();
|
|
338
|
+
const canSend = (allowEmptySend || !!text.trim() || files.length > 0) && !sending;
|
|
339
|
+
|
|
340
|
+
const handleSend = async (actionId?: string): Promise<void> => {
|
|
341
|
+
const body = getText();
|
|
342
|
+
if (!canSend) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
await onSend(clearOnSend ? body.trim() : body, { files, action: actionId });
|
|
346
|
+
if (clearOnSend) {
|
|
347
|
+
setText('');
|
|
348
|
+
setFiles([]);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const handleKeyDown = (e: ReactKeyboardEvent): void => {
|
|
353
|
+
onEditorKeyDown?.(e);
|
|
354
|
+
if (submitOnEnter && isPlainSubmitEnter(e)) {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
void handleSend(primary.id);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (disabled) {
|
|
361
|
+
return (
|
|
362
|
+
<ReqoreMessage intent='muted' icon='LockLine' size='small' flat>
|
|
363
|
+
{disabledReason}
|
|
364
|
+
</ReqoreMessage>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const extraAttachItems = attachMenuItems ? attachMenuItems(addFiles) : [];
|
|
369
|
+
|
|
370
|
+
// Icon-only when `label` is omitted; `customTheme`/`minimal` let a caller match
|
|
371
|
+
// the Qonsole accent send (minimal, custom1-themed) or any other look.
|
|
372
|
+
const sendButton = (action: IComposerSendAction) => (
|
|
373
|
+
<ReqoreButton
|
|
374
|
+
icon={action.icon}
|
|
375
|
+
intent={action.intent}
|
|
376
|
+
customTheme={action.customTheme}
|
|
377
|
+
minimal={action.minimal}
|
|
378
|
+
size={size}
|
|
379
|
+
disabled={!canSend}
|
|
380
|
+
loading={sending}
|
|
381
|
+
flat
|
|
382
|
+
onClick={() => handleSend(action.id)}
|
|
383
|
+
fixed={!isSplit}
|
|
384
|
+
>
|
|
385
|
+
{action.label}
|
|
386
|
+
</ReqoreButton>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<StyledContentsWrapper ref={wrapperRef}>
|
|
391
|
+
<StyledComposer
|
|
392
|
+
as='div'
|
|
393
|
+
theme={theme}
|
|
394
|
+
$minimal={minimal}
|
|
395
|
+
// transparent fill (like QonsoleInput) → the page shows through and only
|
|
396
|
+
// the effect's gradient *border* glows; `minimal` opts into a solid fill.
|
|
397
|
+
transparent={!minimal}
|
|
398
|
+
effect={effect}
|
|
399
|
+
onPaste={handlePaste}
|
|
400
|
+
>
|
|
401
|
+
<input
|
|
402
|
+
ref={fileInput}
|
|
403
|
+
type='file'
|
|
404
|
+
multiple
|
|
405
|
+
style={{ display: 'none' }}
|
|
406
|
+
onChange={(e) => {
|
|
407
|
+
if (e.target.files) {
|
|
408
|
+
addFiles(Array.from(e.target.files));
|
|
409
|
+
}
|
|
410
|
+
e.target.value = '';
|
|
411
|
+
}}
|
|
412
|
+
/>
|
|
413
|
+
{files.length > 0 && (
|
|
414
|
+
<StyledAttachmentList>
|
|
415
|
+
{files.map((file, index) => (
|
|
416
|
+
<StyledAttachment key={`${file.name}-${index}`}>
|
|
417
|
+
{previews[index] ? (
|
|
418
|
+
<StyledAttachmentImage src={previews[index]} alt={file.name} />
|
|
419
|
+
) : (
|
|
420
|
+
<StyledAttachmentFile title={file.name}>
|
|
421
|
+
<ReqoreIcon icon='FileLine' size='small' />
|
|
422
|
+
<StyledAttachmentFilename size='small'>{file.name}</StyledAttachmentFilename>
|
|
423
|
+
</StyledAttachmentFile>
|
|
424
|
+
)}
|
|
425
|
+
<StyledAttachmentRemoveWrapper>
|
|
426
|
+
<ReqoreButton
|
|
427
|
+
icon='CloseLine'
|
|
428
|
+
size='micro'
|
|
429
|
+
leftIconProps={{ size: 'tiny' }}
|
|
430
|
+
circle
|
|
431
|
+
onClick={() => removeFile(index)}
|
|
432
|
+
tooltip='Remove attachment'
|
|
433
|
+
/>
|
|
434
|
+
</StyledAttachmentRemoveWrapper>
|
|
435
|
+
</StyledAttachment>
|
|
436
|
+
))}
|
|
437
|
+
</StyledAttachmentList>
|
|
438
|
+
)}
|
|
439
|
+
<ReqoreControlGroup vertical fluid>
|
|
440
|
+
{aboveInput && <StyledAboveInput>{aboveInput}</StyledAboveInput>}
|
|
441
|
+
<ReqoreControlGroup fluid verticalAlign='center' size='small'>
|
|
442
|
+
{editor === 'rich' ? (
|
|
443
|
+
<ReqoreRichTextEditor
|
|
444
|
+
value={richValue}
|
|
445
|
+
panelProps={{ fluid: true }}
|
|
446
|
+
onChange={(value) => {
|
|
447
|
+
setRichValue(value);
|
|
448
|
+
onChange?.(richValueToString(value));
|
|
449
|
+
}}
|
|
450
|
+
onKeyDown={handleKeyDown}
|
|
451
|
+
onFocus={onFocus}
|
|
452
|
+
onBlur={onBlur}
|
|
453
|
+
placeholder={placeholder}
|
|
454
|
+
disabled={sending}
|
|
455
|
+
transparent
|
|
456
|
+
flat
|
|
457
|
+
fluid
|
|
458
|
+
size={size}
|
|
459
|
+
actions={{ styling: false, undo: false, redo: false }}
|
|
460
|
+
onClearClick={text ? () => setText('') : undefined}
|
|
461
|
+
/>
|
|
462
|
+
) : (
|
|
463
|
+
<ReqoreTextarea
|
|
464
|
+
value={plainValue}
|
|
465
|
+
scaleWithContent
|
|
466
|
+
fluid
|
|
467
|
+
transparent
|
|
468
|
+
flat
|
|
469
|
+
size={size}
|
|
470
|
+
placeholder={placeholder}
|
|
471
|
+
disabled={sending}
|
|
472
|
+
onChange={(e: ReactChangeEvent<HTMLTextAreaElement>) => {
|
|
473
|
+
setPlainValue(e.target.value);
|
|
474
|
+
onChange?.(e.target.value);
|
|
475
|
+
}}
|
|
476
|
+
onKeyDown={handleKeyDown}
|
|
477
|
+
onFocus={onFocus}
|
|
478
|
+
onBlur={onBlur}
|
|
479
|
+
/>
|
|
480
|
+
)}
|
|
481
|
+
</ReqoreControlGroup>
|
|
482
|
+
<ReqoreControlGroup fluid size='small' spaceBetween verticalAlign='center'>
|
|
483
|
+
<ReqoreControlGroup stack fluid={false} size='small' verticalAlign='center'>
|
|
484
|
+
{!hideAttach &&
|
|
485
|
+
(extraAttachItems.length ? (
|
|
486
|
+
<ReqoreDropdown
|
|
487
|
+
icon={attachIcon}
|
|
488
|
+
flat
|
|
489
|
+
transparent
|
|
490
|
+
disabled={sending}
|
|
491
|
+
tooltip='Attach'
|
|
492
|
+
items={[
|
|
493
|
+
{
|
|
494
|
+
label: 'Upload files',
|
|
495
|
+
icon: 'Upload2Line',
|
|
496
|
+
onClick: () => fileInput.current?.click(),
|
|
497
|
+
},
|
|
498
|
+
...extraAttachItems,
|
|
499
|
+
]}
|
|
500
|
+
/>
|
|
501
|
+
) : (
|
|
502
|
+
<ReqoreButton
|
|
503
|
+
icon={attachIcon}
|
|
504
|
+
flat
|
|
505
|
+
transparent
|
|
506
|
+
disabled={sending}
|
|
507
|
+
tooltip='Attach files'
|
|
508
|
+
onClick={() => fileInput.current?.click()}
|
|
509
|
+
/>
|
|
510
|
+
))}
|
|
511
|
+
{toolbarStart}
|
|
512
|
+
{footerActions}
|
|
513
|
+
</ReqoreControlGroup>
|
|
514
|
+
<ReqoreControlGroup stack fluid={false} size='small'>
|
|
515
|
+
{rightActions}
|
|
516
|
+
{isSplit ? (
|
|
517
|
+
<>
|
|
518
|
+
{sendButton(primary)}
|
|
519
|
+
<ReqoreDropdown
|
|
520
|
+
className={sendMenuClassName}
|
|
521
|
+
icon='ArrowDownSLine'
|
|
522
|
+
intent={primary.intent}
|
|
523
|
+
customTheme={primary.customTheme}
|
|
524
|
+
minimal={primary.minimal}
|
|
525
|
+
disabled={!canSend}
|
|
526
|
+
tooltip='More send options'
|
|
527
|
+
items={actions.map((action) => ({
|
|
528
|
+
label: action.label,
|
|
529
|
+
icon: action.icon,
|
|
530
|
+
intent: action.intent,
|
|
531
|
+
onClick: () => handleSend(action.id),
|
|
532
|
+
}))}
|
|
533
|
+
/>
|
|
534
|
+
</>
|
|
535
|
+
) : (
|
|
536
|
+
sendButton(primary)
|
|
537
|
+
)}
|
|
538
|
+
</ReqoreControlGroup>
|
|
539
|
+
</ReqoreControlGroup>
|
|
540
|
+
</ReqoreControlGroup>
|
|
541
|
+
</StyledComposer>
|
|
542
|
+
{belowInput}
|
|
543
|
+
</StyledContentsWrapper>
|
|
544
|
+
);
|
|
545
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Copyright 2026 Qore Technologies, s.r.o.
|
|
2
|
+
// Pure helpers for the shared <Composer>. No DOM/app coupling — safe on any surface.
|
|
3
|
+
|
|
4
|
+
import type { ClipboardEvent, KeyboardEvent } from 'react';
|
|
5
|
+
import type { IReqoreRichTextEditorProps } from '@qoretechnologies/reqore/dist/components/RichTextEditor';
|
|
6
|
+
|
|
7
|
+
/** The Slate value for an empty rich editor — a single empty paragraph. */
|
|
8
|
+
export const EMPTY_RICH_VALUE: IReqoreRichTextEditorProps['value'] = [
|
|
9
|
+
{ type: 'paragraph', children: [{ text: '' }] },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rich editor value → plain string. Top-level blocks join with `\n` so a
|
|
14
|
+
* multi-paragraph message (a support reply, a pasted log) survives as real
|
|
15
|
+
* newlines; within a block, text leaves and `tag` values concatenate. Mirrors
|
|
16
|
+
* qorus-ide's `richtextToString` but newline-preserving for multi-line composers.
|
|
17
|
+
*/
|
|
18
|
+
export const richValueToString = (value: IReqoreRichTextEditorProps['value']): string => {
|
|
19
|
+
if (!value) {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
const nodeText = (node: any): string => {
|
|
26
|
+
if (node == null) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
if (typeof node.text === 'string') {
|
|
30
|
+
return node.text;
|
|
31
|
+
}
|
|
32
|
+
if (node.type === 'tag') {
|
|
33
|
+
return node.value?.toString() ?? '';
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(node.children)) {
|
|
36
|
+
return node.children.map(nodeText).join('');
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
};
|
|
40
|
+
return value.map(nodeText).join('\n');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Plain string → rich editor value: one paragraph per line, so `setText`
|
|
44
|
+
* round-trips with `richValueToString`. */
|
|
45
|
+
export const richValueFromString = (text: string): IReqoreRichTextEditorProps['value'] => {
|
|
46
|
+
if (!text) {
|
|
47
|
+
return EMPTY_RICH_VALUE;
|
|
48
|
+
}
|
|
49
|
+
return text.split('\n').map((line) => ({
|
|
50
|
+
type: 'paragraph',
|
|
51
|
+
children: [{ text: line }],
|
|
52
|
+
})) as IReqoreRichTextEditorProps['value'];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Image files present on a clipboard paste (a screenshot, a copied image), so
|
|
56
|
+
* pasting-to-attach works on any composer without an IDE dependency. */
|
|
57
|
+
export const clipboardImages = (event: ClipboardEvent): File[] => {
|
|
58
|
+
const images: File[] = [];
|
|
59
|
+
for (const item of Array.from(event.clipboardData?.items ?? [])) {
|
|
60
|
+
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
61
|
+
const file = item.getAsFile();
|
|
62
|
+
if (file) {
|
|
63
|
+
images.push(file);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return images;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Enter with no modifier — the "submit" chord (Shift/Ctrl/Alt/Meta+Enter insert
|
|
71
|
+
* a newline instead). */
|
|
72
|
+
export const isPlainSubmitEnter = (
|
|
73
|
+
e: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey'>
|
|
74
|
+
): boolean => e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|