Skip to content

Commit df13584

Browse files
authored
feat: show a message after pasting multiline text as an Array, offering to paste as string instead (#549)
1 parent 2dbcdcc commit df13584

File tree

6 files changed

+193
-1
lines changed

6 files changed

+193
-1
lines changed

src/lib/components/__snapshots__/JSONEditor.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ exports[`JSONEditor > render table mode 1`] = `
589589
590590
<!---->
591591
592+
<!---->
593+
592594
<!---->
593595
594596
<!---->
@@ -2545,6 +2547,8 @@ exports[`JSONEditor > render tree mode 1`] = `
25452547
25462548
<!---->
25472549
2550+
<!---->
2551+
25482552
<!---->
25492553
25502554
<!---->

src/lib/components/modes/tablemode/TableMode.svelte

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@
230230
let parseError: ParseError | undefined = undefined
231231
232232
let pastedJson: PastedJson | undefined
233+
let pastedMultilineText: string | undefined
233234
234235
let searchResultDetails: SearchResultDetails | undefined
235236
let searchResults: SearchResults | undefined
@@ -614,6 +615,7 @@
614615
text = undefined
615616
textIsRepaired = false
616617
pastedJson = undefined
618+
pastedMultilineText = undefined
617619
parseError = undefined
618620
619621
history.add({
@@ -688,6 +690,12 @@
688690
pastedJson = newPastedJson
689691
}
690692
693+
function handlePasteMultilineText(pastedText: string) {
694+
debug('pasted multiline text', { pastedText })
695+
696+
pastedMultilineText = pastedText
697+
}
698+
691699
function findNextInside(path: JSONPath): JSONSelection {
692700
const index = parseInt(path[0], 10)
693701
const nextPath = [String(index + 1), ...path.slice(1)]
@@ -1127,12 +1135,30 @@
11271135
}
11281136
}
11291137
1138+
async function handleParsePastedMultilineText() {
1139+
debug('apply pasted multiline text', pastedMultilineText)
1140+
if (!pastedMultilineText) {
1141+
return
1142+
}
1143+
1144+
_paste(JSON.stringify(pastedMultilineText))
1145+
1146+
// TODO: get rid of the setTimeout here
1147+
setTimeout(focus)
1148+
}
1149+
11301150
function handleClearPastedJson() {
11311151
debug('clear pasted json')
11321152
pastedJson = undefined
11331153
focus()
11341154
}
11351155
1156+
function handleClearPastedMultilineText() {
1157+
debug('clear pasted multiline text')
1158+
pastedMultilineText = undefined
1159+
focus()
1160+
}
1161+
11361162
function handleRequestRepair() {
11371163
onChangeMode(Mode.text)
11381164
}
@@ -1398,6 +1424,7 @@
13981424
parser,
13991425
onPatch: handlePatch,
14001426
onChangeText: handleChangeText,
1427+
onPasteMultilineText: handlePasteMultilineText,
14011428
openRepairModal
14021429
})
14031430
}
@@ -1928,6 +1955,26 @@
19281955
/>
19291956
{/if}
19301957

1958+
{#if pastedMultilineText}
1959+
<Message
1960+
type="info"
1961+
message="Multiline text was pasted as array"
1962+
actions={[
1963+
{
1964+
icon: faWrench,
1965+
text: 'Paste as string instead',
1966+
title: 'Paste the clipboard data as a single string value instead of an array',
1967+
onClick: handleParsePastedMultilineText
1968+
},
1969+
{
1970+
text: 'Leave as is',
1971+
title: 'Keep the pasted array',
1972+
onClick: handleClearPastedMultilineText
1973+
}
1974+
]}
1975+
/>
1976+
{/if}
1977+
19311978
{#if textIsRepaired}
19321979
<Message
19331980
type="success"

src/lib/components/modes/treemode/TreeMode.svelte

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@
279279
})
280280
281281
let pastedJson: PastedJson | undefined
282+
let pastedMultilineText: string | undefined
282283
283284
let searchResultDetails: SearchResultDetails | undefined
284285
let searchResults: SearchResults | undefined
@@ -639,6 +640,7 @@
639640
text = undefined
640641
textIsRepaired = false
641642
pastedJson = undefined
643+
pastedMultilineText = undefined
642644
parseError = undefined
643645
644646
// ensure the selection is valid
@@ -785,6 +787,7 @@
785787
parser,
786788
onPatch: handlePatch,
787789
onChangeText: handleChangeText,
790+
onPasteMultilineText: handlePasteMultilineText,
788791
openRepairModal
789792
})
790793
}
@@ -1463,6 +1466,12 @@
14631466
pastedJson = newPastedJson
14641467
}
14651468
1469+
function handlePasteMultilineText(pastedText: string) {
1470+
debug('pasted multiline text', { pastedText })
1471+
1472+
pastedMultilineText = pastedText
1473+
}
1474+
14661475
function handleKeyDown(event: KeyboardEvent) {
14671476
const combo = keyComboFromEvent(event)
14681477
const keepAnchorPath = event.shiftKey
@@ -1795,12 +1804,30 @@
17951804
setTimeout(focus)
17961805
}
17971806
1807+
async function handleParsePastedMultilineText() {
1808+
debug('apply pasted multiline text', pastedMultilineText)
1809+
if (!pastedMultilineText) {
1810+
return
1811+
}
1812+
1813+
_paste(JSON.stringify(pastedMultilineText))
1814+
1815+
// TODO: get rid of the setTimeout here
1816+
setTimeout(focus)
1817+
}
1818+
17981819
function handleClearPastedJson() {
17991820
debug('clear pasted json')
18001821
pastedJson = undefined
18011822
focus()
18021823
}
18031824
1825+
function handleClearPastedMultilineText() {
1826+
debug('clear pasted multiline text')
1827+
pastedMultilineText = undefined
1828+
focus()
1829+
}
1830+
18041831
function handleRequestRepair() {
18051832
onChangeMode(Mode.text)
18061833
}
@@ -2035,6 +2062,26 @@
20352062
/>
20362063
{/if}
20372064

2065+
{#if pastedMultilineText}
2066+
<Message
2067+
type="info"
2068+
message="Multiline text was pasted as array"
2069+
actions={[
2070+
{
2071+
icon: faWrench,
2072+
text: 'Paste as string instead',
2073+
title: 'Paste the clipboard data as a single string value instead of an array',
2074+
onClick: handleParsePastedMultilineText
2075+
},
2076+
{
2077+
text: 'Leave as is',
2078+
title: 'Keep the pasted array',
2079+
onClick: handleClearPastedMultilineText
2080+
}
2081+
]}
2082+
/>
2083+
{/if}
2084+
20382085
{#if textIsRepaired}
20392086
<Message
20402087
type="success"

src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const MAX_HEADER_NAME_CHARACTERS = 50
1717
export const DEFAULT_VISIBLE_SECTIONS: Section[] = [{ start: 0, end: ARRAY_SECTION_SIZE }]
1818
export const MAX_VALIDATABLE_SIZE = 100 * 1024 * 1024 // 1 MB
1919
export const MAX_AUTO_REPAIRABLE_SIZE = 1024 * 1024 // 1 MB
20+
export const MAX_MULTILINE_PASTE_SIZE = 1024 * 1024 // 1 MB
2021
export const MAX_DOCUMENT_SIZE_TEXT_MODE = 10 * 1024 * 1024 // 10 MB
2122
export const MAX_DOCUMENT_SIZE_EXPAND_ALL = 10 * 1024 // 10 KB
2223

src/lib/logic/actions.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { isMultilineTextPastedAsArray } from '$lib/logic/actions'
3+
import { insert } from '$lib/logic/operations'
4+
import { createValueSelection } from 'svelte-jsoneditor'
5+
6+
describe('actions', () => {
7+
describe('isMultiLineTextPastedAsArray', () => {
8+
const maxMultilinePasteSize = 100
9+
10+
function isMultilineText(
11+
clipboardText: string,
12+
json = {},
13+
selection = createValueSelection([])
14+
): boolean {
15+
const parser = JSON
16+
const operations = insert(json, selection, clipboardText, parser)
17+
18+
return isMultilineTextPastedAsArray(clipboardText, operations, parser, maxMultilinePasteSize)
19+
}
20+
21+
test('should return false when the text is too large', () => {
22+
expect(isMultilineText('a\n'.repeat(maxMultilinePasteSize / 2 + 1))).toEqual(false)
23+
})
24+
25+
test('should return false when not containing a newline', () => {
26+
expect(isMultilineText('Hello world')).toEqual(false)
27+
})
28+
29+
test('should return false when containing partial JSON', () => {
30+
expect(isMultilineText('1,\n2,')).toEqual(false)
31+
expect(isMultilineText('"a",\n"b",')).toEqual(false)
32+
expect(isMultilineText('"a":1,\n"b":2,')).toEqual(false)
33+
})
34+
35+
test('should return true when containing a newline and not being partial JSON', () => {
36+
expect(isMultilineText('A\nB\nC\n')).toEqual(true)
37+
expect(isMultilineText('Hello,\nmulti line text')).toEqual(true)
38+
})
39+
})
40+
})

src/lib/logic/actions.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,16 @@ import {
3636
isJSONObject,
3737
isJSONPatchAdd,
3838
isJSONPatchReplace,
39+
type JSONPatchOperation,
3940
type JSONPath,
4041
parsePath
4142
} from 'immutable-json-patch'
4243
import { isObject, isObjectOrArray } from '$lib/utils/typeUtils.js'
4344
import { expandAll, expandNone, expandPath, expandSmart } from '$lib/logic/documentState.js'
4445
import { initial, isEmpty, last } from 'lodash-es'
4546
import { fromTableCellPosition, toTableCellPosition } from '$lib/logic/table.js'
47+
import { parsePartialJson } from '$lib/utils/jsonUtils'
48+
import { MAX_MULTILINE_PASTE_SIZE } from '$lib/constants'
4649

4750
const debug = createDebug('jsoneditor:actions')
4851

@@ -114,6 +117,7 @@ interface OnPasteAction {
114117
parser: JSONParser
115118
onPatch: OnPatch
116119
onChangeText: OnChangeText
120+
onPasteMultilineText: (pastedText: string) => void
117121
openRepairModal: RepairModalCallback
118122
}
119123

@@ -126,6 +130,7 @@ export function onPaste({
126130
parser,
127131
onPatch,
128132
onChangeText,
133+
onPasteMultilineText,
129134
openRepairModal
130135
}: OnPasteAction) {
131136
if (readOnly) {
@@ -138,7 +143,9 @@ export function onPaste({
138143

139144
const operations = insert(json, ensureSelection, pastedText, parser)
140145

141-
debug('paste', { pastedText, operations, ensureSelection })
146+
const pasteMultilineText = isMultilineTextPastedAsArray(clipboardText, operations, parser)
147+
148+
debug('paste', { pastedText, operations, ensureSelection, pasteMultilineText })
142149

143150
onPatch(operations, (patchedJson, patchedState) => {
144151
let updatedState = patchedState
@@ -159,6 +166,10 @@ export function onPaste({
159166
state: updatedState
160167
}
161168
})
169+
170+
if (pasteMultilineText) {
171+
onPasteMultilineText(pastedText)
172+
}
162173
} else {
163174
// no json: empty document, or the contents is invalid text
164175
debug('paste text', { pastedText })
@@ -186,6 +197,48 @@ export function onPaste({
186197
}
187198
}
188199

200+
/**
201+
* When pasting text, we cannot always know how whether the text was intended as
202+
* a list with items that should be parsed into a JSON Array (after jsonrepair),
203+
* or a text with multiple lines that should be parsed into a single string.
204+
*
205+
* This function checks whether we're dealing with such a case, after which
206+
* we can show a message to the user asking about the intended behavior.
207+
*/
208+
export function isMultilineTextPastedAsArray(
209+
clipboardText: string,
210+
operators: JSONPatchOperation[],
211+
parser: JSONParser,
212+
maxSize = MAX_MULTILINE_PASTE_SIZE
213+
): boolean {
214+
if (clipboardText.length > maxSize) {
215+
// we don't want this feature detecting multiline text to impact performance, hence this max
216+
return false
217+
}
218+
219+
const containsNewline = /\n/.test(clipboardText)
220+
if (!containsNewline) {
221+
return false
222+
}
223+
224+
const replaceArrayOperation = operators.some(
225+
(operator) => operator.op === 'replace' && Array.isArray(operator.value)
226+
)
227+
const multipleAddOperations = operators.filter((operator) => operator.op === 'add').length > 1
228+
const pastingArray = replaceArrayOperation || multipleAddOperations
229+
if (!pastingArray) {
230+
return false
231+
}
232+
233+
try {
234+
parsePartialJson(clipboardText, parser.parse)
235+
236+
return false
237+
} catch {
238+
return true
239+
}
240+
}
241+
189242
export interface OnRemoveAction {
190243
json: unknown | undefined
191244
text: string | undefined

0 commit comments

Comments
 (0)