@adatechnology/conversations-ui 0.2.1 → 0.3.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/dist/{ConversationSimulatorPanel-ikgmlciE.d.ts → ConversationSimulatorPanel-DmV8uaFE.d.ts} +1 -1
- package/dist/{chunk-DKPXKQGC.js → chunk-HMCOTZAX.js} +18 -1
- package/dist/{chunk-NHQDQJL2.js → chunk-L4OE4ORQ.js} +18 -19
- package/dist/{chunk-WCBDXZ3X.js → chunk-PJNR6UMN.js} +73 -8
- package/dist/flows/index.js +32 -28
- package/dist/index.d.ts +44 -5
- package/dist/index.js +563 -289
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/package.json +2 -2
- package/src/InteractiveMessage.tsx +13 -13
- package/src/MessageText.tsx +1 -3
- package/src/blockFormattingNesting.test.tsx +47 -0
- package/src/flows/FlowWhatsAppPreview.tsx +40 -42
- package/src/index.ts +3 -0
- package/src/lib/WhatsAppMessagePreview.tsx +32 -0
- package/src/lib/composer-formatting.ts +8 -0
- package/src/lib/whatsapp-formatting.test.tsx +65 -2
- package/src/lib/whatsapp-formatting.tsx +110 -13
- package/src/quickReplies/QuickRepliesWorkspace.tsx +249 -164
- package/src/quickReplies/QuickReplyFormattingToolbar.tsx +62 -0
- package/src/quickReplies/QuickReplyWhatsAppPreview.tsx +57 -0
- package/src/quickReplies/labels.ts +27 -1
- package/src/quickReplies/quickReplyFormatting.test.tsx +336 -0
- package/src/quickReplies/quickReplyFormatting.ts +232 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +1 -1
|
@@ -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. */
|