frappe-ui 1.0.0-beta.49 → 1.0.0-beta.50

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": "frappe-ui",
3
- "version": "1.0.0-beta.49",
3
+ "version": "1.0.0-beta.50",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { Editor, Extension, type Extensions } from '@tiptap/core'
6
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
7
+ import Document from '@tiptap/extension-document'
8
+ import Paragraph from '@tiptap/extension-paragraph'
9
+ import Text from '@tiptap/extension-text'
10
+ import { UndoRedo } from '@tiptap/extensions'
11
+ import { LinkExtension } from './link-extension'
12
+
13
+ const openEditors: Editor[] = []
14
+
15
+ afterEach(() => {
16
+ // An undestroyed view keeps a DOM observer timer alive past teardown.
17
+ while (openEditors.length) openEditors.pop()?.destroy()
18
+ })
19
+
20
+ function editorWith(content: string, extensions: Extensions = []) {
21
+ const editor = new Editor({
22
+ extensions: [Document, Paragraph, Text, LinkExtension, ...extensions],
23
+ content,
24
+ })
25
+ openEditors.push(editor)
26
+ return editor
27
+ }
28
+
29
+ /** The document's inline HTML, with the link's boilerplate attributes dropped. */
30
+ function inlineHTML(editor: Editor): string {
31
+ return editor
32
+ .getHTML()
33
+ .replace(/ target="_blank" rel="noopener noreferrer nofollow"/g, '')
34
+ .replace(/^<p>|<\/p>$/g, '')
35
+ }
36
+
37
+ /** Type `text` at the cursor the way the view does, one character at a time. */
38
+ function type(editor: Editor, text: string) {
39
+ for (const char of text) {
40
+ editor.view.dispatch(editor.state.tr.insertText(char))
41
+ }
42
+ }
43
+
44
+ /** Put the cursor at the end of the link in `<p>…<a>link</a>…</p>`. */
45
+ function cursorAtLinkEnd(editor: Editor, linkText: string) {
46
+ const text = editor.state.doc.textContent
47
+ editor.commands.setTextSelection(
48
+ text.indexOf(linkText) + linkText.length + 1, // +1 for the paragraph open token
49
+ )
50
+ }
51
+
52
+ describe('exitLinkOnSpacePlugin', () => {
53
+ it('leaves a space typed at the end of a link outside the link', () => {
54
+ const editor = editorWith(
55
+ '<p>see <a href="https://example.com">docs</a></p>',
56
+ )
57
+ cursorAtLinkEnd(editor, 'docs')
58
+
59
+ type(editor, ' ')
60
+
61
+ expect(inlineHTML(editor)).toBe(
62
+ 'see <a href="https://example.com">docs</a> ',
63
+ )
64
+ })
65
+
66
+ it('ends the link, so text typed after the space is not linked', () => {
67
+ const editor = editorWith(
68
+ '<p>see <a href="https://example.com">docs</a></p>',
69
+ )
70
+ cursorAtLinkEnd(editor, 'docs')
71
+
72
+ type(editor, ' and more')
73
+
74
+ expect(inlineHTML(editor)).toBe(
75
+ 'see <a href="https://example.com">docs</a> and more',
76
+ )
77
+ })
78
+
79
+ it('handles a run of spaces', () => {
80
+ const editor = editorWith(
81
+ '<p>see <a href="https://example.com">docs</a></p>',
82
+ )
83
+ cursorAtLinkEnd(editor, 'docs')
84
+
85
+ type(editor, ' x')
86
+
87
+ expect(inlineHTML(editor)).toBe(
88
+ 'see <a href="https://example.com">docs</a> x',
89
+ )
90
+ })
91
+
92
+ it('ends the link when the space is typed between the link and later text', () => {
93
+ const editor = editorWith(
94
+ '<p><a href="https://example.com">docs</a>tail</p>',
95
+ )
96
+ cursorAtLinkEnd(editor, 'docs')
97
+
98
+ type(editor, ' ')
99
+
100
+ expect(inlineHTML(editor)).toBe(
101
+ '<a href="https://example.com">docs</a> tail',
102
+ )
103
+ })
104
+
105
+ it('keeps a space typed inside link text linked', () => {
106
+ const editor = editorWith(
107
+ '<p><a href="https://example.com">clickme</a></p>',
108
+ )
109
+ editor.commands.setTextSelection(1 + 'click'.length)
110
+
111
+ type(editor, ' ')
112
+
113
+ expect(inlineHTML(editor)).toBe(
114
+ '<a href="https://example.com">click me</a>',
115
+ )
116
+ })
117
+
118
+ it('leaves non-whitespace typed at the end of a link alone', () => {
119
+ // The mark is inclusive on purpose: a URL still being typed keeps growing.
120
+ const editor = editorWith('<p><a href="https://example.com">docs</a></p>')
121
+ cursorAtLinkEnd(editor, 'docs')
122
+
123
+ type(editor, '/api')
124
+
125
+ expect(inlineHTML(editor)).toBe(
126
+ '<a href="https://example.com">docs/api</a>',
127
+ )
128
+ })
129
+
130
+ it('does not relink an autolinked URL onto the space that triggered it', () => {
131
+ const editor = editorWith('<p></p>')
132
+
133
+ type(editor, 'see https://example.com/a then')
134
+
135
+ expect(inlineHTML(editor)).toBe(
136
+ 'see <a href="https://example.com/a">https://example.com/a</a> then',
137
+ )
138
+ })
139
+
140
+ it('leaves a trailing space outside a link applied over it', () => {
141
+ // A mark-only change: no text moves, so the plugin has to read the changed
142
+ // range off the AddMarkStep itself.
143
+ const editor = editorWith('<p>see docs tail</p>')
144
+
145
+ editor
146
+ .chain()
147
+ .setTextSelection({ from: 5, to: 10 }) // "docs "
148
+ .setLink({ href: 'https://example.com' })
149
+ .setTextSelection(10)
150
+ .run()
151
+
152
+ expect(inlineHTML(editor)).toBe(
153
+ 'see <a href="https://example.com">docs</a> tail',
154
+ )
155
+ })
156
+
157
+ it('keeps a link whose text is only whitespace', () => {
158
+ // Ending the link would delete it outright, which is not this plugin's job.
159
+ const editor = editorWith('<p>a b</p>')
160
+
161
+ editor
162
+ .chain()
163
+ .setTextSelection({ from: 2, to: 3 })
164
+ .setLink({ href: 'https://example.com' })
165
+ .setTextSelection(3)
166
+ .run()
167
+
168
+ expect(inlineHTML(editor)).toBe('a<a href="https://example.com"> </a>b')
169
+ })
170
+
171
+ it('ignores a change made elsewhere in the document', () => {
172
+ // A collaborator's keystroke (or an undo) must not rewrite marks the user
173
+ // never touched, even with the cursor resting after an already-linked space.
174
+ const editor = editorWith(
175
+ '<p>see <a href="https://example.com">docs </a>tail</p>',
176
+ )
177
+ editor.commands.setTextSelection(10) // just after the linked space
178
+
179
+ const tr = editor.state.tr.insertText('Z', 1, 1)
180
+ tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping))
181
+ editor.view.dispatch(tr)
182
+
183
+ expect(inlineHTML(editor)).toBe(
184
+ 'Zsee <a href="https://example.com">docs </a>tail',
185
+ )
186
+ })
187
+
188
+ it('leaves a remote collaboration change to the peer that made it', () => {
189
+ // Stand-in for y-prosemirror's plugin: the real one is pulled in by a
190
+ // consumer's Collaboration extension, so it cannot be imported here.
191
+ const ySyncKey = new PluginKey('y-sync')
192
+ const YSync = Extension.create({
193
+ name: 'ySyncStandIn',
194
+ addProseMirrorPlugins: () => [new Plugin({ key: ySyncKey })],
195
+ })
196
+ const editor = editorWith(
197
+ '<p>see <a href="https://example.com">docs</a></p>',
198
+ [YSync],
199
+ )
200
+ cursorAtLinkEnd(editor, 'docs')
201
+
202
+ // That peer runs this same plugin; its result replicates on its own.
203
+ editor.view.dispatch(editor.state.tr.insertText(' ').setMeta(ySyncKey, {}))
204
+
205
+ expect(inlineHTML(editor)).toBe(
206
+ 'see <a href="https://example.com">docs </a>',
207
+ )
208
+ })
209
+
210
+ it('undoes the space and the mark change as one step', () => {
211
+ const editor = editorWith(
212
+ '<p>see <a href="https://example.com">docs</a></p>',
213
+ [UndoRedo],
214
+ )
215
+ cursorAtLinkEnd(editor, 'docs')
216
+ type(editor, ' ')
217
+
218
+ editor.commands.undo()
219
+
220
+ expect(inlineHTML(editor)).toBe(
221
+ 'see <a href="https://example.com">docs</a>',
222
+ )
223
+ })
224
+
225
+ it('stays out of the way when the editor is not editable', () => {
226
+ // A read-only user cannot type, so this drives the guard through
227
+ // `view.dispatch` instead: it pins the guard rather than the user path,
228
+ // which matters for a programmatic write into a read-only editor.
229
+ const editor = editorWith('<p><a href="https://example.com">docs</a></p>')
230
+ editor.setEditable(false)
231
+ cursorAtLinkEnd(editor, 'docs')
232
+
233
+ type(editor, ' x')
234
+
235
+ expect(inlineHTML(editor)).toBe('<a href="https://example.com">docs x</a>')
236
+ })
237
+ })
@@ -0,0 +1,130 @@
1
+ import {
2
+ combineTransactionSteps,
3
+ getChangedRanges,
4
+ getMarkRange,
5
+ type Editor,
6
+ } from '@tiptap/core'
7
+ import type { MarkType, ResolvedPos } from '@tiptap/pm/model'
8
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
9
+ import { isRemoteChange } from '#molecules/editor/extensions/shared/collaboration'
10
+
11
+ /**
12
+ * Options for {@link exitLinkOnSpacePlugin}.
13
+ */
14
+ export interface ExitLinkOnSpacePluginOptions {
15
+ editor: Editor
16
+ type: MarkType
17
+ }
18
+
19
+ /**
20
+ * End the link when whitespace is typed at the end of one.
21
+ *
22
+ * The Link mark is *inclusive* whenever `autolink` is on (tiptap ties the two
23
+ * together, so the mark can grow while a URL is still being typed). The side
24
+ * effect is that a space typed right after a link is pulled inside the `<a>`,
25
+ * and everything typed after it stays linked too. So `<a>docs</a>` becomes
26
+ * `<a>docs and more</a>` one keystroke at a time.
27
+ *
28
+ * Whitespace is where a URL unambiguously ends, so it is the boundary we act
29
+ * on: strip the link mark off a whitespace run the user just typed at the end
30
+ * of a link. Text typed after it then inherits the (unlinked) space's marks,
31
+ * which ends the link without touching the inclusive-mark behaviour that
32
+ * autolink relies on while a URL is mid-typing.
33
+ *
34
+ * Runs as `appendTransaction` rather than `handleTextInput` so input rules keep
35
+ * their first crack at the keystroke, and so pasted or IME-composed whitespace
36
+ * is covered as well.
37
+ */
38
+ export function exitLinkOnSpacePlugin(
39
+ options: ExitLinkOnSpacePluginOptions,
40
+ ): Plugin {
41
+ return new Plugin({
42
+ key: new PluginKey('exitLinkOnSpace'),
43
+ appendTransaction: (transactions, oldState, newState) => {
44
+ if (!options.editor.isEditable) {
45
+ return null
46
+ }
47
+
48
+ if (!transactions.some((transaction) => transaction.docChanged)) {
49
+ return null
50
+ }
51
+
52
+ // A remote peer's space is that peer's link to end: it runs this same
53
+ // plugin and the result replicates. Ending it here too would push a local
54
+ // rewrite into their typing.
55
+ if (isRemoteChange(transactions, newState)) {
56
+ return null
57
+ }
58
+
59
+ const { selection } = newState
60
+ if (!selection.empty) {
61
+ // Only a collapsed cursor means "the user is typing here".
62
+ return null
63
+ }
64
+
65
+ const range = linkedTrailingSpace(selection.$from, options.type)
66
+ if (!range) {
67
+ return null
68
+ }
69
+
70
+ // The whitespace has to be part of what just changed. Otherwise an edit
71
+ // anywhere else — a collaborator's keystroke, an undo — would rewrite
72
+ // marks the user never touched, just because the cursor happened to be
73
+ // resting after an already-linked space.
74
+ const changes = getChangedRanges(
75
+ combineTransactionSteps(oldState.doc, [...transactions]),
76
+ )
77
+ const justWritten = changes.some(
78
+ ({ newRange }) =>
79
+ newRange.to >= range.from && newRange.from <= range.to,
80
+ )
81
+ if (!justWritten) {
82
+ return null
83
+ }
84
+
85
+ return newState.tr.removeMark(range.from, range.to, options.type)
86
+ },
87
+ })
88
+ }
89
+
90
+ /**
91
+ * The run of linked whitespace immediately before `$pos`, when `$pos` is the
92
+ * end of that link. Returns `null` when there is no such run, or when the link
93
+ * continues past the cursor — a space typed *inside* link text stays linked,
94
+ * because splitting a link in two is never what the keystroke meant.
95
+ */
96
+ function linkedTrailingSpace(
97
+ $pos: ResolvedPos,
98
+ type: MarkType,
99
+ ): { from: number; to: number } | null {
100
+ const before = $pos.nodeBefore
101
+ if (!before?.isText || !before.text) {
102
+ return null
103
+ }
104
+
105
+ const link = before.marks.find((mark) => mark.type === type)
106
+ if (!link) {
107
+ return null
108
+ }
109
+
110
+ const after = $pos.nodeAfter
111
+ if (after && link.isInSet(after.marks)) {
112
+ // Mid-link: the whitespace separates link text rather than ending it.
113
+ return null
114
+ }
115
+
116
+ const trailing = before.text.length - before.text.trimEnd().length
117
+ if (trailing === 0) {
118
+ return null
119
+ }
120
+
121
+ const from = $pos.pos - trailing
122
+ const linkRange = getMarkRange($pos, type)
123
+ if (linkRange && from <= linkRange.from) {
124
+ // The link is whitespace all the way down, so ending it here would delete
125
+ // it outright. Ending a link is this plugin's job; removing one is not.
126
+ return null
127
+ }
128
+
129
+ return { from, to: $pos.pos }
130
+ }
@@ -2,6 +2,7 @@ import Link from '@tiptap/extension-link'
2
2
  import { buildOpenLinkEditor, type OpenLinkEditorOptions } from './link-commands'
3
3
  import { linkPastePlugin } from './link-paste-plugin'
4
4
  import { clearLinkOnBoundaryPlugin } from './clear-link-on-boundary-plugin'
5
+ import { exitLinkOnSpacePlugin } from './exit-link-on-space-plugin'
5
6
  import { linkClickPlugin } from './link-click-plugin'
6
7
  import { linkShortcutPlugin } from './link-shortcut-plugin'
7
8
 
@@ -21,7 +22,8 @@ declare module '@tiptap/core' {
21
22
  * from `@tiptap/extension-link` via `...this.parent?.()` and deliberately NOT
22
23
  * weakened. This extension only adds:
23
24
  * - the `openLinkEditor` command (bubble popup) — see `link-commands.ts`;
24
- * - four ProseMirror plugins (paste / boundary / click / shortcut) — one per file.
25
+ * - five ProseMirror plugins (paste / boundary / space-exit / click / shortcut) —
26
+ * one per file.
25
27
  * `Mod-k` lives in the shortcut plugin (not `addKeyboardShortcuts`) so it can
26
28
  * stop the keystroke propagating to app-level listeners; see that file.
27
29
  */
@@ -57,6 +59,7 @@ export const LinkExtension = Link.extend({
57
59
  plugins.push(
58
60
  linkPastePlugin({ editor: this.editor, type: this.type }),
59
61
  clearLinkOnBoundaryPlugin({ editor: this.editor, type: this.type }),
62
+ exitLinkOnSpacePlugin({ editor: this.editor, type: this.type }),
60
63
  linkClickPlugin({ editor: this.editor, type: this.type }),
61
64
  linkShortcutPlugin({ editor: this.editor }),
62
65
  )
@@ -4,13 +4,9 @@ import type {
4
4
  NodeType,
5
5
  Schema,
6
6
  } from '@tiptap/pm/model'
7
- import {
8
- Plugin,
9
- PluginKey,
10
- type EditorState,
11
- type Transaction,
12
- } from '@tiptap/pm/state'
7
+ import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
13
8
  import { canJoin } from '@tiptap/pm/transform'
9
+ import { isRemoteChange } from '#molecules/editor/extensions/shared/collaboration'
14
10
 
15
11
  const listTypeCache = new WeakMap<Schema, Set<NodeType>>()
16
12
 
@@ -92,37 +88,6 @@ function joinAdjacentListsIn(tr: Transaction, schema: Schema): boolean {
92
88
  return positions.length > 0
93
89
  }
94
90
 
95
- /**
96
- * Whether these transactions carry a remote collaboration update.
97
- *
98
- * Collaboration is a consumer-supplied extension (`@tiptap/extension-collaboration`
99
- * brings y-prosemirror), so its plugin key cannot be imported here. Find it on
100
- * the live state instead: y-prosemirror tags every remote application with the
101
- * meta of its `y-sync` plugin.
102
- */
103
- function isRemoteChange(
104
- transactions: readonly Transaction[],
105
- state: EditorState,
106
- ): boolean {
107
- const syncKey = state.plugins.find((plugin) =>
108
- ((plugin.spec.key as PluginKey | undefined)?.key ?? '').startsWith(
109
- 'y-sync',
110
- ),
111
- )?.spec.key as PluginKey | undefined
112
- if (!syncKey) return false
113
- return transactions.some((transaction) => {
114
- if (transaction.getMeta(syncKey)) return true
115
- // Another plugin appending to the same dispatch (TrailingNode does, on any
116
- // structural change) produces a transaction that carries no sync meta of
117
- // its own. ProseMirror tags it with the root transaction that caused it,
118
- // so ask that one — otherwise the guard would depend on plugin order.
119
- const root = transaction.getMeta('appendedTransaction') as
120
- | Transaction
121
- | undefined
122
- return Boolean(root?.getMeta(syncKey))
123
- })
124
- }
125
-
126
91
  declare module '@tiptap/core' {
127
92
  interface Commands<ReturnType> {
128
93
  listJoin: {
@@ -0,0 +1,42 @@
1
+ import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
2
+
3
+ /** `PluginKey`'s runtime `key` string, which its public type does not declare. */
4
+ type KeyedPluginKey = PluginKey & { key?: string }
5
+
6
+ /**
7
+ * Whether these transactions carry a remote collaboration update.
8
+ *
9
+ * A remote peer's edit arrives as an ordinary local transaction, so a plugin
10
+ * that rewrites the document in `appendTransaction` cannot tell the two apart
11
+ * without asking. It needs to: that peer runs the same plugin and its own
12
+ * rewrite replicates on its own, so acting here too means two peers editing the
13
+ * same range concurrently — and, worse, silently pushing a local
14
+ * normalisation into someone else's typing.
15
+ *
16
+ * Collaboration is a consumer-supplied extension (`@tiptap/extension-collaboration`
17
+ * brings y-prosemirror), so its plugin key cannot be imported here. Find it on
18
+ * the live state instead: y-prosemirror tags every remote application with the
19
+ * meta of its `y-sync` plugin.
20
+ */
21
+ export function isRemoteChange(
22
+ transactions: readonly Transaction[],
23
+ state: EditorState,
24
+ ): boolean {
25
+ const syncKey = state.plugins.find((plugin) =>
26
+ ((plugin.spec.key as KeyedPluginKey | undefined)?.key ?? '').startsWith(
27
+ 'y-sync',
28
+ ),
29
+ )?.spec.key as PluginKey | undefined
30
+ if (!syncKey) return false
31
+ return transactions.some((transaction) => {
32
+ if (transaction.getMeta(syncKey)) return true
33
+ // Another plugin appending to the same dispatch (TrailingNode does, on any
34
+ // structural change) produces a transaction that carries no sync meta of
35
+ // its own. ProseMirror tags it with the root transaction that caused it,
36
+ // so ask that one — otherwise the guard would depend on plugin order.
37
+ const root = transaction.getMeta('appendedTransaction') as
38
+ | Transaction
39
+ | undefined
40
+ return Boolean(root?.getMeta(syncKey))
41
+ })
42
+ }