@adatechnology/conversations-ui 0.2.1 → 0.3.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.
@@ -0,0 +1,336 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+
5
+ import { FORMATTING_ACTION, WHATSAPP_MARKER_BY_ACTION } from '../lib/composer-formatting'
6
+ import { FlowWhatsAppPreview } from '../flows/FlowWhatsAppPreview'
7
+ import { DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS } from './labels'
8
+ import { QuickRepliesWorkspace } from './QuickRepliesWorkspace'
9
+ import { QuickReplyFormattingToolbar } from './QuickReplyFormattingToolbar'
10
+ import { QuickReplyWhatsAppPreview, resolvePreviewVariables } from './QuickReplyWhatsAppPreview'
11
+ import {
12
+ changedRange,
13
+ computeFormattingEdit,
14
+ toggleLinePrefix,
15
+ formattingActionForShortcut,
16
+ wrapSelection,
17
+ type FormattingShortcutEvent,
18
+ } from './quickReplyFormatting'
19
+
20
+ describe('wrapSelection — regras do WhatsApp', () => {
21
+ it('deixa o espaço das pontas fora do marcador', () => {
22
+ expect(wrapSelection({ text: 'Olá mundo ', start: 3, end: 10, marker: '*' })).toEqual({
23
+ text: 'Olá *mundo* ',
24
+ selectionStart: 5,
25
+ selectionEnd: 10,
26
+ })
27
+ })
28
+
29
+ it('desliga quando o marcador está dentro da seleção', () => {
30
+ expect(wrapSelection({ text: 'a *b* c', start: 2, end: 5, marker: '*' })).toEqual({
31
+ text: 'a b c',
32
+ selectionStart: 2,
33
+ selectionEnd: 3,
34
+ })
35
+ })
36
+
37
+ it('desliga quando o marcador está logo fora da seleção', () => {
38
+ expect(wrapSelection({ text: 'a ```b``` c', start: 5, end: 6, marker: '```' })).toEqual({
39
+ text: 'a b c',
40
+ selectionStart: 2,
41
+ selectionEnd: 3,
42
+ })
43
+ })
44
+
45
+ it('formata cada linha não vazia separadamente', () => {
46
+ expect(wrapSelection({ text: 'um\n\n dois ', start: 0, end: 10, marker: '_' })).toEqual({
47
+ text: '_um_\n\n _dois_ ',
48
+ selectionStart: 0,
49
+ selectionEnd: 14,
50
+ })
51
+ })
52
+
53
+ it('desliga em todas as linhas quando todas já estão formatadas', () => {
54
+ expect(wrapSelection({ text: '~um~\n~dois~', start: 0, end: 11, marker: '~' }).text).toBe('um\ndois')
55
+ })
56
+
57
+ it('só espaço selecionado cai no par vazio', () => {
58
+ expect(wrapSelection({ text: 'a b', start: 1, end: 3, marker: '*' }).text).toBe('a* *b')
59
+ })
60
+ })
61
+
62
+ describe('computeFormattingEdit', () => {
63
+ it('aplica a notação da ação e devolve a seleção nova', () => {
64
+ expect(
65
+ computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'bold', maximumLength: 10 }),
66
+ ).toEqual({ text: '*oi*', selectionStart: 1, selectionEnd: 3 })
67
+ })
68
+
69
+ it('não aplica quando passaria do limite', () => {
70
+ expect(
71
+ computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'monospace', maximumLength: 7 }),
72
+ ).toBeUndefined()
73
+ })
74
+
75
+ it('aceita chegar exatamente ao limite', () => {
76
+ expect(
77
+ computeFormattingEdit({ text: 'oi', selectionStart: 0, selectionEnd: 2, action: 'italic', maximumLength: 4 })
78
+ ?.text,
79
+ ).toBe('_oi_')
80
+ })
81
+
82
+ it('desligar nunca esbarra no limite', () => {
83
+ expect(
84
+ computeFormattingEdit({ text: '*oi*', selectionStart: 0, selectionEnd: 4, action: 'bold', maximumLength: 4 })
85
+ ?.text,
86
+ ).toBe('oi')
87
+ })
88
+ })
89
+
90
+ describe('formattingActionForShortcut', () => {
91
+ const base: FormattingShortcutEvent = {
92
+ key: 'b',
93
+ ctrlKey: true,
94
+ metaKey: false,
95
+ shiftKey: false,
96
+ altKey: false,
97
+ isComposing: false,
98
+ }
99
+
100
+ it('Ctrl/Cmd+B é negrito, Ctrl/Cmd+I é itálico', () => {
101
+ expect(formattingActionForShortcut(base)).toBe('bold')
102
+ expect(formattingActionForShortcut({ ...base, ctrlKey: false, metaKey: true, key: 'I' })).toBe('italic')
103
+ })
104
+
105
+ it('ignora sem modificador, com shift, alt, composição ou outra tecla', () => {
106
+ expect(formattingActionForShortcut({ ...base, ctrlKey: false })).toBeUndefined()
107
+ expect(formattingActionForShortcut({ ...base, shiftKey: true })).toBeUndefined()
108
+ expect(formattingActionForShortcut({ ...base, altKey: true })).toBeUndefined()
109
+ expect(formattingActionForShortcut({ ...base, isComposing: true })).toBeUndefined()
110
+ expect(formattingActionForShortcut({ ...base, key: 'u' })).toBeUndefined()
111
+ })
112
+ })
113
+
114
+ describe('changedRange', () => {
115
+ it('isola a inserção', () => {
116
+ expect(changedRange('a b', 'a *b*')).toEqual({ start: 2, end: 3, insertedText: '*b*' })
117
+ })
118
+
119
+ it('isola a remoção', () => {
120
+ expect(changedRange('*b*', '*b')).toEqual({ start: 2, end: 3, insertedText: '' })
121
+ })
122
+ })
123
+
124
+ const LABELS = DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS
125
+
126
+ describe('wrapSelection', () => {
127
+ it('envolve a seleção e mantém o trecho selecionado', () => {
128
+ expect(wrapSelection({ text: 'Olá mundo', start: 4, end: 9, marker: '*' })).toEqual({
129
+ text: 'Olá *mundo*',
130
+ selectionStart: 5,
131
+ selectionEnd: 10,
132
+ })
133
+ })
134
+
135
+ it('sem seleção, abre o par com o cursor no meio', () => {
136
+ expect(wrapSelection({ text: 'Olá ', start: 4, end: 4, marker: '_' })).toEqual({
137
+ text: 'Olá __',
138
+ selectionStart: 5,
139
+ selectionEnd: 5,
140
+ })
141
+ })
142
+
143
+ it.each([
144
+ [FORMATTING_ACTION.BOLD, '*oi*'],
145
+ [FORMATTING_ACTION.ITALIC, '_oi_'],
146
+ [FORMATTING_ACTION.STRIKETHROUGH, '~oi~'],
147
+ [FORMATTING_ACTION.MONOSPACE, '```oi```'],
148
+ ])('%s usa a notação do WhatsApp', (action, expected) => {
149
+ const marker = WHATSAPP_MARKER_BY_ACTION[action]
150
+ expect(wrapSelection({ text: 'oi', start: 0, end: 2, marker }).text).toBe(expected)
151
+ })
152
+ })
153
+
154
+ describe('QuickReplyWhatsAppPreview', () => {
155
+ it('renderiza negrito, itálico, tachado e monoespaçado', () => {
156
+ const markup = renderToStaticMarkup(<QuickReplyWhatsAppPreview body={'*a* _b_ ~c~ ```d```'} labels={LABELS} />)
157
+ expect(markup).toContain('<strong>a</strong>')
158
+ expect(markup).toContain('<em>b</em>')
159
+ expect(markup).toMatch(/<(del|s)>c<\/(del|s)>/)
160
+ expect(markup).toMatch(/<code[^>]*>d<\/code>/)
161
+ expect(markup).toContain(LABELS.previewTitle)
162
+ })
163
+
164
+ it('sem texto, mostra o aviso de corpo vazio', () => {
165
+ expect(renderToStaticMarkup(<QuickReplyWhatsAppPreview body="" labels={LABELS} />)).toContain(
166
+ LABELS.previewEmptyBody,
167
+ )
168
+ })
169
+
170
+ it('lista os anexos pelo nome do arquivo', () => {
171
+ const markup = renderToStaticMarkup(
172
+ <QuickReplyWhatsAppPreview
173
+ body="Segue"
174
+ attachments={[{ uploadId: 'u1', filename: 'contrato.pdf', mimeType: 'application/pdf', sizeBytes: 10 }]}
175
+ labels={LABELS}
176
+ />,
177
+ )
178
+ expect(markup).toContain('contrato.pdf')
179
+ })
180
+ })
181
+
182
+ describe('resolvePreviewVariables', () => {
183
+ const variables = [
184
+ { id: 'name', label: 'Nome do cliente', marker: '{{nome}}', value: 'Maria' },
185
+ { id: 'city', label: 'Cidade', marker: '{{cidade}}', value: '' },
186
+ ]
187
+
188
+ it('usa o exemplo, cai no rótulo e deixa marcador desconhecido como está', () => {
189
+ expect(resolvePreviewVariables('Oi {{nome}} de {{cidade}} {{cpf}}', variables)).toBe('Oi Maria de Cidade {{cpf}}')
190
+ })
191
+
192
+ it('ignora variável sem marcador', () => {
193
+ expect(resolvePreviewVariables('Oi', [{ id: 'x', label: 'X', marker: '', value: 'Y' }])).toBe('Oi')
194
+ })
195
+ })
196
+
197
+ describe('QuickReplyFormattingToolbar', () => {
198
+ it('expõe os quatro botões com nome acessível', () => {
199
+ const markup = renderToStaticMarkup(<QuickReplyFormattingToolbar labels={LABELS} onFormat={() => {}} />)
200
+ for (const label of [LABELS.formatBold, LABELS.formatItalic, LABELS.formatStrikethrough, LABELS.formatMonospace]) {
201
+ expect(markup).toContain(`aria-label="${label}"`)
202
+ }
203
+ expect(markup).toContain('role="toolbar"')
204
+ })
205
+
206
+ it('o clique entrega a ação ao dono do campo', () => {
207
+ const received: string[] = []
208
+ const element = QuickReplyFormattingToolbar({ labels: LABELS, onFormat: (action) => received.push(action) })
209
+ const buttons = element.props.children as { props: { onClick: () => void } }[]
210
+ for (const button of buttons) button.props.onClick()
211
+ expect(received).toEqual([
212
+ 'bold',
213
+ 'italic',
214
+ 'strikethrough',
215
+ 'monospace',
216
+ 'inlineCode',
217
+ 'bulletedList',
218
+ 'numberedList',
219
+ 'quote',
220
+ ])
221
+ })
222
+ })
223
+
224
+ describe('QuickRepliesWorkspace com labels antigas', () => {
225
+ it('aceita labels parciais sem as chaves novas', () => {
226
+ expect(() => renderToStaticMarkup(<QuickRepliesWorkspace api={{}} labels={{ title: 'X' }} />)).not.toThrow()
227
+ })
228
+ })
229
+
230
+ describe('FlowWhatsAppPreview sobre o balão compartilhado', () => {
231
+ it('mantém corpo formatado, botões e aviso de modo', () => {
232
+ const markup = renderToStaticMarkup(
233
+ <FlowWhatsAppPreview
234
+ body="*Oi*"
235
+ options={[
236
+ ['1', 'Sim'],
237
+ ['2', 'Não'],
238
+ ]}
239
+ />,
240
+ )
241
+ expect(markup).toContain('bg-[#e5ddd5]')
242
+ expect(markup).toContain('<strong>Oi</strong>')
243
+ expect(markup).toContain('Sim')
244
+ expect(markup).toContain('Não')
245
+ })
246
+
247
+ it('sem corpo nem opções, mostra só o placeholder, fora do balão', () => {
248
+ expect(renderToStaticMarkup(<FlowWhatsAppPreview body="" />)).not.toContain('bg-[#e5ddd5]')
249
+ })
250
+ })
251
+
252
+ describe('toggleLinePrefix', () => {
253
+ it('põe o prefixo na linha do cursor e move o cursor junto', () => {
254
+ expect(toggleLinePrefix({ text: 'a\nbc', start: 3, end: 3, action: 'bulletedList' })).toEqual({
255
+ text: 'a\n- bc',
256
+ selectionStart: 5,
257
+ selectionEnd: 5,
258
+ })
259
+ })
260
+
261
+ it('numera cada linha selecionada em sequência, pulando a linha em branco', () => {
262
+ expect(toggleLinePrefix({ text: 'um\n\ndois\ntrês', start: 1, end: 10, action: 'numberedList' })).toEqual({
263
+ text: '1. um\n\n2. dois\n3. três',
264
+ selectionStart: 0,
265
+ selectionEnd: 22,
266
+ })
267
+ })
268
+
269
+ it('tira o prefixo de todas quando todas já têm', () => {
270
+ expect(toggleLinePrefix({ text: '> a\n> b', start: 0, end: 7, action: 'quote' }).text).toBe('a\nb')
271
+ })
272
+
273
+ it('completa as que faltam quando só algumas têm', () => {
274
+ expect(toggleLinePrefix({ text: '- a\nb', start: 0, end: 5, action: 'bulletedList' }).text).toBe('- a\n- b')
275
+ })
276
+
277
+ it('linha vazia com cursor ganha o prefixo', () => {
278
+ expect(toggleLinePrefix({ text: '', start: 0, end: 0, action: 'quote' })).toEqual({
279
+ text: '> ',
280
+ selectionStart: 2,
281
+ selectionEnd: 2,
282
+ })
283
+ })
284
+
285
+ it('seleção terminando na quebra não puxa a linha de baixo', () => {
286
+ expect(toggleLinePrefix({ text: 'a\nb', start: 0, end: 2, action: 'bulletedList' }).text).toBe('- a\nb')
287
+ })
288
+ })
289
+
290
+ describe('computeFormattingEdit com as ações de linha e código', () => {
291
+ it('código inline usa crase simples', () => {
292
+ expect(
293
+ computeFormattingEdit({ text: 'x', selectionStart: 0, selectionEnd: 1, action: 'inlineCode', maximumLength: 10 })
294
+ ?.text,
295
+ ).toBe('`x`')
296
+ })
297
+
298
+ it('prefixo de linha respeita o limite', () => {
299
+ expect(
300
+ computeFormattingEdit({
301
+ text: 'a\nb',
302
+ selectionStart: 0,
303
+ selectionEnd: 3,
304
+ action: 'bulletedList',
305
+ maximumLength: 6,
306
+ }),
307
+ ).toBeUndefined()
308
+ expect(
309
+ computeFormattingEdit({
310
+ text: 'a\nb',
311
+ selectionStart: 0,
312
+ selectionEnd: 3,
313
+ action: 'bulletedList',
314
+ maximumLength: 7,
315
+ })?.text,
316
+ ).toBe('- a\n- b')
317
+ })
318
+ })
319
+
320
+ describe('QuickRepliesWorkspace — ações da tabela no padrão do pacote', () => {
321
+ // As linhas só chegam depois do carregamento assíncrono; o render estático não as mostra, então o
322
+ // contrato é conferido no fonte, como em flows/workspaceContract.test.ts.
323
+ const source = readFileSync(new URL('./QuickRepliesWorkspace.tsx', import.meta.url), 'utf8')
324
+
325
+ it('editar e excluir são cv-header-icon com Pencil e Trash2, nome acessível com o título', () => {
326
+ expect(source).toContain('className="cv-header-icon"')
327
+ expect(source).toContain('<Pencil size={14}')
328
+ expect(source).toContain('<Trash2 size={14}')
329
+ expect(source).toContain('aria-label={`${text.edit}: ${quickReply.title}`}')
330
+ })
331
+
332
+ it('confirmação usa cv-header-action--danger e o criar usa --primary', () => {
333
+ expect(source).toContain('cv-header-action cv-header-action--danger')
334
+ expect(source).toContain('cv-header-action cv-header-action--primary')
335
+ })
336
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Edição pura do texto da mensagem pronta pela barra de formatação — seguindo as regras do WhatsApp:
5
+ * o marcador não pode encostar em espaço, cada linha é formatada sozinha, e clicar de novo desliga.
6
+ */
7
+
8
+ import { FORMATTING_ACTION, WHATSAPP_MARKER_BY_ACTION, type FormattingAction } from '../lib/composer-formatting'
9
+
10
+ export type WrapSelectionParams = {
11
+ readonly text: string
12
+ readonly start: number
13
+ readonly end: number
14
+ readonly marker: string
15
+ }
16
+
17
+ export type TextSelectionEdit = {
18
+ readonly text: string
19
+ readonly selectionStart: number
20
+ readonly selectionEnd: number
21
+ }
22
+
23
+ const LEADING_WHITESPACE = /^\s*/
24
+ const TRAILING_WHITESPACE = /\s*$/
25
+
26
+ function isWrappedBy(core: string, marker: string): boolean {
27
+ return core.length >= marker.length * 2 && core.startsWith(marker) && core.endsWith(marker)
28
+ }
29
+
30
+ type ToggleLineParams = { readonly line: string; readonly marker: string; readonly shouldUnwrap: boolean }
31
+
32
+ function toggleLine({ line, marker, shouldUnwrap }: ToggleLineParams): string {
33
+ if (!line.trim()) return line
34
+ const leading = LEADING_WHITESPACE.exec(line)?.[0] ?? ''
35
+ const trailing = TRAILING_WHITESPACE.exec(line)?.[0] ?? ''
36
+ const core = line.slice(leading.length, line.length - trailing.length)
37
+ const toggled = shouldUnwrap ? core.slice(marker.length, core.length - marker.length) : marker + core + marker
38
+ return leading + toggled + trailing
39
+ }
40
+
41
+ function wrapLines({ text, start, end, marker }: WrapSelectionParams): TextSelectionEdit {
42
+ const lines = text.slice(start, end).split('\n')
43
+ const shouldUnwrap = lines.filter((line) => line.trim()).every((line) => isWrappedBy(line.trim(), marker))
44
+ const replaced = lines.map((line) => toggleLine({ line, marker, shouldUnwrap })).join('\n')
45
+ return {
46
+ text: text.slice(0, start) + replaced + text.slice(end),
47
+ selectionStart: start,
48
+ selectionEnd: start + replaced.length,
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Envolve (ou desliga) a formatação da seleção. Sem seleção, abre o par e deixa o cursor no meio —
54
+ * é o que permite clicar em "Negrito" antes de digitar.
55
+ */
56
+ export function wrapSelection({ text, start, end, marker }: WrapSelectionParams): TextSelectionEdit {
57
+ const selected = text.slice(start, end)
58
+ if (!selected.trim()) {
59
+ return {
60
+ text: text.slice(0, start) + marker + selected + marker + text.slice(end),
61
+ selectionStart: start + marker.length,
62
+ selectionEnd: end + marker.length,
63
+ }
64
+ }
65
+ if (selected.includes('\n')) return wrapLines({ text, start, end, marker })
66
+
67
+ const coreStart = start + (LEADING_WHITESPACE.exec(selected)?.[0].length ?? 0)
68
+ const coreEnd = end - (TRAILING_WHITESPACE.exec(selected)?.[0].length ?? 0)
69
+ const core = text.slice(coreStart, coreEnd)
70
+ const before = text.slice(0, coreStart)
71
+ const after = text.slice(coreEnd)
72
+ const size = marker.length
73
+
74
+ if (isWrappedBy(core, marker)) {
75
+ const unwrapped = core.slice(size, core.length - size)
76
+ return { text: before + unwrapped + after, selectionStart: coreStart, selectionEnd: coreStart + unwrapped.length }
77
+ }
78
+ if (before.endsWith(marker) && after.startsWith(marker)) {
79
+ return {
80
+ text: before.slice(0, before.length - size) + core + after.slice(size),
81
+ selectionStart: coreStart - size,
82
+ selectionEnd: coreEnd - size,
83
+ }
84
+ }
85
+ return {
86
+ text: before + marker + core + marker + after,
87
+ selectionStart: coreStart + size,
88
+ selectionEnd: coreEnd + size,
89
+ }
90
+ }
91
+
92
+ /** As ações do campo rico mais as que só o texto puro da mensagem pronta oferece. */
93
+ export const QUICK_REPLY_FORMATTING_ACTION = {
94
+ ...FORMATTING_ACTION,
95
+ INLINE_CODE: 'inlineCode',
96
+ BULLETED_LIST: 'bulletedList',
97
+ NUMBERED_LIST: 'numberedList',
98
+ QUOTE: 'quote',
99
+ } as const
100
+ export type QuickReplyFormattingAction =
101
+ (typeof QUICK_REPLY_FORMATTING_ACTION)[keyof typeof QUICK_REPLY_FORMATTING_ACTION]
102
+
103
+ type LinePrefixAction =
104
+ | typeof QUICK_REPLY_FORMATTING_ACTION.BULLETED_LIST
105
+ | typeof QUICK_REPLY_FORMATTING_ACTION.NUMBERED_LIST
106
+ | typeof QUICK_REPLY_FORMATTING_ACTION.QUOTE
107
+
108
+ const LINE_PREFIX_PATTERN: Readonly<Record<LinePrefixAction, RegExp>> = {
109
+ [QUICK_REPLY_FORMATTING_ACTION.BULLETED_LIST]: /^[-*] /,
110
+ [QUICK_REPLY_FORMATTING_ACTION.NUMBERED_LIST]: /^\d+\. /,
111
+ [QUICK_REPLY_FORMATTING_ACTION.QUOTE]: /^> /,
112
+ }
113
+
114
+ const INLINE_MARKER_BY_ACTION: Readonly<Partial<Record<QuickReplyFormattingAction, string>>> = {
115
+ ...WHATSAPP_MARKER_BY_ACTION,
116
+ [QUICK_REPLY_FORMATTING_ACTION.INLINE_CODE]: '`',
117
+ }
118
+
119
+ function isLinePrefixAction(action: QuickReplyFormattingAction): action is LinePrefixAction {
120
+ return action in LINE_PREFIX_PATTERN
121
+ }
122
+
123
+ function prefixFor(action: LinePrefixAction, position: number): string {
124
+ if (action === QUICK_REPLY_FORMATTING_ACTION.NUMBERED_LIST) return `${position}. `
125
+ return action === QUICK_REPLY_FORMATTING_ACTION.QUOTE ? '> ' : '- '
126
+ }
127
+
128
+ export type ToggleLinePrefixParams = {
129
+ readonly text: string
130
+ readonly start: number
131
+ readonly end: number
132
+ readonly action: LinePrefixAction
133
+ }
134
+
135
+ /**
136
+ * Lista, lista numerada e citação valem por linha: o prefixo vai em cada linha tocada pela seleção
137
+ * (numerando em sequência) e sai de todas quando todas já o têm. Linha em branco no meio fica como está.
138
+ */
139
+ export function toggleLinePrefix({ text, start, end, action }: ToggleLinePrefixParams): TextSelectionEdit {
140
+ const lineStart = start === 0 ? 0 : text.lastIndexOf('\n', start - 1) + 1
141
+ const searchFrom = end > start && text[end - 1] === '\n' ? end - 1 : end
142
+ const nextBreak = text.indexOf('\n', searchFrom)
143
+ const lineEnd = nextBreak === -1 ? text.length : nextBreak
144
+ const original = text.slice(lineStart, lineEnd)
145
+ const lines = original.split('\n')
146
+ const pattern = LINE_PREFIX_PATTERN[action]
147
+ const filledLines = lines.filter((line) => line.trim())
148
+ const shouldRemove = filledLines.length > 0 && filledLines.every((line) => pattern.test(line))
149
+
150
+ let position = 0
151
+ const replaced = lines
152
+ .map((line) => {
153
+ if (shouldRemove) return line.replace(pattern, '')
154
+ if (!line.trim() && lines.length > 1) return line
155
+ position += 1
156
+ return prefixFor(action, position) + line.replace(pattern, '')
157
+ })
158
+ .join('\n')
159
+
160
+ const nextText = text.slice(0, lineStart) + replaced + text.slice(lineEnd)
161
+ if (start === end) {
162
+ const caret = Math.max(lineStart, start + replaced.length - original.length)
163
+ return { text: nextText, selectionStart: caret, selectionEnd: caret }
164
+ }
165
+ return { text: nextText, selectionStart: lineStart, selectionEnd: lineStart + replaced.length }
166
+ }
167
+
168
+ export type ComputeFormattingEditParams = {
169
+ readonly text: string
170
+ readonly selectionStart: number
171
+ readonly selectionEnd: number
172
+ readonly action: QuickReplyFormattingAction
173
+ readonly maximumLength: number
174
+ }
175
+
176
+ /** `undefined` quando o resultado passaria do limite do campo — o `maxLength` só barra a digitação. */
177
+ export function computeFormattingEdit({
178
+ text,
179
+ selectionStart,
180
+ selectionEnd,
181
+ action,
182
+ maximumLength,
183
+ }: ComputeFormattingEditParams): TextSelectionEdit | undefined {
184
+ const edit = isLinePrefixAction(action)
185
+ ? toggleLinePrefix({ text, start: selectionStart, end: selectionEnd, action })
186
+ : wrapSelection({ text, start: selectionStart, end: selectionEnd, marker: INLINE_MARKER_BY_ACTION[action] ?? '' })
187
+ return edit.text.length > maximumLength ? undefined : edit
188
+ }
189
+
190
+ export type FormattingShortcutEvent = {
191
+ readonly key: string
192
+ readonly ctrlKey: boolean
193
+ readonly metaKey: boolean
194
+ readonly shiftKey: boolean
195
+ readonly altKey: boolean
196
+ readonly isComposing: boolean
197
+ }
198
+
199
+ const SHORTCUT_ACTION_BY_KEY: Readonly<Record<string, FormattingAction>> = {
200
+ b: FORMATTING_ACTION.BOLD,
201
+ i: FORMATTING_ACTION.ITALIC,
202
+ }
203
+
204
+ export const FORMATTING_SHORTCUT_HINT: Readonly<Partial<Record<QuickReplyFormattingAction, string>>> = {
205
+ [FORMATTING_ACTION.BOLD]: 'Ctrl/⌘+B',
206
+ [FORMATTING_ACTION.ITALIC]: 'Ctrl/⌘+I',
207
+ }
208
+
209
+ export function formattingActionForShortcut(event: FormattingShortcutEvent): FormattingAction | undefined {
210
+ if (event.isComposing || event.shiftKey || event.altKey) return undefined
211
+ if (!(event.ctrlKey || event.metaKey)) return undefined
212
+ return SHORTCUT_ACTION_BY_KEY[event.key.toLowerCase()]
213
+ }
214
+
215
+ export type ChangedRange = {
216
+ readonly start: number
217
+ /** Fim no texto anterior. */
218
+ readonly end: number
219
+ readonly insertedText: string
220
+ }
221
+
222
+ /** Menor trecho que muda — substituir só ele mantém o desfazer nativo do campo enxuto. */
223
+ export function changedRange(previous: string, next: string): ChangedRange {
224
+ let prefix = 0
225
+ const shortest = Math.min(previous.length, next.length)
226
+ while (prefix < shortest && previous[prefix] === next[prefix]) prefix += 1
227
+ let suffix = 0
228
+ while (suffix < shortest - prefix && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) {
229
+ suffix += 1
230
+ }
231
+ return { start: prefix, end: previous.length - suffix, insertedText: next.slice(prefix, next.length - suffix) }
232
+ }
@@ -40,7 +40,7 @@ export type QuickRepliesWorkspaceEditing = {
40
40
  }
41
41
 
42
42
  const TITLE_MAX_LENGTH = 40
43
- const BODY_MAX_LENGTH = 1000
43
+ export const BODY_MAX_LENGTH = 1000
44
44
  const SHORTCUT_PATTERN = /^[a-z0-9-]{1,20}$/
45
45
 
46
46
  /** Casca genérica de erro de API com `code` e `details[]` (`apis.md`) — sem acoplar a um cliente HTTP específico. */