@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.
- package/dist/CodeBlockColours.d.ts +11 -0
- package/dist/CodeBlockColours.d.ts.map +1 -0
- 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/Link.d.ts +1 -1
- package/dist/PastedImages.d.ts +33 -0
- package/dist/PastedImages.d.ts.map +1 -0
- package/dist/RichText.d.ts +2 -1
- package/dist/RichText.d.ts.map +1 -1
- package/dist/codeColours.d.ts +29 -0
- package/dist/codeColours.d.ts.map +1 -0
- package/dist/editor.d.ts +2 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +86 -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 +291 -237
- package/dist/index.js.map +4 -4
- package/package.json +8 -2
- package/registry.json +179 -16
- package/src/CodeBlockColours.mdx +48 -0
- package/src/CodeBlockColours.stories.tsx +37 -0
- package/src/CodeBlockColours.ts +14 -0
- 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/Link.mdx +1 -1
- package/src/Link.stories.tsx +1 -1
- package/src/Link.test.tsx +2 -2
- package/src/Link.tsx +2 -2
- 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/RichText.stories.tsx +24 -0
- package/src/RichText.tsx +31 -17
- package/src/codeColours.ts +33 -0
- package/src/editor.ts +2 -0
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +1 -0
- package/src/pages.test.ts +5 -1
- package/src/registry/registry.test.ts +7 -7
- package/stories/Choosing.mdx +3 -0
- package/tailwind-preset.js +8 -0
- package/tokens.css +50 -0
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
|
+
}
|
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
|
|
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.
|
package/src/Link.stories.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
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
|
+
})
|
package/src/RichText.stories.tsx
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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> = {
|
|
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')
|