@estiva-app/ui 0.72.0-widgets2.2 → 0.73.0-inline-text.1
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/CommentHighlights.d.ts +100 -0
- package/dist/CommentHighlights.d.ts.map +1 -0
- package/dist/ComposeBox.d.ts +55 -0
- package/dist/ComposeBox.d.ts.map +1 -0
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/InlineText.d.ts +56 -0
- package/dist/InlineText.d.ts.map +1 -0
- package/dist/SelectionToolbar.d.ts +17 -1
- package/dist/SelectionToolbar.d.ts.map +1 -1
- package/dist/editor.d.ts +4 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +748 -240
- package/dist/editor.js.map +4 -4
- package/dist/index.js.map +1 -1
- package/dist/looks.d.ts +6 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -5
- package/registry.json +403 -3
- package/src/CommentHighlights.mdx +61 -0
- package/src/CommentHighlights.stories.tsx +72 -0
- package/src/CommentHighlights.test.ts +100 -0
- package/src/CommentHighlights.ts +413 -0
- package/src/ComposeBox.mdx +58 -0
- package/src/ComposeBox.stories.tsx +81 -0
- package/src/ComposeBox.tsx +183 -0
- package/src/ComposerTriggers.tsx +9 -3
- package/src/InlineText.mdx +64 -0
- package/src/InlineText.stories.tsx +86 -0
- package/src/InlineText.test.tsx +187 -0
- package/src/InlineText.tsx +187 -0
- package/src/SelectionToolbar.actions.test.tsx +44 -0
- package/src/SelectionToolbar.mdx +1 -0
- package/src/SelectionToolbar.tsx +36 -2
- package/src/editor.ts +16 -1
- package/src/looks.ts +8 -0
- package/src/registry/app.test.ts +7 -1
- package/src/registry/registry.test.ts +6 -6
- package/stories/Choosing.mdx +3 -0
- package/dist/widgets-2/fixtures.d.ts +0 -102
- package/dist/widgets-2/fixtures.d.ts.map +0 -1
- package/dist/widgets-2/places.d.ts +0 -105
- package/dist/widgets-2/places.d.ts.map +0 -1
- package/dist/widgets-2/proposed.d.ts +0 -130
- package/dist/widgets-2/proposed.d.ts.map +0 -1
- package/dist/widgets-2.d.ts +0 -12
- package/dist/widgets-2.d.ts.map +0 -1
- package/dist/widgets-2.js +0 -585
- package/dist/widgets-2.js.map +0 -7
- package/src/widgets-2/fixtures.ts +0 -161
- package/src/widgets-2/places.tsx +0 -316
- package/src/widgets-2/proposed.tsx +0 -596
- package/src/widgets-2.ts +0 -11
- package/stories/widgets-2/ReadMe.mdx +0 -60
- package/stories/widgets-2/Widgets.stories.tsx +0 -504
- package/stories/widgets-2/today.tsx +0 -352
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Where a comment on a selection is anchored (§13.6), and where an anchor is
|
|
4
|
+
* found again: what `anchorOfSelection` hands the app to write on the
|
|
5
|
+
* comment, and what `commentPlace` says when the page has changed since.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { Editor } from '@tiptap/core'
|
|
9
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
10
|
+
import { BlockId } from './BlockNodes'
|
|
11
|
+
import { CommentHighlights, anchorOfSelection, commentPlace } from './CommentHighlights'
|
|
12
|
+
|
|
13
|
+
let editor: Editor | undefined
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
editor?.destroy()
|
|
17
|
+
editor = undefined
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const paragraph = (blockId: string, text: string) => ({ type: 'paragraph', attrs: { blockId }, content: [{ type: 'text', text }] })
|
|
21
|
+
|
|
22
|
+
/** Two paragraphs: "alpha beta gamma" (text at 1–17) and "delta epsilon" (text at 19–32). */
|
|
23
|
+
function open(content: object[] = [paragraph('p1', 'alpha beta gamma'), paragraph('p2', 'delta epsilon')]) {
|
|
24
|
+
editor = new Editor({ extensions: [StarterKit, BlockId, CommentHighlights], content: { type: 'doc', content } })
|
|
25
|
+
return editor
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('anchorOfSelection', () => {
|
|
29
|
+
it('quotes the first word of a paragraph, not the whole paragraph', () => {
|
|
30
|
+
const e = open()
|
|
31
|
+
e.commands.setTextSelection({ from: 1, to: 6 })
|
|
32
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['p1'], quote: { start: 'alpha' } })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('quotes the last word of a paragraph, not the whole paragraph', () => {
|
|
36
|
+
const e = open()
|
|
37
|
+
e.commands.setTextSelection({ from: 12, to: 17 })
|
|
38
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['p1'], quote: { start: 'gamma' } })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('anchors a selection of a whole paragraph to the block, with no words', () => {
|
|
42
|
+
const e = open()
|
|
43
|
+
e.commands.setTextSelection({ from: 1, to: 17 })
|
|
44
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['p1'] })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('quotes where a selection across blocks starts and where it ends', () => {
|
|
48
|
+
const e = open()
|
|
49
|
+
e.commands.setTextSelection({ from: 7, to: 24 })
|
|
50
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['p1', 'p2'], quote: { start: 'beta gamma', end: 'delta' } })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('anchors nothing when only the edge between two blocks is selected', () => {
|
|
54
|
+
const e = open()
|
|
55
|
+
e.commands.setTextSelection({ from: 17, to: 19 })
|
|
56
|
+
expect(anchorOfSelection(e)).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('anchors two lines of a quote to the quote', () => {
|
|
60
|
+
const e = open([
|
|
61
|
+
{ type: 'blockquote', attrs: { blockId: 'q1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one line' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'two line' }] }] },
|
|
62
|
+
])
|
|
63
|
+
// The quote opens at 0, its first line's text at 2–10, the second's at 12–20.
|
|
64
|
+
e.commands.setTextSelection({ from: 6, to: 15 })
|
|
65
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['q1'], quote: { start: 'linetwo' } })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('keeps the words around a quote only when the quoted words repeat', () => {
|
|
69
|
+
const e = open([paragraph('p1', 'see it, then see it again')])
|
|
70
|
+
e.commands.setTextSelection({ from: 14, to: 20 })
|
|
71
|
+
expect(anchorOfSelection(e)).toEqual({ blocks: ['p1'], quote: { start: 'see it', prefix: 'see it, then ', suffix: ' again' } })
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('commentPlace', () => {
|
|
76
|
+
it('finds quoted words on the words', () => {
|
|
77
|
+
const e = open()
|
|
78
|
+
expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'beta' } })).toEqual({ state: 'resolved', from: 7, to: 11, words: true })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('draws a comment with no words on its whole blocks', () => {
|
|
82
|
+
const e = open()
|
|
83
|
+
expect(commentPlace(e, { id: 'c', blocks: ['p1', 'p2'] })).toEqual({ state: 'resolved', from: 0, to: 33, words: false })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('says reworded when the blocks are there and the words are not', () => {
|
|
87
|
+
const e = open()
|
|
88
|
+
expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'omega' } })).toEqual({ state: 'reworded', from: 0, to: 18 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('says detached when none of the blocks is left', () => {
|
|
92
|
+
const e = open()
|
|
93
|
+
expect(commentPlace(e, { id: 'c', blocks: ['gone'], quote: { start: 'alpha' } })).toEqual({ state: 'detached' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('picks the repeated words whose context matches', () => {
|
|
97
|
+
const e = open([paragraph('p1', 'see it, then see it again')])
|
|
98
|
+
expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'see it', suffix: ' again' } })).toEqual({ state: 'resolved', from: 14, to: 20, words: true })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/react'
|
|
2
|
+
import { Extension } from '@tiptap/core'
|
|
3
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
4
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
5
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Comments on a selection, drawn where they are, from Katerina's Leaf prototype
|
|
9
|
+
* (9 October), with a run, whole blocks and changed words added for LEAF-7.
|
|
10
|
+
*
|
|
11
|
+
* A comment's place is the one SPEC §13.6 gives it: a run of blocks, and the
|
|
12
|
+
* words where the selection starts and where it ends as a quote. Words inside
|
|
13
|
+
* one block are quoted whole; a selection across blocks quotes its start in
|
|
14
|
+
* the first block and its end in the last; a selection of whole blocks quotes
|
|
15
|
+
* nothing and is about the blocks. Nothing is written into the document: the
|
|
16
|
+
* highlight is a decoration, so a save never carries it.
|
|
17
|
+
*
|
|
18
|
+
* Three places a comment can be in, as `@estiva-app/protocol`'s
|
|
19
|
+
* `resolveCommentAnchor` reads them:
|
|
20
|
+
* - **resolved** — its words are found (drawn on them), or it quotes none
|
|
21
|
+
* (drawn on its whole blocks);
|
|
22
|
+
* - **reworded** — its blocks are there and its words are not: nothing is
|
|
23
|
+
* drawn, and the comment stands beside its first block;
|
|
24
|
+
* - **detached** — none of its blocks is left.
|
|
25
|
+
*
|
|
26
|
+
* why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-comment-remembers-where-its-selection-starts-and-ends.md
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The words a comment quotes: §13.6's `quote` tag. */
|
|
30
|
+
export interface CommentQuote {
|
|
31
|
+
/** The selection's words in one block, or its first words when it runs across blocks. */
|
|
32
|
+
start: string
|
|
33
|
+
/** Its last words, in the run's last block. Absent: `start` is the whole selection. */
|
|
34
|
+
end?: string
|
|
35
|
+
/** A few characters before `start`, only when the words repeat in their block. */
|
|
36
|
+
prefix?: string
|
|
37
|
+
/** A few characters after the end, only when the words repeat in their block. */
|
|
38
|
+
suffix?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One comment's place: the blocks it is anchored to and the words it is about. */
|
|
42
|
+
export interface CommentAnchor {
|
|
43
|
+
/** The thread's id. Clicks report it; `active` names it. */
|
|
44
|
+
id: string
|
|
45
|
+
/** The run: the §13.3 block ids the comment's `block` tag lists, in order. */
|
|
46
|
+
blocks: readonly string[]
|
|
47
|
+
/** The words, as they were when the comment was written. Absent: the whole blocks. */
|
|
48
|
+
quote?: CommentQuote
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where a comment is now, in document positions. */
|
|
52
|
+
export type CommentPlace =
|
|
53
|
+
/** Drawn: on its words (`words`), or on its whole blocks. */
|
|
54
|
+
| { state: 'resolved'; from: number; to: number; words: boolean }
|
|
55
|
+
/** Its blocks (`from`–`to`) are there, its words are not. Draw nothing; show the quote as the commenter's. */
|
|
56
|
+
| { state: 'reworded'; from: number; to: number }
|
|
57
|
+
/** None of its blocks is left. */
|
|
58
|
+
| { state: 'detached' }
|
|
59
|
+
|
|
60
|
+
interface HighlightState {
|
|
61
|
+
anchors: readonly CommentAnchor[]
|
|
62
|
+
active: string | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const commentHighlightsKey = new PluginKey<HighlightState>('commentHighlights')
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The highlight's two looks: every commented selection, and the one being
|
|
69
|
+
* read. A block carries the same classes as words do: its underline reaches
|
|
70
|
+
* every line of its text.
|
|
71
|
+
*/
|
|
72
|
+
const COMMENT_HIGHLIGHT_CLASSES = {
|
|
73
|
+
rest: 'cursor-pointer rounded-sm bg-warning-muted',
|
|
74
|
+
active: 'cursor-pointer rounded-sm bg-warning-muted underline decoration-warning-default decoration-2 underline-offset-4',
|
|
75
|
+
} as const
|
|
76
|
+
|
|
77
|
+
/** Up to this many characters, a selection in one block is quoted whole (§13.6). */
|
|
78
|
+
const QUOTE_WHOLE_MAX = 1000
|
|
79
|
+
/** Otherwise about this many at each end, cut at a word. */
|
|
80
|
+
const QUOTE_END_LENGTH = 150
|
|
81
|
+
/** How much of the text around repeated words is kept to tell them apart. */
|
|
82
|
+
const QUOTE_CONTEXT_LENGTH = 32
|
|
83
|
+
|
|
84
|
+
/** A block with an id: where it is, where its parent's content starts, and its index there. */
|
|
85
|
+
interface Place {
|
|
86
|
+
node: ProseMirrorNode
|
|
87
|
+
pos: number
|
|
88
|
+
parent: number
|
|
89
|
+
index: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every block carrying an id, by id. */
|
|
93
|
+
function placesIn(doc: ProseMirrorNode): Map<string, Place> {
|
|
94
|
+
const places = new Map<string, Place>()
|
|
95
|
+
const walk = (parent: ProseMirrorNode, parentPos: number) => {
|
|
96
|
+
parent.forEach((node, offset, index) => {
|
|
97
|
+
const pos = parentPos + offset
|
|
98
|
+
const id = node.attrs.blockId
|
|
99
|
+
if (typeof id === 'string' && id && !places.has(id)) places.set(id, { node, pos, parent: parentPos, index })
|
|
100
|
+
if (!node.isTextblock && !node.isLeaf) walk(node, pos + 1)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
walk(doc, 0)
|
|
104
|
+
return places
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A block's text, and the document position where each of its characters
|
|
109
|
+
* starts and ends. A chip counts as its `nostr:` text, as on the wire.
|
|
110
|
+
*/
|
|
111
|
+
function textOf(block: Place): { text: string; starts: number[]; ends: number[] } {
|
|
112
|
+
let text = ''
|
|
113
|
+
const starts: number[] = []
|
|
114
|
+
const ends: number[] = []
|
|
115
|
+
block.node.descendants((node, offset) => {
|
|
116
|
+
const at = block.pos + 1 + offset
|
|
117
|
+
if (node.isText && node.text) {
|
|
118
|
+
for (let i = 0; i < node.text.length; i++) (starts.push(at + i), ends.push(at + i + 1))
|
|
119
|
+
text += node.text
|
|
120
|
+
} else if (node.isInline && node.isLeaf) {
|
|
121
|
+
const wire = typeof node.attrs.uri === 'string' ? node.attrs.uri : ''
|
|
122
|
+
for (let i = 0; i < wire.length; i++) (starts.push(at), ends.push(at + node.nodeSize))
|
|
123
|
+
text += wire
|
|
124
|
+
}
|
|
125
|
+
return true
|
|
126
|
+
})
|
|
127
|
+
return { text, starts, ends }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where `words` sits in `text` at or after `from`: the occurrence whose context matches, else the first. */
|
|
131
|
+
function find(text: string, words: string, from: number, context: (at: number) => boolean): number {
|
|
132
|
+
let first = -1
|
|
133
|
+
for (let at = text.indexOf(words, from); at !== -1; at = text.indexOf(words, at + 1)) {
|
|
134
|
+
if (first === -1) first = at
|
|
135
|
+
if (context(at)) return at
|
|
136
|
+
}
|
|
137
|
+
return first
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The run as §13.3 reads it: the parent holding most of its listed blocks, and
|
|
142
|
+
* every block there from the earliest listed one to the latest. Null when none
|
|
143
|
+
* is left.
|
|
144
|
+
*/
|
|
145
|
+
function runIn(doc: ProseMirrorNode, places: Map<string, Place>, ids: readonly string[]): Place[] | null {
|
|
146
|
+
const present = ids.map((id) => places.get(id)).filter((p): p is Place => !!p)
|
|
147
|
+
if (present.length === 0) return null
|
|
148
|
+
const byParent = new Map<number, Place[]>()
|
|
149
|
+
for (const p of present) byParent.set(p.parent, [...(byParent.get(p.parent) ?? []), p])
|
|
150
|
+
const group = [...byParent.values()].reduce((best, one) => (one.length > best.length ? one : best))
|
|
151
|
+
const low = Math.min(...group.map((p) => p.index))
|
|
152
|
+
const high = Math.max(...group.map((p) => p.index))
|
|
153
|
+
// `parent` is where the parent's content starts: 0 for the document itself.
|
|
154
|
+
const contentStart = group[0].parent
|
|
155
|
+
const parent = contentStart === 0 ? doc : doc.nodeAt(contentStart - 1)
|
|
156
|
+
if (!parent) return group
|
|
157
|
+
const run: Place[] = []
|
|
158
|
+
parent.forEach((node, offset, index) => {
|
|
159
|
+
if (index >= low && index <= high) run.push({ node, pos: contentStart + offset, parent: contentStart, index })
|
|
160
|
+
})
|
|
161
|
+
return run
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Where a comment is in this document. */
|
|
165
|
+
function placeOf(doc: ProseMirrorNode, anchor: CommentAnchor, places = placesIn(doc)): CommentPlace {
|
|
166
|
+
const run = runIn(doc, places, anchor.blocks)
|
|
167
|
+
if (!run) return { state: 'detached' }
|
|
168
|
+
const from = run[0].pos
|
|
169
|
+
const to = run[run.length - 1].pos + run[run.length - 1].node.nodeSize
|
|
170
|
+
const { quote } = anchor
|
|
171
|
+
if (!quote) return { state: 'resolved', from, to, words: false }
|
|
172
|
+
|
|
173
|
+
const reworded: CommentPlace = { state: 'reworded', from, to }
|
|
174
|
+
const firstId = anchor.blocks[0]
|
|
175
|
+
const lastId = anchor.blocks[anchor.blocks.length - 1]
|
|
176
|
+
const first = run.findIndex((p) => p.node.attrs.blockId === firstId)
|
|
177
|
+
const last = run.findIndex((p) => p.node.attrs.blockId === lastId)
|
|
178
|
+
if (first === -1 || last === -1 || last < first) return reworded
|
|
179
|
+
const a = textOf(run[first])
|
|
180
|
+
const b = first === last ? a : textOf(run[last])
|
|
181
|
+
// Context longer than a writer ever sends is a stranger's, and ignored, as the protocol's `resolveCommentAnchor` does.
|
|
182
|
+
const prefix = quote.prefix && quote.prefix.length <= QUOTE_CONTEXT_LENGTH ? quote.prefix : undefined
|
|
183
|
+
const suffix = quote.suffix && quote.suffix.length <= QUOTE_CONTEXT_LENGTH ? quote.suffix : undefined
|
|
184
|
+
const before = (text: string, i: number) => !prefix || text.slice(Math.max(0, i - prefix.length), i) === prefix
|
|
185
|
+
const after = (text: string, i: number) => !suffix || text.slice(i, i + suffix.length) === suffix
|
|
186
|
+
|
|
187
|
+
if (quote.end === undefined) {
|
|
188
|
+
const at = find(a.text, quote.start, 0, (i) => before(a.text, i) && after(a.text, i + quote.start.length))
|
|
189
|
+
if (at === -1 || quote.start === '') return reworded
|
|
190
|
+
return { state: 'resolved', from: a.starts[at], to: a.ends[at + quote.start.length - 1], words: true }
|
|
191
|
+
}
|
|
192
|
+
const startAt = find(a.text, quote.start, 0, (i) => before(a.text, i))
|
|
193
|
+
if (startAt === -1 || quote.start === '') return reworded
|
|
194
|
+
const endFrom = first === last ? startAt + quote.start.length : 0
|
|
195
|
+
const endAt = find(b.text, quote.end, endFrom, (i) => after(b.text, i + quote.end!.length))
|
|
196
|
+
if (endAt === -1 || quote.end === '') return reworded
|
|
197
|
+
return { state: 'resolved', from: a.starts[startAt], to: b.ends[endAt + quote.end.length - 1], words: true }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
declare module '@tiptap/core' {
|
|
201
|
+
interface EditorEvents {
|
|
202
|
+
/** A comment's highlight was clicked: `editor.on('commentSelect', ({ id }) => …)`. */
|
|
203
|
+
commentSelect: { id: string }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface CommentHighlightsOptions {
|
|
208
|
+
/** A highlight was clicked: the thread to show. Or listen for the editor's `commentSelect`. */
|
|
209
|
+
onSelect: ((id: string) => void) | null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The extension an editor registers to draw comments where they are. */
|
|
213
|
+
export const CommentHighlights = Extension.create<CommentHighlightsOptions>({
|
|
214
|
+
name: 'commentHighlights',
|
|
215
|
+
|
|
216
|
+
addOptions() {
|
|
217
|
+
return { onSelect: null }
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
addProseMirrorPlugins() {
|
|
221
|
+
const options = this.options
|
|
222
|
+
const editor = this.editor
|
|
223
|
+
return [
|
|
224
|
+
new Plugin<HighlightState>({
|
|
225
|
+
key: commentHighlightsKey,
|
|
226
|
+
state: {
|
|
227
|
+
init: () => ({ anchors: [], active: null }),
|
|
228
|
+
apply(tr, previous) {
|
|
229
|
+
const meta = tr.getMeta(commentHighlightsKey) as HighlightState | undefined
|
|
230
|
+
return meta ?? previous
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
props: {
|
|
234
|
+
decorations(state) {
|
|
235
|
+
const { anchors, active } = commentHighlightsKey.getState(state) ?? { anchors: [], active: null }
|
|
236
|
+
const places = placesIn(state.doc)
|
|
237
|
+
const drawn = anchors.flatMap((anchor) => {
|
|
238
|
+
const place = placeOf(state.doc, anchor, places)
|
|
239
|
+
if (place.state !== 'resolved') return []
|
|
240
|
+
const attrs = { class: anchor.id === active ? COMMENT_HIGHLIGHT_CLASSES.active : COMMENT_HIGHLIGHT_CLASSES.rest, 'data-comment-id': anchor.id }
|
|
241
|
+
if (place.words) return [Decoration.inline(place.from, place.to, attrs)]
|
|
242
|
+
// Whole blocks: each block of the run carries the look itself.
|
|
243
|
+
const blocks: Decoration[] = []
|
|
244
|
+
state.doc.nodesBetween(place.from, place.to, (node, pos) => {
|
|
245
|
+
if (pos < place.from || pos + node.nodeSize > place.to) return true
|
|
246
|
+
blocks.push(Decoration.node(pos, pos + node.nodeSize, attrs))
|
|
247
|
+
return false
|
|
248
|
+
})
|
|
249
|
+
return blocks
|
|
250
|
+
})
|
|
251
|
+
return drawn.length ? DecorationSet.create(state.doc, drawn) : null
|
|
252
|
+
},
|
|
253
|
+
handleClick(view, pos) {
|
|
254
|
+
const { anchors } = commentHighlightsKey.getState(view.state) ?? { anchors: [] }
|
|
255
|
+
const places = placesIn(view.state.doc)
|
|
256
|
+
// The narrowest drawn comment under the click: words before the paragraph around them.
|
|
257
|
+
let hit: { id: string; size: number } | null = null
|
|
258
|
+
for (const anchor of anchors) {
|
|
259
|
+
const place = placeOf(view.state.doc, anchor, places)
|
|
260
|
+
if (place.state !== 'resolved' || pos < place.from || pos >= place.to) continue
|
|
261
|
+
const size = place.to - place.from
|
|
262
|
+
if (!hit || size < hit.size) hit = { id: anchor.id, size }
|
|
263
|
+
}
|
|
264
|
+
if (hit) {
|
|
265
|
+
options.onSelect?.(hit.id)
|
|
266
|
+
editor.emit('commentSelect', { id: hit.id })
|
|
267
|
+
}
|
|
268
|
+
return false
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
/** Draw these comments, `active` the one being read. */
|
|
277
|
+
export function setCommentAnchors(editor: Editor, anchors: readonly CommentAnchor[], active: string | null = null) {
|
|
278
|
+
if (editor.isDestroyed) return
|
|
279
|
+
editor.view.dispatch(editor.state.tr.setMeta(commentHighlightsKey, { anchors, active }))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Where a comment is now: on its words or blocks, beside its blocks with its words gone, or detached. */
|
|
283
|
+
export function commentPlace(editor: Editor, anchor: CommentAnchor): CommentPlace {
|
|
284
|
+
return placeOf(editor.state.doc, anchor)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* What a comment's card stands beside: its drawn highlight, or — its words
|
|
289
|
+
* gone — its first block. Null when it is detached.
|
|
290
|
+
*/
|
|
291
|
+
export function commentElement(editor: Editor, anchor: CommentAnchor): HTMLElement | null {
|
|
292
|
+
const drawn = editor.view.dom.querySelector<HTMLElement>(`[data-comment-id="${CSS.escape(anchor.id)}"]`)
|
|
293
|
+
if (drawn) return drawn
|
|
294
|
+
const place = placeOf(editor.state.doc, anchor)
|
|
295
|
+
if (place.state === 'detached') return null
|
|
296
|
+
const dom = editor.view.nodeDOM(place.from)
|
|
297
|
+
return dom instanceof HTMLElement ? dom : null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A quote in one line, as a card says it: its start and end words around an ellipsis. */
|
|
301
|
+
export function quoteText(quote: CommentQuote): string {
|
|
302
|
+
return quote.end ? `${quote.start} … ${quote.end}` : quote.start
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** At most `length` characters from the front of `text`, cut back to a word when one is near. */
|
|
306
|
+
function headOf(text: string, length: number): string {
|
|
307
|
+
if (text.length <= length) return text
|
|
308
|
+
const cut = text.lastIndexOf(' ', length)
|
|
309
|
+
return text.slice(0, cut > length / 2 ? cut : length)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** At most `length` characters from the back of `text`, cut forward to a word when one is near. */
|
|
313
|
+
function tailOf(text: string, length: number): string {
|
|
314
|
+
if (text.length <= length) return text
|
|
315
|
+
const from = text.length - length
|
|
316
|
+
const cut = text.indexOf(' ', from)
|
|
317
|
+
return text.slice(cut !== -1 && cut < from + length / 2 ? cut + 1 : from)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function occurrences(text: string, words: string): number {
|
|
321
|
+
let count = 0
|
|
322
|
+
for (let at = text.indexOf(words); words && at !== -1; at = text.indexOf(words, at + 1)) count++
|
|
323
|
+
return count
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** How many characters of `block`'s text come before document position `pos`. */
|
|
327
|
+
function offsetIn(block: { starts: number[] }, pos: number): number {
|
|
328
|
+
const at = block.starts.findIndex((start) => start >= pos)
|
|
329
|
+
return at === -1 ? block.starts.length : at
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** What a comment on the selection would carry: its run, and its words unless it covers whole blocks. */
|
|
333
|
+
export interface SelectionAnchor {
|
|
334
|
+
blocks: string[]
|
|
335
|
+
quote?: CommentQuote
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The anchor a comment on the selection carries, as §13.6 writes it: the
|
|
340
|
+
* selected blocks, and the selected words in one block or the words where it
|
|
341
|
+
* starts and ends across several. No quote when whole blocks are selected.
|
|
342
|
+
* Null when nothing is selected or a selected block has no id.
|
|
343
|
+
*/
|
|
344
|
+
export function anchorOfSelection(editor: Editor): SelectionAnchor | null {
|
|
345
|
+
const { doc, selection } = editor.state
|
|
346
|
+
if (selection.empty) return null
|
|
347
|
+
const { $from, $to } = selection
|
|
348
|
+
const places = placesIn(doc)
|
|
349
|
+
// The block with an id at or around `depth`: the lines of a quote or a list
|
|
350
|
+
// item are paragraphs with no id of their own, and belong to it.
|
|
351
|
+
const around = (depth: number): Place | undefined => {
|
|
352
|
+
for (let d = depth; d > 0; d--) {
|
|
353
|
+
const id = $from.node(d).attrs.blockId
|
|
354
|
+
if (typeof id === 'string' && id && places.has(id)) return places.get(id)
|
|
355
|
+
}
|
|
356
|
+
return undefined
|
|
357
|
+
}
|
|
358
|
+
// The blocks the selection runs across: children of the deepest node holding
|
|
359
|
+
// both ends, or — both ends in one textblock, or in blocks with no ids — the
|
|
360
|
+
// block with an id around them.
|
|
361
|
+
let run: Place[]
|
|
362
|
+
if ($from.parent === $to.parent && $from.parent.isTextblock) {
|
|
363
|
+
const found = around($from.depth)
|
|
364
|
+
if (!found) return null
|
|
365
|
+
run = [found]
|
|
366
|
+
} else {
|
|
367
|
+
const depth = $from.sharedDepth($to.pos)
|
|
368
|
+
const low = $from.index(depth)
|
|
369
|
+
const high = $to.pos >= $to.end(depth) ? $from.node(depth).childCount - 1 : $to.index(depth)
|
|
370
|
+
run = []
|
|
371
|
+
for (let i = low; i <= high; i++) {
|
|
372
|
+
const id = $from.node(depth).child(i).attrs.blockId
|
|
373
|
+
const place = typeof id === 'string' ? places.get(id) : undefined
|
|
374
|
+
if (!place) {
|
|
375
|
+
const found = around(depth)
|
|
376
|
+
if (!found) return null
|
|
377
|
+
run = [found]
|
|
378
|
+
break
|
|
379
|
+
}
|
|
380
|
+
run.push(place)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
let firstText = textOf(run[0])
|
|
384
|
+
let lastText = run.length === 1 ? firstText : textOf(run[run.length - 1])
|
|
385
|
+
let start = offsetIn(firstText, selection.from)
|
|
386
|
+
let end = offsetIn(lastText, selection.to)
|
|
387
|
+
// A block the selection only touches at its edge holds none of it: it is not
|
|
388
|
+
// in the run. The same rule as the protocol's `selectionAnchorTags`.
|
|
389
|
+
if (run.length > 1 && start >= firstText.text.length) ((run = run.slice(1)), (firstText = textOf(run[0])), (start = 0))
|
|
390
|
+
if (run.length > 1 && end <= 0) ((run = run.slice(0, -1)), (lastText = textOf(run[run.length - 1])), (end = lastText.text.length))
|
|
391
|
+
if (run.length === 1) lastText = firstText
|
|
392
|
+
// Between two blocks, with no character selected: nothing to comment on.
|
|
393
|
+
if (run.length === 1 && firstText.text && start >= end) return null
|
|
394
|
+
const blocks = run.map((p) => p.node.attrs.blockId as string)
|
|
395
|
+
if (start === 0 && end >= lastText.text.length) return { blocks }
|
|
396
|
+
|
|
397
|
+
const single = run.length === 1
|
|
398
|
+
const quote: CommentQuote = { start: '' }
|
|
399
|
+
if (single && end - start <= QUOTE_WHOLE_MAX) {
|
|
400
|
+
quote.start = firstText.text.slice(start, end)
|
|
401
|
+
} else {
|
|
402
|
+
const startWords = single ? firstText.text.slice(start, end) : firstText.text.slice(start)
|
|
403
|
+
const endWords = single ? startWords : lastText.text.slice(0, end)
|
|
404
|
+
quote.start = headOf(startWords, QUOTE_END_LENGTH)
|
|
405
|
+
quote.end = tailOf(endWords, QUOTE_END_LENGTH)
|
|
406
|
+
}
|
|
407
|
+
if (!quote.start || quote.end === '') return { blocks }
|
|
408
|
+
const prefix = firstText.text.slice(Math.max(0, start - QUOTE_CONTEXT_LENGTH), start)
|
|
409
|
+
const suffix = lastText.text.slice(end, end + QUOTE_CONTEXT_LENGTH)
|
|
410
|
+
if (prefix && occurrences(firstText.text, quote.start) > 1) quote.prefix = prefix
|
|
411
|
+
if (suffix && occurrences(lastText.text, quote.end ?? quote.start) > 1) quote.suffix = suffix
|
|
412
|
+
return { blocks, quote }
|
|
413
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ComposeBoxStories from './ComposeBox.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ComposeBoxStories} />
|
|
5
|
+
|
|
6
|
+
# ComposeBox
|
|
7
|
+
|
|
8
|
+
The box a message, a reply or a comment is written in: the text, the selection strip over it, what is attached, the tools and Send.
|
|
9
|
+
|
|
10
|
+
<Canvas of={ComposeBoxStories.Default} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **Writing something that is sent**: a message, a reply, a comment. Enter sends, Shift+Enter is a new line.
|
|
15
|
+
- `tools` for what acts on the draft — attach, highlight — as `ToolbarButton`s; they are one strip.
|
|
16
|
+
- `attachments` for what is attached, as pending `AttachmentCard`s, under the text.
|
|
17
|
+
- `marked` for the bar down the text's left while it holds an urgent mention or a highlight.
|
|
18
|
+
|
|
19
|
+
<Canvas of={ComposeBoxStories.Reply} />
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- Editing a document in place → the editor itself, with **EditorBlockHandle** and **SelectionToolbar**. Nothing is sent.
|
|
24
|
+
- One line of plain text in a form → **TextInput** or **Textarea**, in a **Form**.
|
|
25
|
+
|
|
26
|
+
## How
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { useEditor } from '@tiptap/react'
|
|
30
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
31
|
+
import { ComposeBox, ComposeKeys, COMPOSE_EDITOR_CLASSES, KeptSelection } from '@estiva-app/ui/editor'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
const editor = useEditor({
|
|
35
|
+
extensions: [StarterKit, KeptSelection, ComposeKeys /* and the app's own */],
|
|
36
|
+
editorProps: { attributes: { class: COMPOSE_EDITOR_CLASSES } },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
<ComposeBox editor={editor} onSend={(ed) => send(ed.getJSON())} placeholder={<span>Reply…</span>} />
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **The app makes the editor.** Its extensions — mentions, references, `/` — are the app's; add `ComposeKeys`, and `COMPOSE_EDITOR_CLASSES` on the editor.
|
|
43
|
+
- **`onSend` gets the editor** and writes what it holds in the app's own format. The box empties after it, unless it returns `false`.
|
|
44
|
+
- `ready` when something can be sent with no words (a file is ready); `holding` while sending must wait (a file is uploading).
|
|
45
|
+
|
|
46
|
+
## What it owns
|
|
47
|
+
|
|
48
|
+
It does these by itself. Write none of them around it.
|
|
49
|
+
|
|
50
|
+
| It does | So you never write |
|
|
51
|
+
|---|---|
|
|
52
|
+
| Enter sends, unless a `/`, `@` or `[` list is open | a keydown handler |
|
|
53
|
+
| Shift+Enter: a new line, a new list item, out of an empty item or quote line | list and quote commands |
|
|
54
|
+
| Send presses on the way down, so the caret stays in the text | `preventDefault` on the button |
|
|
55
|
+
| The placeholder while nothing is written | an empty check |
|
|
56
|
+
| The selection strip over selected words | `SelectionToolbar` beside it |
|
|
57
|
+
|
|
58
|
+
<Controls />
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { IconPaperclip } from '@tabler/icons-react'
|
|
4
|
+
import { useEditor, type Editor } from '@tiptap/react'
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import { AttachmentCard } from './AttachmentCard'
|
|
7
|
+
import { Kbd } from './Kbd'
|
|
8
|
+
import { ToolbarButton } from './Toolbar'
|
|
9
|
+
import { ComposeBox, ComposeKeys, COMPOSE_EDITOR_CLASSES } from './ComposeBox'
|
|
10
|
+
import { KeptSelection } from './SelectionToolbar'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The box a message, a reply or a comment is written in. Type, select
|
|
14
|
+
* words for the strip, Enter to send, Shift+Enter for a new line.
|
|
15
|
+
*/
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Primitives/ComposeBox',
|
|
18
|
+
component: ComposeBox,
|
|
19
|
+
parameters: { layout: 'padded' },
|
|
20
|
+
decorators: [
|
|
21
|
+
(Story) => (
|
|
22
|
+
<div className="w-[480px]">
|
|
23
|
+
<Story />
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
} satisfies Meta<typeof ComposeBox>
|
|
28
|
+
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj
|
|
31
|
+
|
|
32
|
+
function Writing({ tools, placeholder, file }: { tools?: boolean; placeholder: 'new' | 'reply'; file?: boolean }) {
|
|
33
|
+
const [sent, setSent] = useState<string[]>([])
|
|
34
|
+
const editor = useEditor({
|
|
35
|
+
extensions: [StarterKit.configure({ link: { openOnClick: false } }), KeptSelection, ComposeKeys],
|
|
36
|
+
editorProps: { attributes: { class: COMPOSE_EDITOR_CLASSES } },
|
|
37
|
+
})
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-col gap-3">
|
|
40
|
+
{sent.map((text, i) => (
|
|
41
|
+
<p key={i} className="text-body-2 text-text-primary">
|
|
42
|
+
{text}
|
|
43
|
+
</p>
|
|
44
|
+
))}
|
|
45
|
+
<ComposeBox
|
|
46
|
+
editor={editor}
|
|
47
|
+
onSend={(ed: Editor) => setSent((all) => [...all, ed.getText()])}
|
|
48
|
+
selection={{ link: true }}
|
|
49
|
+
ready={file}
|
|
50
|
+
attachments={file ? <AttachmentCard pending name="brief.pdf" size={182_000} contentType="application/pdf" state="ready" onRemove={() => {}} /> : undefined}
|
|
51
|
+
placeholder={
|
|
52
|
+
placeholder === 'reply' ? (
|
|
53
|
+
<span>Reply…</span>
|
|
54
|
+
) : (
|
|
55
|
+
<>
|
|
56
|
+
<span>Start a new conversation or type</span>
|
|
57
|
+
<Kbd>/</Kbd>
|
|
58
|
+
<span>for commands</span>
|
|
59
|
+
</>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
tools={
|
|
63
|
+
tools ? (
|
|
64
|
+
<ToolbarButton tooltip="Attach files" aria-label="Attach files">
|
|
65
|
+
<IconPaperclip size={16} stroke={1.5} />
|
|
66
|
+
</ToolbarButton>
|
|
67
|
+
) : undefined
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A new message, with a tool under the text. */
|
|
75
|
+
export const Default: Story = { render: () => <Writing placeholder="new" tools /> }
|
|
76
|
+
|
|
77
|
+
/** A reply: no tools, only Send. */
|
|
78
|
+
export const Reply: Story = { render: () => <Writing placeholder="reply" /> }
|
|
79
|
+
|
|
80
|
+
/** A file waiting to be sent: under the text, and Send is ready with no words. */
|
|
81
|
+
export const WithAFile: Story = { render: () => <Writing placeholder="new" tools file /> }
|