Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
19aed53e
Commit
19aed53e
authored
Jan 12, 2024
by
Alejandro Celaya
Committed by
Alejandro Celaya
Jan 16, 2024
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add copy to clipboard button to export annotations
parent
d823e0f6
Changes
7
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
259 additions
and
63 deletions
+259
-63
sidebar.tsx
src/annotator/sidebar.tsx
+1
-0
download-file.ts
src/shared/download-file.ts
+2
-19
download-file-test.js
src/shared/test/download-file-test.js
+3
-4
ExportAnnotations.tsx
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
+90
-25
ExportAnnotations-test.js
...bar/components/ShareDialog/test/ExportAnnotations-test.js
+82
-8
copy-to-clipboard.ts
src/sidebar/util/copy-to-clipboard.ts
+34
-0
copy-to-clipboard-test.js
src/sidebar/util/test/copy-to-clipboard-test.js
+47
-7
No files found.
src/annotator/sidebar.tsx
View file @
19aed53e
...
...
@@ -85,6 +85,7 @@ function createSidebarIframe(config: SidebarConfig): HTMLIFrameElement {
sidebarFrame
.
src
=
sidebarAppSrc
;
sidebarFrame
.
title
=
'Hypothesis annotation viewer'
;
sidebarFrame
.
className
=
'sidebar-frame'
;
sidebarFrame
.
allow
=
'clipboard-write'
;
return
sidebarFrame
;
}
...
...
src/shared/download-file.ts
View file @
19aed53e
...
...
@@ -19,25 +19,6 @@ function downloadFile(
URL
.
revokeObjectURL
(
url
);
}
/**
* Download a file containing JSON-serialized `object` as `filename`
*
* @param data - JSON-serializable object
* @return The contents of the downloaded file
* @throws {Error} If provided data cannot be JSON-serialized
*/
export
function
downloadJSONFile
(
data
:
object
,
filename
:
string
,
/* istanbul ignore next - test seam */
_document
=
document
,
):
string
{
const
fileContent
=
JSON
.
stringify
(
data
,
null
,
2
);
downloadFile
(
fileContent
,
'application/json'
,
filename
,
_document
);
return
fileContent
;
}
function
buildTextFileDownloader
(
type
:
string
)
{
return
(
text
:
string
,
...
...
@@ -47,6 +28,8 @@ function buildTextFileDownloader(type: string) {
)
=>
downloadFile
(
text
,
type
,
filename
,
_document
);
}
export
const
downloadJSONFile
=
buildTextFileDownloader
(
'application/json'
);
export
const
downloadTextFile
=
buildTextFileDownloader
(
'text/plain'
);
export
const
downloadCSVFile
=
buildTextFileDownloader
(
'text/csv'
);
...
...
src/shared/test/download-file-test.js
View file @
19aed53e
...
...
@@ -48,13 +48,12 @@ describe('download-file', () => {
}
it
(
'downloadJSONFile generates JSON file with provided data'
,
()
=>
{
const
data
=
{
foo
:
[
'bar'
,
'baz'
]
}
;
const
data
=
JSON
.
stringify
({
foo
:
[
'bar'
,
'baz'
]
},
null
,
2
)
;
const
filename
=
'my-file.json'
;
const
fileContent
=
downloadJSONFile
(
data
,
filename
,
fakeDocument
);
downloadJSONFile
(
data
,
filename
,
fakeDocument
);
assert
.
equal
(
fileContent
,
JSON
.
stringify
(
data
,
null
,
2
));
assertDownloadHappened
(
filename
,
fileContent
,
'application/json'
);
assertDownloadHappened
(
filename
,
data
,
'application/json'
);
});
it
(
'downloadTextFile generates text file with provided data'
,
()
=>
{
...
...
src/sidebar/components/ShareDialog/ExportAnnotations.tsx
View file @
19aed53e
...
...
@@ -4,6 +4,7 @@ import {
Link
,
Input
,
SelectNext
,
CopyIcon
,
}
from
'@hypothesis/frontend-shared'
;
import
{
useCallback
,
useId
,
useMemo
,
useState
}
from
'preact/hooks'
;
...
...
@@ -22,6 +23,7 @@ import { withServices } from '../../service-context';
import
type
{
AnnotationsExporter
}
from
'../../services/annotations-exporter'
;
import
type
{
ToastMessengerService
}
from
'../../services/toast-messenger'
;
import
{
useSidebarStore
}
from
'../../store'
;
import
{
copyPlainText
,
copyHTML
}
from
'../../util/copy-to-clipboard'
;
import
LoadingSpinner
from
'./LoadingSpinner'
;
import
{
UserAnnotationsListItem
}
from
'./UserAnnotationsListItem'
;
...
...
@@ -133,30 +135,20 @@ function ExportAnnotations({
);
const
[
customFilename
,
setCustomFilename
]
=
useState
<
string
>
();
if
(
!
exportReady
)
{
return
<
LoadingSpinner
/>;
}
const
exportAnnotations
=
(
e
:
Event
)
=>
{
e
.
preventDefault
();
try
{
const
format
=
exportFormat
.
value
;
const
buildExportContent
=
useCallback
(
(
format
:
ExportFormat
[
'value'
]):
string
=>
{
const
annotationsToExport
=
selectedUser
?.
annotations
??
exportableAnnotations
;
const
filename
=
`
${
customFilename
??
defaultFilename
}.
$
{
format
}
`;
switch
(
format
)
{
case
'json'
:
{
const
exportD
ata = annotationsExporter.buildJSONExportContent(
const
d
ata
=
annotationsExporter
.
buildJSONExportContent
(
annotationsToExport
,
{
profile
},
);
downloadJSONFile(exportData, filename);
break;
return
JSON
.
stringify
(
data
,
null
,
2
);
}
case
'txt'
:
{
const exportData =
annotationsExporter.buildTextExportContent(
return
annotationsExporter
.
buildTextExportContent
(
annotationsToExport
,
{
groupName
:
group
?.
name
,
...
...
@@ -164,11 +156,9 @@ function ExportAnnotations({
displayNamesEnabled
,
},
);
downloadTextFile(exportData, filename);
break;
}
case
'csv'
:
{
const exportData =
annotationsExporter.buildCSVExportContent(
return
annotationsExporter
.
buildCSVExportContent
(
annotationsToExport
,
{
groupName
:
group
?.
name
,
...
...
@@ -176,11 +166,9 @@ function ExportAnnotations({
displayNamesEnabled
,
},
);
downloadCSVFile(exportData, filename);
break;
}
case
'html'
:
{
const exportData =
annotationsExporter.buildHTMLExportContent(
return
annotationsExporter
.
buildHTMLExportContent
(
annotationsToExport
,
{
groupName
:
group
?.
name
,
...
...
@@ -188,14 +176,81 @@ function ExportAnnotations({
displayNamesEnabled
,
},
);
downloadHTMLFile(exportData, filename);
break;
}
/* istanbul ignore next - This should never happen */
default
:
throw
new
Error
(
`Invalid format:
${
format
}
`
);
}
},
[
annotationsExporter
,
defaultAuthority
,
displayNamesEnabled
,
exportableAnnotations
,
group
?.
name
,
profile
,
selectedUser
?.
annotations
,
],
);
const
exportAnnotations
=
useCallback
(
(
e
:
Event
)
=>
{
e
.
preventDefault
();
try
{
const
format
=
exportFormat
.
value
;
const
filename
=
`
${
customFilename
??
defaultFilename
}.
$
{
format
}
`;
const exportData = buildExportContent(format);
switch (format) {
case 'json': {
downloadJSONFile(exportData, filename);
break;
}
case 'txt': {
downloadTextFile(exportData, filename);
break;
}
case 'csv': {
downloadCSVFile(exportData, filename);
break;
}
case 'html': {
downloadHTMLFile(exportData, filename);
break;
}
}
} catch (e) {
toastMessenger.error('Exporting annotations failed');
}
},
[
buildExportContent,
customFilename,
defaultFilename,
exportFormat.value,
toastMessenger,
],
);
const copyAnnotationsExport = useCallback(async () => {
const format = exportFormat.value;
const exportData = buildExportContent(format);
try {
if (format === 'html') {
await copyHTML(exportData);
} else {
await copyPlainText(exportData);
}
toastMessenger.success('Annotations copied');
} catch (e) {
toastMessenger.error('
Export
ing annotations failed');
toastMessenger.error('
Copy
ing annotations failed');
}
};
}, [buildExportContent, exportFormat.value, toastMessenger]);
if (!exportReady) {
return <LoadingSpinner />;
}
// Naive simple English pluralization
const pluralize = (count: number, singular: string, plural: string) => {
...
...
@@ -310,6 +365,16 @@ function ExportAnnotations({
</p>
)}
<CardActions>
{exportFormatsEnabled && (
<Button
data-testid="copy-button"
icon={CopyIcon}
onClick={copyAnnotationsExport}
disabled={exportableAnnotations.length === 0}
>
Copy to clipboard
</Button>
)}
<Button
data-testid="export-button"
variant="primary"
...
...
src/sidebar/components/ShareDialog/test/ExportAnnotations-test.js
View file @
19aed53e
...
...
@@ -19,6 +19,8 @@ describe('ExportAnnotations', () => {
let
fakeDownloadCSVFile
;
let
fakeDownloadHTMLFile
;
let
fakeSuggestedFilename
;
let
fakeCopyPlainText
;
let
fakeCopyHTML
;
const
fakePrivateGroup
=
{
type
:
'private'
,
...
...
@@ -44,6 +46,7 @@ describe('ExportAnnotations', () => {
};
fakeToastMessenger
=
{
error
:
sinon
.
stub
(),
success
:
sinon
.
stub
(),
};
fakeDownloadJSONFile
=
sinon
.
stub
();
fakeDownloadTextFile
=
sinon
.
stub
();
...
...
@@ -64,6 +67,8 @@ describe('ExportAnnotations', () => {
.
returns
([
fixtures
.
oldAnnotation
(),
fixtures
.
oldAnnotation
()]),
};
fakeSuggestedFilename
=
sinon
.
stub
().
returns
(
'suggested-filename'
);
fakeCopyPlainText
=
sinon
.
stub
().
resolves
(
undefined
);
fakeCopyHTML
=
sinon
.
stub
().
resolves
(
undefined
);
$imports
.
$mock
(
mockImportedComponents
());
...
...
@@ -78,6 +83,10 @@ describe('ExportAnnotations', () => {
suggestedFilename
:
fakeSuggestedFilename
,
},
'../../store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
'../../util/copy-to-clipboard'
:
{
copyPlainText
:
fakeCopyPlainText
,
copyHTML
:
fakeCopyHTML
,
},
});
$imports
.
$restore
({
...
...
@@ -96,6 +105,15 @@ describe('ExportAnnotations', () => {
const
waitForTestId
=
async
(
wrapper
,
testId
)
=>
waitForElement
(
wrapper
,
`[data-testid="
${
testId
}
"]`
);
const
selectExportFormat
=
async
(
wrapper
,
format
)
=>
{
const
select
=
await
waitForElement
(
wrapper
,
'[data-testid="export-format-select"]'
,
);
select
.
props
().
onChange
({
value
:
format
});
wrapper
.
update
();
};
context
(
'export annotations not ready (loading)'
,
()
=>
{
it
(
'renders a loading spinner if there is no focused group'
,
()
=>
{
fakeStore
.
focusedGroup
.
returns
(
null
);
...
...
@@ -288,14 +306,6 @@ describe('ExportAnnotations', () => {
describe
(
'export form submitted'
,
()
=>
{
const
submitExportForm
=
wrapper
=>
wrapper
.
find
(
'[data-testid="export-form"]'
).
simulate
(
'submit'
);
const
selectExportFormat
=
async
(
wrapper
,
format
)
=>
{
const
select
=
await
waitForElement
(
wrapper
,
'[data-testid="export-format-select"]'
,
);
select
.
props
().
onChange
({
value
:
format
});
wrapper
.
update
();
};
[
{
...
...
@@ -456,6 +466,70 @@ describe('ExportAnnotations', () => {
});
});
context
(
'when copying annotations export to clipboard'
,
()
=>
{
[
true
,
false
].
forEach
(
exportFormatsEnabled
=>
{
it
(
'displays copy button if `export_formats` FF is enabled'
,
()
=>
{
fakeStore
.
isFeatureEnabled
.
callsFake
(
ff
=>
exportFormatsEnabled
||
ff
!==
'export_formats'
,
);
const
wrapper
=
createComponent
();
assert
.
equal
(
wrapper
.
exists
(
'[data-testid="copy-button"]'
),
exportFormatsEnabled
,
);
});
});
[
{
format
:
'json'
,
getExpectedInvokedCallback
:
()
=>
fakeCopyPlainText
,
},
{
format
:
'txt'
,
getExpectedInvokedCallback
:
()
=>
fakeCopyPlainText
,
},
{
format
:
'csv'
,
getExpectedInvokedCallback
:
()
=>
fakeCopyPlainText
,
},
{
format
:
'html'
,
getExpectedInvokedCallback
:
()
=>
fakeCopyHTML
,
},
].
forEach
(({
format
,
getExpectedInvokedCallback
})
=>
{
it
(
'copies export content as rich or plain text depending on format'
,
async
()
=>
{
fakeStore
.
isFeatureEnabled
.
callsFake
(
ff
=>
ff
===
'export_formats'
);
const
wrapper
=
createComponent
();
const
copyButton
=
wrapper
.
find
(
'button[data-testid="copy-button"]'
);
await
selectExportFormat
(
wrapper
,
format
);
await
act
(()
=>
{
copyButton
.
simulate
(
'click'
);
});
assert
.
called
(
getExpectedInvokedCallback
());
assert
.
calledWith
(
fakeToastMessenger
.
success
,
'Annotations copied'
);
});
});
it
(
'adds error toast message when copying annotations fails'
,
async
()
=>
{
fakeStore
.
isFeatureEnabled
.
callsFake
(
ff
=>
ff
===
'export_formats'
);
fakeCopyPlainText
.
rejects
(
new
Error
(
'Something failed'
));
const
wrapper
=
createComponent
();
const
copyButton
=
wrapper
.
find
(
'button[data-testid="copy-button"]'
);
await
act
(()
=>
{
copyButton
.
simulate
(
'click'
);
});
assert
.
calledWith
(
fakeToastMessenger
.
error
,
'Copying annotations failed'
);
});
});
context
(
'no annotations available to export'
,
()
=>
{
beforeEach
(()
=>
{
fakeStore
.
savedAnnotations
.
returns
([]);
...
...
src/sidebar/util/copy-to-clipboard.ts
View file @
19aed53e
...
...
@@ -7,6 +7,8 @@
* @throws {Error}
* This function may throw an exception if the browser rejects the attempt
* to copy text.
*
* @deprecated Use copyPlainText instead
*/
export
function
copyText
(
text
:
string
)
{
const
temp
=
document
.
createElement
(
'textarea'
);
// use textarea instead of input to preserve line breaks
...
...
@@ -30,3 +32,35 @@ export function copyText(text: string) {
temp
.
remove
();
}
}
/**
* Copy the string `text` to the clipboard verbatim.
*
* @throws {Error}
* This function may throw an error if the `clipboard-write` permission was
* not allowed.
*/
export
async
function
copyPlainText
(
text
:
string
,
navigator_
=
navigator
)
{
await
navigator_
.
clipboard
.
writeText
(
text
);
}
/**
* Copy the string `text` to the clipboard, rendering HTML if any, instead of
* raw markup.
*
* If the browser does not support this, it will fall back to copy the string
* as plain text.
*
* @throws {Error}
* This function may throw an error if the `clipboard-write` permission was
* not allowed.
*/
export
async
function
copyHTML
(
text
:
string
,
navigator_
=
navigator
)
{
if
(
!
navigator_
.
clipboard
.
write
)
{
await
copyPlainText
(
text
,
navigator_
);
}
else
{
const
type
=
'text/html'
;
const
blob
=
new
Blob
([
text
],
{
type
});
await
navigator_
.
clipboard
.
write
([
new
ClipboardItem
({
[
type
]:
blob
})]);
}
}
src/sidebar/util/test/copy-to-clipboard-test.js
View file @
19aed53e
import
{
copyText
}
from
'../copy-to-clipboard'
;
import
{
copy
PlainText
,
copyHTML
,
copy
Text
}
from
'../copy-to-clipboard'
;
describe
(
'copy-to-clipboard'
,
()
=>
{
beforeEach
(()
=>
{
sinon
.
stub
(
document
,
'execCommand'
);
});
afterEach
(()
=>
{
document
.
execCommand
.
restore
();
const
createFakeNavigator
=
({
supportsWrite
=
true
}
=
{})
=>
({
clipboard
:
{
writeText
:
sinon
.
stub
(),
write
:
supportsWrite
?
sinon
.
stub
()
:
undefined
,
},
});
describe
(
'copyText'
,
()
=>
{
beforeEach
(()
=>
{
sinon
.
stub
(
document
,
'execCommand'
);
});
afterEach
(()
=>
{
document
.
execCommand
.
restore
();
});
/**
* Returns the temporary element used to hold text being copied.
*/
...
...
@@ -51,4 +58,37 @@ describe('copy-to-clipboard', () => {
assert
.
isNull
(
tempSpan
());
});
});
describe
(
'copyPlainText'
,
()
=>
{
it
(
'writes provided text to clipboard'
,
async
()
=>
{
const
text
=
'Lorem ipsum dolor sit amet'
;
const
navigator
=
createFakeNavigator
();
await
copyPlainText
(
text
,
navigator
);
assert
.
calledWith
(
navigator
.
clipboard
.
writeText
,
text
);
assert
.
notCalled
(
navigator
.
clipboard
.
write
);
});
});
describe
(
'copyHTML'
,
()
=>
{
it
(
'writes provided text to clipboard'
,
async
()
=>
{
const
text
=
'Lorem ipsum dolor sit amet'
;
const
navigator
=
createFakeNavigator
();
await
copyHTML
(
text
,
navigator
);
assert
.
called
(
navigator
.
clipboard
.
write
);
assert
.
notCalled
(
navigator
.
clipboard
.
writeText
);
});
it
(
'falls back to plain text if rich text is not supported'
,
async
()
=>
{
const
text
=
'Lorem ipsum dolor sit amet'
;
const
navigator
=
createFakeNavigator
({
supportsWrite
:
false
});
await
copyHTML
(
text
,
navigator
);
assert
.
calledWith
(
navigator
.
clipboard
.
writeText
,
text
);
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment