Skip to content

Commit ea62de6

Browse files
committed
fix(ui): close paste admission edge cases
1 parent 27b97cb commit ea62de6

13 files changed

Lines changed: 355 additions & 57 deletions

File tree

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

Lines changed: 37 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 } = {}
24+
): Event {
2125
const event = new Event('paste', {
2226
bubbles: true,
2327
cancelable: true,
@@ -27,7 +31,8 @@ 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
},
3338
},
@@ -93,9 +98,10 @@ describe('PasteAdmissionGuard', () => {
9398
expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
9499
})
95100

96-
it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
101+
it('lets a prompt consume a compact Sim selection reference before its large plain text', () => {
97102
const input = document.createElement('textarea')
98103
input.dataset.pasteMaxBytes = '4'
104+
input.dataset.pasteSelectionContext = 'reference'
99105
host.appendChild(input)
100106
const selectionContext = JSON.stringify({
101107
kind: 'table_selection',
@@ -105,6 +111,33 @@ describe('PasteAdmissionGuard', () => {
105111
label: 'Large table (1 row)',
106112
})
107113

108-
expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false)
114+
expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(false)
115+
})
116+
117+
it('still bounds a Sim selection plain-text representation outside the prompt', () => {
118+
const input = document.createElement('textarea')
119+
input.dataset.pasteMaxBytes = '4'
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(true)
130+
})
131+
132+
it('bounds rich HTML separately from its smaller plain-text representation', () => {
133+
const editable = document.createElement('div')
134+
editable.setAttribute('contenteditable', 'true')
135+
editable.dataset.pasteMaxBytes = '100'
136+
editable.dataset.pasteMaxHtmlBytes = '10'
137+
host.appendChild(editable)
138+
139+
expect(dispatchPaste(editable, 'abc', { html: '<strong>abc</strong>' }).defaultPrevented).toBe(
140+
true
141+
)
109142
})
110143
})

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

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,31 +33,48 @@ export function PasteAdmissionGuard() {
3333
return
3434
}
3535

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

3839
const text = event.clipboardData?.getData('text/plain') ?? ''
39-
if (!text) return
40-
4140
const policyElement = event.target.closest('[data-paste-max-bytes]')
4241
const maxPastedBytes =
4342
finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES
4443
const maxPastedCharacters = finitePositiveAttribute(
4544
policyElement,
4645
'data-paste-max-characters'
4746
)
48-
const admission = assessTextPaste({
49-
pastedText: text,
50-
maxPastedBytes,
51-
maxPastedCharacters,
52-
})
53-
if (admission.accepted) return
47+
const textAdmission = text
48+
? assessTextPaste({
49+
pastedText: text,
50+
maxPastedBytes,
51+
maxPastedCharacters,
52+
})
53+
: null
54+
const htmlPolicyElement = event.target.closest('[data-paste-max-html-bytes]')
55+
const maxPastedHtmlBytes = finitePositiveAttribute(
56+
htmlPolicyElement,
57+
'data-paste-max-html-bytes'
58+
)
59+
const html = maxPastedHtmlBytes ? (event.clipboardData?.getData('text/html') ?? '') : ''
60+
const htmlAdmission =
61+
html && maxPastedHtmlBytes
62+
? assessTextPaste({ pastedText: html, maxPastedBytes: maxPastedHtmlBytes })
63+
: null
64+
const rejection =
65+
textAdmission && !textAdmission.accepted
66+
? textAdmission
67+
: htmlAdmission && !htmlAdmission.accepted
68+
? htmlAdmission
69+
: null
70+
if (!rejection) return
5471

5572
event.preventDefault()
5673
event.stopImmediatePropagation()
5774
const limit =
58-
admission.reason === 'pasted-characters'
59-
? `${admission.limit.toLocaleString()} characters`
60-
: formatPasteLimit(admission.limit)
75+
rejection.reason === 'pasted-characters'
76+
? `${rejection.limit.toLocaleString()} characters`
77+
: formatPasteLimit(rejection.limit)
6178
notifyRef.current.warning('Paste is too large for this editor', {
6279
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
6380
})

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

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Editor } from '@tiptap/core'
55
import { TextSelection } from '@tiptap/pm/state'
66
import { afterEach, describe, expect, it, vi } from 'vitest'
77
import { createMarkdownContentExtensions } from './extensions'
8-
import { createRichMarkdownPasteAdmission } from './paste-admission'
8+
import { assessRawMarkdownPaste, createRichMarkdownPasteAdmission } from './paste-admission'
99

1010
let editor: Editor | null = null
1111

@@ -14,10 +14,16 @@ afterEach(() => {
1414
editor = null
1515
})
1616

17-
function runPaste(ed: Editor, text: string): { handled: boolean; prevented: boolean } {
17+
function runPaste(ed: Editor, text: string, html = ''): { handled: boolean; prevented: boolean } {
1818
let prevented = false
1919
const event = {
20-
clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') },
20+
clipboardData: {
21+
getData: (type: string) => {
22+
if (type === 'text/plain') return text
23+
if (type === 'text/html') return html
24+
return ''
25+
},
26+
},
2127
preventDefault: () => {
2228
prevented = true
2329
},
@@ -31,6 +37,20 @@ function runPaste(ed: Editor, text: string): { handled: boolean; prevented: bool
3137
}
3238

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

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

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

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
1-
import { utf8ByteLength } from '@sim/utils/paste'
1+
import {
2+
assessTextPaste,
3+
PASTE_LIMITS,
4+
type TextPasteAdmission,
5+
utf8ByteLength,
6+
} from '@sim/utils/paste'
27
import { Extension } from '@tiptap/core'
38
import { Plugin } from '@tiptap/pm/state'
9+
import { postProcessSerializedMarkdown } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
410

511
export interface RichMarkdownPasteAdmissionOptions {
612
maxResultBytes: number
713
getCurrentText: () => string
814
onRejected: () => void
915
}
1016

17+
interface RawMarkdownPasteInput {
18+
pastedText: string
19+
currentText: string
20+
selectionStart: number
21+
selectionEnd: number
22+
}
23+
24+
/** Applies the rich-document boundary to a projected raw-text paste result. */
25+
export function assessRawMarkdownPaste(
26+
input: RawMarkdownPasteInput,
27+
maxResultBytes = PASTE_LIMITS.RICH_MARKDOWN_BYTES
28+
): TextPasteAdmission {
29+
return assessTextPaste({ ...input, maxResultBytes })
30+
}
31+
1132
/**
12-
* Rejects a paste before Markdown parsing when its projected document would leave the editor's
13-
* supported collaboration envelope. The selected ProseMirror text is subtracted from the current
14-
* Markdown size, so replacing a large selection is admitted instead of being treated as an append.
33+
* Rejects oversized clipboard representations before parsing, then filters the exact canonical
34+
* Markdown transaction before it can leave the editor's supported collaboration envelope. The early
35+
* plain-text projection subtracts the selected content, so a large replacement remains fast and is
36+
* not treated as an append.
1537
*/
1638
export function createRichMarkdownPasteAdmission({
1739
maxResultBytes,
@@ -23,34 +45,69 @@ export function createRichMarkdownPasteAdmission({
2345
priority: 1_000,
2446

2547
addProseMirrorPlugins() {
48+
const { editor } = this
49+
let pasteInProgress = false
50+
2651
return [
2752
new Plugin({
53+
filterTransaction: (transaction) => {
54+
const isPaste = pasteInProgress || transaction.getMeta('uiEvent') === 'paste'
55+
if (!isPaste || !transaction.docChanged) return true
56+
pasteInProgress = false
57+
58+
if (!editor.markdown) {
59+
throw new Error('Rich Markdown paste admission requires the Markdown extension')
60+
}
61+
const projectedMarkdown = postProcessSerializedMarkdown(
62+
editor.markdown.serialize(transaction.doc.toJSON())
63+
)
64+
if (utf8ByteLength(projectedMarkdown, maxResultBytes) <= maxResultBytes) return true
65+
66+
onRejected()
67+
return false
68+
},
2869
props: {
2970
handleDOMEvents: {
3071
paste: (view, event) => {
3172
const pastedText = event.clipboardData?.getData('text/plain') ?? ''
32-
if (!pastedText) return false
33-
34-
const currentText = getCurrentText()
35-
const { from, to } = view.state.selection
36-
const replacedText = view.state.doc.textBetween(from, to, '\n')
37-
const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1
38-
const projectedCharacters = replacesWholeDocument
39-
? pastedText.length
40-
: Math.max(0, currentText.length - replacedText.length) + pastedText.length
41-
if (projectedCharacters <= Math.floor(maxResultBytes / 3)) return false
42-
43-
const currentBytes = utf8ByteLength(currentText, maxResultBytes)
44-
const pastedBytes = utf8ByteLength(pastedText, maxResultBytes)
45-
const replacedBytes = replacesWholeDocument
46-
? currentBytes
47-
: utf8ByteLength(replacedText, maxResultBytes)
48-
const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes
49-
if (projectedBytes <= maxResultBytes) return false
50-
51-
event.preventDefault()
52-
onRejected()
53-
return true
73+
const pastedHtml = event.clipboardData?.getData('text/html') ?? ''
74+
if (!pastedText && !pastedHtml) return false
75+
76+
if (pastedHtml && utf8ByteLength(pastedHtml, maxResultBytes) > maxResultBytes) {
77+
event.preventDefault()
78+
onRejected()
79+
return true
80+
}
81+
82+
if (pastedText) {
83+
const currentText = getCurrentText()
84+
const { from, to } = view.state.selection
85+
const replacedText = view.state.doc.textBetween(from, to, '\n')
86+
const replacesWholeDocument = from <= 1 && to >= view.state.doc.content.size - 1
87+
const projectedCharacters = replacesWholeDocument
88+
? pastedText.length
89+
: Math.max(0, currentText.length - replacedText.length) + pastedText.length
90+
91+
if (projectedCharacters > Math.floor(maxResultBytes / 3)) {
92+
const currentBytes = utf8ByteLength(currentText, maxResultBytes)
93+
const pastedBytes = utf8ByteLength(pastedText, maxResultBytes)
94+
const replacedBytes = replacesWholeDocument
95+
? currentBytes
96+
: utf8ByteLength(replacedText, maxResultBytes)
97+
const projectedBytes = Math.max(0, currentBytes - replacedBytes) + pastedBytes
98+
if (projectedBytes > maxResultBytes) {
99+
event.preventDefault()
100+
onRejected()
101+
return true
102+
}
103+
}
104+
}
105+
106+
pasteInProgress = true
107+
queueMicrotask(() => {
108+
pasteInProgress = false
109+
})
110+
return false
54111
},
55112
},
56113
},

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ export function LoadedRichMarkdownEditor({
556556
class: 'rich-markdown-nodes rich-markdown-prose',
557557
'data-owned-shortcuts': 'Mod+K',
558558
'data-paste-max-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES),
559+
'data-paste-max-html-bytes': String(PASTE_LIMITS.RICH_MARKDOWN_BYTES),
559560
},
560561
handleKeyDown: (_view, event) => {
561562
const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's'

0 commit comments

Comments
 (0)