Skip to content

Commit 9813193

Browse files
committed
fix(ui): account for multi-cursor pastes
1 parent ea62de6 commit 9813193

4 files changed

Lines changed: 135 additions & 32 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
import { Editor } from '@tiptap/core'
55
import { TextSelection } from '@tiptap/pm/state'
66
import { afterEach, describe, expect, it, vi } from 'vitest'
7-
import { createMarkdownContentExtensions } from './extensions'
8-
import { assessRawMarkdownPaste, createRichMarkdownPasteAdmission } from './paste-admission'
7+
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
8+
import {
9+
assessRawMarkdownPaste,
10+
createRichMarkdownPasteAdmission,
11+
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission'
912

1013
let editor: Editor | null = null
1114

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-paste.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ describe('assessTextEditorPaste', () => {
88
{
99
pastedText: '56789',
1010
currentText: '123456',
11-
selectionStart: 6,
12-
selectionEnd: 6,
11+
selections: [{ start: 6, end: 6 }],
1312
},
1413
10
1514
)
@@ -22,11 +21,26 @@ describe('assessTextEditorPaste', () => {
2221
{
2322
pastedText: '56789',
2423
currentText: '123456',
25-
selectionStart: 1,
26-
selectionEnd: 6,
24+
selections: [{ start: 1, end: 6 }],
2725
},
2826
6
2927
)
3028
).toMatchObject({ accepted: true, resultBytes: 6 })
3129
})
30+
31+
it('projects the clipboard text at every Monaco cursor', () => {
32+
expect(
33+
assessTextEditorPaste(
34+
{
35+
pastedText: 'xy',
36+
currentText: '12345678',
37+
selections: [
38+
{ start: 2, end: 2 },
39+
{ start: 6, end: 6 },
40+
],
41+
},
42+
10
43+
)
44+
).toMatchObject({ accepted: false, reason: 'result-bytes', limit: 10 })
45+
})
3246
})
Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,108 @@
1-
import { assessTextPaste, PASTE_LIMITS, type TextPasteAdmission } from '@sim/utils/paste'
1+
import {
2+
PASTE_LIMITS,
3+
type TextPasteAdmission,
4+
utf8ByteLength,
5+
utf8ByteLengthRange,
6+
} from '@sim/utils/paste'
7+
8+
interface TextEditorPasteSelection {
9+
start: number
10+
end: number
11+
}
212

313
interface TextEditorPasteInput {
414
pastedText: string
515
currentText: string
6-
selectionStart: number
7-
selectionEnd: number
16+
selections: readonly TextEditorPasteSelection[]
17+
}
18+
19+
function normalizedSelections(
20+
selections: readonly TextEditorPasteSelection[],
21+
textLength: number
22+
): TextEditorPasteSelection[] {
23+
const source = selections.length > 0 ? selections : [{ start: textLength, end: textLength }]
24+
return source
25+
.map(({ start, end }) => ({
26+
start: Math.min(Math.max(Math.min(start, end), 0), textLength),
27+
end: Math.min(Math.max(Math.max(start, end), 0), textLength),
28+
}))
29+
.sort((left, right) => left.start - right.start || left.end - right.end)
30+
}
31+
32+
function mergedReplacementRanges(
33+
selections: readonly TextEditorPasteSelection[]
34+
): TextEditorPasteSelection[] {
35+
const ranges: TextEditorPasteSelection[] = []
36+
for (const selection of selections) {
37+
if (selection.start === selection.end) continue
38+
const previous = ranges.at(-1)
39+
if (previous && selection.start <= previous.end) {
40+
previous.end = Math.max(previous.end, selection.end)
41+
} else {
42+
ranges.push({ ...selection })
43+
}
44+
}
45+
return ranges
846
}
947

10-
/** Applies the workspace-file content contract to a projected Monaco paste result. */
48+
/** Applies the workspace-file content contract to every selection in a projected Monaco paste. */
1149
export function assessTextEditorPaste(
1250
input: TextEditorPasteInput,
1351
maxBytes = PASTE_LIMITS.TEXT_EDITOR_BYTES
1452
): TextPasteAdmission {
15-
return assessTextPaste({
16-
...input,
17-
maxPastedBytes: maxBytes,
18-
maxResultBytes: maxBytes,
19-
})
53+
const selections = normalizedSelections(input.selections, input.currentText.length)
54+
const replacementRanges = mergedReplacementRanges(selections)
55+
const replacedCharacters = replacementRanges.reduce(
56+
(total, selection) => total + selection.end - selection.start,
57+
0
58+
)
59+
const resultCharacters =
60+
input.currentText.length - replacedCharacters + input.pastedText.length * selections.length
61+
62+
if (resultCharacters <= Math.floor(maxBytes / 3)) {
63+
return { accepted: true, resultCharacters }
64+
}
65+
66+
const pastedBytes = utf8ByteLength(input.pastedText, maxBytes)
67+
if (pastedBytes > maxBytes) {
68+
return { accepted: false, reason: 'pasted-bytes', actual: pastedBytes, limit: maxBytes }
69+
}
70+
71+
const insertedBytes = pastedBytes * selections.length
72+
if (insertedBytes > maxBytes) {
73+
return { accepted: false, reason: 'result-bytes', actual: insertedBytes, limit: maxBytes }
74+
}
75+
76+
let retainedBytes = 0
77+
let retainedStart = 0
78+
for (const selection of replacementRanges) {
79+
retainedBytes += utf8ByteLengthRange(
80+
input.currentText,
81+
retainedStart,
82+
selection.start,
83+
maxBytes - insertedBytes - retainedBytes
84+
)
85+
if (retainedBytes + insertedBytes > maxBytes) {
86+
return {
87+
accepted: false,
88+
reason: 'result-bytes',
89+
actual: retainedBytes + insertedBytes,
90+
limit: maxBytes,
91+
}
92+
}
93+
retainedStart = selection.end
94+
}
95+
retainedBytes += utf8ByteLengthRange(
96+
input.currentText,
97+
retainedStart,
98+
input.currentText.length,
99+
maxBytes - insertedBytes - retainedBytes
100+
)
101+
102+
const resultBytes = retainedBytes + insertedBytes
103+
if (resultBytes > maxBytes) {
104+
return { accepted: false, reason: 'result-bytes', actual: resultBytes, limit: maxBytes }
105+
}
106+
107+
return { accepted: true, pastedBytes, resultBytes, resultCharacters }
20108
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -600,28 +600,26 @@ export const TextEditor = memo(function TextEditor({
600600

601601
const editor = monacoEditorRef.current
602602
const model = editor?.getModel()
603-
const selection = editor?.getSelection()
603+
const selections = editor?.getSelections()
604604
const currentText = contentRef.current
605-
const selectionStart =
606-
model && selection
607-
? model.getOffsetAt({
608-
lineNumber: selection.startLineNumber,
609-
column: selection.startColumn,
610-
})
611-
: currentText.length
612-
const selectionEnd =
613-
model && selection
614-
? model.getOffsetAt({
615-
lineNumber: selection.endLineNumber,
616-
column: selection.endColumn,
617-
})
618-
: selectionStart
605+
const selectionOffsets =
606+
model && selections?.length
607+
? selections.map((selection) => ({
608+
start: model.getOffsetAt({
609+
lineNumber: selection.startLineNumber,
610+
column: selection.startColumn,
611+
}),
612+
end: model.getOffsetAt({
613+
lineNumber: selection.endLineNumber,
614+
column: selection.endColumn,
615+
}),
616+
}))
617+
: [{ start: currentText.length, end: currentText.length }]
619618

620619
const admission = assessTextEditorPaste({
621620
pastedText,
622621
currentText,
623-
selectionStart,
624-
selectionEnd,
622+
selections: selectionOffsets,
625623
})
626624
if (admission.accepted) return
627625

0 commit comments

Comments
 (0)