Skip to content

Commit 286b30e

Browse files
authored
fix(ui): close paste admission edge cases (#7156)
* fix(ui): close paste admission edge cases * fix(ui): account for multi-cursor pastes * fix(ui): preserve rich image pastes * fix(ui): match distributed multi-cursor paste * fix(ui): defer exact Monaco paste admission
1 parent b44b537 commit 286b30e

13 files changed

Lines changed: 610 additions & 58 deletions

File tree

apps/sim/app/_shell/paste-admission-guard.test.tsx

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
1717
let host: HTMLDivElement
1818
let root: Root
1919

20-
function dispatchPaste(target: Element, text: string, selectionContext?: string): Event {
20+
function dispatchPaste(
21+
target: Element,
22+
text: string,
23+
options: { selectionContext?: string; html?: string; imageFile?: boolean } = {}
24+
): Event {
2125
const event = new Event('paste', {
2226
bubbles: true,
2327
cancelable: true,
@@ -27,9 +31,12 @@ function dispatchPaste(target: Element, text: string, selectionContext?: string)
2731
value: {
2832
getData: (type: string) => {
2933
if (type === 'text/plain') return text
30-
if (type === SIM_SELECTION_MIME) return selectionContext ?? ''
34+
if (type === SIM_SELECTION_MIME) return options.selectionContext ?? ''
35+
if (type === 'text/html') return options.html ?? ''
3136
return ''
3237
},
38+
files: options.imageFile ? [new File(['image'], 'pasted.png', { type: 'image/png' })] : [],
39+
items: options.imageFile ? [{ kind: 'file', type: 'image/png' }] : [],
3340
},
3441
})
3542
target.dispatchEvent(event)
@@ -93,7 +100,36 @@ describe('PasteAdmissionGuard', () => {
93100
expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
94101
})
95102

96-
it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
103+
it('defers text admission to an editor that projects its exact paste result', () => {
104+
const editor = document.createElement('div')
105+
editor.setAttribute('contenteditable', 'true')
106+
editor.dataset.pasteMaxBytes = '4'
107+
editor.dataset.pasteProjectsTextResult = 'true'
108+
host.appendChild(editor)
109+
110+
const targetHandler = vi.fn()
111+
editor.addEventListener('paste', targetHandler)
112+
expect(dispatchPaste(editor, '12345').defaultPrevented).toBe(false)
113+
expect(targetHandler).toHaveBeenCalledOnce()
114+
})
115+
116+
it('lets a prompt consume a compact Sim selection reference before its large plain text', () => {
117+
const input = document.createElement('textarea')
118+
input.dataset.pasteMaxBytes = '4'
119+
input.dataset.pasteSelectionContext = 'reference'
120+
host.appendChild(input)
121+
const selectionContext = JSON.stringify({
122+
kind: 'table_selection',
123+
tableId: 'table-1',
124+
tableName: 'Large table',
125+
rowIds: ['row-1'],
126+
label: 'Large table (1 row)',
127+
})
128+
129+
expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(false)
130+
})
131+
132+
it('still bounds a Sim selection plain-text representation outside the prompt', () => {
97133
const input = document.createElement('textarea')
98134
input.dataset.pasteMaxBytes = '4'
99135
host.appendChild(input)
@@ -105,6 +141,37 @@ describe('PasteAdmissionGuard', () => {
105141
label: 'Large table (1 row)',
106142
})
107143

108-
expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false)
144+
expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(true)
145+
})
146+
147+
it('bounds rich HTML separately from its smaller plain-text representation', () => {
148+
const editable = document.createElement('div')
149+
editable.setAttribute('contenteditable', 'true')
150+
editable.dataset.pasteMaxBytes = '100'
151+
editable.dataset.pasteMaxHtmlBytes = '10'
152+
host.appendChild(editable)
153+
154+
expect(dispatchPaste(editable, 'abc', { html: '<strong>abc</strong>' }).defaultPrevented).toBe(
155+
true
156+
)
157+
})
158+
159+
it('lets an opted-in rich editor handle clipboard image files before text admission', () => {
160+
const editable = document.createElement('div')
161+
editable.setAttribute('contenteditable', 'true')
162+
editable.dataset.pasteMaxBytes = '4'
163+
editable.dataset.pasteMaxHtmlBytes = '4'
164+
editable.dataset.pasteHandlesImages = 'true'
165+
host.appendChild(editable)
166+
167+
const targetHandler = vi.fn()
168+
editable.addEventListener('paste', targetHandler)
169+
const event = dispatchPaste(editable, '12345', {
170+
html: '<img src="data:image/png;base64,large">',
171+
imageFile: true,
172+
})
173+
174+
expect(event.defaultPrevented).toBe(false)
175+
expect(targetHandler).toHaveBeenCalledOnce()
109176
})
110177
})

apps/sim/app/_shell/paste-admission-guard.tsx

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,20 @@ function finitePositiveAttribute(element: Element | null, name: string): number
1515
return Number.isFinite(value) && value > 0 ? value : undefined
1616
}
1717

18+
function clipboardHasImageFile(data: DataTransfer | null): boolean {
19+
if (!data) return false
20+
if (Array.from(data.files).some((file) => file.type.startsWith('image/'))) return true
21+
return Array.from(data.items).some(
22+
(item) => item.kind === 'file' && item.type.startsWith('image/')
23+
)
24+
}
25+
1826
/**
1927
* Last-resort admission for every editable workspace surface. Specialized editors publish their
2028
* downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a
2129
* crash-only fallback. This layer bounds only the clipboard payload, so a small paste into an already
2230
* large field keeps native behavior. Editors with a real result-size contract enforce it themselves.
31+
* Targets that explicitly handle clipboard images may claim those file events before the text guards.
2332
* The capture listener runs before React, ProseMirror, Monaco, and xterm parse the clipboard value.
2433
*/
2534
export function PasteAdmissionGuard() {
@@ -33,31 +42,53 @@ export function PasteAdmissionGuard() {
3342
return
3443
}
3544

36-
if (readSelectionContextFromClipboard(event.clipboardData)) return
45+
const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]')
46+
if (acceptsSelectionContext && readSelectionContextFromClipboard(event.clipboardData)) return
3747

38-
const text = event.clipboardData?.getData('text/plain') ?? ''
39-
if (!text) return
48+
const handlesImageFiles = event.target.closest('[data-paste-handles-images="true"]')
49+
if (handlesImageFiles && clipboardHasImageFile(event.clipboardData)) return
4050

51+
const text = event.clipboardData?.getData('text/plain') ?? ''
52+
const projectsTextResult = event.target.closest('[data-paste-projects-text-result="true"]')
4153
const policyElement = event.target.closest('[data-paste-max-bytes]')
4254
const maxPastedBytes =
4355
finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES
4456
const maxPastedCharacters = finitePositiveAttribute(
4557
policyElement,
4658
'data-paste-max-characters'
4759
)
48-
const admission = assessTextPaste({
49-
pastedText: text,
50-
maxPastedBytes,
51-
maxPastedCharacters,
52-
})
53-
if (admission.accepted) return
60+
const textAdmission =
61+
text && !projectsTextResult
62+
? assessTextPaste({
63+
pastedText: text,
64+
maxPastedBytes,
65+
maxPastedCharacters,
66+
})
67+
: null
68+
const htmlPolicyElement = event.target.closest('[data-paste-max-html-bytes]')
69+
const maxPastedHtmlBytes = finitePositiveAttribute(
70+
htmlPolicyElement,
71+
'data-paste-max-html-bytes'
72+
)
73+
const html = maxPastedHtmlBytes ? (event.clipboardData?.getData('text/html') ?? '') : ''
74+
const htmlAdmission =
75+
html && maxPastedHtmlBytes
76+
? assessTextPaste({ pastedText: html, maxPastedBytes: maxPastedHtmlBytes })
77+
: null
78+
const rejection =
79+
textAdmission && !textAdmission.accepted
80+
? textAdmission
81+
: htmlAdmission && !htmlAdmission.accepted
82+
? htmlAdmission
83+
: null
84+
if (!rejection) return
5485

5586
event.preventDefault()
5687
event.stopImmediatePropagation()
5788
const limit =
58-
admission.reason === 'pasted-characters'
59-
? `${admission.limit.toLocaleString()} characters`
60-
: formatPasteLimit(admission.limit)
89+
rejection.reason === 'pasted-characters'
90+
? `${rejection.limit.toLocaleString()} characters`
91+
: formatPasteLimit(rejection.limit)
6192
notifyRef.current.warning('Paste is too large for this editor', {
6293
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
6394
})

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

Lines changed: 74 additions & 4 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 { 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

@@ -14,10 +17,16 @@ afterEach(() => {
1417
editor = null
1518
})
1619

17-
function runPaste(ed: Editor, text: string): { handled: boolean; prevented: boolean } {
20+
function runPaste(ed: Editor, text: string, html = ''): { handled: boolean; prevented: boolean } {
1821
let prevented = false
1922
const event = {
20-
clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') },
23+
clipboardData: {
24+
getData: (type: string) => {
25+
if (type === 'text/plain') return text
26+
if (type === 'text/html') return html
27+
return ''
28+
},
29+
},
2130
preventDefault: () => {
2231
prevented = true
2332
},
@@ -31,6 +40,20 @@ function runPaste(ed: Editor, text: string): { handled: boolean; prevented: bool
3140
}
3241

3342
describe('rich Markdown paste admission', () => {
43+
it('rejects a raw-text append whose projected result exceeds the limit', () => {
44+
expect(
45+
assessRawMarkdownPaste(
46+
{
47+
pastedText: '56789',
48+
currentText: '123456',
49+
selectionStart: 6,
50+
selectionEnd: 6,
51+
},
52+
10
53+
)
54+
).toEqual({ accepted: false, reason: 'result-bytes', actual: 11, limit: 10 })
55+
})
56+
3457
it('rejects before downstream paste parsing when projected bytes exceed the document limit', () => {
3558
const onRejected = vi.fn()
3659
editor = new Editor({
@@ -88,4 +111,51 @@ describe('rich Markdown paste admission', () => {
88111

89112
expect(runPaste(editor, '1234567890')).toEqual({ handled: false, prevented: false })
90113
})
114+
115+
it('rejects oversized rich HTML before downstream parsing', () => {
116+
const onRejected = vi.fn()
117+
editor = new Editor({
118+
extensions: [
119+
...createMarkdownContentExtensions(),
120+
createRichMarkdownPasteAdmission({
121+
maxResultBytes: 10,
122+
getCurrentText: () => '',
123+
onRejected,
124+
}),
125+
],
126+
content: '<p></p>',
127+
})
128+
129+
expect(runPaste(editor, 'x', '<strong>abc</strong>')).toEqual({
130+
handled: true,
131+
prevented: true,
132+
})
133+
expect(onRejected).toHaveBeenCalledOnce()
134+
})
135+
136+
it('rejects a paste whose canonical Markdown result exceeds the limit', () => {
137+
const onRejected = vi.fn()
138+
editor = new Editor({
139+
extensions: [
140+
...createMarkdownContentExtensions(),
141+
createRichMarkdownPasteAdmission({
142+
maxResultBytes: 10,
143+
getCurrentText: () => '123456',
144+
onRejected,
145+
}),
146+
],
147+
content: '<p>123456</p>',
148+
})
149+
const strong = editor.schema.marks.bold.create()
150+
const transaction = editor.state.tr
151+
.replaceSelectionWith(editor.schema.text('abc', [strong]), false)
152+
.setMeta('uiEvent', 'paste')
153+
154+
expect(editor.markdown.serialize(transaction.doc.toJSON())).toBe('**abc**123456')
155+
expect(transaction.getMeta('uiEvent')).toBe('paste')
156+
editor.view.dispatch(transaction)
157+
158+
expect(editor.getText()).toBe('123456')
159+
expect(onRejected).toHaveBeenCalledOnce()
160+
})
91161
})

0 commit comments

Comments
 (0)