@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.74.0",
3
+ "version": "0.74.1",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.74.0",
7
+ "packageVersion": "0.74.1",
8
8
  "exports": 178,
9
9
  "typeExports": 164,
10
10
  "files": null
@@ -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
- expect(anchorOfSelection(e)).toEqual({ blocks: ['q1'], quote: { start: 'linetwo' } })
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' })
@@ -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. A chip counts as its `nostr:` text, as on the wire.
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
- 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
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
- return true
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