@estiva-app/ui 0.62.1 → 0.64.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 (54) hide show
  1. package/dist/CodeBlockColours.d.ts +11 -0
  2. package/dist/CodeBlockColours.d.ts.map +1 -0
  3. package/dist/ComposerTriggers.d.ts +8 -2
  4. package/dist/ComposerTriggers.d.ts.map +1 -1
  5. package/dist/KindIcon.d.ts +30 -0
  6. package/dist/KindIcon.d.ts.map +1 -0
  7. package/dist/Link.d.ts +1 -1
  8. package/dist/PastedImages.d.ts +33 -0
  9. package/dist/PastedImages.d.ts.map +1 -0
  10. package/dist/RichText.d.ts +2 -1
  11. package/dist/RichText.d.ts.map +1 -1
  12. package/dist/codeColours.d.ts +29 -0
  13. package/dist/codeColours.d.ts.map +1 -0
  14. package/dist/editor.d.ts +2 -0
  15. package/dist/editor.d.ts.map +1 -1
  16. package/dist/editor.js +86 -13
  17. package/dist/editor.js.map +4 -4
  18. package/dist/eslint/index.js +1 -0
  19. package/dist/eslint/index.js.map +2 -2
  20. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +291 -237
  24. package/dist/index.js.map +4 -4
  25. package/package.json +8 -2
  26. package/registry.json +179 -16
  27. package/src/CodeBlockColours.mdx +48 -0
  28. package/src/CodeBlockColours.stories.tsx +37 -0
  29. package/src/CodeBlockColours.ts +14 -0
  30. package/src/ComposerTriggers.mdx +6 -4
  31. package/src/ComposerTriggers.test.tsx +30 -0
  32. package/src/ComposerTriggers.tsx +9 -3
  33. package/src/KindIcon.mdx +58 -0
  34. package/src/KindIcon.stories.tsx +64 -0
  35. package/src/KindIcon.tsx +64 -0
  36. package/src/Link.mdx +1 -1
  37. package/src/Link.stories.tsx +1 -1
  38. package/src/Link.test.tsx +2 -2
  39. package/src/Link.tsx +2 -2
  40. package/src/PastedImages.mdx +61 -0
  41. package/src/PastedImages.stories.tsx +53 -0
  42. package/src/PastedImages.test.ts +83 -0
  43. package/src/PastedImages.ts +105 -0
  44. package/src/RichText.stories.tsx +24 -0
  45. package/src/RichText.tsx +31 -17
  46. package/src/codeColours.ts +33 -0
  47. package/src/editor.ts +2 -0
  48. package/src/eslint/no-restyled-part.ts +1 -0
  49. package/src/index.ts +1 -0
  50. package/src/pages.test.ts +5 -1
  51. package/src/registry/registry.test.ts +7 -7
  52. package/stories/Choosing.mdx +3 -0
  53. package/tailwind-preset.js +8 -0
  54. package/tokens.css +50 -0
@@ -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
+ }
package/src/Link.mdx CHANGED
@@ -14,7 +14,7 @@ the app decides how it navigates.
14
14
 
15
15
  - **It takes the size and colour of the text around it**: put it inside that
16
16
  text.
17
- - **`text`** — a link written inside text: the info colour, always
17
+ - **`text`** — a link written inside text: the theme's link colour, always
18
18
  underlined, dimming on hover.
19
19
  - **`quiet`** — a title or a time that is also a link. It takes the colour and
20
20
  size of the text it sits in, and underlines on hover.
@@ -15,7 +15,7 @@ const meta = {
15
15
  export default meta
16
16
  type Story = StoryObj<typeof meta>
17
17
 
18
- /** Inside written text: the info colour, always underlined. Hover dims it. */
18
+ /** Inside written text: the theme's link colour, always underlined. Hover dims it. */
19
19
  export const Text: Story = {
20
20
  render: (args) => (
21
21
  <p className="max-w-[480px] text-body-2 text-text-primary">
package/src/Link.test.tsx CHANGED
@@ -53,10 +53,10 @@ describe('Link', () => {
53
53
  expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
54
54
  })
55
55
 
56
- it('text is the info colour, always underlined, and is the default', () => {
56
+ it('text is the link colour, always underlined, and is the default', () => {
57
57
  render(<Link href="#">Label</Link>)
58
58
  const classes = classesOf(screen.getByRole('link'))
59
- expect(classes).toContain('text-info-default')
59
+ expect(classes).toContain('text-text-interactive')
60
60
  expect(classes).toContain('underline')
61
61
  expect(classes).toContain('underline-offset-2')
62
62
  })
package/src/Link.tsx CHANGED
@@ -9,7 +9,7 @@ import { cn } from './cn'
9
9
  * on 13 September 2026, in three jobs (UIG-27). The looks are the ones those
10
10
  * links already had, reduced by Katerina's ruling:
11
11
  *
12
- * - `text` — a link inside written text: the info colour, always underlined,
12
+ * - `text` — a link inside written text: the theme's link colour, always underlined,
13
13
  * dimming on hover. The look a link in a body of text already had.
14
14
  * - `quiet` — a title or a timestamp that is also a link. It takes the colour
15
15
  * and size of the text it sits in and underlines on hover. Two looks that
@@ -43,7 +43,7 @@ export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
43
43
  const COVER_CLASSES = 'after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:[outline-style:auto]'
44
44
 
45
45
  const VARIANT_CLASSES: Record<LinkVariant, string> = {
46
- text: 'text-info-default underline underline-offset-2 hover:opacity-80',
46
+ text: 'text-text-interactive underline underline-offset-2 hover:opacity-80',
47
47
  quiet: 'hover:underline',
48
48
  underlined: 'underline underline-offset-2 hover:text-text-primary',
49
49
  plain: '',
@@ -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
+ })
@@ -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
+ })
@@ -57,6 +57,7 @@ export const Links: Story = {
57
57
  blocks: [
58
58
  p(t('A link with its own words: [the guide](https://example.com/guide). A bare address: https://example.com/a/long/address/that/breaks/where/it/must.')),
59
59
  p(t('Inside code it stays text: '), t('[not a link](https://example.com)', 'code')),
60
+ p(t('An '), t('underlined', 'underline'), t(' word beside [a link](https://example.com), and '), t('item.count', 'code'), t(' in a sentence.')),
60
61
  ],
61
62
  },
62
63
  }
@@ -89,3 +90,26 @@ export const UnknownBlock: Story = {
89
90
  blocks: [p(t('The block below is of a kind this part does not know.')), { type: 'unknown', typeName: 'later', inline: [t('Its words are still here.')] }],
90
91
  },
91
92
  }
93
+
94
+ /** A code block in a named language. */
95
+ export const CodeInALanguage: Story = {
96
+ args: {
97
+ blocks: [
98
+ {
99
+ type: 'codeBlock',
100
+ language: 'ts',
101
+ inline: [
102
+ t(
103
+ [
104
+ '// Count the items that are done',
105
+ 'export function countDone(items: Item[]): number {',
106
+ " const done = items.filter((item) => item.state === 'done')",
107
+ ' return done.length + 0',
108
+ '}',
109
+ ].join('\n'),
110
+ ),
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ }
package/src/RichText.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { MouseEvent, ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { Link } from './Link'
4
+ import { codeColours } from './codeColours'
4
5
 
5
6
  /**
6
7
  * Formatted text, drawn: headings, paragraphs, lists, a quote, code, a divider,
@@ -11,7 +12,8 @@ import { Link } from './Link'
11
12
  * and the two had drifted: headings, list markers, quotes and code differed,
12
13
  * one app had tables and the other dropped them, one had links and the other
13
14
  * printed the brackets (UIG-30). The look here is Katerina's picks of
14
- * 28 September, one per difference, most of them Peek's.
15
+ * 28 September, one per difference, most of them Peek's, with the room and
16
+ * colours she changed on 9 October.
15
17
  *
16
18
  * **It takes the tree, not the text.** The caller parses (the format comes from
17
19
  * the item's tag, never from the body) and hands the blocks in. So this package
@@ -84,21 +86,32 @@ export interface RichTextProps {
84
86
  * paragraph inside each list item, quote and table cell (the `> p` lines).
85
87
  */
86
88
  const RICH_TEXT_CLASSES = {
87
- base: 'flex flex-col gap-1 break-words text-text-secondary',
88
- /* A heading binds to what follows it: 12px above (8 and the 4px gap), 8px below (4 and the gap); two headings in a row, 12px apart. Level 3 keeps the text's colour, one step lower. */
89
- headings: '[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary',
90
- lists: '[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc',
89
+ /* Blocks 8px apart (Katerina, 9 October; 4 before). A list's own items stay together. */
90
+ base: 'flex flex-col gap-2 break-words text-text-secondary',
91
+ /* A heading binds to what follows it: 12px above (4 and the 8px gap), 8px below (the gap); two headings in a row, 12px apart. The same as before the gap grew (Katerina, 9 October: headings do not change). Level 3 keeps the text's colour, one step lower: at the same size as level 2, colour is what tells them apart (Katerina, 9 October). */
92
+ headings: '[&>:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2)]:text-text-primary',
93
+ /* Bullets and numbers in the text's own colour, so a number can be read (Katerina, 9 October; they were the muted grey, 2.4 : 1 in Leaf). */
94
+ lists: '[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-secondary [&_ol]:list-decimal [&_ul]:list-disc',
95
+ /* The line in the muted text grey, not the border grey: a border grey was 1.3 : 1 in Leaf (Katerina, 9 October). */
91
96
  // eslint-disable-next-line token-spacing/no-restricted-classes -- @estiva-escape: the quote line is Peek's, 3px, between the ramp's 2 and 4
92
- quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5',
93
- // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.9 of the text around it, so it scales with a heading and with `small`; a named size is fixed
94
- code: '[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary',
95
- // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.9 of the text around it; the code inside the block is the block's own size, not 0.9 of it again
96
- codeBlock: '[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary',
97
- divider: '[&_hr]:border-border-default',
97
+ quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-text-muted [&_blockquote]:pl-2.5',
98
+ /* Code in a sentence is amber, its own colour (Katerina, 9 October: option I). */
99
+ // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.85 of the text around it, so it scales with a heading and with `small`; a named size is fixed
100
+ code: '[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-text-code [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary',
101
+ /* 12px either side, 10px above and below, looser lines than the text (Katerina, 9 October; 8px all round before). */
102
+ // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.85 of the text around it; the code inside the block is the block's own size, not 0.85 of it again
103
+ codeBlock: '[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:px-3 [&_pre]:py-2.5 [&_pre]:font-mono [&_pre]:text-[0.85em] [&_pre]:leading-relaxed [&_pre]:text-text-primary',
104
+ /* Colours inside a code block, by language: the classes `codeColours` gives each word, after highlight.js's own GitHub theme (Katerina, 9 October). */
105
+ syntax: '[&_:is(.hljs-keyword,.hljs-doctag,.hljs-type,.hljs-template-tag,.hljs-template-variable)]:text-syntax-keyword [&_.hljs-title]:text-syntax-function [&_:is(.hljs-number,.hljs-literal,.hljs-attr,.hljs-attribute,.hljs-meta,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id)]:text-syntax-constant [&_:is(.hljs-string,.hljs-regexp)]:text-syntax-string [&_:is(.hljs-built_in,.hljs-symbol)]:text-syntax-variable [&_.hljs-comment]:text-syntax-comment [&_:is(.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo)]:text-syntax-tag',
106
+ /* A divider splits the text in two: 16px either side (Katerina, 9 October; 4 before). */
107
+ divider: '[&_hr]:my-2 [&_hr]:border-border-default',
98
108
  table: '[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary',
99
- marks: '[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2',
100
- default: 'text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4',
101
- small: 'text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold',
109
+ /* Bold in the heading colour, not only heavier. An underlined word draws a faint line, so it does not read as a link (Katerina, 9 October). */
110
+ marks: '[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2',
111
+ /* Lines 1.5 apart (Katerina, 9 October; 1.4 before). */
112
+ default: 'text-body-2 leading-normal [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4',
113
+ /* Blocks 6px apart; a heading keeps its 12px above and 8px below. */
114
+ small: 'gap-1.5 text-caption leading-snug [&>:is(h1,h2,h3)]:mb-0.5 [&>:is(h1,h2,h3)]:mt-1.5 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold',
102
115
  }
103
116
 
104
117
  /**
@@ -106,8 +119,8 @@ const RICH_TEXT_CLASSES = {
106
119
  * being written looks the same as what is read. Put it on the editor's root.
107
120
  */
108
121
  export function richTextClassName(size: RichTextSize = 'default', className?: string) {
109
- const { base, headings, lists, quote, code, codeBlock, divider, table, marks } = RICH_TEXT_CLASSES
110
- return cn(base, headings, lists, quote, code, codeBlock, divider, table, marks, RICH_TEXT_CLASSES[size], className)
122
+ const { base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
123
+ return cn(base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
111
124
  }
112
125
 
113
126
  /**
@@ -251,9 +264,10 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
251
264
  return <blockquote data-block-id={block.id}>{runs}</blockquote>
252
265
  case 'codeBlock':
253
266
  // Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
267
+ // In a language highlight.js knows, each word takes its colour; in any other, the text as it is.
254
268
  return (
255
269
  <pre data-block-id={block.id} data-language={block.language}>
256
- <code>{(block.inline ?? []).map((run) => run.text).join('')}</code>
270
+ <code>{codeColours((block.inline ?? []).map((run) => run.text).join(''), block.language)}</code>
257
271
  </pre>
258
272
  )
259
273
  case 'horizontalRule':
@@ -0,0 +1,33 @@
1
+ import { createElement, type ReactNode } from 'react'
2
+ import { common, createLowlight } from 'lowlight'
3
+
4
+ /**
5
+ * Colours inside a code block, by language (Katerina, 9 October).
6
+ *
7
+ * highlight.js reads the code and names each word (a keyword, a string, a
8
+ * comment); RichText's class list gives each name its colour from the theme's
9
+ * `syntax-*` tokens. The same instance colours the editor's code block
10
+ * (`CodeBlockColours`), so what is written looks the same as what is read.
11
+ *
12
+ * `common` is highlight.js's 37 everyday languages. A language outside them, or
13
+ * none, leaves the code in one colour.
14
+ */
15
+ export const lowlight = createLowlight(common)
16
+
17
+ interface HastNode {
18
+ type: string
19
+ value?: string
20
+ properties?: { className?: string[] }
21
+ children?: HastNode[]
22
+ }
23
+
24
+ function draw(node: HastNode, key: number): ReactNode {
25
+ if (node.type === 'text') return node.value
26
+ return createElement('span', { key, className: node.properties?.className?.join(' ') }, (node.children ?? []).map(draw))
27
+ }
28
+
29
+ /** The code, its words wrapped in highlight.js's classes; the text as it is when the language is unknown. */
30
+ export function codeColours(text: string, language?: string): ReactNode {
31
+ if (!language || !lowlight.registered(language)) return text
32
+ return (lowlight.highlight(language, text).children as HastNode[]).map(draw)
33
+ }
package/src/editor.ts CHANGED
@@ -8,6 +8,8 @@
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'
12
+ export { CodeBlockColours } from './CodeBlockColours'
11
13
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
12
14
  export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
13
15
  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,11 @@ 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
+ CodeBlockColours: 'Nothing. It names each word of a code block; the RichText class list colours them.',
45
+ }
42
46
 
43
47
  const dir = new URL('.', import.meta.url)
44
48
  const read = (file: string) => readFileSync(new URL(file, dir), 'utf8').replace(/\r\n/g, '\n')