@estiva-app/ui 0.74.0 → 0.74.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.map +1 -1
- package/dist/editor.js +42 -17
- package/dist/editor.js.map +3 -3
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/CommentHighlights.test.ts +56 -2
- package/src/CommentHighlights.ts +51 -12
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -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
|
|