@estiva-app/ui 0.62.0 → 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/MembersDialog.d.ts.map +1 -1
- 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 +237 -207
- 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/MembersDialog.tsx +2 -1
- 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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
3
|
+
|
|
4
|
+
/** As much of a `DataTransfer` as the paste reads. */
|
|
5
|
+
export interface ClipboardSource {
|
|
6
|
+
files?: ArrayLike<File> | null
|
|
7
|
+
getData(format: string): string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** MIME type → the extension a pasted picture is named with. */
|
|
11
|
+
const IMAGE_EXTENSIONS: Record<string, string> = {
|
|
12
|
+
'image/png': 'png',
|
|
13
|
+
'image/jpeg': 'jpg',
|
|
14
|
+
'image/gif': 'gif',
|
|
15
|
+
'image/webp': 'webp',
|
|
16
|
+
'image/heic': 'heic',
|
|
17
|
+
'image/heif': 'heif',
|
|
18
|
+
'image/svg+xml': 'svg',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Names a browser invents for a clipboard picture. A made-up one is better. */
|
|
22
|
+
const GENERIC_STEMS = new Set(['image', 'blob', 'unknown', 'untitled', 'screenshot'])
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
An unlisted type keeps its own subtype rather than becoming `png`, so a
|
|
26
|
+
picture the app does not accept is refused by the app's own message instead
|
|
27
|
+
of arriving disguised as one it does.
|
|
28
|
+
*/
|
|
29
|
+
function extensionFor(contentType: string): string {
|
|
30
|
+
const type = contentType.toLowerCase()
|
|
31
|
+
return IMAGE_EXTENSIONS[type] ?? type.split('/')[1]?.split('+')[0] ?? 'png'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `'Screenshot 2026-09-07 14-32-05.png'`: sortable, and readable on a file card. */
|
|
35
|
+
export function pastedImageName(contentType: string, at: Date = new Date(), index = 0): string {
|
|
36
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
37
|
+
const stamp =
|
|
38
|
+
`${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
|
|
39
|
+
`${pad(at.getHours())}-${pad(at.getMinutes())}-${pad(at.getSeconds())}`
|
|
40
|
+
const nth = index > 0 ? ` (${index + 1})` : ''
|
|
41
|
+
return `Screenshot ${stamp}${nth}.${extensionFor(contentType)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A real filename is kept; `image.png`, `blob` and '' are not names. */
|
|
45
|
+
function hasUsableName(name: string): boolean {
|
|
46
|
+
const dot = name.lastIndexOf('.')
|
|
47
|
+
if (dot <= 0 || dot === name.length - 1) return false
|
|
48
|
+
return !GENERIC_STEMS.has(name.slice(0, dot).toLowerCase())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The pictures on the clipboard, named — `[]` when there is nothing to attach.
|
|
53
|
+
*
|
|
54
|
+
* **Text wins.** A copy from a spreadsheet or a document puts a rendered
|
|
55
|
+
* picture of the selection beside the text; attaching the picture instead of
|
|
56
|
+
* pasting the text is wrong every time. So a clipboard holding text is left to
|
|
57
|
+
* the editor, and a screenshot, which holds none, lands here. Pictures only:
|
|
58
|
+
* any other file comes through the app's own file picker.
|
|
59
|
+
*/
|
|
60
|
+
export function pastedImages(source: ClipboardSource | null | undefined, at: Date = new Date()): File[] {
|
|
61
|
+
if (!source) return []
|
|
62
|
+
if (source.getData('text/plain').trim() !== '') return []
|
|
63
|
+
const images = Array.from(source.files ?? []).filter((f) => f.type.toLowerCase().startsWith('image/'))
|
|
64
|
+
let generated = 0
|
|
65
|
+
return images.map((file) =>
|
|
66
|
+
hasUsableName(file.name)
|
|
67
|
+
? file
|
|
68
|
+
: new File([file], pastedImageName(file.type, at, generated++), { type: file.type, lastModified: file.lastModified }),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PastedImagesOptions {
|
|
73
|
+
/**
|
|
74
|
+
* The pictures a paste carried, named. Return `true` when the app took them
|
|
75
|
+
* (an upload started), `false` to let the editor paste as it would have —
|
|
76
|
+
* for a box that cannot upload right now.
|
|
77
|
+
*/
|
|
78
|
+
onImages: (files: File[]) => boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pasting a picture attaches it: a screenshot pasted into the editor reaches
|
|
83
|
+
* the app as named files, the way the app's own file picker would hand them.
|
|
84
|
+
* Text on the clipboard wins (see {@link pastedImages}).
|
|
85
|
+
*/
|
|
86
|
+
export const PastedImages = Extension.create<PastedImagesOptions>({
|
|
87
|
+
name: 'pastedImages',
|
|
88
|
+
addOptions() {
|
|
89
|
+
return { onImages: () => false }
|
|
90
|
+
},
|
|
91
|
+
addProseMirrorPlugins() {
|
|
92
|
+
const options = this.options
|
|
93
|
+
return [
|
|
94
|
+
new Plugin({
|
|
95
|
+
key: new PluginKey('pastedImages'),
|
|
96
|
+
props: {
|
|
97
|
+
handlePaste: (_view, event) => {
|
|
98
|
+
const files = pastedImages(event.clipboardData)
|
|
99
|
+
return files.length > 0 && options.onImages(files)
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
})
|
package/src/editor.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `Toolbar`, `MenuItem`, `SuggestionMenu` — is the main entry's own, not a copy.
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
|
+
export { PastedImages, pastedImages, pastedImageName, type PastedImagesOptions, type ClipboardSource } from './PastedImages'
|
|
11
12
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
13
|
export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
|
|
13
14
|
export {
|
|
@@ -131,6 +131,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
131
131
|
Card: ['fill', 'hover', 'attention', 'selected', 'active', 'hovered', 'quietUntilHover', 'unreadable', 'clip'],
|
|
132
132
|
Checkbox: ['row'],
|
|
133
133
|
Chip: ['type'],
|
|
134
|
+
KindIcon: ['tone'],
|
|
134
135
|
CollapsibleSection: ['look'],
|
|
135
136
|
Divider: ['tone', 'orientation'],
|
|
136
137
|
EmptyState: ['scope'],
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ export { Link, type LinkProps, type LinkVariant } from './Link'
|
|
|
55
55
|
export { RichText, richTextClassName, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
|
|
56
56
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
57
57
|
export { Kbd, type KbdProps } from './Kbd'
|
|
58
|
+
export { KindIcon, thingKindOf, type KindIconProps, type ThingKind } from './KindIcon'
|
|
58
59
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
59
60
|
export { Tooltip, TooltipProvider, WithTooltip, type TooltipProps, type TooltipProviderProps, type WithTooltipProps } from './Tooltip'
|
|
60
61
|
export { Field, FieldLine, type FieldLineProps, type FieldLineTone, type FieldProps } from './Field'
|
package/src/pages.test.ts
CHANGED
|
@@ -38,7 +38,10 @@ const WAITING: string[] = []
|
|
|
38
38
|
|
|
39
39
|
const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
|
|
40
40
|
/** A part that draws nothing at all says so instead: it only holds what the parts below it ask for. */
|
|
41
|
-
const HOLDS_ONLY: Record<string, string> = {
|
|
41
|
+
const HOLDS_ONLY: Record<string, string> = {
|
|
42
|
+
AppProvider: 'Nothing. It draws nothing; it holds two answers for the parts below it.',
|
|
43
|
+
PastedImages: 'Nothing. It draws nothing; it hands a pasted picture to the app, named.',
|
|
44
|
+
}
|
|
42
45
|
|
|
43
46
|
const dir = new URL('.', import.meta.url)
|
|
44
47
|
const read = (file: string) => readFileSync(new URL(file, dir), 'utf8').replace(/\r\n/g, '\n')
|
|
@@ -26,7 +26,7 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
|
|
|
26
26
|
const root = process.cwd()
|
|
27
27
|
const registry = buildRegistry({ root })
|
|
28
28
|
/** Editor extensions: named like components by the catalogue, and not React components. */
|
|
29
|
-
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands']
|
|
29
|
+
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages']
|
|
30
30
|
const entry = (name: string): RegistryEntry => {
|
|
31
31
|
const found = registry.entries.find((candidate) => candidate.name === name)
|
|
32
32
|
if (!found) throw new Error(`no entry for ${name}`)
|
|
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 139 names over 75 files: 106 components, 32 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The ten `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 106, helper: 32, hook: 1 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(75)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(71)
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(68)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
package/stories/Choosing.mdx
CHANGED
|
@@ -96,7 +96,9 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
96
96
|
| Where am I, and the way back up | **Breadcrumb** |
|
|
97
97
|
| A link — in text, on a title, beside a note, or a whole box | **Link** — `text`, `quiet`, `underlined`, `plain`; `external` for a new tab |
|
|
98
98
|
| A document or an image attached to something, posted or waiting to be sent | **AttachmentCard** — the file decides the shape; `pending` in a composer |
|
|
99
|
+
| A picture pasted into an editor, attached rather than pasted | **PastedImages** — from `@estiva-app/ui/editor`; the app hands it its upload |
|
|
99
100
|
| A person or a thing named inside a sentence | **InlineChip** — one line high, level with the words; `href` makes it a link |
|
|
101
|
+
| What kind of thing a name stands for — a topic, a project, an issue, a file, a message | **KindIcon** — before the name in a row, or inside an **InlineChip**; `thingKindOf` turns an event kind into one |
|
|
100
102
|
| Formatted text: headings, lists, a quote, code, a table, links | **RichText** — pass the parsed tree; `small` inside a card |
|
|
101
103
|
| The key that does this too | **Kbd** — or the `shortcut` prop on **MenuItem**, **Tooltip** and **SearchInput** |
|
|
102
104
|
|