@estiva-app/ui 0.74.0 → 0.75.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/CardUnder.d.ts +10 -0
- package/dist/CardUnder.d.ts.map +1 -0
- package/dist/CommentHighlights.d.ts.map +1 -1
- package/dist/InlineChip.d.ts +2 -0
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/LinkCard.d.ts +17 -0
- package/dist/LinkCard.d.ts.map +1 -0
- package/dist/LinkChip.d.ts +8 -0
- package/dist/LinkChip.d.ts.map +1 -0
- package/dist/LinkParts.d.ts +45 -0
- package/dist/LinkParts.d.ts.map +1 -0
- package/dist/LinkPreview.d.ts +21 -0
- package/dist/LinkPreview.d.ts.map +1 -0
- package/dist/LinkRow.d.ts +24 -0
- package/dist/LinkRow.d.ts.map +1 -0
- package/dist/PartCard.d.ts +32 -0
- package/dist/PartCard.d.ts.map +1 -0
- package/dist/Skeleton.d.ts +12 -2
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/editor.js +42 -17
- package/dist/editor.js.map +3 -3
- 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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +480 -71
- package/dist/index.js.map +4 -4
- package/dist/linked.d.ts +168 -0
- package/dist/linked.d.ts.map +1 -0
- package/dist/linked.examples.d.ts +27 -0
- package/dist/linked.examples.d.ts.map +1 -0
- package/package.json +1 -1
- package/registry.json +784 -7
- package/src/CardUnder.mdx +53 -0
- package/src/CardUnder.stories.tsx +45 -0
- package/src/CardUnder.test.tsx +40 -0
- package/src/CardUnder.tsx +45 -0
- package/src/CommentHighlights.test.ts +56 -2
- package/src/CommentHighlights.ts +51 -12
- package/src/InlineChip.tsx +2 -0
- package/src/LinkCard.mdx +58 -0
- package/src/LinkCard.stories.tsx +68 -0
- package/src/LinkCard.test.tsx +103 -0
- package/src/LinkCard.tsx +209 -0
- package/src/LinkChip.mdx +51 -0
- package/src/LinkChip.stories.tsx +40 -0
- package/src/LinkChip.test.tsx +83 -0
- package/src/LinkChip.tsx +51 -0
- package/src/LinkParts.mdx +49 -0
- package/src/LinkParts.stories.tsx +61 -0
- package/src/LinkParts.tsx +147 -0
- package/src/LinkPreview.mdx +47 -0
- package/src/LinkPreview.stories.tsx +38 -0
- package/src/LinkPreview.tsx +55 -0
- package/src/LinkRow.mdx +47 -0
- package/src/LinkRow.stories.tsx +49 -0
- package/src/LinkRow.test.tsx +38 -0
- package/src/LinkRow.tsx +107 -0
- package/src/PartCard.mdx +43 -0
- package/src/PartCard.stories.tsx +35 -0
- package/src/PartCard.test.tsx +30 -0
- package/src/PartCard.tsx +59 -0
- package/src/Skeleton.tsx +7 -3
- package/src/eslint/no-native-title.test.ts +3 -2
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +25 -0
- package/src/linked.examples.ts +107 -0
- package/src/linked.test.ts +58 -0
- package/src/linked.ts +217 -0
- package/src/registry/registry.test.ts +8 -8
- package/stories/Choosing.mdx +7 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as CardUnderStories from './CardUnder.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={CardUnderStories} />
|
|
5
|
+
|
|
6
|
+
# CardUnder
|
|
7
|
+
|
|
8
|
+
The one card added under a piece of text, and the ✕ that removes it.
|
|
9
|
+
|
|
10
|
+
<Canvas of={CardUnderStories.WhileWriting} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **while writing** — under the box: the ✕ always shows.
|
|
15
|
+
- **after sending** — `revealOnHover`: the ✕ shows when the pointer is over the card or the ✕ has focus, and always on a screen that cannot hover, so a tap reaches it.
|
|
16
|
+
- **no ✕** — no `onRemove`: to anyone but who wrote it.
|
|
17
|
+
|
|
18
|
+
<Canvas of={CardUnderStories.AfterSending} />
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
- A link alone on its line, with no card chosen → **LinkRow**.
|
|
23
|
+
- A part of something longer: it has no ✕ → **PartCard**.
|
|
24
|
+
|
|
25
|
+
## How
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { CardUnder } from '@estiva-app/ui'
|
|
29
|
+
|
|
30
|
+
<CardUnder link={link} onRemove={isAuthor ? removeCard : undefined} revealOnHover={sent} />
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **Which link gets the card is the app's**, by the rule every app reads by (`defaultCardOf` in `@estiva-app/protocol`). This draws the one it is given.
|
|
34
|
+
- `onRemove` is the app's: before sending it drops the card from what will be sent; after, it sends the edit that removes it.
|
|
35
|
+
- It is at most 480px wide.
|
|
36
|
+
|
|
37
|
+
## What it owns
|
|
38
|
+
|
|
39
|
+
| It does | So you never write |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Places the ✕ at the card's top corner | a remove button of your own |
|
|
42
|
+
| Reveals the ✕ on hover and focus, and always shows it on touch | a hover or touch check |
|
|
43
|
+
|
|
44
|
+
## Keys
|
|
45
|
+
|
|
46
|
+
| Key | Does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Tab | Reaches the ✕, and shows it when it was waiting for hover |
|
|
49
|
+
| Enter, Space | Removes the card |
|
|
50
|
+
|
|
51
|
+
## Props
|
|
52
|
+
|
|
53
|
+
<Controls of={CardUnderStories.NoRemove} />
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Button } from './Button'
|
|
4
|
+
import { CardUnder } from './CardUnder'
|
|
5
|
+
import { ITEM, LOADING, ready, SAID, UNAVAILABLE } from './linked.examples'
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/CardUnder',
|
|
9
|
+
component: CardUnder,
|
|
10
|
+
parameters: { layout: 'padded' },
|
|
11
|
+
args: { link: ready(ITEM) },
|
|
12
|
+
} satisfies Meta<typeof CardUnder>
|
|
13
|
+
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof meta>
|
|
16
|
+
|
|
17
|
+
/** Under a box being written in: the ✕ always shows. Press it to remove the card. */
|
|
18
|
+
export const WhileWriting: Story = {
|
|
19
|
+
render: (args) => {
|
|
20
|
+
const [removed, setRemoved] = useState(false)
|
|
21
|
+
if (removed) return <Button onClick={() => setRemoved(false)}>Put it back</Button>
|
|
22
|
+
return <CardUnder {...args} onRemove={() => setRemoved(true)} />
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Under text already sent: the ✕ shows on hover or focus, and always on a screen that cannot hover. */
|
|
27
|
+
export const AfterSending: Story = {
|
|
28
|
+
render: (args) => {
|
|
29
|
+
const [removed, setRemoved] = useState(false)
|
|
30
|
+
if (removed) return <Button onClick={() => setRemoved(false)}>Put it back</Button>
|
|
31
|
+
return <CardUnder {...args} revealOnHover onRemove={() => setRemoved(true)} />
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** To anyone but who wrote it: no ✕. */
|
|
36
|
+
export const NoRemove: Story = {}
|
|
37
|
+
|
|
38
|
+
/** Words a person wrote, as the card. */
|
|
39
|
+
export const WordsAPersonWrote: Story = { args: { link: ready(SAID) }, render: (args) => <CardUnder {...args} onRemove={() => {}} /> }
|
|
40
|
+
|
|
41
|
+
/** Still being looked up: at the card's height, with its ✕. */
|
|
42
|
+
export const Loading: Story = { args: { link: LOADING }, render: (args) => <CardUnder {...args} onRemove={() => {}} /> }
|
|
43
|
+
|
|
44
|
+
/** Deleted, or not this reader's to see. */
|
|
45
|
+
export const NotAvailable: Story = { args: { link: UNAVAILABLE }, render: (args) => <CardUnder {...args} onRemove={() => {}} /> }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the CardUnder page claims, pinned: the ✕ removes the card, by pointer and by keyboard, and only where it is given. */
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import userEvent from '@testing-library/user-event'
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { CardUnder } from './CardUnder'
|
|
7
|
+
import { ITEM, ready } from './linked.examples'
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup)
|
|
10
|
+
|
|
11
|
+
describe('CardUnder', () => {
|
|
12
|
+
it('draws the card it is given', () => {
|
|
13
|
+
render(<CardUnder link={ready(ITEM)} />)
|
|
14
|
+
expect(screen.getByRole('link', { name: ITEM.title })).toBeTruthy()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('removes the card when the ✕ is pressed', async () => {
|
|
18
|
+
const user = userEvent.setup()
|
|
19
|
+
const onRemove = vi.fn()
|
|
20
|
+
render(<CardUnder link={ready(ITEM)} onRemove={onRemove} />)
|
|
21
|
+
await user.click(screen.getByRole('button', { name: 'Remove card' }))
|
|
22
|
+
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('reaches the ✕ from the keyboard once sent, and removes the card with Enter', async () => {
|
|
26
|
+
const user = userEvent.setup()
|
|
27
|
+
const onRemove = vi.fn()
|
|
28
|
+
render(<CardUnder link={ready({ ...ITEM, conversation: undefined, status: undefined, owner: undefined })} onRemove={onRemove} revealOnHover />)
|
|
29
|
+
await user.tab()
|
|
30
|
+
await user.tab()
|
|
31
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Remove card' }))
|
|
32
|
+
await user.keyboard('{Enter}')
|
|
33
|
+
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('has no ✕ for anyone but who wrote it', () => {
|
|
37
|
+
render(<CardUnder link={ready(ITEM)} />)
|
|
38
|
+
expect(screen.queryByRole('button', { name: 'Remove card' })).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one card added under a piece of text, and the ✕ that removes it (why:
|
|
3
|
+
* https://github.com/estiva-app/estiva-docs/blob/main/decisions/where-a-link-sits-and-which-file-gets-the-card.md).
|
|
4
|
+
*
|
|
5
|
+
* Under a box being written in, the ✕ always shows. Under text already sent
|
|
6
|
+
* (`revealOnHover`) it shows when the pointer is over the card or the ✕ has
|
|
7
|
+
* focus, and always on a screen that cannot hover, so a tap reaches it (Miky,
|
|
8
|
+
* 16 Oct, kickoff).
|
|
9
|
+
*/
|
|
10
|
+
import { IconX } from '@tabler/icons-react'
|
|
11
|
+
import { cn } from './cn'
|
|
12
|
+
import { IconButton } from './IconButton'
|
|
13
|
+
import { LinkCard } from './LinkCard'
|
|
14
|
+
import type { LinkState } from './linked'
|
|
15
|
+
|
|
16
|
+
export interface CardUnderProps {
|
|
17
|
+
link: LinkState
|
|
18
|
+
/** Draws the ✕. Without it the card shows with none, as it does to anyone but the author. */
|
|
19
|
+
onRemove?: () => void
|
|
20
|
+
/** The ✕ waits for hover or focus: under text already sent. Always shown on a screen that cannot hover. */
|
|
21
|
+
revealOnHover?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CardUnder({ link, onRemove, revealOnHover = false }: CardUnderProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="group/card flex max-w-[480px] items-start gap-1">
|
|
27
|
+
<div className="min-w-0 flex-1">
|
|
28
|
+
<LinkCard link={link} />
|
|
29
|
+
</div>
|
|
30
|
+
{onRemove && (
|
|
31
|
+
// At the card's top corner: halfway down a tall card a ✕ reads as belonging to nothing.
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
'flex shrink-0 self-start pt-2',
|
|
35
|
+
revealOnHover && 'opacity-0 transition-opacity group-hover/card:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100',
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<IconButton aria-label="Remove card" tooltip="Remove card" onClick={onRemove}>
|
|
39
|
+
<IconX size={16} />
|
|
40
|
+
</IconButton>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
8
|
import { Editor } from '@tiptap/core'
|
|
9
9
|
import StarterKit from '@tiptap/starter-kit'
|
|
10
|
-
import { BlockId } from './BlockNodes'
|
|
10
|
+
import { BlockId, ReferenceNode } from './BlockNodes'
|
|
11
11
|
import { CommentHighlights, anchorOfSelection, commentPlace } from './CommentHighlights'
|
|
12
12
|
|
|
13
13
|
let editor: Editor | undefined
|
|
@@ -62,7 +62,49 @@ describe('anchorOfSelection', () => {
|
|
|
62
62
|
])
|
|
63
63
|
// The quote opens at 0, its first line's text at 2–10, the second's at 12–20.
|
|
64
64
|
e.commands.setTextSelection({ from: 6, to: 15 })
|
|
65
|
-
|
|
65
|
+
// The quote's lines are joined with '\n', as the protocol reads the quote's text.
|
|
66
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['q1'], quote: { start: 'line\ntwo' } })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('quotes a line break inside a paragraph as a new line', () => {
|
|
70
|
+
const e = open([{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'A line' }, { type: 'hardBreak' }, { type: 'text', text: 'broken here' }] }])
|
|
71
|
+
// "A line" at 1–7, the break at 7, "broken here" at 8–19.
|
|
72
|
+
e.commands.setTextSelection({ from: 3, to: 14 })
|
|
73
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['p1'], quote: { start: 'line\nbroken' } })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('anchors nothing when only the edge between two lines of a quote, or a line break, is selected', () => {
|
|
77
|
+
const e = open([
|
|
78
|
+
{ type: 'blockquote', attrs: { blockId: 'q1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one line' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'two line' }] }] },
|
|
79
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'A line' }, { type: 'hardBreak' }, { type: 'text', text: 'broken here' }] },
|
|
80
|
+
])
|
|
81
|
+
// The quote's first line ends at 10, its second starts at 12; the paragraph opens at 22, its break at 29.
|
|
82
|
+
e.commands.setTextSelection({ from: 10, to: 12 })
|
|
83
|
+
expect(anchorOfSelection(e)).toBeNull()
|
|
84
|
+
e.commands.setTextSelection({ from: 29, to: 30 })
|
|
85
|
+
expect(anchorOfSelection(e)).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('quotes a list item as its own words, its nested items as the items after it', () => {
|
|
89
|
+
const item = (blockId: string, text: string, nested?: object) => ({
|
|
90
|
+
type: 'listItem',
|
|
91
|
+
attrs: { blockId },
|
|
92
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }, ...(nested ? [nested] : [])],
|
|
93
|
+
})
|
|
94
|
+
const e = open([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('a', 'alpha', { type: 'bulletList', content: [item('b', 'beta')] }), item('c', 'gamma')] }])
|
|
95
|
+
// "alpha" at 3–8; the nested list's item "beta" at 12–16.
|
|
96
|
+
e.commands.setTextSelection({ from: 3, to: 8 })
|
|
97
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['a'] })
|
|
98
|
+
// The list's text on the wire is "alpha\nbeta\ngamma": the nested item sits between its parent and the next.
|
|
99
|
+
expect(commentPlace(e, { id: 'c', blocks: ['l1'], quote: { start: 'alpha\nbeta' } })).toEqual({ state: 'resolved', from: 3, to: 16, words: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('quotes a URL chip as its URL, as on the wire', () => {
|
|
103
|
+
const content = [
|
|
104
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'see ' }, { type: 'reference', attrs: { uri: '', href: 'https://ship.estiva.app/x' } }, { type: 'text', text: ' ok' }] },
|
|
105
|
+
]
|
|
106
|
+
const e = (editor = new Editor({ extensions: [StarterKit, BlockId, ReferenceNode, CommentHighlights], content: { type: 'doc', content } }))
|
|
107
|
+
expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'see https://ship' } })).toMatchObject({ state: 'resolved', from: 1 })
|
|
66
108
|
})
|
|
67
109
|
|
|
68
110
|
it('keeps the words around a quote only when the quoted words repeat', () => {
|
|
@@ -88,6 +130,18 @@ describe('commentPlace', () => {
|
|
|
88
130
|
expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'omega' } })).toEqual({ state: 'reworded', from: 0, to: 18 })
|
|
89
131
|
})
|
|
90
132
|
|
|
133
|
+
it('finds words across two lines of a quote, as the protocol writes them', () => {
|
|
134
|
+
const e = open([
|
|
135
|
+
{ type: 'blockquote', attrs: { blockId: 'q1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one line' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'two line' }] }] },
|
|
136
|
+
])
|
|
137
|
+
expect(commentPlace(e, { id: 'c', blocks: ['q1'], quote: { start: 'line\ntwo' } })).toEqual({ state: 'resolved', from: 6, to: 15, words: true })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('finds words across a line break, as the protocol writes them', () => {
|
|
141
|
+
const e = open([{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'A line' }, { type: 'hardBreak' }, { type: 'text', text: 'broken here' }] }])
|
|
142
|
+
expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'line\nbroken' } })).toEqual({ state: 'resolved', from: 3, to: 14, words: true })
|
|
143
|
+
})
|
|
144
|
+
|
|
91
145
|
it('says detached when none of the blocks is left', () => {
|
|
92
146
|
const e = open()
|
|
93
147
|
expect(commentPlace(e, { id: 'c', blocks: ['gone'], quote: { start: 'alpha' } })).toEqual({ state: 'detached' })
|
package/src/CommentHighlights.ts
CHANGED
|
@@ -106,27 +106,64 @@ function placesIn(doc: ProseMirrorNode): Map<string, Place> {
|
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
108
|
* A block's text, and the document position where each of its characters
|
|
109
|
-
* starts and ends.
|
|
109
|
+
* starts and ends. It is the text the protocol's `inlineTextOf` reads from the
|
|
110
|
+
* same block on the wire, or a quote written here would not be found there: a
|
|
111
|
+
* chip counts as the text it is on the wire (a URL chip its URL, any other its
|
|
112
|
+
* `nostr:` reference), a line break as `\n`, and the blocks inside a block (a
|
|
113
|
+
* quote's lines, a list's items, a table's cells) are joined with `\n`. A list
|
|
114
|
+
* item is its own text: the list inside it is flat on the wire, its items
|
|
115
|
+
* following it in the list.
|
|
110
116
|
*/
|
|
111
117
|
function textOf(block: Place): { text: string; starts: number[]; ends: number[] } {
|
|
112
118
|
let text = ''
|
|
113
119
|
const starts: number[] = []
|
|
114
120
|
const ends: number[] = []
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
const add = (chars: string, start: number, end: number) => {
|
|
122
|
+
for (let i = 0; i < chars.length; i++) (starts.push(start), ends.push(end))
|
|
123
|
+
text += chars
|
|
124
|
+
}
|
|
125
|
+
const walk = (node: ProseMirrorNode, pos: number) => {
|
|
126
|
+
if (node.isTextblock) {
|
|
127
|
+
node.forEach((child, offset) => {
|
|
128
|
+
const at = pos + 1 + offset
|
|
129
|
+
if (child.isText && child.text) {
|
|
130
|
+
for (let i = 0; i < child.text.length; i++) (starts.push(at + i), ends.push(at + i + 1))
|
|
131
|
+
text += child.text
|
|
132
|
+
} else if (child.type.name === 'hardBreak') add('\n', at, at + child.nodeSize)
|
|
133
|
+
else if (child.isInline && child.isLeaf) add(String(child.attrs.href || child.attrs.uri || ''), at, at + child.nodeSize)
|
|
134
|
+
})
|
|
135
|
+
return
|
|
124
136
|
}
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
// A list item's paragraphs are one run on the wire, with nothing between them.
|
|
138
|
+
if (node.type.name === 'listItem') {
|
|
139
|
+
node.forEach((child, offset) => void (LISTS.has(child.type.name) || walk(child, pos + 1 + offset)))
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
const children = LISTS.has(node.type.name) ? itemsOf(node, pos) : childrenOf(node, pos)
|
|
143
|
+
children.forEach(({ node: child, pos: at }, index) => {
|
|
144
|
+
// Between two blocks: the boundary, where neither one's text is.
|
|
145
|
+
if (index > 0) add('\n', at, at)
|
|
146
|
+
walk(child, at)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
walk(block.node, block.pos)
|
|
127
150
|
return { text, starts, ends }
|
|
128
151
|
}
|
|
129
152
|
|
|
153
|
+
/** The lists whose nested items the wire holds flat, after the item they are in. */
|
|
154
|
+
const LISTS = new Set(['bulletList', 'orderedList'])
|
|
155
|
+
|
|
156
|
+
function childrenOf(node: ProseMirrorNode, pos: number): { node: ProseMirrorNode; pos: number }[] {
|
|
157
|
+
const out: { node: ProseMirrorNode; pos: number }[] = []
|
|
158
|
+
node.forEach((child, offset) => void out.push({ node: child, pos: pos + 1 + offset }))
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A list's items as the wire lists them: each one, then the items of a list inside it. */
|
|
163
|
+
function itemsOf(list: ProseMirrorNode, pos: number): { node: ProseMirrorNode; pos: number }[] {
|
|
164
|
+
return childrenOf(list, pos).flatMap((item) => [item, ...childrenOf(item.node, item.pos).flatMap((c) => (LISTS.has(c.node.type.name) ? itemsOf(c.node, c.pos) : []))])
|
|
165
|
+
}
|
|
166
|
+
|
|
130
167
|
/** Where `words` sits in `text` at or after `from`: the occurrence whose context matches, else the first. */
|
|
131
168
|
function find(text: string, words: string, from: number, context: (at: number) => boolean): number {
|
|
132
169
|
let first = -1
|
|
@@ -391,6 +428,8 @@ export function anchorOfSelection(editor: Editor): SelectionAnchor | null {
|
|
|
391
428
|
if (run.length === 1) lastText = firstText
|
|
392
429
|
// Between two blocks, with no character selected: nothing to comment on.
|
|
393
430
|
if (run.length === 1 && firstText.text && start >= end) return null
|
|
431
|
+
// Only a line break, or the edge between two lines of one block: no word to comment on either.
|
|
432
|
+
if (run.length === 1 && firstText.text && !firstText.text.slice(start, end).replace(/\n/g, '')) return null
|
|
394
433
|
const blocks = run.map((p) => p.node.attrs.blockId as string)
|
|
395
434
|
if (start === 0 && end >= lastText.text.length) return { blocks }
|
|
396
435
|
|
package/src/InlineChip.tsx
CHANGED
|
@@ -45,6 +45,8 @@ export const INLINE_CHIP_TONE_CLASSES = {
|
|
|
45
45
|
* copy of this chip still has the 16.8px box).
|
|
46
46
|
*/
|
|
47
47
|
quiet: 'h-[19.6px] bg-bg-active text-text-muted font-mono text-caption',
|
|
48
|
+
/** A file this reader cannot open — deleted, or not theirs to see: said in words, quieter than a link. */
|
|
49
|
+
muted: 'bg-bg-active text-text-muted',
|
|
48
50
|
} as const
|
|
49
51
|
|
|
50
52
|
export type InlineChipTone = keyof typeof INLINE_CHIP_TONE_CLASSES
|
package/src/LinkCard.mdx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as LinkCardStories from './LinkCard.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={LinkCardStories} />
|
|
5
|
+
|
|
6
|
+
# LinkCard
|
|
7
|
+
|
|
8
|
+
A link's card: what a row cannot hold, and the changes the reader may make from it.
|
|
9
|
+
|
|
10
|
+
<Canvas of={LinkCardStories.Default} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **ready** — the title as the link (the icon opens it too), the key and what it is in, its summary, the items inside it, its newest conversation, and a footer: the pickers and the conversation count, or who wrote it and when. No summary, the title alone.
|
|
15
|
+
- **read only** — a picker the app passed no `onChange` for reads as words in the same place: the reader may not change it. `readOnly` makes every value words, as the hover preview does.
|
|
16
|
+
- **words a person wrote** — their face, their name, the time as the link, and the words.
|
|
17
|
+
- **loading** — the card it will become in bars, at its own height: `shape` from `loadingShapeOf(linked)` the last time it drew, or the kind's usual card. **LinkCardLoading**, from this file.
|
|
18
|
+
- **unavailable** — one line, as **LinkRow** draws it.
|
|
19
|
+
|
|
20
|
+
<Canvas of={LinkCardStories.ReadOnly} />
|
|
21
|
+
<Canvas of={LinkCardStories.Loading} />
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- A link alone on its line, where the card was not chosen → **LinkRow**.
|
|
26
|
+
- The one card under a piece of text, with its ✕ → **CardUnder**, which draws this.
|
|
27
|
+
- A part of something longer → **PartCard**.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { LinkCard } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
const linked = {
|
|
35
|
+
...fromLookup,
|
|
36
|
+
status: { value: statusKey, options: statuses, onChange: mayChange ? setStatus : undefined },
|
|
37
|
+
owner: { value: ownerKey, options: people, onChange: mayChange ? setOwner : undefined },
|
|
38
|
+
ownerLabel: 'Owner',
|
|
39
|
+
}
|
|
40
|
+
<LinkCard link={{ state: 'ready', linked }} />
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **Whether the reader may change it is the app's.** Pass `onChange` only to a reader who may; anyone else sees words.
|
|
44
|
+
- The picker offers only the options it is given: to let the reader set nobody as the owner, include an option for it.
|
|
45
|
+
- `linked.latest` is drawn whenever it is given: pass it only to a reader who may read it.
|
|
46
|
+
- `linked.conversation.unread` only for a member of it.
|
|
47
|
+
|
|
48
|
+
## What it owns
|
|
49
|
+
|
|
50
|
+
| It does | So you never write |
|
|
51
|
+
|---|---|
|
|
52
|
+
| Draws the pickers, or the same values as words | a read-only variant of your own |
|
|
53
|
+
| Holds its own height while it loads | a placeholder height of your own |
|
|
54
|
+
| Opens each item inside it on its own | a row click handler |
|
|
55
|
+
|
|
56
|
+
## Props
|
|
57
|
+
|
|
58
|
+
<Controls of={LinkCardStories.ReadOnly} />
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { LinkCard, LinkCardLoading } from './LinkCard'
|
|
4
|
+
import { loadingShapeOf, type Linked } from './linked'
|
|
5
|
+
import { GROUP, ITEM, ITEM_NO_SUMMARY, ITEM_READ_ONLY, ITEM_UNREAD, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/LinkCard',
|
|
9
|
+
component: LinkCard,
|
|
10
|
+
parameters: { layout: 'padded' },
|
|
11
|
+
args: { link: ready(ITEM) },
|
|
12
|
+
decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
|
|
13
|
+
} satisfies Meta<typeof LinkCard>
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof meta>
|
|
17
|
+
|
|
18
|
+
/** Its status and owner change from the card: pick one. */
|
|
19
|
+
export const Default: Story = {
|
|
20
|
+
render: () => {
|
|
21
|
+
const [status, setStatus] = useState('doing')
|
|
22
|
+
const [owner, setOwner] = useState<string>('a')
|
|
23
|
+
const linked: Linked = { ...ITEM, status: { ...ITEM.status!, value: status, onChange: setStatus }, owner: { ...ITEM.owner!, value: owner, onChange: setOwner } }
|
|
24
|
+
return <LinkCard link={ready(linked)} />
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** To a reader who may not change it: the same two values, as words. */
|
|
29
|
+
export const ReadOnly: Story = { args: { link: ready(ITEM_READ_ONLY) } }
|
|
30
|
+
|
|
31
|
+
/** Something unread in its conversation, to a member: the count is brighter, with a dot. */
|
|
32
|
+
export const Unread: Story = { args: { link: ready(ITEM_UNREAD) } }
|
|
33
|
+
|
|
34
|
+
/** No summary: the title alone. */
|
|
35
|
+
export const NoSummary: Story = { args: { link: ready(ITEM_NO_SUMMARY) } }
|
|
36
|
+
|
|
37
|
+
/** A group: its progress, its lead, and each item inside, which opens on its own. */
|
|
38
|
+
export const WithItems: Story = { args: { link: ready(GROUP) } }
|
|
39
|
+
|
|
40
|
+
/** A place people talk: its members and its newest conversation. */
|
|
41
|
+
export const WithLatest: Story = { args: { link: ready(PLACE) } }
|
|
42
|
+
|
|
43
|
+
/** A page of text: who wrote it, and when. */
|
|
44
|
+
export const WithAuthors: Story = { args: { link: ready(PAGE) } }
|
|
45
|
+
|
|
46
|
+
/** Words a person wrote: their face, the time as the link, and the words. */
|
|
47
|
+
export const WordsAPersonWrote: Story = { args: { link: ready(SAID) } }
|
|
48
|
+
|
|
49
|
+
/** Still being looked up, beside the card it becomes: the same height. */
|
|
50
|
+
export const Loading: Story = {
|
|
51
|
+
render: () => (
|
|
52
|
+
<div className="flex flex-col gap-3">
|
|
53
|
+
{[ITEM, GROUP, PLACE, PAGE].map((linked) => (
|
|
54
|
+
// Each at a card's full width, as under a message: a narrower column wraps the summary.
|
|
55
|
+
<div key={linked.id} className="grid w-[968px] grid-cols-[480px_480px] items-start gap-2">
|
|
56
|
+
<LinkCardLoading kind={linked.kind} shape={loadingShapeOf(linked)} />
|
|
57
|
+
<LinkCard link={ready(linked)} />
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Nothing remembered of it: the kind's usual card. */
|
|
65
|
+
export const LoadingByKind: Story = { args: { link: { state: 'loading', kind: 'issue' } } }
|
|
66
|
+
|
|
67
|
+
/** Deleted, or not this reader's to see: one line. */
|
|
68
|
+
export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the LinkCard and LinkParts pages claim, pinned: the pickers for a
|
|
4
|
+
* reader who may change it and words for one who may not, the conversation
|
|
5
|
+
* count and its unread, what each kind draws, and the loading and
|
|
6
|
+
* not-available states.
|
|
7
|
+
*/
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { LinkCard } from './LinkCard'
|
|
12
|
+
import { GROUP, ITEM, ITEM_NO_SUMMARY, ITEM_READ_ONLY, ITEM_UNREAD, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
describe('LinkCard', () => {
|
|
17
|
+
it('opens what it links from its title', () => {
|
|
18
|
+
render(<LinkCard link={ready(ITEM)} />)
|
|
19
|
+
expect(screen.getByRole('link', { name: ITEM.title }).getAttribute('href')).toBe('#item')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('lets a reader who may change it pick a new status and owner', async () => {
|
|
23
|
+
const user = userEvent.setup()
|
|
24
|
+
const onStatus = vi.fn()
|
|
25
|
+
const onOwner = vi.fn()
|
|
26
|
+
render(<LinkCard link={ready({ ...ITEM, status: { ...ITEM.status!, onChange: onStatus }, owner: { ...ITEM.owner!, onChange: onOwner } })} />)
|
|
27
|
+
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
28
|
+
await user.click(await screen.findByRole('option', { name: /Done/ }))
|
|
29
|
+
expect(onStatus).toHaveBeenCalledWith('done')
|
|
30
|
+
await user.click(screen.getByRole('combobox', { name: 'Owner' }))
|
|
31
|
+
await user.click(await screen.findByRole('option', { name: /Person three/ }))
|
|
32
|
+
expect(onOwner).toHaveBeenCalledWith('c')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('shows the same values as words to a reader who may not change them', () => {
|
|
36
|
+
render(<LinkCard link={ready(ITEM_READ_ONLY)} />)
|
|
37
|
+
expect(screen.queryByRole('combobox')).toBeNull()
|
|
38
|
+
expect(screen.getByText('In progress')).toBeTruthy()
|
|
39
|
+
expect(screen.getByText('Person one')).toBeTruthy()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('shows words, never pickers, when read only, even to a reader who could change it', () => {
|
|
43
|
+
render(<LinkCard link={ready(ITEM)} readOnly />)
|
|
44
|
+
expect(screen.queryByRole('combobox')).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('says nobody owns it when nobody does', () => {
|
|
48
|
+
render(<LinkCard link={ready({ ...ITEM_READ_ONLY, owner: { value: undefined, options: [] } })} />)
|
|
49
|
+
expect(screen.getByText('Unassigned')).toBeTruthy()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('shows the title alone when there is no summary', () => {
|
|
53
|
+
render(<LinkCard link={ready(ITEM_NO_SUMMARY)} />)
|
|
54
|
+
expect(screen.getByRole('link', { name: 'Item two' })).toBeTruthy()
|
|
55
|
+
expect(screen.queryByText(ITEM.summary!)).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('opens the conversation from its count, and says when it holds something unread', () => {
|
|
59
|
+
const { rerender } = render(<LinkCard link={ready(ITEM)} />)
|
|
60
|
+
const count = screen.getByRole('link', { name: /comments — open the conversation/ })
|
|
61
|
+
expect(count.getAttribute('href')).toBe('#item-conversation')
|
|
62
|
+
expect(count.textContent).not.toContain('unread')
|
|
63
|
+
rerender(<LinkCard link={ready(ITEM_UNREAD)} />)
|
|
64
|
+
expect(screen.getByRole('link', { name: /comments, unread — open the conversation/ })).toBeTruthy()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('opens each item inside it on its own', () => {
|
|
68
|
+
render(<LinkCard link={ready(GROUP)} />)
|
|
69
|
+
expect(screen.getByRole('link', { name: 'Item three' }).getAttribute('href')).toBe('#c3')
|
|
70
|
+
expect(screen.getByText('2/5')).toBeTruthy()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('shows the newest conversation where it is given, and who wrote a page', () => {
|
|
74
|
+
render(
|
|
75
|
+
<>
|
|
76
|
+
<LinkCard link={ready(PLACE)} />
|
|
77
|
+
<LinkCard link={ready(PAGE)} />
|
|
78
|
+
</>,
|
|
79
|
+
)
|
|
80
|
+
expect(screen.getByText('Latest conversation')).toBeTruthy()
|
|
81
|
+
expect(screen.getByText(PLACE.latest!.body)).toBeTruthy()
|
|
82
|
+
expect(screen.getByText('Yesterday')).toBeTruthy()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('draws a person’s words as theirs, with the time as the link', () => {
|
|
86
|
+
render(<LinkCard link={ready(SAID)} />)
|
|
87
|
+
expect(screen.getByText('Person one')).toBeTruthy()
|
|
88
|
+
expect(screen.getByText(SAID.body!)).toBeTruthy()
|
|
89
|
+
expect(screen.getByRole('link', { name: '10:42' }).getAttribute('href')).toBe('#said')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('while it loads, is busy and shows nothing of it', () => {
|
|
93
|
+
const { container } = render(<LinkCard link={LOADING} />)
|
|
94
|
+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
|
|
95
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('says it is not available, and where it may still open', () => {
|
|
99
|
+
render(<LinkCard link={UNAVAILABLE} />)
|
|
100
|
+
expect(screen.getByText('Item — not available.')).toBeTruthy()
|
|
101
|
+
expect(screen.getByRole('link', { name: /Open in App one/ }).getAttribute('href')).toBe('#elsewhere')
|
|
102
|
+
})
|
|
103
|
+
})
|