@estiva-app/ui 0.62.1 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.62.1",
3
+ "version": "0.63.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.62.1",
8
- "exports": 134,
9
- "typeExports": 141,
7
+ "packageVersion": "0.63.0",
8
+ "exports": 139,
9
+ "typeExports": 145,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -3228,6 +3228,68 @@
3228
3228
  "app": null,
3229
3229
  "looks": []
3230
3230
  },
3231
+ {
3232
+ "name": "KindIcon",
3233
+ "repo": "estiva-ui",
3234
+ "kind": "component",
3235
+ "importPath": "@estiva-app/ui",
3236
+ "sourceFile": "src/KindIcon.tsx",
3237
+ "purpose": "The icon before a thing's name that says what kind of thing it is: a topic, a project, an issue, another app's file, or a message.",
3238
+ "purposeFrom": "page",
3239
+ "props": [
3240
+ {
3241
+ "name": "kind",
3242
+ "takes": "topic | project | issue | file | message",
3243
+ "required": true,
3244
+ "note": null
3245
+ },
3246
+ {
3247
+ "name": "size",
3248
+ "takes": "14 | 16",
3249
+ "required": false,
3250
+ "note": "16 in a row or a list; 14 inside a chip."
3251
+ },
3252
+ {
3253
+ "name": "tone",
3254
+ "takes": "secondary | inherit",
3255
+ "required": false,
3256
+ "note": "`secondary`: the quieter grey a list row's icon wears."
3257
+ },
3258
+ {
3259
+ "name": "className",
3260
+ "takes": "text",
3261
+ "required": false,
3262
+ "note": "Space and position only."
3263
+ }
3264
+ ],
3265
+ "variants": [
3266
+ {
3267
+ "prop": "kind",
3268
+ "values": [
3269
+ "topic",
3270
+ "project",
3271
+ "issue",
3272
+ "file",
3273
+ "message"
3274
+ ]
3275
+ },
3276
+ {
3277
+ "prop": "tone",
3278
+ "values": [
3279
+ "secondary",
3280
+ "inherit"
3281
+ ]
3282
+ }
3283
+ ],
3284
+ "ownsBehaviours": [],
3285
+ "status": "stable",
3286
+ "migrationStage": null,
3287
+ "docsId": "primitives-kindicon--docs",
3288
+ "storyId": "primitives-kindicon--default",
3289
+ "docPage": "src/KindIcon.mdx",
3290
+ "app": null,
3291
+ "looks": []
3292
+ },
3231
3293
  {
3232
3294
  "name": "Lightbox",
3233
3295
  "repo": "estiva-ui",
@@ -4413,6 +4475,63 @@
4413
4475
  "app": null,
4414
4476
  "looks": []
4415
4477
  },
4478
+ {
4479
+ "name": "pastedImageName",
4480
+ "repo": "estiva-ui",
4481
+ "kind": "helper",
4482
+ "importPath": "@estiva-app/ui/editor",
4483
+ "sourceFile": "src/PastedImages.ts",
4484
+ "purpose": "`'Screenshot 2026-09-07 14-32-05.png'`: sortable, and readable on a file card.",
4485
+ "purposeFrom": "comment",
4486
+ "props": [],
4487
+ "variants": [],
4488
+ "ownsBehaviours": [],
4489
+ "status": "stable",
4490
+ "migrationStage": null,
4491
+ "docsId": "primitives-pastedimages--docs",
4492
+ "storyId": "primitives-pastedimages--in-an-editor",
4493
+ "docPage": null,
4494
+ "app": null,
4495
+ "looks": []
4496
+ },
4497
+ {
4498
+ "name": "pastedImages",
4499
+ "repo": "estiva-ui",
4500
+ "kind": "helper",
4501
+ "importPath": "@estiva-app/ui/editor",
4502
+ "sourceFile": "src/PastedImages.ts",
4503
+ "purpose": "The pictures on the clipboard, named — `[]` when there is nothing to attach.",
4504
+ "purposeFrom": "comment",
4505
+ "props": [],
4506
+ "variants": [],
4507
+ "ownsBehaviours": [],
4508
+ "status": "stable",
4509
+ "migrationStage": null,
4510
+ "docsId": "primitives-pastedimages--docs",
4511
+ "storyId": "primitives-pastedimages--in-an-editor",
4512
+ "docPage": null,
4513
+ "app": null,
4514
+ "looks": []
4515
+ },
4516
+ {
4517
+ "name": "PastedImages",
4518
+ "repo": "estiva-ui",
4519
+ "kind": "component",
4520
+ "importPath": "@estiva-app/ui/editor",
4521
+ "sourceFile": "src/PastedImages.ts",
4522
+ "purpose": "Pasting a picture into an editor attaches it.",
4523
+ "purposeFrom": "page",
4524
+ "props": [],
4525
+ "variants": [],
4526
+ "ownsBehaviours": [],
4527
+ "status": "stable",
4528
+ "migrationStage": null,
4529
+ "docsId": "primitives-pastedimages--docs",
4530
+ "storyId": "primitives-pastedimages--in-an-editor",
4531
+ "docPage": "src/PastedImages.mdx",
4532
+ "app": null,
4533
+ "looks": []
4534
+ },
4416
4535
  {
4417
4536
  "name": "peopleMenu",
4418
4537
  "repo": "estiva-ui",
@@ -5161,7 +5280,13 @@
5161
5280
  "name": "icon",
5162
5281
  "takes": "anything",
5163
5282
  "required": true,
5164
- "note": "At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`)."
5283
+ "note": "At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`), or `iconFor` answers."
5284
+ },
5285
+ {
5286
+ "name": "iconFor",
5287
+ "takes": "a handler",
5288
+ "required": false,
5289
+ "note": "A row's 16px icon from what it points at — its `uri` — when the list holds several kinds of thing; `undefined` from it draws `icon`."
5165
5290
  }
5166
5291
  ],
5167
5292
  "variants": [],
@@ -6410,6 +6535,25 @@
6410
6535
  "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-caption text-text-primary placeholder:text-text-muted outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field signal:transition-shadow signal:focus:shadow-focus-ring"
6411
6536
  ]
6412
6537
  },
6538
+ {
6539
+ "name": "thingKindOf",
6540
+ "repo": "estiva-ui",
6541
+ "kind": "helper",
6542
+ "importPath": "@estiva-app/ui",
6543
+ "sourceFile": "src/KindIcon.tsx",
6544
+ "purpose": "The kind of thing behind an event kind: a message is a message, a channel or a bare file is a topic, a project and an issue are themselves, and anything else is another app's file.",
6545
+ "purposeFrom": "comment",
6546
+ "props": [],
6547
+ "variants": [],
6548
+ "ownsBehaviours": [],
6549
+ "status": "stable",
6550
+ "migrationStage": null,
6551
+ "docsId": "primitives-kindicon--docs",
6552
+ "storyId": "primitives-kindicon--default",
6553
+ "docPage": null,
6554
+ "app": null,
6555
+ "looks": []
6556
+ },
6413
6557
  {
6414
6558
  "name": "Toast",
6415
6559
  "repo": "estiva-ui",
@@ -92,10 +92,12 @@ const urgent = editor ? urgentPubkeys(editor) : undefined
92
92
  the same query; nothing waits for it. A row's `description` is its second
93
93
  line, which the chip does not wear. A row the person has arrowed to stays
94
94
  highlighted when the list is drawn again.
95
- - **Several kinds of thing in one `[` list:** a row's `icon` is drawn in
96
- place of the list's, and the chip's `iconFor(uri)` picks the chip's from
97
- what it points at, so the chip wears what its row wore. A row with no `icon`,
98
- or a `uri` `iconFor` answers `undefined` for, keeps the one icon.
95
+ - **Several kinds of thing in one `[` list:** give `ReferenceTrigger` and
96
+ `CaptionedReference` the same `iconFor(uri)` — at 16px for the rows, 14px
97
+ for the chip — and every row and the chip it inserts wear what they point
98
+ at. **KindIcon** is the answer for Estiva's own kinds. A row's own `icon`
99
+ wins over `iconFor`; a row with neither, or a `uri` `iconFor` answers
100
+ `undefined` for, keeps the list's one icon.
99
101
 
100
102
  <Canvas of={ComposerTriggersStories.ReferencesOfSeveralKinds} />
101
103
 
@@ -145,6 +145,36 @@ describe('[', () => {
145
145
  expect(editor!.getHTML()).toContain('data-message-mention="true"')
146
146
  })
147
147
 
148
+ it('gives each row the icon `iconFor` answers for its uri; a row’s own icon wins, and no answer keeps the list’s', async () => {
149
+ const rows = [
150
+ { ...ITEMS[0], uri: 'nostr:a' },
151
+ { ...ITEMS[1], uri: 'nostr:b', icon: <i data-icon="own" /> },
152
+ { ...ITEMS[2], uri: 'nostr:c' },
153
+ ]
154
+ function Iconed() {
155
+ const made = useEditor({
156
+ extensions: [
157
+ StarterKit,
158
+ Chip,
159
+ ReferenceTrigger.configure({
160
+ items: () => rows,
161
+ nodeName: 'messageMention',
162
+ ariaLabel: 'Things',
163
+ sectionLabel: 'Things',
164
+ icon: <i data-icon="list" />,
165
+ iconFor: (uri) => (uri === 'nostr:a' ? <i data-icon="kind-a" /> : undefined),
166
+ }),
167
+ ],
168
+ })
169
+ editor = made
170
+ return <EditorContent editor={made} />
171
+ }
172
+ render(<Iconed />)
173
+ await type('[')
174
+ const icons = within(screen.getByRole('listbox')).getAllByRole('option').map((row) => row.querySelector('[data-icon]')?.getAttribute('data-icon'))
175
+ expect(icons).toEqual(['kind-a', 'own', 'list'])
176
+ })
177
+
148
178
  it('draws the app’s sections in order, skips an empty one, and redraws the open list when told', async () => {
149
179
  const listeners = new Set<() => void>()
150
180
  let late: CaptionedItem[] = []
@@ -321,8 +321,14 @@ export interface ReferenceMenuOptions {
321
321
  ariaLabel: string
322
322
  /** The section's heading. */
323
323
  sectionLabel: string
324
- /** At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`). */
324
+ /** At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`), or `iconFor` answers. */
325
325
  icon: ReactNode
326
+ /**
327
+ * A row's 16px icon from what it points at — its `uri` — when the list holds
328
+ * several kinds of thing; `undefined` from it draws `icon`. Give the chip the
329
+ * same answer at 14px (`CaptionedReference`'s `iconFor`), so a pick keeps it.
330
+ */
331
+ iconFor?: (uri: string) => ReactNode | undefined
326
332
  }
327
333
 
328
334
  /** A row as the list holds it: which section it is drawn under, when the list has several. */
@@ -333,7 +339,7 @@ type ListedItem = CaptionedItem & { section?: string }
333
339
  * One section headed `sectionLabel`, or — when the rows say which section they
334
340
  * are in — a heading for each, in the order the rows arrive.
335
341
  */
336
- export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
342
+ export function referenceMenu({ ariaLabel, sectionLabel, icon, iconFor }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
337
343
  return {
338
344
  ariaLabel,
339
345
  width: 'w-[658px]',
@@ -349,7 +355,7 @@ export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOp
349
355
  return groups
350
356
  },
351
357
  itemKey: (item) => `reference-${item.id}`,
352
- row: (item) => ({ leading: item.icon ?? icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
358
+ row: (item) => ({ leading: item.icon ?? iconFor?.(item.uri) ?? icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
353
359
  }
354
360
  }
355
361
 
@@ -0,0 +1,58 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as KindIconStories from './KindIcon.stories'
3
+
4
+ <Meta of={KindIconStories} />
5
+
6
+ # KindIcon
7
+
8
+ The icon before a thing's name that says what kind of thing it is: a topic, a
9
+ project, an issue, another app's file, or a message. One table for every app,
10
+ so the same thing wears the same icon wherever it is named.
11
+
12
+ <Canvas of={KindIconStories.EveryKind} />
13
+
14
+ ## When
15
+
16
+ - **Before a thing's name in a list row**, at 16px: a `[` list row, a recent
17
+ file, a search result.
18
+ - **Inside a chip**, at 14px: `InlineChip`'s `icon` when a sentence names a
19
+ thing.
20
+ - **As `iconFor` of the `[` list and its chip** (`ReferenceTrigger`,
21
+ `CaptionedReference`), so every row and every chip it inserts wears its own
22
+ kind.
23
+
24
+ <Canvas of={KindIconStories.InAChip} />
25
+
26
+ ## When not
27
+
28
+ - A person → **Avatar** or **Person**. A person is a face, not a kind.
29
+ - A status (open, resolved) → **Chip**, or the app's own status mark.
30
+ - An icon for an action → **IconButton** with the icon you need.
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { KindIcon, thingKindOf } from '@estiva-app/ui'
36
+
37
+ <KindIcon kind="issue" /> // 16px, in a row
38
+ <KindIcon kind={thingKindOf(event.kind)} size={14} tone="inherit" /> // inside a chip
39
+ ```
40
+
41
+ - `thingKindOf(eventKind)` turns an event kind into a kind of thing. A pointer
42
+ written as `nostr:naddr…` carries its kind once decoded; a `nostr:nevent…`
43
+ is a message.
44
+ - The icon is `aria-hidden`: the name beside it says what it is.
45
+ - **Which icon is which kind is this part's.** An app never picks its own icon
46
+ for these. Its stroke is 1.5; it comes in two sizes and two tones: the
47
+ quieter grey of a list row, or `inherit`, the ink of the chip or row that
48
+ holds it.
49
+
50
+ <Canvas of={KindIconStories.InAList} />
51
+
52
+ ## What it owns
53
+
54
+ Nothing. It only draws. Clicks and keys are yours.
55
+
56
+ ## Props
57
+
58
+ <Controls of={KindIconStories.Default} />
@@ -0,0 +1,64 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { InlineChip } from './InlineChip'
3
+ import { KindIcon, type ThingKind } from './KindIcon'
4
+ import { MenuItem, MenuPanel, MenuSection } from './Menu'
5
+
6
+ const meta = {
7
+ title: 'Primitives/KindIcon',
8
+ component: KindIcon,
9
+ args: { kind: 'topic' },
10
+ argTypes: { kind: { control: 'select', options: ['topic', 'project', 'issue', 'file', 'message'] } },
11
+ } satisfies Meta<typeof KindIcon>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ const KINDS: { kind: ThingKind; name: string }[] = [
17
+ { kind: 'topic', name: 'Topic' },
18
+ { kind: 'project', name: 'Project' },
19
+ { kind: 'issue', name: 'Issue' },
20
+ { kind: 'file', name: 'Another app’s file' },
21
+ { kind: 'message', name: 'Message' },
22
+ ]
23
+
24
+ export const Default: Story = {}
25
+
26
+ /** Every kind, at 16px, beside its name. */
27
+ export const EveryKind: Story = {
28
+ parameters: { controls: { disable: true } },
29
+ render: () => (
30
+ <div className="flex flex-col gap-2 text-body-2 text-text-primary">
31
+ {KINDS.map(({ kind, name }) => (
32
+ <div key={kind} className="flex items-center gap-2">
33
+ <KindIcon kind={kind} />
34
+ {name}
35
+ </div>
36
+ ))}
37
+ </div>
38
+ ),
39
+ }
40
+
41
+ /** At 14px inside a chip, the way a sentence names a thing. */
42
+ export const InAChip: Story = {
43
+ parameters: { controls: { disable: true } },
44
+ render: () => (
45
+ <p className="text-body-2 text-text-primary">
46
+ Tracked in <InlineChip icon={<KindIcon kind="issue" size={14} tone="inherit" />}>Ship the block model</InlineChip> and{' '}
47
+ <InlineChip icon={<KindIcon kind="topic" size={14} tone="inherit" />}>Planning</InlineChip> for now.
48
+ </p>
49
+ ),
50
+ }
51
+
52
+ /** At the start of list rows, each saying what it points at. */
53
+ export const InAList: Story = {
54
+ parameters: { controls: { disable: true } },
55
+ render: () => (
56
+ <MenuPanel className="w-72">
57
+ <MenuSection label="Files">
58
+ {KINDS.map(({ kind, name }) => (
59
+ <MenuItem key={kind} label={name} leading={<KindIcon kind={kind} />} onClick={() => {}} />
60
+ ))}
61
+ </MenuSection>
62
+ </MenuPanel>
63
+ ),
64
+ }
@@ -0,0 +1,64 @@
1
+ import { IconBox, IconCircleDashed, IconFile, IconListDetails, IconMessage } from '@tabler/icons-react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * What a thing is, as the apps name it: a topic, a project, an issue, another
6
+ * app's file, or a message.
7
+ *
8
+ * The one part that names Estiva's own kinds of thing (Katerina, 8 October):
9
+ * every app draws the same icon for the same kind, so the table lives here once
10
+ * instead of in each app.
11
+ */
12
+ export type ThingKind = 'topic' | 'project' | 'issue' | 'file' | 'message'
13
+
14
+ const ICONS = {
15
+ topic: IconCircleDashed,
16
+ project: IconBox,
17
+ issue: IconListDetails,
18
+ file: IconFile,
19
+ message: IconMessage,
20
+ } satisfies Record<ThingKind, unknown>
21
+
22
+ /*
23
+ The event kinds behind them (SPEC §6.7 and each app's manifest). Numbers
24
+ rather than an import: this package depends on no protocol code.
25
+ */
26
+ const KIND_MESSAGE = 9
27
+ const KIND_CHANNEL = 39000
28
+ const KIND_BARE_FILE = 30840
29
+ const KIND_PROJECT = 30850
30
+ const KIND_ISSUE = 30851
31
+
32
+ /**
33
+ * The kind of thing behind an event kind: a message is a message, a channel or
34
+ * a bare file is a topic, a project and an issue are themselves, and anything
35
+ * else is another app's file.
36
+ */
37
+ export function thingKindOf(eventKind: number | undefined): ThingKind {
38
+ if (eventKind === KIND_MESSAGE) return 'message'
39
+ if (eventKind === KIND_CHANNEL || eventKind === KIND_BARE_FILE) return 'topic'
40
+ if (eventKind === KIND_PROJECT) return 'project'
41
+ if (eventKind === KIND_ISSUE) return 'issue'
42
+ return 'file'
43
+ }
44
+
45
+ export interface KindIconProps {
46
+ kind: ThingKind
47
+ /** 16 in a row or a list; 14 inside a chip. Default 16. */
48
+ size?: 14 | 16
49
+ /**
50
+ * `secondary`: the quieter grey a list row's icon wears. `inherit`: the ink
51
+ * of what holds it — a chip, a selected row. Default `secondary`.
52
+ */
53
+ tone?: 'secondary' | 'inherit'
54
+ /** Space and position only. */
55
+ className?: string
56
+ }
57
+
58
+ const TONE_CLASSES = { secondary: 'text-text-secondary', inherit: '' } as const
59
+
60
+ /** The icon before a thing's name that says what kind of thing it is. */
61
+ export function KindIcon({ kind, size = 16, tone = 'secondary', className }: KindIconProps) {
62
+ const Icon = ICONS[kind]
63
+ return <Icon size={size} stroke={1.5} className={cn('shrink-0', TONE_CLASSES[tone], className)} aria-hidden />
64
+ }
@@ -0,0 +1,61 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as PastedImagesStories from './PastedImages.stories'
3
+
4
+ <Meta of={PastedImagesStories} />
5
+
6
+ # PastedImages
7
+
8
+ Pasting a picture into an editor attaches it. A screenshot on the clipboard
9
+ reaches the app as named files, the same as a pick from its file picker.
10
+
11
+ <Canvas of={PastedImagesStories.InAnEditor} />
12
+
13
+ ## When
14
+
15
+ - **Any editor that can attach files**: a message box, an edit box, a
16
+ description. Give it the same upload the paperclip uses.
17
+
18
+ ## When not
19
+
20
+ - A box that cannot attach files → leave it out; a pasted picture then does
21
+ nothing, which is the honest answer.
22
+ - Any file other than a picture → the app's file picker. A paste attaches
23
+ pictures only.
24
+ - Drawing the attached file → **AttachmentCard**, `pending` while it uploads.
25
+
26
+ ## How
27
+
28
+ ```tsx
29
+ import StarterKit from '@tiptap/starter-kit'
30
+ import { PastedImages } from '@estiva-app/ui/editor'
31
+
32
+ useEditor({
33
+ extensions: [
34
+ StarterKit,
35
+ PastedImages.configure({
36
+ // Return true when the upload started; false lets the editor paste as usual.
37
+ onImages: (files) => {
38
+ if (!canUpload) return false
39
+ uploads.add(files)
40
+ return true
41
+ },
42
+ }),
43
+ ],
44
+ })
45
+ ```
46
+
47
+ - **Text wins.** A copy from a spreadsheet or a document carries a picture of
48
+ the selection beside its text. That paste stays text.
49
+ - **A screenshot gets a name**: `Screenshot 2026-09-07 14-32-05.png`, with
50
+ `(2)`, `(3)` when one paste holds several. A picture that already has a real
51
+ name keeps it.
52
+ - A picture type the app does not accept keeps its own extension, so the app's
53
+ own check refuses it by name.
54
+ - `pastedImages(clipboardData)` is the same rule as a function, for an editor
55
+ that handles its own `handlePaste`.
56
+ - Which clipboard contents count as pictures, and their names, are this
57
+ part's. The upload, its limits and its messages are the app's.
58
+
59
+ ## What it owns
60
+
61
+ Nothing. It draws nothing; it hands a pasted picture to the app, named.
@@ -0,0 +1,53 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { EditorContent, useEditor } from '@tiptap/react'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import { AttachmentCard } from './AttachmentCard'
6
+ import { Card } from './Card'
7
+ import { PastedImages } from './PastedImages'
8
+ import { richTextClassName } from './RichText'
9
+
10
+ /**
11
+ * Paste a screenshot into the box: it arrives as a named file under the text,
12
+ * the way the app's file picker would hand it. Copy some text and paste it:
13
+ * the text wins.
14
+ */
15
+ const meta = {
16
+ title: 'Primitives/PastedImages',
17
+ parameters: { layout: 'padded' },
18
+ } satisfies Meta
19
+
20
+ export default meta
21
+ type Story = StoryObj
22
+
23
+ function Box() {
24
+ const [files, setFiles] = useState<File[]>([])
25
+ const editor = useEditor({
26
+ extensions: [
27
+ StarterKit,
28
+ PastedImages.configure({
29
+ onImages: (pasted) => {
30
+ setFiles((held) => [...held, ...pasted])
31
+ return true
32
+ },
33
+ }),
34
+ ],
35
+ content: '<p>Paste a screenshot here.</p>',
36
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
37
+ })
38
+ return (
39
+ <Card fill="surface" className="flex w-[520px] flex-col gap-2 p-3">
40
+ <EditorContent editor={editor} />
41
+ {files.length > 0 && (
42
+ <div className="flex flex-wrap gap-2">
43
+ {files.map((file, i) => (
44
+ <AttachmentCard key={`${file.name}-${i}`} name={file.name} size={file.size} contentType={file.type} />
45
+ ))}
46
+ </div>
47
+ )}
48
+ </Card>
49
+ )
50
+ }
51
+
52
+ /** A box that attaches what is pasted. Try it with a screenshot on the clipboard. */
53
+ export const InAnEditor: Story = { render: () => <Box /> }
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import { PastedImages, pastedImageName, pastedImages, type ClipboardSource } from './PastedImages'
6
+
7
+ const AT = new Date(2026, 8, 7, 14, 32, 5) // 2026-09-07 14:32:05, local
8
+
9
+ /** A clipboard as the paste sees it. */
10
+ function clipboard(text: string, files: File[] = []): ClipboardSource {
11
+ return { files, getData: (format) => (format === 'text/plain' ? text : '') }
12
+ }
13
+
14
+ const png = (name = 'image.png', type = 'image/png') => new File([new Uint8Array([1, 2, 3])], name, { type })
15
+
16
+ describe('pastedImages', () => {
17
+ it('names a screenshot after the moment it was pasted', () => {
18
+ expect(pastedImageName('image/png', AT)).toBe('Screenshot 2026-09-07 14-32-05.png')
19
+ expect(pastedImageName('image/jpeg', AT)).toBe('Screenshot 2026-09-07 14-32-05.jpg')
20
+ expect(pastedImageName('image/png', AT, 1)).toBe('Screenshot 2026-09-07 14-32-05 (2).png')
21
+ })
22
+
23
+ it('gives an unlisted picture its own extension, so the app can refuse it by name', () => {
24
+ expect(pastedImageName('image/tiff', AT)).toBe('Screenshot 2026-09-07 14-32-05.tiff')
25
+ })
26
+
27
+ it('attaches a screenshot, renamed, its bytes and type kept', () => {
28
+ const [file] = pastedImages(clipboard('', [png()]), AT)
29
+ expect(file.name).toBe('Screenshot 2026-09-07 14-32-05.png')
30
+ expect(file.type).toBe('image/png')
31
+ expect(file.size).toBe(3)
32
+ })
33
+
34
+ it('leaves the clipboard to the editor when it carries text', () => {
35
+ expect(pastedImages(clipboard('Q3 revenue\t120', [png()]), AT)).toEqual([])
36
+ expect(pastedImages(clipboard(' ', [png()]), AT)).toHaveLength(1)
37
+ })
38
+
39
+ it('keeps a real filename', () => {
40
+ const [file] = pastedImages(clipboard('', [png('quarterly-chart.png')]), AT)
41
+ expect(file.name).toBe('quarterly-chart.png')
42
+ })
43
+
44
+ it('names several pictures in one paste apart', () => {
45
+ const files = pastedImages(clipboard('', [png(), png(), png('kept.png')]), AT)
46
+ expect(files.map((f) => f.name)).toEqual(['Screenshot 2026-09-07 14-32-05.png', 'Screenshot 2026-09-07 14-32-05 (2).png', 'kept.png'])
47
+ })
48
+
49
+ it('ignores anything that is not a picture, and an empty clipboard', () => {
50
+ const pdf = new File([new Uint8Array([1])], 'report.pdf', { type: 'application/pdf' })
51
+ expect(pastedImages(clipboard('', [pdf]), AT)).toEqual([])
52
+ expect(pastedImages(clipboard(''), AT)).toEqual([])
53
+ expect(pastedImages(null, AT)).toEqual([])
54
+ })
55
+ })
56
+
57
+ describe('PastedImages', () => {
58
+ /** A paste event as ProseMirror hands it to `handlePaste`. */
59
+ const paste = (editor: Editor, data: ClipboardSource) => {
60
+ const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent
61
+ Object.defineProperty(event, 'clipboardData', { value: data })
62
+ let handled = false
63
+ editor.view.someProp('handlePaste', (f) => (handled = !!f(editor.view, event, editor.state.doc.slice(0, 0)) || handled))
64
+ return handled
65
+ }
66
+
67
+ it('hands a pasted screenshot to the app, named, and stops the editor pasting it', () => {
68
+ const onImages = vi.fn(() => true)
69
+ const editor = new Editor({ extensions: [StarterKit, PastedImages.configure({ onImages })] })
70
+ expect(paste(editor, clipboard('', [png()]))).toBe(true)
71
+ expect(onImages).toHaveBeenCalledWith([expect.objectContaining({ name: expect.stringMatching(/^Screenshot .*\.png$/) })])
72
+ editor.destroy()
73
+ })
74
+
75
+ it('lets the editor paste when the app declines, or when there is text', () => {
76
+ const onImages = vi.fn(() => false)
77
+ const editor = new Editor({ extensions: [StarterKit, PastedImages.configure({ onImages })] })
78
+ expect(paste(editor, clipboard('', [png()]))).toBe(false)
79
+ expect(paste(editor, clipboard('a sentence', [png()]))).toBe(false)
80
+ expect(onImages).toHaveBeenCalledTimes(1)
81
+ editor.destroy()
82
+ })
83
+ })