@estiva-app/ui 0.68.0 → 0.69.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.68.0",
3
+ "version": "0.69.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.68.0",
7
+ "packageVersion": "0.69.1",
8
8
  "exports": 162,
9
9
  "typeExports": 152,
10
10
  "files": null
package/src/LinkChips.mdx CHANGED
@@ -21,10 +21,10 @@ recognise, or has not answered for yet, stays text.
21
21
 
22
22
  - A document that is only read → **RichText**, whose text already says which
23
23
  runs are chips.
24
- - An editor with a link mark (a composer's Link button) → its links are the
25
- author's words, not chips. Turn the link mark off in a document editor
26
- (`StarterKit.configure({ link: false })`), or URLs are linked before this
27
- sees them.
24
+ - A link on words (a composer's Link button, a URL pasted over selected
25
+ words) → it stays the author's link, not a chip. A link whose text is its
26
+ own URL (an autolink, a link mark's paste rule) is that URL, and becomes
27
+ its chip.
28
28
 
29
29
  ## How
30
30
 
@@ -47,10 +47,13 @@ LinkChips.configure({
47
47
  never turns into a chip while it is being typed. A pasted URL is whole: it
48
48
  is asked about and becomes its chip at once, with the caret at its end, so
49
49
  **ReferenceMenu** can open its menu.
50
- - A URL in code, in a code block, or under a link mark stays text.
51
- - Each URL is asked about once, and one editor asks about at most `limit`
50
+ - A URL in code or in a code block stays text, and so does a link on words.
51
+ - Each URL is asked about once while the document holds it, and one editor
52
+ asks about at most `limit` of the URLs it holds unanswered at a time
52
53
  (24 by default), the first ones: an answer can cost the app a query.
53
- - Making a chip is not an undo step: undo takes back what was typed.
54
+ - A pasted URL's chip is its own undo step: undo gives the URL back, and a
55
+ second undo takes the paste away. A typed URL's chip is not a step: undo
56
+ takes back what was typed.
54
57
 
55
58
  ## What it owns
56
59
 
@@ -83,6 +83,23 @@ describe('LinkChips', () => {
83
83
  expect(asked).toEqual([['https://a.example/1', 'https://a.example/2']])
84
84
  })
85
85
 
86
+ it('counts its limit over the URLs the document holds: a message box keeps asking after the last message is sent', async () => {
87
+ const asked: string[][] = []
88
+ editor = new Editor({
89
+ extensions: [
90
+ StarterKit.configure({ link: false }),
91
+ BlockId,
92
+ ReferenceNode,
93
+ LinkChips.configure({ referenceOf: () => undefined, ask: (urls) => void asked.push(urls), limit: 2 }),
94
+ ],
95
+ content: para({ type: 'text', text: 'https://a.example/1 https://a.example/2 ' }),
96
+ })
97
+ await tick()
98
+ // Sent: the box is emptied, and the next message's links are asked about.
99
+ editor.commands.setContent(para({ type: 'text', text: 'https://a.example/3 ' }))
100
+ expect(asked).toEqual([['https://a.example/1', 'https://a.example/2'], ['https://a.example/3']])
101
+ })
102
+
86
103
  it('turns the URL into its chip once the answer comes back', async () => {
87
104
  const known = new Map<string, string>()
88
105
  const ed = make(para({ type: 'text', text: `See ${URL_} now` }), known)
@@ -175,4 +192,47 @@ describe('LinkChips', () => {
175
192
  await tick()
176
193
  expect(ed.getText()).toBe(`${URL_} `)
177
194
  })
195
+
196
+ it('makes a pasted URL its chip as its own undo step: undo gives the URL back, then takes the paste away', async () => {
197
+ const ed = make(para({ type: 'text', text: 'See ' }), new Map([[URL_, V]]))
198
+ await tick()
199
+ ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
200
+ ed.view.pasteText(URL_, new Event('paste') as ClipboardEvent)
201
+ expect(inline(ed).map((n) => n.type)).toEqual(['text', 'reference'])
202
+ ed.commands.undo()
203
+ expect(inline(ed)).toEqual([{ type: 'text', text: `See ${URL_}` }])
204
+ ed.commands.undo()
205
+ expect(inline(ed)).toEqual([{ type: 'text', text: 'See ' }])
206
+ })
207
+
208
+ it('makes a pasted URL answered later its own undo step too', async () => {
209
+ const known = new Map<string, string>()
210
+ const ed = make(para({ type: 'text', text: 'See ' }), known)
211
+ await tick()
212
+ ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, ed.state.doc.content.size - 1)))
213
+ ed.view.pasteText(URL_, new Event('paste') as ClipboardEvent)
214
+ expect(inline(ed)).toEqual([{ type: 'text', text: `See ${URL_}` }])
215
+ known.set(URL_, V)
216
+ refreshLinkChips(ed)
217
+ expect(inline(ed)[1].type).toBe('reference')
218
+ ed.commands.undo()
219
+ expect(inline(ed)).toEqual([{ type: 'text', text: `See ${URL_}` }])
220
+ })
221
+
222
+ it('turns a link whose text is its own URL into its chip, and leaves a link on words', async () => {
223
+ editor = new Editor({
224
+ // A composer's link mark: an autolink, or its paste rule, links a bare URL to itself.
225
+ extensions: [StarterKit.configure({ link: { autolink: false, openOnClick: false } }), BlockId, ReferenceNode, LinkChips.configure({ referenceOf: (url) => (url === URL_ ? V : undefined), ask: null })],
226
+ content: para(
227
+ { type: 'text', text: URL_, marks: [{ type: 'link', attrs: { href: URL_ } }, { type: 'bold' }] },
228
+ { type: 'text', text: ' and ' },
229
+ { type: 'text', text: 'the words', marks: [{ type: 'link', attrs: { href: URL_ } }] },
230
+ ),
231
+ })
232
+ await tick()
233
+ const [chip, , words] = inline(editor)
234
+ expect(chip.type).toBe('reference')
235
+ expect(chip.attrs).toMatchObject({ uri: V, href: URL_, marks: ['bold'] })
236
+ expect(words).toMatchObject({ type: 'text', text: 'the words' })
237
+ })
178
238
  })
package/src/LinkChips.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * still at its end — where `ReferenceMenu` opens its menu.
20
20
  */
21
21
  import { Extension, type Editor } from '@tiptap/core'
22
+ import { closeHistory } from '@tiptap/pm/history'
22
23
  import type { Node as PmNode } from '@tiptap/pm/model'
23
24
  import { Plugin, PluginKey, type EditorState, type Selection, type Transaction } from '@tiptap/pm/state'
24
25
 
@@ -111,9 +112,14 @@ export function findLinkChips(doc: PmNode, selection: Selection | null, referenc
111
112
  return false
112
113
  }
113
114
  if (!node.isText || !node.text) return true
114
- // A URL in code is literal, and one already linked on purpose is the link's.
115
- if (node.marks.some((m) => m.type.spec.code || m.type.name === 'code' || m.type.name === 'link')) return false
116
- for (const run of urlRuns(node.text)) {
115
+ // A URL in code is literal.
116
+ if (node.marks.some((m) => m.type.spec.code || m.type.name === 'code')) return false
117
+ // A link on words is the link's. A link whose text is its own URL — an editor's autolink, or its paste rule — is
118
+ // that URL, as a document reads one (protocol's `inlineToEditor`): its chip, with the link mark left behind.
119
+ const link = node.marks.find((m) => m.type.name === 'link')
120
+ if (link && !(link.attrs.href === node.text && /^https?:\/\//.test(node.text))) return false
121
+ const runs = link ? [{ url: node.text, from: 0, to: node.text.length, end: node.text.length }] : urlRuns(node.text)
122
+ for (const run of runs) {
117
123
  const from = pos + run.from
118
124
  const to = pos + run.to
119
125
  // Being typed or selected: the caret inside it or at its end (`https://a.` is still being typed), or a
@@ -123,7 +129,7 @@ export function findLinkChips(doc: PmNode, selection: Selection | null, referenc
123
129
  const typing = selection && selection.to > from && selection.from <= pos + run.end
124
130
  if (typing && !(selection.empty && wasPasted)) continue
125
131
  const uri = referenceOf?.(run.url)
126
- if (uri) found.chips.push({ from, to, url: run.url, uri, marks: node.marks.map((m) => m.type.name), pasted: wasPasted })
132
+ if (uri) found.chips.push({ from, to, url: run.url, uri, marks: node.marks.filter((m) => m !== link).map((m) => m.type.name), pasted: wasPasted })
127
133
  else open.add(run.url)
128
134
  }
129
135
  return false
@@ -145,9 +151,12 @@ function apply(state: EditorState, found: Found): Transaction | null {
145
151
  for (const chip of [...found.chips].reverse()) {
146
152
  tr.replaceWith(chip.from, chip.to, type.create({ uri: chip.uri, href: chip.url, marks: chip.marks }))
147
153
  }
148
- // Not a step of the person's: undo takes back what they typed, not the chip it became.
149
154
  // Whether a pasted URL became its chip: `ReferenceMenu` opens on that one, never on a URL typed by hand.
150
- return tr.setMeta('addToHistory', false).setMeta(CHIPS_MADE, { pasted: found.chips.some((chip) => chip.pasted) })
155
+ const pasted = found.chips.some((chip) => chip.pasted)
156
+ tr.setMeta(CHIPS_MADE, { pasted })
157
+ // A pasted URL's chip is its own undo step, so Ctrl+Z gives the URL back rather than taking the paste away (OPT-19,
158
+ // as Peek's pasted link did). Any other chip is not a step of the person's: undo takes back what they typed.
159
+ return pasted ? closeHistory(tr) : tr.setMeta('addToHistory', false)
151
160
  }
152
161
 
153
162
  /**
@@ -206,6 +215,10 @@ export const LinkChips = Extension.create<LinkChipsOptions>({
206
215
  const look = (state: EditorState) => {
207
216
  if (!options.ask) return
208
217
  const { open } = findLinkChips(state.doc, state.selection, options.referenceOf, key.getState(state)?.pasted)
218
+ // The limit counts the URLs the document still holds unanswered: a message box lives across many messages,
219
+ // and one that counted every URL it was ever asked would stop making chips after a day's links (OPT-19).
220
+ const waiting = new Set(open)
221
+ for (const url of asked) if (!waiting.has(url)) asked.delete(url)
209
222
  const fresh = open.filter((url) => !asked.has(url)).slice(0, Math.max(0, options.limit - asked.size))
210
223
  if (!fresh.length) return
211
224
  for (const url of fresh) asked.add(url)