@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/dist/ComposerTriggers.d.ts +8 -2
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/KindIcon.d.ts +30 -0
- package/dist/KindIcon.d.ts.map +1 -0
- package/dist/PastedImages.d.ts +33 -0
- package/dist/PastedImages.d.ts.map +1 -0
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +74 -13
- package/dist/editor.js.map +4 -4
- package/dist/eslint/index.js +1 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +236 -206
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +148 -4
- package/src/ComposerTriggers.mdx +6 -4
- package/src/ComposerTriggers.test.tsx +30 -0
- package/src/ComposerTriggers.tsx +9 -3
- package/src/KindIcon.mdx +58 -0
- package/src/KindIcon.stories.tsx +64 -0
- package/src/KindIcon.tsx +64 -0
- package/src/PastedImages.mdx +61 -0
- package/src/PastedImages.stories.tsx +53 -0
- package/src/PastedImages.test.ts +83 -0
- package/src/PastedImages.ts +105 -0
- package/src/editor.ts +1 -0
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +1 -0
- package/src/pages.test.ts +4 -1
- package/src/registry/registry.test.ts +6 -6
- package/stories/Choosing.mdx +2 -0
package/package.json
CHANGED
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
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",
|
package/src/ComposerTriggers.mdx
CHANGED
|
@@ -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:**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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[] = []
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -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
|
|
package/src/KindIcon.mdx
ADDED
|
@@ -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
|
+
}
|
package/src/KindIcon.tsx
ADDED
|
@@ -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
|
+
})
|