frappe-ui 1.0.0-beta.66 → 1.0.0-beta.67
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 +3 -1
- package/scripts/migrate-editor-v1.js +477 -0
- package/src/molecules/editor/EditorFixedMenu.vue +2 -2
- package/src/molecules/editor/MenuItems.vue +2 -2
- package/src/molecules/editor/SuggestionExtension.ts +10 -3
- package/src/molecules/editor/extensions/emoji/emoji-extension.ts +1 -1
- package/src/molecules/editor/extensions/mention/mention-extension.test.ts +17 -1
- package/src/molecules/editor/extensions/mention/mention-extension.ts +11 -14
- package/src/molecules/editor/extensions/shared/suggestion-open.test.ts +1 -1
- package/src/molecules/editor/extensions/slash-commands/slash-commands-extension.ts +1 -1
- package/src/molecules/editor/extensions/suggestion/createSuggestionExtension.ts +2 -2
- package/src/molecules/editor/extensions/tag/tag-extension.ts +1 -1
- package/src/molecules/editor/kits.ts +1 -1
- package/src/molecules/editor/menu.test.ts +11 -0
- package/src/molecules/editor/useEditor.test.ts +30 -2
- package/src/utils/warnDeprecated.ts +21 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.67",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.19.0"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"bin": {
|
|
22
22
|
"tokens-v2": "./tailwind/migrate-tokens-v2.js",
|
|
23
23
|
"shortcuts-v1": "./scripts/migrate-shortcuts-v1.js",
|
|
24
|
+
"editor-v1": "./scripts/migrate-editor-v1.js",
|
|
24
25
|
"destinations-v1": "./scripts/migrate-destinations-v1.js"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"tailwind",
|
|
104
105
|
"vitepress",
|
|
105
106
|
"scripts/migrate-shortcuts-v1.js",
|
|
107
|
+
"scripts/migrate-editor-v1.js",
|
|
106
108
|
"experimental",
|
|
107
109
|
"experimental.ts",
|
|
108
110
|
"tsconfig.base.json"
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Editor v1 migration codemod.
|
|
4
|
+
*
|
|
5
|
+
* Renames the public EditorFixedMenu `buttonSize` prop to `size` in Vue
|
|
6
|
+
* templates. Suggestion `component` keys deliberately stay manual because the
|
|
7
|
+
* replacement is `nodeView` or `listComponent` depending on the config shape.
|
|
8
|
+
*
|
|
9
|
+
* Usage: editor-v1 [--dry-run] <dir-or-file...>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import ts from 'typescript'
|
|
16
|
+
|
|
17
|
+
const USAGE = 'Usage: editor-v1 [--dry-run] <dir-or-file...>'
|
|
18
|
+
const OPTIONS = new Set(['--dry-run', '--help', '-h'])
|
|
19
|
+
const VUE_EXTENSIONS = new Set(['.vue'])
|
|
20
|
+
const SKIP_DIRS = new Set([
|
|
21
|
+
'node_modules',
|
|
22
|
+
'.git',
|
|
23
|
+
'dist',
|
|
24
|
+
'build',
|
|
25
|
+
'cache',
|
|
26
|
+
'coverage',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function kebabCase(name) {
|
|
30
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function editorFixedMenuBindings(source) {
|
|
34
|
+
const bindings = new Set()
|
|
35
|
+
const importPattern =
|
|
36
|
+
/import\s*\{([^}]*)\}\s*from\s*['"]frappe-ui\/editor['"]/g
|
|
37
|
+
for (const match of source.matchAll(importPattern)) {
|
|
38
|
+
for (const specifier of match[1].split(',')) {
|
|
39
|
+
const parts = specifier
|
|
40
|
+
.trim()
|
|
41
|
+
.match(/^EditorFixedMenu(?:\s+as\s+([A-Za-z_$][\w$]*))?$/)
|
|
42
|
+
if (parts) bindings.add(parts[1] || 'EditorFixedMenu')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return bindings
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lineAt(source, offset) {
|
|
49
|
+
return source.slice(0, offset).split('\n').length
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tagEnd(source, start) {
|
|
53
|
+
let quote
|
|
54
|
+
for (let index = start; index < source.length; index++) {
|
|
55
|
+
const char = source[index]
|
|
56
|
+
if (quote) {
|
|
57
|
+
if (char === quote) quote = undefined
|
|
58
|
+
} else if (char === '"' || char === "'") {
|
|
59
|
+
quote = char
|
|
60
|
+
} else if (char === '>') {
|
|
61
|
+
return index + 1
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return source.length
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expressionEnd(source, start) {
|
|
68
|
+
let quote
|
|
69
|
+
let escaped = false
|
|
70
|
+
for (let index = start; index < source.length - 1; index++) {
|
|
71
|
+
const char = source[index]
|
|
72
|
+
if (quote) {
|
|
73
|
+
if (escaped) escaped = false
|
|
74
|
+
else if (char === '\\') escaped = true
|
|
75
|
+
else if (char === quote) quote = undefined
|
|
76
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
77
|
+
quote = char
|
|
78
|
+
} else if (char === '}' && source[index + 1] === '}') {
|
|
79
|
+
return index + 2
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return source.length
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function templateContentRange(source) {
|
|
86
|
+
let index = 0
|
|
87
|
+
while (index < source.length) {
|
|
88
|
+
const start = source.indexOf('<', index)
|
|
89
|
+
if (start === -1) return
|
|
90
|
+
if (source.startsWith('<!--', start)) {
|
|
91
|
+
const end = source.indexOf('-->', start + 4)
|
|
92
|
+
index = end === -1 ? source.length : end + 3
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const opening = source.slice(start).match(/^<([A-Za-z][\w.-]*)\b/)
|
|
97
|
+
if (!opening) {
|
|
98
|
+
index = start + 1
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const name = opening[1]
|
|
102
|
+
const contentStart = tagEnd(source, start)
|
|
103
|
+
if (name !== 'template') {
|
|
104
|
+
const closing = new RegExp(`</${name}\\s*>`, 'i').exec(
|
|
105
|
+
source.slice(contentStart),
|
|
106
|
+
)
|
|
107
|
+
index = closing
|
|
108
|
+
? contentStart + closing.index + closing[0].length
|
|
109
|
+
: contentStart
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let depth = 1
|
|
114
|
+
index = contentStart
|
|
115
|
+
while (index < source.length) {
|
|
116
|
+
if (source.startsWith('<!--', index)) {
|
|
117
|
+
const end = source.indexOf('-->', index + 4)
|
|
118
|
+
index = end === -1 ? source.length : end + 3
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (source.startsWith('{{', index)) {
|
|
122
|
+
index = expressionEnd(source, index + 2)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (source[index] !== '<') {
|
|
126
|
+
index++
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const tag = source.slice(index).match(/^<(\/?)([A-Za-z][\w.-]*)\b/i)
|
|
130
|
+
if (!tag) {
|
|
131
|
+
index++
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
const end = tagEnd(source, index)
|
|
135
|
+
// Skip the whole tag so a literal `<template>` inside an attribute
|
|
136
|
+
// value is not mistaken for a nested template boundary.
|
|
137
|
+
if (tag[2].toLowerCase() === 'template') {
|
|
138
|
+
if (tag[1]) depth--
|
|
139
|
+
else if (!/\/\s*>$/.test(source.slice(index, end))) depth++
|
|
140
|
+
if (depth === 0) return { start: contentStart, end: index }
|
|
141
|
+
}
|
|
142
|
+
index = end
|
|
143
|
+
}
|
|
144
|
+
return { start: contentStart, end: source.length }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function attributesIn(tag, tagNameLength) {
|
|
149
|
+
const attributes = []
|
|
150
|
+
let index = 1 + tagNameLength
|
|
151
|
+
while (index < tag.length - 1) {
|
|
152
|
+
while (/\s/.test(tag[index])) index++
|
|
153
|
+
if (tag[index] === '/' || tag[index] === '>' || index >= tag.length - 1)
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
const start = index
|
|
157
|
+
while (index < tag.length && !/[\s=/>]/.test(tag[index])) index++
|
|
158
|
+
const nameEnd = index
|
|
159
|
+
while (/\s/.test(tag[index])) index++
|
|
160
|
+
|
|
161
|
+
let value
|
|
162
|
+
if (tag[index] === '=') {
|
|
163
|
+
index++
|
|
164
|
+
while (/\s/.test(tag[index])) index++
|
|
165
|
+
const quote = tag[index]
|
|
166
|
+
if (quote === '"' || quote === "'") {
|
|
167
|
+
const valueStart = ++index
|
|
168
|
+
while (index < tag.length && tag[index] !== quote) index++
|
|
169
|
+
value = tag.slice(valueStart, index)
|
|
170
|
+
if (index < tag.length) index++
|
|
171
|
+
} else {
|
|
172
|
+
const valueStart = index
|
|
173
|
+
while (index < tag.length && !/[\s>]/.test(tag[index])) index++
|
|
174
|
+
value = tag.slice(valueStart, index)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
attributes.push({ start, nameEnd, name: tag.slice(start, nameEnd), value })
|
|
178
|
+
}
|
|
179
|
+
return attributes
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function propertyNameText(name) {
|
|
183
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text
|
|
184
|
+
if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) {
|
|
185
|
+
return name.expression.text
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mergeObjectBindAnalysis(left, right) {
|
|
190
|
+
return {
|
|
191
|
+
hasLegacy: left.hasLegacy || right.hasLegacy,
|
|
192
|
+
hasSize: left.hasSize || right.hasSize,
|
|
193
|
+
opaque: left.opaque || right.opaque,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function analyzeObjectBindExpression(expression) {
|
|
198
|
+
while (
|
|
199
|
+
ts.isParenthesizedExpression(expression) ||
|
|
200
|
+
ts.isAsExpression(expression) ||
|
|
201
|
+
ts.isTypeAssertionExpression(expression) ||
|
|
202
|
+
ts.isSatisfiesExpression(expression)
|
|
203
|
+
) {
|
|
204
|
+
expression = expression.expression
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (ts.isConditionalExpression(expression)) {
|
|
208
|
+
return mergeObjectBindAnalysis(
|
|
209
|
+
analyzeObjectBindExpression(expression.whenTrue),
|
|
210
|
+
analyzeObjectBindExpression(expression.whenFalse),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
ts.isBinaryExpression(expression) &&
|
|
215
|
+
[
|
|
216
|
+
ts.SyntaxKind.BarBarToken,
|
|
217
|
+
ts.SyntaxKind.QuestionQuestionToken,
|
|
218
|
+
ts.SyntaxKind.AmpersandAmpersandToken,
|
|
219
|
+
].includes(expression.operatorToken.kind)
|
|
220
|
+
) {
|
|
221
|
+
return mergeObjectBindAnalysis(
|
|
222
|
+
analyzeObjectBindExpression(expression.left),
|
|
223
|
+
analyzeObjectBindExpression(expression.right),
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
if (!ts.isObjectLiteralExpression(expression)) {
|
|
227
|
+
return { hasLegacy: false, hasSize: false, opaque: true }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return expression.properties.reduce(
|
|
231
|
+
(analysis, property) => {
|
|
232
|
+
if (ts.isSpreadAssignment(property)) {
|
|
233
|
+
return mergeObjectBindAnalysis(
|
|
234
|
+
analysis,
|
|
235
|
+
analyzeObjectBindExpression(property.expression),
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
if (!property.name) return analysis
|
|
239
|
+
if (
|
|
240
|
+
ts.isComputedPropertyName(property.name) &&
|
|
241
|
+
!ts.isStringLiteral(property.name.expression)
|
|
242
|
+
) {
|
|
243
|
+
return { ...analysis, opaque: true }
|
|
244
|
+
}
|
|
245
|
+
const name = propertyNameText(property.name)
|
|
246
|
+
return {
|
|
247
|
+
hasLegacy:
|
|
248
|
+
analysis.hasLegacy || name === 'buttonSize' || name === 'button-size',
|
|
249
|
+
hasSize: analysis.hasSize || name === 'size',
|
|
250
|
+
opaque: analysis.opaque,
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
{ hasLegacy: false, hasSize: false, opaque: false },
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function analyzeObjectBind(value) {
|
|
258
|
+
const source = ts.createSourceFile(
|
|
259
|
+
'editor-v1-bind.ts',
|
|
260
|
+
`const bound = (${value})`,
|
|
261
|
+
ts.ScriptTarget.Latest,
|
|
262
|
+
true,
|
|
263
|
+
ts.ScriptKind.TS,
|
|
264
|
+
)
|
|
265
|
+
const statement = source.statements[0]
|
|
266
|
+
const initializer =
|
|
267
|
+
statement &&
|
|
268
|
+
ts.isVariableStatement(statement) &&
|
|
269
|
+
statement.declarationList.declarations[0]?.initializer
|
|
270
|
+
if (!initializer || source.parseDiagnostics.length) {
|
|
271
|
+
return { hasLegacy: false, hasSize: false, opaque: true }
|
|
272
|
+
}
|
|
273
|
+
return analyzeObjectBindExpression(initializer)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function rewriteTag(tag, tagName, filename, offset, source) {
|
|
277
|
+
const edits = []
|
|
278
|
+
const refusals = []
|
|
279
|
+
const attributes = attributesIn(tag, tagName.length)
|
|
280
|
+
const objectBinds = attributes
|
|
281
|
+
.filter((attribute) => attribute.name === 'v-bind' && attribute.value)
|
|
282
|
+
.map((attribute) => ({
|
|
283
|
+
attribute,
|
|
284
|
+
analysis: analyzeObjectBind(attribute.value),
|
|
285
|
+
}))
|
|
286
|
+
const legacyAttributes = attributes.filter((attribute) =>
|
|
287
|
+
/^(?:v-bind:|:)?(?:button-size|buttonSize)$/.test(attribute.name),
|
|
288
|
+
)
|
|
289
|
+
const sizeAttributes = attributes.filter((attribute) =>
|
|
290
|
+
/^(?:v-bind:|:)?size$/.test(attribute.name),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if (
|
|
294
|
+
legacyAttributes.length &&
|
|
295
|
+
(legacyAttributes.length + sizeAttributes.length > 1 ||
|
|
296
|
+
objectBinds.some(({ analysis }) => analysis.hasSize))
|
|
297
|
+
) {
|
|
298
|
+
refusals.push({
|
|
299
|
+
file: filename,
|
|
300
|
+
line: lineAt(source, offset + legacyAttributes[0].start),
|
|
301
|
+
message:
|
|
302
|
+
'EditorFixedMenu carries both `buttonSize` and `size` values. Renaming would produce duplicate props; merge them manually. This file is unchanged.',
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const { attribute, analysis } of objectBinds) {
|
|
307
|
+
if (analysis.hasLegacy) {
|
|
308
|
+
refusals.push({
|
|
309
|
+
file: filename,
|
|
310
|
+
line: lineAt(source, offset + attribute.start),
|
|
311
|
+
message:
|
|
312
|
+
'EditorFixedMenu uses an object v-bind containing `buttonSize`; rename that JavaScript property to `size` manually. This file is unchanged.',
|
|
313
|
+
})
|
|
314
|
+
} else if (analysis.opaque) {
|
|
315
|
+
refusals.push({
|
|
316
|
+
file: filename,
|
|
317
|
+
line: lineAt(source, offset + attribute.start),
|
|
318
|
+
message:
|
|
319
|
+
'EditorFixedMenu uses an opaque object v-bind that may contain `buttonSize`; inspect it and rename that JavaScript property to `size` manually if needed. This file is unchanged.',
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (refusals.length) return { tag, refusals }
|
|
325
|
+
|
|
326
|
+
for (const attribute of legacyAttributes) {
|
|
327
|
+
const match = attribute.name.match(
|
|
328
|
+
/^((?:v-bind:|:)?)(button-size|buttonSize)$/,
|
|
329
|
+
)
|
|
330
|
+
if (!match) continue
|
|
331
|
+
const [, prefix] = match
|
|
332
|
+
const replacement =
|
|
333
|
+
prefix && attribute.value === undefined
|
|
334
|
+
? `${prefix}size="buttonSize"`
|
|
335
|
+
: `${prefix}size`
|
|
336
|
+
edits.push({ start: attribute.start, end: attribute.nameEnd, replacement })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const edit of edits.reverse()) {
|
|
340
|
+
tag = tag.slice(0, edit.start) + edit.replacement + tag.slice(edit.end)
|
|
341
|
+
}
|
|
342
|
+
return { tag, refusals }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function migrateEditor(source, filename = '<source>') {
|
|
346
|
+
const bindings = editorFixedMenuBindings(source)
|
|
347
|
+
if (!bindings.size) return { migrated: source, changed: false, refusals: [] }
|
|
348
|
+
|
|
349
|
+
const tagNames = new Set()
|
|
350
|
+
for (const binding of bindings) {
|
|
351
|
+
tagNames.add(binding)
|
|
352
|
+
tagNames.add(kebabCase(binding))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const edits = []
|
|
356
|
+
const refusals = []
|
|
357
|
+
const template = templateContentRange(source)
|
|
358
|
+
if (!template) return { migrated: source, changed: false, refusals: [] }
|
|
359
|
+
|
|
360
|
+
let index = template.start
|
|
361
|
+
while (index < template.end) {
|
|
362
|
+
if (source.startsWith('<!--', index)) {
|
|
363
|
+
const end = source.indexOf('-->', index + 4)
|
|
364
|
+
index = end === -1 ? template.end : end + 3
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
if (source.startsWith('{{', index)) {
|
|
368
|
+
index = expressionEnd(source, index + 2)
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
if (source[index] !== '<') {
|
|
372
|
+
index++
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
const match = source.slice(index).match(/^<([A-Za-z][\w.-]*)\b/)
|
|
376
|
+
if (!match) {
|
|
377
|
+
index++
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
const tagName = match[1]
|
|
381
|
+
const start = index
|
|
382
|
+
const end = tagEnd(source, start)
|
|
383
|
+
index = end
|
|
384
|
+
if (!tagNames.has(tagName)) continue
|
|
385
|
+
const tag = source.slice(start, end)
|
|
386
|
+
const result = rewriteTag(tag, tagName, filename, start, source)
|
|
387
|
+
refusals.push(...result.refusals)
|
|
388
|
+
if (result.tag !== tag) edits.push({ start, end, replacement: result.tag })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (refusals.length) return { migrated: source, changed: false, refusals }
|
|
392
|
+
let migrated = source
|
|
393
|
+
for (const edit of edits.reverse()) {
|
|
394
|
+
migrated =
|
|
395
|
+
migrated.slice(0, edit.start) +
|
|
396
|
+
edit.replacement +
|
|
397
|
+
migrated.slice(edit.end)
|
|
398
|
+
}
|
|
399
|
+
return { migrated, changed: migrated !== source, refusals }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function* walk(target) {
|
|
403
|
+
if (fs.statSync(target).isFile()) {
|
|
404
|
+
if (VUE_EXTENSIONS.has(path.extname(target))) yield target
|
|
405
|
+
else console.error(`Skipped ${target}: not a Vue file`)
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
409
|
+
const full = path.join(target, entry.name)
|
|
410
|
+
if (entry.isSymbolicLink()) continue
|
|
411
|
+
if (entry.isDirectory()) {
|
|
412
|
+
if (!SKIP_DIRS.has(entry.name)) yield* walk(full)
|
|
413
|
+
} else if (VUE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
414
|
+
yield full
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function main(argv) {
|
|
420
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
421
|
+
console.log(USAGE)
|
|
422
|
+
return 0
|
|
423
|
+
}
|
|
424
|
+
const unknown = argv.filter((arg) => arg.startsWith('-') && !OPTIONS.has(arg))
|
|
425
|
+
if (unknown.length) {
|
|
426
|
+
console.error(`Unknown option: ${unknown.join(' ')}`)
|
|
427
|
+
console.error(USAGE)
|
|
428
|
+
return 1
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const dryRun = argv.includes('--dry-run')
|
|
432
|
+
const targets = argv.filter((arg) => !arg.startsWith('-'))
|
|
433
|
+
if (!targets.length) {
|
|
434
|
+
console.error(USAGE)
|
|
435
|
+
return 1
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const files = []
|
|
439
|
+
const seen = new Set()
|
|
440
|
+
for (const target of targets) {
|
|
441
|
+
if (!fs.existsSync(target)) {
|
|
442
|
+
console.error(`Invalid path: ${target}`)
|
|
443
|
+
return 1
|
|
444
|
+
}
|
|
445
|
+
for (const file of walk(path.resolve(target))) {
|
|
446
|
+
const resolved = fs.realpathSync(file)
|
|
447
|
+
if (seen.has(resolved)) continue
|
|
448
|
+
seen.add(resolved)
|
|
449
|
+
files.push(file)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let changed = 0
|
|
454
|
+
let refused = 0
|
|
455
|
+
for (const file of files) {
|
|
456
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
457
|
+
const result = migrateEditor(source, file)
|
|
458
|
+
for (const refusal of result.refusals) {
|
|
459
|
+
refused++
|
|
460
|
+
console.error(`${refusal.file}:${refusal.line}: ${refusal.message}`)
|
|
461
|
+
}
|
|
462
|
+
if (!result.changed) continue
|
|
463
|
+
changed++
|
|
464
|
+
if (!dryRun) fs.writeFileSync(file, result.migrated)
|
|
465
|
+
console.log(`${dryRun ? 'Would update' : 'Updated'} ${file}`)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!changed && !refused)
|
|
469
|
+
console.log('No EditorFixedMenu buttonSize props found.')
|
|
470
|
+
return refused ? 1 : 0
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
474
|
+
const invokedPath = process.argv[1]
|
|
475
|
+
const isCLI =
|
|
476
|
+
invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
|
|
477
|
+
if (isCLI) process.exitCode = main(process.argv.slice(2))
|
|
@@ -8,7 +8,7 @@ const props = defineProps<{
|
|
|
8
8
|
// Optional inside <Editor> — falls back to the provided editor context.
|
|
9
9
|
editor?: Editor | null
|
|
10
10
|
items: MenuItem[]
|
|
11
|
-
|
|
11
|
+
size?: 'xs' | 'sm'
|
|
12
12
|
}>()
|
|
13
13
|
|
|
14
14
|
const editor = useResolvedEditor(() => props.editor)
|
|
@@ -16,6 +16,6 @@ const editor = useResolvedEditor(() => props.editor)
|
|
|
16
16
|
|
|
17
17
|
<template>
|
|
18
18
|
<div data-slot="fixed-menu" class="flex items-center gap-1">
|
|
19
|
-
<MenuItems :editor="editor" :items="items" :
|
|
19
|
+
<MenuItems :editor="editor" :items="items" :size="size" />
|
|
20
20
|
</div>
|
|
21
21
|
</template>
|
|
@@ -9,7 +9,7 @@ import TooltipProvider from '#components/Tooltip/TooltipProvider.vue'
|
|
|
9
9
|
const props = defineProps<{
|
|
10
10
|
editor: Editor | null
|
|
11
11
|
items: MenuItem[]
|
|
12
|
-
|
|
12
|
+
size?: 'xs' | 'sm'
|
|
13
13
|
}>()
|
|
14
14
|
|
|
15
15
|
// The editor from `useEditor` is built on `@tiptap/core` (not `@tiptap/vue-3`),
|
|
@@ -101,7 +101,7 @@ function run(item: CommandMenuItem, event?: MouseEvent) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const menuButtonSize = computed(() => props.
|
|
104
|
+
const menuButtonSize = computed(() => props.size ?? 'xs')
|
|
105
105
|
</script>
|
|
106
106
|
|
|
107
107
|
<template>
|
|
@@ -3,6 +3,7 @@ import Suggestion from '@tiptap/suggestion'
|
|
|
3
3
|
import { PluginKey } from '@tiptap/pm/state'
|
|
4
4
|
import type { Component } from 'vue'
|
|
5
5
|
import type { Editor } from './useEditor'
|
|
6
|
+
import { warnRemoved } from '#utils/warnDeprecated'
|
|
6
7
|
import {
|
|
7
8
|
createSuggestionRenderer,
|
|
8
9
|
type SuggestionFloatingOptions,
|
|
@@ -14,7 +15,7 @@ export type SuggestionExtensionOptions<TItem = any> = {
|
|
|
14
15
|
name: string
|
|
15
16
|
trigger: string
|
|
16
17
|
items: TItem[] | ((query: string) => TItem[] | Promise<TItem[]>)
|
|
17
|
-
|
|
18
|
+
listComponent?: Component
|
|
18
19
|
floatingOptions?: SuggestionFloatingOptions
|
|
19
20
|
allowSpaces?: boolean
|
|
20
21
|
/**
|
|
@@ -33,6 +34,12 @@ export type SuggestionExtensionOptions<TItem = any> = {
|
|
|
33
34
|
function buildSuggestionExtension<TItem = any>(
|
|
34
35
|
options: SuggestionExtensionOptions<TItem>,
|
|
35
36
|
) {
|
|
37
|
+
if ('component' in options) {
|
|
38
|
+
warnRemoved(
|
|
39
|
+
'SuggestionExtension.component',
|
|
40
|
+
'SuggestionExtension.listComponent',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
36
43
|
return Extension.create({
|
|
37
44
|
name: options.name,
|
|
38
45
|
addOptions() {
|
|
@@ -57,10 +64,10 @@ function buildSuggestionExtension<TItem = any>(
|
|
|
57
64
|
}) => {
|
|
58
65
|
options.command({ editor, item: props, range })
|
|
59
66
|
},
|
|
60
|
-
render: options.
|
|
67
|
+
render: options.listComponent
|
|
61
68
|
? () =>
|
|
62
69
|
createSuggestionRenderer(
|
|
63
|
-
options.
|
|
70
|
+
options.listComponent as Component,
|
|
64
71
|
options.floatingOptions,
|
|
65
72
|
)
|
|
66
73
|
: undefined,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (`jane@example.com`). Typography rewrites straight quotes to curly ones, so
|
|
7
7
|
* those prefixes are covered too.
|
|
8
8
|
*/
|
|
9
|
-
import { describe, it, expect, afterEach } from 'vitest'
|
|
9
|
+
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
10
10
|
import { Editor, type AnyExtension } from '@tiptap/core'
|
|
11
11
|
import { Document } from '@tiptap/extension-document'
|
|
12
12
|
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
@@ -14,6 +14,7 @@ import { Text } from '@tiptap/extension-text'
|
|
|
14
14
|
import { PluginKey } from '@tiptap/pm/state'
|
|
15
15
|
import { Typography } from '../../extensions'
|
|
16
16
|
import { MentionExtension } from './mention-extension'
|
|
17
|
+
import { _resetWarnDeprecated } from '../../../../utils/warnDeprecated'
|
|
17
18
|
|
|
18
19
|
const openEditors: Editor[] = []
|
|
19
20
|
|
|
@@ -60,6 +61,7 @@ function type(editor: Editor, text: string) {
|
|
|
60
61
|
describe('Mention allowedPrefixes', () => {
|
|
61
62
|
afterEach(() => {
|
|
62
63
|
while (openEditors.length) openEditors.pop()?.destroy()
|
|
64
|
+
_resetWarnDeprecated()
|
|
63
65
|
})
|
|
64
66
|
|
|
65
67
|
it('opens at the start of a paragraph and after a space', () => {
|
|
@@ -72,6 +74,20 @@ describe('Mention allowedPrefixes', () => {
|
|
|
72
74
|
expect(mentionActive(editor)).toBe(true)
|
|
73
75
|
})
|
|
74
76
|
|
|
77
|
+
it('warns in development when Mention receives the old component key', () => {
|
|
78
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
79
|
+
const extension = MentionExtension.configure({ component: {} } as any)
|
|
80
|
+
|
|
81
|
+
;(extension.config.addExtensions as Function).call(extension)
|
|
82
|
+
;(extension.config.addExtensions as Function).call(extension)
|
|
83
|
+
|
|
84
|
+
expect(warn).toHaveBeenCalledWith(
|
|
85
|
+
'[frappe-ui] Mention.component was removed. Use Mention.nodeView instead.',
|
|
86
|
+
)
|
|
87
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
88
|
+
warn.mockRestore()
|
|
89
|
+
})
|
|
90
|
+
|
|
75
91
|
it.each([
|
|
76
92
|
'(',
|
|
77
93
|
'[',
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type BaseSuggestionItem,
|
|
15
15
|
} from '../suggestion/createSuggestionExtension'
|
|
16
16
|
import SuggestionList from '../suggestion/SuggestionList.vue'
|
|
17
|
+
import { warnRemoved } from '#utils/warnDeprecated'
|
|
17
18
|
import {
|
|
18
19
|
insertSuggestionNode,
|
|
19
20
|
filterByQuery,
|
|
@@ -33,9 +34,9 @@ interface MentionSuggestionOptions {
|
|
|
33
34
|
mentions: MaybeRefOrGetter<MentionSuggestionItem[]>
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
function createMentionNode(
|
|
37
|
-
const
|
|
38
|
-
? { addNodeView: () => VueNodeViewRenderer(
|
|
37
|
+
function createMentionNode(nodeView?: Component) {
|
|
38
|
+
const nodeViewExtension = nodeView
|
|
39
|
+
? { addNodeView: () => VueNodeViewRenderer(nodeView) }
|
|
39
40
|
: {}
|
|
40
41
|
|
|
41
42
|
return Node.create({
|
|
@@ -44,12 +45,6 @@ function createMentionNode(component?: Component) {
|
|
|
44
45
|
inline: true,
|
|
45
46
|
selectable: true,
|
|
46
47
|
atom: true,
|
|
47
|
-
addOptions() {
|
|
48
|
-
return {
|
|
49
|
-
component: undefined,
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
|
|
53
48
|
addAttributes() {
|
|
54
49
|
return {
|
|
55
50
|
id: {
|
|
@@ -104,7 +99,7 @@ function createMentionNode(component?: Component) {
|
|
|
104
99
|
return `@${node.attrs.label || node.attrs.id || ''}`
|
|
105
100
|
},
|
|
106
101
|
|
|
107
|
-
...
|
|
102
|
+
...nodeViewExtension,
|
|
108
103
|
})
|
|
109
104
|
}
|
|
110
105
|
|
|
@@ -150,7 +145,7 @@ const MentionSuggestionExtension =
|
|
|
150
145
|
name: 'mentionSuggestion',
|
|
151
146
|
char: '@',
|
|
152
147
|
pluginKey: new PluginKey('mentionSuggestion'),
|
|
153
|
-
|
|
148
|
+
listComponent: SuggestionList,
|
|
154
149
|
allowedPrefixes: ALLOWED_MENTION_PREFIXES,
|
|
155
150
|
|
|
156
151
|
addOptions() {
|
|
@@ -189,19 +184,21 @@ const MentionSuggestionExtension =
|
|
|
189
184
|
|
|
190
185
|
export const MentionExtension = Extension.create<{
|
|
191
186
|
items: MaybeRefOrGetter<MentionSuggestionItem[]> | null
|
|
192
|
-
|
|
187
|
+
nodeView?: Component
|
|
193
188
|
}>({
|
|
194
189
|
name: 'mentionExtension',
|
|
195
190
|
|
|
196
191
|
addOptions() {
|
|
197
192
|
return {
|
|
198
193
|
items: null,
|
|
199
|
-
component: undefined,
|
|
200
194
|
}
|
|
201
195
|
},
|
|
202
196
|
|
|
203
197
|
addExtensions() {
|
|
204
|
-
|
|
198
|
+
if ('component' in this.options) {
|
|
199
|
+
warnRemoved('Mention.component', 'Mention.nodeView')
|
|
200
|
+
}
|
|
201
|
+
const node = createMentionNode(this.options.nodeView)
|
|
205
202
|
// Inert until configured: only wire the `@` suggestion when an item source
|
|
206
203
|
// is provided. Existing mentions in content still render through the node.
|
|
207
204
|
if (this.options.items == null) return [node]
|
|
@@ -57,7 +57,7 @@ export interface CreateSuggestionExtensionOptions<
|
|
|
57
57
|
editor: Editor
|
|
58
58
|
}) => TItem[] | Promise<TItem[]>
|
|
59
59
|
command: (props: { editor: Editor; range: Range; props: TItem }) => void
|
|
60
|
-
|
|
60
|
+
listComponent: VueComponent
|
|
61
61
|
floatingOptions?: SuggestionFloatingOptions
|
|
62
62
|
allowSpaces?: boolean
|
|
63
63
|
/**
|
|
@@ -111,7 +111,7 @@ export function createSuggestionExtension<TItem extends BaseSuggestionItem>(
|
|
|
111
111
|
decorationClass: options.decorationClass || 'suggestion',
|
|
112
112
|
render: () =>
|
|
113
113
|
createSuggestionRenderer(
|
|
114
|
-
options.
|
|
114
|
+
options.listComponent,
|
|
115
115
|
options.floatingOptions,
|
|
116
116
|
),
|
|
117
117
|
} as Omit<SuggestionOptions<TItem>, 'editor'>,
|
|
@@ -113,7 +113,7 @@ export const TagExtension = createSuggestionExtension<TagSuggestionItem>({
|
|
|
113
113
|
name: 'tagSuggestion',
|
|
114
114
|
char: '#',
|
|
115
115
|
pluginKey: new PluginKey('tagSuggestion'),
|
|
116
|
-
|
|
116
|
+
listComponent: SuggestionList,
|
|
117
117
|
|
|
118
118
|
addOptions() {
|
|
119
119
|
return {
|
|
@@ -144,6 +144,17 @@ describe('editor menu primitives and presets', () => {
|
|
|
144
144
|
expect(calls.join('|')).toContain('focus:|toggleBold:|run:')
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
+
it('uses the fixed menu size for its buttons', async () => {
|
|
148
|
+
const { EditorFixedMenu, Bold } = editorMenu
|
|
149
|
+
const { editor } = fakeEditor()
|
|
150
|
+
|
|
151
|
+
const root = mount(EditorFixedMenu, { editor, items: [Bold], size: 'sm' })
|
|
152
|
+
|
|
153
|
+
expect(root.querySelector('[aria-label="Bold"]')?.className).toContain(
|
|
154
|
+
'h-7',
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
147
158
|
it('hides items whose mark/node/extension is absent (self-pruning)', async () => {
|
|
148
159
|
const { EditorFixedMenu, Bold, InsertTable, AlignLeft } = editorMenu
|
|
149
160
|
// A trimmed editor: no table node, no textAlign extension.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
6
6
|
import { createApp, defineComponent, h, nextTick, ref, toRaw } from 'vue'
|
|
7
|
+
import { _resetWarnDeprecated } from '../../utils/warnDeprecated'
|
|
7
8
|
|
|
8
9
|
const editors: any[] = []
|
|
9
10
|
|
|
@@ -126,6 +127,7 @@ vi.mock('@tiptap/vue-3', () => ({
|
|
|
126
127
|
|
|
127
128
|
beforeEach(() => {
|
|
128
129
|
editors.length = 0
|
|
130
|
+
_resetWarnDeprecated()
|
|
129
131
|
})
|
|
130
132
|
|
|
131
133
|
describe('frappe-ui/editor minimal primitives', () => {
|
|
@@ -141,7 +143,7 @@ describe('frappe-ui/editor minimal primitives', () => {
|
|
|
141
143
|
expect(editor.InsertIframe).toBeTruthy()
|
|
142
144
|
})
|
|
143
145
|
|
|
144
|
-
it('wires the public SuggestionExtension component into the suggestion renderer', async () => {
|
|
146
|
+
it('wires the public SuggestionExtension list component into the suggestion renderer', async () => {
|
|
145
147
|
const component = defineComponent({ setup: () => () => h('div') })
|
|
146
148
|
const { SuggestionExtension } = await import('./index')
|
|
147
149
|
|
|
@@ -149,7 +151,7 @@ describe('frappe-ui/editor minimal primitives', () => {
|
|
|
149
151
|
name: 'people',
|
|
150
152
|
trigger: '@',
|
|
151
153
|
items: [],
|
|
152
|
-
component,
|
|
154
|
+
listComponent: component,
|
|
153
155
|
command: vi.fn(),
|
|
154
156
|
})
|
|
155
157
|
|
|
@@ -157,6 +159,32 @@ describe('frappe-ui/editor minimal primitives', () => {
|
|
|
157
159
|
expect(typeof options.suggestion.render).toBe('function')
|
|
158
160
|
})
|
|
159
161
|
|
|
162
|
+
it('warns in development when SuggestionExtension receives the old component key', async () => {
|
|
163
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
164
|
+
const { SuggestionExtension } = await import('./index')
|
|
165
|
+
|
|
166
|
+
SuggestionExtension.configure({
|
|
167
|
+
name: 'people',
|
|
168
|
+
trigger: '@',
|
|
169
|
+
items: [],
|
|
170
|
+
component: defineComponent({ setup: () => () => h('div') }),
|
|
171
|
+
command: vi.fn(),
|
|
172
|
+
} as any)
|
|
173
|
+
SuggestionExtension.configure({
|
|
174
|
+
name: 'tags',
|
|
175
|
+
trigger: '#',
|
|
176
|
+
items: [],
|
|
177
|
+
component: defineComponent({ setup: () => () => h('div') }),
|
|
178
|
+
command: vi.fn(),
|
|
179
|
+
} as any)
|
|
180
|
+
|
|
181
|
+
expect(warn).toHaveBeenCalledWith(
|
|
182
|
+
'[frappe-ui] SuggestionExtension.component was removed. Use SuggestionExtension.listComponent instead.',
|
|
183
|
+
)
|
|
184
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
185
|
+
warn.mockRestore()
|
|
186
|
+
})
|
|
187
|
+
|
|
160
188
|
it('forwards allowSpaces from SuggestionExtension options to the suggestion plugin', async () => {
|
|
161
189
|
const { SuggestionExtension } = await import('./index')
|
|
162
190
|
|
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
const warned = new Set<string>()
|
|
2
2
|
|
|
3
|
+
function warnOnce(key: string, message: string) {
|
|
4
|
+
if (import.meta.env.PROD) return
|
|
5
|
+
if (warned.has(key)) return
|
|
6
|
+
warned.add(key)
|
|
7
|
+
console.warn(message)
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export function warnDeprecated(
|
|
4
11
|
name: string,
|
|
5
12
|
replacement: string,
|
|
6
13
|
docHref?: string,
|
|
7
14
|
) {
|
|
8
|
-
if (import.meta.env.PROD) return
|
|
9
|
-
if (warned.has(name)) return
|
|
10
|
-
warned.add(name)
|
|
11
15
|
const suffix = docHref ? ` See ${docHref}` : ''
|
|
12
|
-
|
|
16
|
+
warnOnce(
|
|
17
|
+
`deprecated:${name}`,
|
|
13
18
|
`[frappe-ui] ${name} is deprecated. Use ${replacement} instead.${suffix}`,
|
|
14
19
|
)
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
export function warnRemoved(
|
|
23
|
+
name: string,
|
|
24
|
+
replacement: string,
|
|
25
|
+
docHref?: string,
|
|
26
|
+
) {
|
|
27
|
+
const suffix = docHref ? ` See ${docHref}` : ''
|
|
28
|
+
warnOnce(
|
|
29
|
+
`removed:${name}`,
|
|
30
|
+
`[frappe-ui] ${name} was removed. Use ${replacement} instead.${suffix}`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
/** Test-only: clear the dedup set so each test sees a fresh warning surface. */
|
|
18
35
|
export function _resetWarnDeprecated() {
|
|
19
36
|
warned.clear()
|