@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.
@@ -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> = { AppProvider: 'Nothing. It draws nothing; it holds two answers for the parts below it.' }
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 134 names over 73 files: 104 components, 29 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, and AppProvider on 8 October)', () => {
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: 104, helper: 29, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(73)
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(69)
64
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
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', () => {
@@ -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