@estiva-app/ui 0.72.0-widgets2.1 → 0.72.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.
Files changed (49) hide show
  1. package/dist/CommentHighlights.d.ts +100 -0
  2. package/dist/CommentHighlights.d.ts.map +1 -0
  3. package/dist/ComposeBox.d.ts +55 -0
  4. package/dist/ComposeBox.d.ts.map +1 -0
  5. package/dist/ComposerTriggers.d.ts.map +1 -1
  6. package/dist/SelectionToolbar.d.ts +17 -1
  7. package/dist/SelectionToolbar.d.ts.map +1 -1
  8. package/dist/editor.d.ts +3 -1
  9. package/dist/editor.d.ts.map +1 -1
  10. package/dist/editor.js +618 -217
  11. package/dist/editor.js.map +4 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/looks.d.ts +6 -0
  14. package/dist/looks.d.ts.map +1 -1
  15. package/package.json +1 -5
  16. package/registry.json +309 -3
  17. package/src/CommentHighlights.mdx +61 -0
  18. package/src/CommentHighlights.stories.tsx +72 -0
  19. package/src/CommentHighlights.test.ts +100 -0
  20. package/src/CommentHighlights.ts +413 -0
  21. package/src/ComposeBox.mdx +58 -0
  22. package/src/ComposeBox.stories.tsx +81 -0
  23. package/src/ComposeBox.tsx +183 -0
  24. package/src/ComposerTriggers.tsx +9 -3
  25. package/src/SelectionToolbar.actions.test.tsx +44 -0
  26. package/src/SelectionToolbar.mdx +1 -0
  27. package/src/SelectionToolbar.tsx +36 -2
  28. package/src/editor.ts +15 -1
  29. package/src/looks.ts +8 -0
  30. package/src/registry/app.test.ts +7 -1
  31. package/src/registry/registry.test.ts +6 -6
  32. package/stories/Choosing.mdx +2 -0
  33. package/dist/widgets-2/fixtures.d.ts +0 -102
  34. package/dist/widgets-2/fixtures.d.ts.map +0 -1
  35. package/dist/widgets-2/places.d.ts +0 -99
  36. package/dist/widgets-2/places.d.ts.map +0 -1
  37. package/dist/widgets-2/proposed.d.ts +0 -130
  38. package/dist/widgets-2/proposed.d.ts.map +0 -1
  39. package/dist/widgets-2.d.ts +0 -12
  40. package/dist/widgets-2.d.ts.map +0 -1
  41. package/dist/widgets-2.js +0 -584
  42. package/dist/widgets-2.js.map +0 -7
  43. package/src/widgets-2/fixtures.ts +0 -161
  44. package/src/widgets-2/places.tsx +0 -309
  45. package/src/widgets-2/proposed.tsx +0 -596
  46. package/src/widgets-2.ts +0 -11
  47. package/stories/widgets-2/ReadMe.mdx +0 -60
  48. package/stories/widgets-2/Widgets.stories.tsx +0 -504
  49. package/stories/widgets-2/today.tsx +0 -352
@@ -0,0 +1,413 @@
1
+ import type { Editor } from '@tiptap/react'
2
+ import { Extension } from '@tiptap/core'
3
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
5
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
6
+
7
+ /**
8
+ * Comments on a selection, drawn where they are, from Katerina's Leaf prototype
9
+ * (9 October), with a run, whole blocks and changed words added for LEAF-7.
10
+ *
11
+ * A comment's place is the one SPEC §13.6 gives it: a run of blocks, and the
12
+ * words where the selection starts and where it ends as a quote. Words inside
13
+ * one block are quoted whole; a selection across blocks quotes its start in
14
+ * the first block and its end in the last; a selection of whole blocks quotes
15
+ * nothing and is about the blocks. Nothing is written into the document: the
16
+ * highlight is a decoration, so a save never carries it.
17
+ *
18
+ * Three places a comment can be in, as `@estiva-app/protocol`'s
19
+ * `resolveCommentAnchor` reads them:
20
+ * - **resolved** — its words are found (drawn on them), or it quotes none
21
+ * (drawn on its whole blocks);
22
+ * - **reworded** — its blocks are there and its words are not: nothing is
23
+ * drawn, and the comment stands beside its first block;
24
+ * - **detached** — none of its blocks is left.
25
+ *
26
+ * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-comment-remembers-where-its-selection-starts-and-ends.md
27
+ */
28
+
29
+ /** The words a comment quotes: §13.6's `quote` tag. */
30
+ export interface CommentQuote {
31
+ /** The selection's words in one block, or its first words when it runs across blocks. */
32
+ start: string
33
+ /** Its last words, in the run's last block. Absent: `start` is the whole selection. */
34
+ end?: string
35
+ /** A few characters before `start`, only when the words repeat in their block. */
36
+ prefix?: string
37
+ /** A few characters after the end, only when the words repeat in their block. */
38
+ suffix?: string
39
+ }
40
+
41
+ /** One comment's place: the blocks it is anchored to and the words it is about. */
42
+ export interface CommentAnchor {
43
+ /** The thread's id. Clicks report it; `active` names it. */
44
+ id: string
45
+ /** The run: the §13.3 block ids the comment's `block` tag lists, in order. */
46
+ blocks: readonly string[]
47
+ /** The words, as they were when the comment was written. Absent: the whole blocks. */
48
+ quote?: CommentQuote
49
+ }
50
+
51
+ /** Where a comment is now, in document positions. */
52
+ export type CommentPlace =
53
+ /** Drawn: on its words (`words`), or on its whole blocks. */
54
+ | { state: 'resolved'; from: number; to: number; words: boolean }
55
+ /** Its blocks (`from`–`to`) are there, its words are not. Draw nothing; show the quote as the commenter's. */
56
+ | { state: 'reworded'; from: number; to: number }
57
+ /** None of its blocks is left. */
58
+ | { state: 'detached' }
59
+
60
+ interface HighlightState {
61
+ anchors: readonly CommentAnchor[]
62
+ active: string | null
63
+ }
64
+
65
+ const commentHighlightsKey = new PluginKey<HighlightState>('commentHighlights')
66
+
67
+ /**
68
+ * The highlight's two looks: every commented selection, and the one being
69
+ * read. A block carries the same classes as words do: its underline reaches
70
+ * every line of its text.
71
+ */
72
+ const COMMENT_HIGHLIGHT_CLASSES = {
73
+ rest: 'cursor-pointer rounded-sm bg-warning-muted',
74
+ active: 'cursor-pointer rounded-sm bg-warning-muted underline decoration-warning-default decoration-2 underline-offset-4',
75
+ } as const
76
+
77
+ /** Up to this many characters, a selection in one block is quoted whole (§13.6). */
78
+ const QUOTE_WHOLE_MAX = 1000
79
+ /** Otherwise about this many at each end, cut at a word. */
80
+ const QUOTE_END_LENGTH = 150
81
+ /** How much of the text around repeated words is kept to tell them apart. */
82
+ const QUOTE_CONTEXT_LENGTH = 32
83
+
84
+ /** A block with an id: where it is, where its parent's content starts, and its index there. */
85
+ interface Place {
86
+ node: ProseMirrorNode
87
+ pos: number
88
+ parent: number
89
+ index: number
90
+ }
91
+
92
+ /** Every block carrying an id, by id. */
93
+ function placesIn(doc: ProseMirrorNode): Map<string, Place> {
94
+ const places = new Map<string, Place>()
95
+ const walk = (parent: ProseMirrorNode, parentPos: number) => {
96
+ parent.forEach((node, offset, index) => {
97
+ const pos = parentPos + offset
98
+ const id = node.attrs.blockId
99
+ if (typeof id === 'string' && id && !places.has(id)) places.set(id, { node, pos, parent: parentPos, index })
100
+ if (!node.isTextblock && !node.isLeaf) walk(node, pos + 1)
101
+ })
102
+ }
103
+ walk(doc, 0)
104
+ return places
105
+ }
106
+
107
+ /**
108
+ * A block's text, and the document position where each of its characters
109
+ * starts and ends. A chip counts as its `nostr:` text, as on the wire.
110
+ */
111
+ function textOf(block: Place): { text: string; starts: number[]; ends: number[] } {
112
+ let text = ''
113
+ const starts: number[] = []
114
+ const ends: number[] = []
115
+ block.node.descendants((node, offset) => {
116
+ const at = block.pos + 1 + offset
117
+ if (node.isText && node.text) {
118
+ for (let i = 0; i < node.text.length; i++) (starts.push(at + i), ends.push(at + i + 1))
119
+ text += node.text
120
+ } else if (node.isInline && node.isLeaf) {
121
+ const wire = typeof node.attrs.uri === 'string' ? node.attrs.uri : ''
122
+ for (let i = 0; i < wire.length; i++) (starts.push(at), ends.push(at + node.nodeSize))
123
+ text += wire
124
+ }
125
+ return true
126
+ })
127
+ return { text, starts, ends }
128
+ }
129
+
130
+ /** Where `words` sits in `text` at or after `from`: the occurrence whose context matches, else the first. */
131
+ function find(text: string, words: string, from: number, context: (at: number) => boolean): number {
132
+ let first = -1
133
+ for (let at = text.indexOf(words, from); at !== -1; at = text.indexOf(words, at + 1)) {
134
+ if (first === -1) first = at
135
+ if (context(at)) return at
136
+ }
137
+ return first
138
+ }
139
+
140
+ /**
141
+ * The run as §13.3 reads it: the parent holding most of its listed blocks, and
142
+ * every block there from the earliest listed one to the latest. Null when none
143
+ * is left.
144
+ */
145
+ function runIn(doc: ProseMirrorNode, places: Map<string, Place>, ids: readonly string[]): Place[] | null {
146
+ const present = ids.map((id) => places.get(id)).filter((p): p is Place => !!p)
147
+ if (present.length === 0) return null
148
+ const byParent = new Map<number, Place[]>()
149
+ for (const p of present) byParent.set(p.parent, [...(byParent.get(p.parent) ?? []), p])
150
+ const group = [...byParent.values()].reduce((best, one) => (one.length > best.length ? one : best))
151
+ const low = Math.min(...group.map((p) => p.index))
152
+ const high = Math.max(...group.map((p) => p.index))
153
+ // `parent` is where the parent's content starts: 0 for the document itself.
154
+ const contentStart = group[0].parent
155
+ const parent = contentStart === 0 ? doc : doc.nodeAt(contentStart - 1)
156
+ if (!parent) return group
157
+ const run: Place[] = []
158
+ parent.forEach((node, offset, index) => {
159
+ if (index >= low && index <= high) run.push({ node, pos: contentStart + offset, parent: contentStart, index })
160
+ })
161
+ return run
162
+ }
163
+
164
+ /** Where a comment is in this document. */
165
+ function placeOf(doc: ProseMirrorNode, anchor: CommentAnchor, places = placesIn(doc)): CommentPlace {
166
+ const run = runIn(doc, places, anchor.blocks)
167
+ if (!run) return { state: 'detached' }
168
+ const from = run[0].pos
169
+ const to = run[run.length - 1].pos + run[run.length - 1].node.nodeSize
170
+ const { quote } = anchor
171
+ if (!quote) return { state: 'resolved', from, to, words: false }
172
+
173
+ const reworded: CommentPlace = { state: 'reworded', from, to }
174
+ const firstId = anchor.blocks[0]
175
+ const lastId = anchor.blocks[anchor.blocks.length - 1]
176
+ const first = run.findIndex((p) => p.node.attrs.blockId === firstId)
177
+ const last = run.findIndex((p) => p.node.attrs.blockId === lastId)
178
+ if (first === -1 || last === -1 || last < first) return reworded
179
+ const a = textOf(run[first])
180
+ const b = first === last ? a : textOf(run[last])
181
+ // Context longer than a writer ever sends is a stranger's, and ignored, as the protocol's `resolveCommentAnchor` does.
182
+ const prefix = quote.prefix && quote.prefix.length <= QUOTE_CONTEXT_LENGTH ? quote.prefix : undefined
183
+ const suffix = quote.suffix && quote.suffix.length <= QUOTE_CONTEXT_LENGTH ? quote.suffix : undefined
184
+ const before = (text: string, i: number) => !prefix || text.slice(Math.max(0, i - prefix.length), i) === prefix
185
+ const after = (text: string, i: number) => !suffix || text.slice(i, i + suffix.length) === suffix
186
+
187
+ if (quote.end === undefined) {
188
+ const at = find(a.text, quote.start, 0, (i) => before(a.text, i) && after(a.text, i + quote.start.length))
189
+ if (at === -1 || quote.start === '') return reworded
190
+ return { state: 'resolved', from: a.starts[at], to: a.ends[at + quote.start.length - 1], words: true }
191
+ }
192
+ const startAt = find(a.text, quote.start, 0, (i) => before(a.text, i))
193
+ if (startAt === -1 || quote.start === '') return reworded
194
+ const endFrom = first === last ? startAt + quote.start.length : 0
195
+ const endAt = find(b.text, quote.end, endFrom, (i) => after(b.text, i + quote.end!.length))
196
+ if (endAt === -1 || quote.end === '') return reworded
197
+ return { state: 'resolved', from: a.starts[startAt], to: b.ends[endAt + quote.end.length - 1], words: true }
198
+ }
199
+
200
+ declare module '@tiptap/core' {
201
+ interface EditorEvents {
202
+ /** A comment's highlight was clicked: `editor.on('commentSelect', ({ id }) => …)`. */
203
+ commentSelect: { id: string }
204
+ }
205
+ }
206
+
207
+ export interface CommentHighlightsOptions {
208
+ /** A highlight was clicked: the thread to show. Or listen for the editor's `commentSelect`. */
209
+ onSelect: ((id: string) => void) | null
210
+ }
211
+
212
+ /** The extension an editor registers to draw comments where they are. */
213
+ export const CommentHighlights = Extension.create<CommentHighlightsOptions>({
214
+ name: 'commentHighlights',
215
+
216
+ addOptions() {
217
+ return { onSelect: null }
218
+ },
219
+
220
+ addProseMirrorPlugins() {
221
+ const options = this.options
222
+ const editor = this.editor
223
+ return [
224
+ new Plugin<HighlightState>({
225
+ key: commentHighlightsKey,
226
+ state: {
227
+ init: () => ({ anchors: [], active: null }),
228
+ apply(tr, previous) {
229
+ const meta = tr.getMeta(commentHighlightsKey) as HighlightState | undefined
230
+ return meta ?? previous
231
+ },
232
+ },
233
+ props: {
234
+ decorations(state) {
235
+ const { anchors, active } = commentHighlightsKey.getState(state) ?? { anchors: [], active: null }
236
+ const places = placesIn(state.doc)
237
+ const drawn = anchors.flatMap((anchor) => {
238
+ const place = placeOf(state.doc, anchor, places)
239
+ if (place.state !== 'resolved') return []
240
+ const attrs = { class: anchor.id === active ? COMMENT_HIGHLIGHT_CLASSES.active : COMMENT_HIGHLIGHT_CLASSES.rest, 'data-comment-id': anchor.id }
241
+ if (place.words) return [Decoration.inline(place.from, place.to, attrs)]
242
+ // Whole blocks: each block of the run carries the look itself.
243
+ const blocks: Decoration[] = []
244
+ state.doc.nodesBetween(place.from, place.to, (node, pos) => {
245
+ if (pos < place.from || pos + node.nodeSize > place.to) return true
246
+ blocks.push(Decoration.node(pos, pos + node.nodeSize, attrs))
247
+ return false
248
+ })
249
+ return blocks
250
+ })
251
+ return drawn.length ? DecorationSet.create(state.doc, drawn) : null
252
+ },
253
+ handleClick(view, pos) {
254
+ const { anchors } = commentHighlightsKey.getState(view.state) ?? { anchors: [] }
255
+ const places = placesIn(view.state.doc)
256
+ // The narrowest drawn comment under the click: words before the paragraph around them.
257
+ let hit: { id: string; size: number } | null = null
258
+ for (const anchor of anchors) {
259
+ const place = placeOf(view.state.doc, anchor, places)
260
+ if (place.state !== 'resolved' || pos < place.from || pos >= place.to) continue
261
+ const size = place.to - place.from
262
+ if (!hit || size < hit.size) hit = { id: anchor.id, size }
263
+ }
264
+ if (hit) {
265
+ options.onSelect?.(hit.id)
266
+ editor.emit('commentSelect', { id: hit.id })
267
+ }
268
+ return false
269
+ },
270
+ },
271
+ }),
272
+ ]
273
+ },
274
+ })
275
+
276
+ /** Draw these comments, `active` the one being read. */
277
+ export function setCommentAnchors(editor: Editor, anchors: readonly CommentAnchor[], active: string | null = null) {
278
+ if (editor.isDestroyed) return
279
+ editor.view.dispatch(editor.state.tr.setMeta(commentHighlightsKey, { anchors, active }))
280
+ }
281
+
282
+ /** Where a comment is now: on its words or blocks, beside its blocks with its words gone, or detached. */
283
+ export function commentPlace(editor: Editor, anchor: CommentAnchor): CommentPlace {
284
+ return placeOf(editor.state.doc, anchor)
285
+ }
286
+
287
+ /**
288
+ * What a comment's card stands beside: its drawn highlight, or — its words
289
+ * gone — its first block. Null when it is detached.
290
+ */
291
+ export function commentElement(editor: Editor, anchor: CommentAnchor): HTMLElement | null {
292
+ const drawn = editor.view.dom.querySelector<HTMLElement>(`[data-comment-id="${CSS.escape(anchor.id)}"]`)
293
+ if (drawn) return drawn
294
+ const place = placeOf(editor.state.doc, anchor)
295
+ if (place.state === 'detached') return null
296
+ const dom = editor.view.nodeDOM(place.from)
297
+ return dom instanceof HTMLElement ? dom : null
298
+ }
299
+
300
+ /** A quote in one line, as a card says it: its start and end words around an ellipsis. */
301
+ export function quoteText(quote: CommentQuote): string {
302
+ return quote.end ? `${quote.start} … ${quote.end}` : quote.start
303
+ }
304
+
305
+ /** At most `length` characters from the front of `text`, cut back to a word when one is near. */
306
+ function headOf(text: string, length: number): string {
307
+ if (text.length <= length) return text
308
+ const cut = text.lastIndexOf(' ', length)
309
+ return text.slice(0, cut > length / 2 ? cut : length)
310
+ }
311
+
312
+ /** At most `length` characters from the back of `text`, cut forward to a word when one is near. */
313
+ function tailOf(text: string, length: number): string {
314
+ if (text.length <= length) return text
315
+ const from = text.length - length
316
+ const cut = text.indexOf(' ', from)
317
+ return text.slice(cut !== -1 && cut < from + length / 2 ? cut + 1 : from)
318
+ }
319
+
320
+ function occurrences(text: string, words: string): number {
321
+ let count = 0
322
+ for (let at = text.indexOf(words); words && at !== -1; at = text.indexOf(words, at + 1)) count++
323
+ return count
324
+ }
325
+
326
+ /** How many characters of `block`'s text come before document position `pos`. */
327
+ function offsetIn(block: { starts: number[] }, pos: number): number {
328
+ const at = block.starts.findIndex((start) => start >= pos)
329
+ return at === -1 ? block.starts.length : at
330
+ }
331
+
332
+ /** What a comment on the selection would carry: its run, and its words unless it covers whole blocks. */
333
+ export interface SelectionAnchor {
334
+ blocks: string[]
335
+ quote?: CommentQuote
336
+ }
337
+
338
+ /**
339
+ * The anchor a comment on the selection carries, as §13.6 writes it: the
340
+ * selected blocks, and the selected words in one block or the words where it
341
+ * starts and ends across several. No quote when whole blocks are selected.
342
+ * Null when nothing is selected or a selected block has no id.
343
+ */
344
+ export function anchorOfSelection(editor: Editor): SelectionAnchor | null {
345
+ const { doc, selection } = editor.state
346
+ if (selection.empty) return null
347
+ const { $from, $to } = selection
348
+ const places = placesIn(doc)
349
+ // The block with an id at or around `depth`: the lines of a quote or a list
350
+ // item are paragraphs with no id of their own, and belong to it.
351
+ const around = (depth: number): Place | undefined => {
352
+ for (let d = depth; d > 0; d--) {
353
+ const id = $from.node(d).attrs.blockId
354
+ if (typeof id === 'string' && id && places.has(id)) return places.get(id)
355
+ }
356
+ return undefined
357
+ }
358
+ // The blocks the selection runs across: children of the deepest node holding
359
+ // both ends, or — both ends in one textblock, or in blocks with no ids — the
360
+ // block with an id around them.
361
+ let run: Place[]
362
+ if ($from.parent === $to.parent && $from.parent.isTextblock) {
363
+ const found = around($from.depth)
364
+ if (!found) return null
365
+ run = [found]
366
+ } else {
367
+ const depth = $from.sharedDepth($to.pos)
368
+ const low = $from.index(depth)
369
+ const high = $to.pos >= $to.end(depth) ? $from.node(depth).childCount - 1 : $to.index(depth)
370
+ run = []
371
+ for (let i = low; i <= high; i++) {
372
+ const id = $from.node(depth).child(i).attrs.blockId
373
+ const place = typeof id === 'string' ? places.get(id) : undefined
374
+ if (!place) {
375
+ const found = around(depth)
376
+ if (!found) return null
377
+ run = [found]
378
+ break
379
+ }
380
+ run.push(place)
381
+ }
382
+ }
383
+ let firstText = textOf(run[0])
384
+ let lastText = run.length === 1 ? firstText : textOf(run[run.length - 1])
385
+ let start = offsetIn(firstText, selection.from)
386
+ let end = offsetIn(lastText, selection.to)
387
+ // A block the selection only touches at its edge holds none of it: it is not
388
+ // in the run. The same rule as the protocol's `selectionAnchorTags`.
389
+ if (run.length > 1 && start >= firstText.text.length) ((run = run.slice(1)), (firstText = textOf(run[0])), (start = 0))
390
+ if (run.length > 1 && end <= 0) ((run = run.slice(0, -1)), (lastText = textOf(run[run.length - 1])), (end = lastText.text.length))
391
+ if (run.length === 1) lastText = firstText
392
+ // Between two blocks, with no character selected: nothing to comment on.
393
+ if (run.length === 1 && firstText.text && start >= end) return null
394
+ const blocks = run.map((p) => p.node.attrs.blockId as string)
395
+ if (start === 0 && end >= lastText.text.length) return { blocks }
396
+
397
+ const single = run.length === 1
398
+ const quote: CommentQuote = { start: '' }
399
+ if (single && end - start <= QUOTE_WHOLE_MAX) {
400
+ quote.start = firstText.text.slice(start, end)
401
+ } else {
402
+ const startWords = single ? firstText.text.slice(start, end) : firstText.text.slice(start)
403
+ const endWords = single ? startWords : lastText.text.slice(0, end)
404
+ quote.start = headOf(startWords, QUOTE_END_LENGTH)
405
+ quote.end = tailOf(endWords, QUOTE_END_LENGTH)
406
+ }
407
+ if (!quote.start || quote.end === '') return { blocks }
408
+ const prefix = firstText.text.slice(Math.max(0, start - QUOTE_CONTEXT_LENGTH), start)
409
+ const suffix = lastText.text.slice(end, end + QUOTE_CONTEXT_LENGTH)
410
+ if (prefix && occurrences(firstText.text, quote.start) > 1) quote.prefix = prefix
411
+ if (suffix && occurrences(lastText.text, quote.end ?? quote.start) > 1) quote.suffix = suffix
412
+ return { blocks, quote }
413
+ }
@@ -0,0 +1,58 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ComposeBoxStories from './ComposeBox.stories'
3
+
4
+ <Meta of={ComposeBoxStories} />
5
+
6
+ # ComposeBox
7
+
8
+ The box a message, a reply or a comment is written in: the text, the selection strip over it, what is attached, the tools and Send.
9
+
10
+ <Canvas of={ComposeBoxStories.Default} />
11
+
12
+ ## When
13
+
14
+ - **Writing something that is sent**: a message, a reply, a comment. Enter sends, Shift+Enter is a new line.
15
+ - `tools` for what acts on the draft — attach, highlight — as `ToolbarButton`s; they are one strip.
16
+ - `attachments` for what is attached, as pending `AttachmentCard`s, under the text.
17
+ - `marked` for the bar down the text's left while it holds an urgent mention or a highlight.
18
+
19
+ <Canvas of={ComposeBoxStories.Reply} />
20
+
21
+ ## When not
22
+
23
+ - Editing a document in place → the editor itself, with **EditorBlockHandle** and **SelectionToolbar**. Nothing is sent.
24
+ - One line of plain text in a form → **TextInput** or **Textarea**, in a **Form**.
25
+
26
+ ## How
27
+
28
+ ```tsx
29
+ import { useEditor } from '@tiptap/react'
30
+ import StarterKit from '@tiptap/starter-kit'
31
+ import { ComposeBox, ComposeKeys, COMPOSE_EDITOR_CLASSES, KeptSelection } from '@estiva-app/ui/editor'
32
+
33
+
34
+ const editor = useEditor({
35
+ extensions: [StarterKit, KeptSelection, ComposeKeys /* and the app's own */],
36
+ editorProps: { attributes: { class: COMPOSE_EDITOR_CLASSES } },
37
+ })
38
+
39
+ <ComposeBox editor={editor} onSend={(ed) => send(ed.getJSON())} placeholder={<span>Reply…</span>} />
40
+ ```
41
+
42
+ - **The app makes the editor.** Its extensions — mentions, references, `/` — are the app's; add `ComposeKeys`, and `COMPOSE_EDITOR_CLASSES` on the editor.
43
+ - **`onSend` gets the editor** and writes what it holds in the app's own format. The box empties after it, unless it returns `false`.
44
+ - `ready` when something can be sent with no words (a file is ready); `holding` while sending must wait (a file is uploading).
45
+
46
+ ## What it owns
47
+
48
+ It does these by itself. Write none of them around it.
49
+
50
+ | It does | So you never write |
51
+ |---|---|
52
+ | Enter sends, unless a `/`, `@` or `[` list is open | a keydown handler |
53
+ | Shift+Enter: a new line, a new list item, out of an empty item or quote line | list and quote commands |
54
+ | Send presses on the way down, so the caret stays in the text | `preventDefault` on the button |
55
+ | The placeholder while nothing is written | an empty check |
56
+ | The selection strip over selected words | `SelectionToolbar` beside it |
57
+
58
+ <Controls />
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { IconPaperclip } from '@tabler/icons-react'
4
+ import { useEditor, type Editor } from '@tiptap/react'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import { AttachmentCard } from './AttachmentCard'
7
+ import { Kbd } from './Kbd'
8
+ import { ToolbarButton } from './Toolbar'
9
+ import { ComposeBox, ComposeKeys, COMPOSE_EDITOR_CLASSES } from './ComposeBox'
10
+ import { KeptSelection } from './SelectionToolbar'
11
+
12
+ /**
13
+ * The box a message, a reply or a comment is written in. Type, select
14
+ * words for the strip, Enter to send, Shift+Enter for a new line.
15
+ */
16
+ const meta = {
17
+ title: 'Primitives/ComposeBox',
18
+ component: ComposeBox,
19
+ parameters: { layout: 'padded' },
20
+ decorators: [
21
+ (Story) => (
22
+ <div className="w-[480px]">
23
+ <Story />
24
+ </div>
25
+ ),
26
+ ],
27
+ } satisfies Meta<typeof ComposeBox>
28
+
29
+ export default meta
30
+ type Story = StoryObj
31
+
32
+ function Writing({ tools, placeholder, file }: { tools?: boolean; placeholder: 'new' | 'reply'; file?: boolean }) {
33
+ const [sent, setSent] = useState<string[]>([])
34
+ const editor = useEditor({
35
+ extensions: [StarterKit.configure({ link: { openOnClick: false } }), KeptSelection, ComposeKeys],
36
+ editorProps: { attributes: { class: COMPOSE_EDITOR_CLASSES } },
37
+ })
38
+ return (
39
+ <div className="flex flex-col gap-3">
40
+ {sent.map((text, i) => (
41
+ <p key={i} className="text-body-2 text-text-primary">
42
+ {text}
43
+ </p>
44
+ ))}
45
+ <ComposeBox
46
+ editor={editor}
47
+ onSend={(ed: Editor) => setSent((all) => [...all, ed.getText()])}
48
+ selection={{ link: true }}
49
+ ready={file}
50
+ attachments={file ? <AttachmentCard pending name="brief.pdf" size={182_000} contentType="application/pdf" state="ready" onRemove={() => {}} /> : undefined}
51
+ placeholder={
52
+ placeholder === 'reply' ? (
53
+ <span>Reply…</span>
54
+ ) : (
55
+ <>
56
+ <span>Start a new conversation or type</span>
57
+ <Kbd>/</Kbd>
58
+ <span>for commands</span>
59
+ </>
60
+ )
61
+ }
62
+ tools={
63
+ tools ? (
64
+ <ToolbarButton tooltip="Attach files" aria-label="Attach files">
65
+ <IconPaperclip size={16} stroke={1.5} />
66
+ </ToolbarButton>
67
+ ) : undefined
68
+ }
69
+ />
70
+ </div>
71
+ )
72
+ }
73
+
74
+ /** A new message, with a tool under the text. */
75
+ export const Default: Story = { render: () => <Writing placeholder="new" tools /> }
76
+
77
+ /** A reply: no tools, only Send. */
78
+ export const Reply: Story = { render: () => <Writing placeholder="reply" /> }
79
+
80
+ /** A file waiting to be sent: under the text, and Send is ready with no words. */
81
+ export const WithAFile: Story = { render: () => <Writing placeholder="new" tools file /> }