Skip to content

Commit 17e3386

Browse files
committed
Merge branch 'fix-safari-copy-to-clipboard' into 'develop'
Fix copy to clipboard on Safari Closes baserow#3830 See merge request baserow/baserow!3738
2 parents c1fd799 + c528caa commit 17e3386

File tree

5 files changed

+163
-45
lines changed

5 files changed

+163
-45
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"type": "bug",
3+
"message": "Fix copy/paste on Safari 18.6+",
4+
"domain": "database",
5+
"issue_number": 3830,
6+
"bullet_points": [],
7+
"created_at": "2025-09-18"
8+
}

web-frontend/modules/database/components/view/grid/GridView.vue

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,27 +1460,21 @@ export default {
14601460
* Prepare and copy the multi-select cells into the clipboard,
14611461
* formatted as TSV
14621462
*/
1463-
async copySelection(event, includeHeader = false) {
1463+
copySelection(event, includeHeader = false) {
14641464
const gridStore = this.storePrefix + 'view/grid'
14651465
if (!this.$store.getters[`${gridStore}/isMultiSelectActive`]) {
14661466
return
14671467
}
14681468
1469-
try {
1470-
this.$store.dispatch('toast/setCopying', true)
1471-
await this.copySelectionToClipboard(
1472-
this.$store.dispatch(`${gridStore}/getCurrentSelection`, {
1473-
fields: this.allVisibleFields,
1474-
}),
1475-
includeHeader
1476-
)
1477-
} catch (error) {
1478-
notifyIf(error, 'view')
1479-
} finally {
1480-
this.$store.dispatch('toast/setCopying', false)
1481-
// prevent Safari from beeping since window.getSelection() is empty
1482-
event.preventDefault()
1483-
}
1469+
this.copySelectionToClipboard(
1470+
this.$store.dispatch(`${gridStore}/getCurrentSelection`, {
1471+
fields: this.allVisibleFields,
1472+
}),
1473+
includeHeader
1474+
)
1475+
1476+
// prevent Safari from beeping since window.getSelection() is empty
1477+
event.preventDefault()
14841478
},
14851479
/**
14861480
* Called when the @paste event is triggered from the `GridViewSection` component.

web-frontend/modules/database/mixins/copyPasteHelper.js

Lines changed: 129 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ const PAPA_CONFIG = {
1414

1515
export default {
1616
methods: {
17-
prepareValuesForCopy(fields, rows, includeHeader) {
17+
/**
18+
* Prepares the values of the given fields and rows for copying to the clipboard. It
19+
* returns both a text representation and a json representation of the data. The
20+
* text representation is a 2D array of strings, where each inner array represents a
21+
* row and each string represents a cell. The json representation is a 2D array of
22+
* values, where each inner array represents a row and each value represents a cell.
23+
* The json representation can contain rich values, such as objects or arrays, that
24+
* can be later used to paste rich data back into the grid.
25+
*
26+
* @param {Array} fields The fields to copy.
27+
* @param {Array} rows The rows to copy.
28+
* @param {boolean} includeHeader Whether to include the field names as the first
29+
*/
30+
prepareValuesForCopy(fields, rows, includeHeader = false) {
1831
const textData = []
1932
const jsonData = []
2033
if (includeHeader) {
@@ -37,6 +50,18 @@ export default {
3750
}
3851
return { textData, jsonData }
3952
},
53+
/**
54+
* Prepares the given text data as an HTML table. If the text data only contains
55+
* a single cell, no HTML table is returned as it would conflict with tiptap.
56+
* The html data is used when pasting into external applications that support rich
57+
* clipboard data, such as Google Sheets or Excel. If the firstRowIsHeader is true,
58+
* the first row will be wrapped in <th> tags instead of <td> tags.
59+
*
60+
* @param {Array} textData The text data to prepare.
61+
* @param {boolean} firstRowIsHeader Whether the first row should be treated as a
62+
* header row.
63+
* @returns {string|null} The HTML table or null if no table is needed.
64+
*/
4065
prepareHTMLData(textData, firstRowIsHeader) {
4166
const table = document.createElement('table')
4267
const tbody = document.createElement('tbody')
@@ -71,11 +96,29 @@ export default {
7196
{ root: true }
7297
)
7398
},
74-
async copySelectionToClipboard(selectionPromise, includeHeader = false) {
75-
const { textData, jsonData } = await selectionPromise.then(
76-
([fields, rows]) =>
77-
this.prepareValuesForCopy(fields, rows, includeHeader)
78-
)
99+
/**
100+
* Formats the given text and json data for the clipboard and stores the rich
101+
* representation in local storage. It returns both a tsv representation and an html
102+
* representation of the text data. The tsv representation is used for plain text
103+
* clipboard data, the html representation is used when pasting into external
104+
* applications that support rich clipboard data, such as Google Sheets or Excel.
105+
* The json representation is stored in local storage to be able to paste rich
106+
* values back into the Baserow grid later. If the the stored version is the same as
107+
* the clipboard version, the rich values will be used when pasting instead of the
108+
* text values, so we can be more accurate (i.e. link row values, select options,
109+
* etc.)
110+
*
111+
* @param {Object} data An object containing the text and json data.
112+
* @param {Array} data.textData A 2D array of strings representing the text data.
113+
* @param {Array} data.jsonData A 2D array of values representing the json data.
114+
* @param {boolean} includeHeader Whether the copied data includes a header row.
115+
* @returns {Object} An object containing the tsv and html representation of the
116+
* text data. The html representation can be null if no html table is needed.
117+
*/
118+
formatClipboardDataAndStoreRichCopy(
119+
{ textData, jsonData },
120+
includeHeader = false
121+
) {
79122
const tsvData = this.$papa.unparse(textData, PAPA_CONFIG)
80123
const htmlData = this.prepareHTMLData(textData, includeHeader)
81124
try {
@@ -85,43 +128,107 @@ export default {
85128
)
86129
} catch (e) {
87130
this.showCopyClipboardError()
131+
throw e
88132
}
133+
return { tsvData, htmlData }
134+
},
135+
/**
136+
* Copies the given selection to the clipboard. The selection is a promise that
137+
* resolves to an array containing the fields and rows to copy. The fields are
138+
* used to determine which columns to copy and in which order. The rows are the
139+
* actual data to copy. If includeHeader is true, the field names are included as
140+
* the first row of the copied data.
141+
*
142+
* @param {Promise} selectionPromise A promise that resolves to an array containing
143+
* the fields and rows to copy.
144+
* @param {boolean} includeHeader Whether to include the field names as the first
145+
* row of the copied data.
146+
*/
147+
copySelectionToClipboard(selectionPromise, includeHeader = false) {
148+
this.$store.dispatch('toast/setCopying', true)
149+
const dataPromise = selectionPromise
150+
.then(([fields, rows]) => {
151+
const { textData, jsonData } = this.prepareValuesForCopy(
152+
fields,
153+
rows,
154+
includeHeader
155+
)
156+
this.$store.dispatch('toast/setCopying', false)
157+
return this.formatClipboardDataAndStoreRichCopy(
158+
{ textData, jsonData },
159+
includeHeader
160+
)
161+
})
162+
.catch((error) => {
163+
this.$store.dispatch('toast/setCopying', false)
164+
throw error
165+
})
89166

90167
try {
91-
await this.writeToClipboard(tsvData, htmlData)
168+
this.writeToClipboard(dataPromise)
92169
} catch (e) {
93170
if (!document.hasFocus()) {
94171
window.addEventListener(
95172
'focus',
96-
() => this.writeToClipboard(tsvData, htmlData),
173+
() => this.writeToClipboard(dataPromise),
97174
{ once: true }
98175
)
99176
} else {
100177
this.showCopyClipboardError()
101178
}
102179
}
103180
},
104-
async writeToClipboard(tsvData, htmlData) {
181+
/**
182+
* Writes the given data to the clipboard. It tries to write both a plain text
183+
* representation and a html representation of the data if supported by the browser.
184+
* If the ClipboardItem API is not supported, it falls back to writing only the
185+
* plain text representation. If that is also not supported, it falls back to using
186+
* the rich clipboard utils that uses an older approach to write both a plain text
187+
* and html representation of the data.
188+
*
189+
* @param {Promise} dataPromise A promise that resolves to an object containing
190+
* the tsv and html representation of the data.
191+
*/
192+
writeToClipboard(dataPromise) {
105193
if (typeof ClipboardItem !== 'undefined') {
106-
const clipboardConfig = {
107-
'text/plain': new Blob([tsvData], { type: 'text/plain' }),
108-
}
109-
if (htmlData) {
110-
clipboardConfig['text/html'] = new Blob([htmlData], {
111-
type: 'text/html',
112-
})
113-
}
114-
await navigator.clipboard.write([new ClipboardItem(clipboardConfig)])
194+
const clipboardItem = new ClipboardItem({
195+
'text/plain': Promise.resolve(dataPromise).then(({ tsvData }) =>
196+
tsvData ? new Blob([tsvData], { type: 'text/plain' }) : null
197+
),
198+
'text/html': Promise.resolve(dataPromise).then(({ htmlData }) =>
199+
htmlData ? new Blob([htmlData], { type: 'text/html' }) : null
200+
),
201+
})
202+
navigator.clipboard.write([clipboardItem])
115203
} else if (typeof navigator.clipboard?.writeText !== 'undefined') {
116-
await navigator.clipboard.writeText(tsvData)
204+
navigator.clipboard.writeText(
205+
Promise.resolve(dataPromise).then(({ tsvData }) => tsvData)
206+
)
117207
} else {
118-
const richClipboardConfig = { 'text/plain': tsvData }
119-
if (htmlData) {
120-
richClipboardConfig['text/html'] = htmlData
208+
const richClipboardConfig = {
209+
'text/plain': Promise.resolve(dataPromise).then(
210+
({ tsvData }) => tsvData || null
211+
),
212+
'text/html': Promise.resolve(dataPromise).then(
213+
({ htmlData }) => htmlData || null
214+
),
121215
}
122216
setRichClipboard(richClipboardConfig)
123217
}
124218
},
219+
/**
220+
* Extracts the clipboard data from the given paste event. It tries to extract
221+
* both a plain text representation and a json representation of the data. The
222+
* plain text representation is a 2D array of strings, where each inner array
223+
* represents a row and each string represents a cell. The json representation
224+
* is a 2D array of values, where each inner array represents a row and each
225+
* value represents a cell. The json representation can contain rich values,
226+
* such as objects or arrays, that can be later used to paste rich data back
227+
* into the grid, if the versions match.
228+
*
229+
* @param {Event} event The paste event.
230+
* @returns {Array} An array containing the text and json data.
231+
*/
125232
async extractClipboardData(event) {
126233
const { textRawData, jsonRawData } = await getRichClipboard(event)
127234
const { data: textData } = await this.$papa.parsePromise(

web-frontend/modules/database/mixins/gridField.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,24 @@ export default {
165165
keyDownEventListener
166166
)
167167

168-
const copyEventListener = async (event) => {
168+
const copyEventListener = (event) => {
169169
if (!this.canKeyDown(event) || !this.canKeyboardShortcut(event)) return
170170

171-
await this.copySelectionToClipboard(
172-
Promise.resolve([
173-
[this.field],
174-
[{ [`field_${this.field.id}`]: this.value }],
175-
])
171+
const { textData, jsonData } = this.prepareValuesForCopy(
172+
[this.field],
173+
[{ [`field_${this.field.id}`]: this.value }]
176174
)
175+
const { tsvData } = this.formatClipboardDataAndStoreRichCopy({
176+
textData,
177+
jsonData,
178+
})
179+
180+
try {
181+
navigator.clipboard.writeText(tsvData)
182+
} catch (err) {
183+
console.error('Failed to copy: ', err)
184+
}
185+
177186
// prevent Safari from beeping since the window.getSelection() is empty
178187
event.preventDefault()
179188
}

web-frontend/modules/database/utils/view.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function getRowSortFunction($registry, sortings, fields, groupBys = []) {
3232
})
3333

3434
sortFunction = sortFunction.thenBy((a, b) =>
35-
new BigNumber(a.order).minus(new BigNumber(b.order))
35+
new BigNumber(a.order).minus(new BigNumber(b.order)).toNumber()
3636
)
3737
sortFunction = sortFunction.thenBy((a, b) => a.id - b.id)
3838
return sortFunction

0 commit comments

Comments
 (0)