Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
7da260ce
Commit
7da260ce
authored
Apr 07, 2022
by
Himanshu Kapoor
Committed by
Himanshu Kapoor
Apr 13, 2022
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Allow uploading audio and video in content editor
Changelog: added
parent
395c6b95
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
144 additions
and
38 deletions
+144
-38
app/assets/javascripts/content_editor/components/wrappers/media.vue
.../javascripts/content_editor/components/wrappers/media.vue
+51
-0
app/assets/javascripts/content_editor/extensions/image.js
app/assets/javascripts/content_editor/extensions/image.js
+2
-2
app/assets/javascripts/content_editor/extensions/playable.js
app/assets/javascripts/content_editor/extensions/playable.js
+10
-1
app/assets/javascripts/content_editor/services/upload_helpers.js
...ets/javascripts/content_editor/services/upload_helpers.js
+18
-6
lib/gitlab/content_security_policy/config_loader.rb
lib/gitlab/content_security_policy/config_loader.rb
+1
-1
locale/gitlab.pot
locale/gitlab.pot
+0
-3
spec/frontend/content_editor/components/wrappers/media_spec.js
...frontend/content_editor/components/wrappers/media_spec.js
+12
-9
spec/frontend/content_editor/extensions/attachment_spec.js
spec/frontend/content_editor/extensions/attachment_spec.js
+50
-16
No files found.
app/assets/javascripts/content_editor/components/wrappers/
image
.vue
→
app/assets/javascripts/content_editor/components/wrappers/
media
.vue
View file @
7da260ce
...
...
@@ -2,8 +2,14 @@
import
{
GlLoadingIcon
}
from
'
@gitlab/ui
'
;
import
{
NodeViewWrapper
}
from
'
@tiptap/vue-2
'
;
const
tagNameMap
=
{
image
:
'
img
'
,
video
:
'
video
'
,
audio
:
'
audio
'
,
};
export
default
{
name
:
'
Image
Wrapper
'
,
name
:
'
Media
Wrapper
'
,
components
:
{
NodeViewWrapper
,
GlLoadingIcon
,
...
...
@@ -14,19 +20,32 @@ export default {
required
:
true
,
},
},
computed
:
{
tagName
()
{
return
tagNameMap
[
this
.
node
.
type
.
name
]
||
'
img
'
;
},
},
};
</
script
>
<
template
>
<node-view-wrapper
class=
"gl-display-inline-block"
>
<span
class=
"gl-relative"
>
<img
data-testid=
"image"
class=
"gl-max-w-full gl-h-auto"
:title=
"node.attrs.title"
:class=
"
{ 'gl-opacity-5': node.attrs.uploading }"
<span
class=
"gl-relative"
:class=
"
{ [`media-container ${tagName}-container`]: true }">
<gl-loading-icon
v-if=
"node.attrs.uploading"
class=
"gl-absolute gl-left-50p gl-top-half"
/>
<component
:is=
"tagName"
data-testid=
"media"
:class=
"
{
'gl-max-w-full gl-h-auto': tagName !== 'audio',
'gl-opacity-5': node.attrs.uploading,
}"
:title="node.attrs.title || node.attrs.alt"
:alt="node.attrs.alt"
:src="node.attrs.src"
controls="true"
/>
<gl-loading-icon
v-if=
"node.attrs.uploading"
class=
"gl-absolute gl-left-50p gl-top-half"
/>
<a
v-if=
"tagName !== 'img'"
:href=
"node.attrs.canonicalSrc || node.attrs.src"
@
click
.
prevent
>
{{
node
.
attrs
.
title
||
node
.
attrs
.
alt
}}
</a>
</span>
</node-view-wrapper>
</
template
>
app/assets/javascripts/content_editor/extensions/image.js
View file @
7da260ce
import
{
Image
}
from
'
@tiptap/extension-image
'
;
import
{
VueNodeViewRenderer
}
from
'
@tiptap/vue-2
'
;
import
ImageWrapper
from
'
../components/wrappers/image
.vue
'
;
import
MediaWrapper
from
'
../components/wrappers/media
.vue
'
;
import
{
PARSE_HTML_PRIORITY_HIGHEST
}
from
'
../constants
'
;
const
resolveImageEl
=
(
element
)
=>
...
...
@@ -78,6 +78,6 @@ export default Image.extend({
];
},
addNodeView
()
{
return
VueNodeViewRenderer
(
Image
Wrapper
);
return
VueNodeViewRenderer
(
Media
Wrapper
);
},
});
app/assets/javascripts/content_editor/extensions/playable.js
View file @
7da260ce
/* eslint-disable @gitlab/require-i18n-strings */
import
{
Node
}
from
'
@tiptap/core
'
;
import
{
VueNodeViewRenderer
}
from
'
@tiptap/vue-2
'
;
import
MediaWrapper
from
'
../components/wrappers/media.vue
'
;
const
queryPlayableElement
=
(
element
,
mediaType
)
=>
element
.
querySelector
(
mediaType
);
...
...
@@ -11,6 +13,9 @@ export default Node.create({
addAttributes
()
{
return
{
uploading
:
{
default
:
false
,
},
src
:
{
default
:
null
,
parseHTML
:
(
element
)
=>
{
...
...
@@ -60,7 +65,11 @@ export default Node.create({
...
this
.
extraElementAttrs
,
},
],
[
'
a
'
,
{
href
:
node
.
attrs
.
src
},
node
.
attrs
.
alt
],
[
'
a
'
,
{
href
:
node
.
attrs
.
src
},
node
.
attrs
.
title
||
node
.
attrs
.
alt
||
''
],
];
},
addNodeView
()
{
return
VueNodeViewRenderer
(
MediaWrapper
);
},
});
app/assets/javascripts/content_editor/services/upload_helpers.js
View file @
7da260ce
...
...
@@ -5,6 +5,16 @@ import { extractFilename, readFileAsDataURL } from './utils';
export
const
acceptedMimes
=
{
image
:
[
'
image/jpeg
'
,
'
image/png
'
,
'
image/gif
'
,
'
image/jpg
'
],
audio
:
[
'
audio/basic
'
,
'
audio/mid
'
,
'
audio/mpeg
'
,
'
audio/x-aiff
'
,
'
audio/ogg
'
,
'
audio/vorbis
'
,
'
audio/vnd.wav
'
,
],
video
:
[
'
video/mp4
'
,
'
video/quicktime
'
],
};
const
extractAttachmentLinkUrl
=
(
html
)
=>
{
...
...
@@ -50,11 +60,11 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return
extractAttachmentLinkUrl
(
rendered
);
};
const
upload
Image
=
async
({
editor
,
file
,
uploadsPath
,
renderMarkdown
,
eventHub
})
=>
{
const
upload
Content
=
async
({
type
,
editor
,
file
,
uploadsPath
,
renderMarkdown
,
eventHub
})
=>
{
const
encodedSrc
=
await
readFileAsDataURL
(
file
);
const
{
view
}
=
editor
;
editor
.
commands
.
setImage
({
uploading
:
true
,
src
:
encodedSrc
});
editor
.
commands
.
insertContent
({
type
,
attrs
:
{
uploading
:
true
,
src
:
encodedSrc
}
});
const
{
state
}
=
view
;
const
position
=
state
.
selection
.
from
-
1
;
...
...
@@ -74,7 +84,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub
}
catch
(
e
)
{
editor
.
commands
.
deleteRange
({
from
:
position
,
to
:
position
+
1
});
eventHub
.
$emit
(
'
alert
'
,
{
message
:
__
(
'
An error occurred while uploading the
imag
e. Please try again.
'
),
message
:
__
(
'
An error occurred while uploading the
fil
e. Please try again.
'
),
variant
:
VARIANT_DANGER
,
});
}
...
...
@@ -114,11 +124,13 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
export
const
handleFileEvent
=
({
editor
,
file
,
uploadsPath
,
renderMarkdown
,
eventHub
})
=>
{
if
(
!
file
)
return
false
;
if
(
acceptedMimes
.
image
.
includes
(
file
?.
type
))
{
uploadImage
({
editor
,
file
,
uploadsPath
,
renderMarkdown
,
eventHub
});
for
(
const
[
type
,
mimes
]
of
Object
.
entries
(
acceptedMimes
))
{
if
(
mimes
.
includes
(
file
?.
type
))
{
uploadContent
({
type
,
editor
,
file
,
uploadsPath
,
renderMarkdown
,
eventHub
});
return
true
;
}
}
uploadAttachment
({
editor
,
file
,
uploadsPath
,
renderMarkdown
,
eventHub
});
...
...
lib/gitlab/content_security_policy/config_loader.rb
View file @
7da260ce
...
...
@@ -22,7 +22,7 @@ module Gitlab
'frame_src'
=>
ContentSecurityPolicy
::
Directives
.
frame_src
,
'img_src'
=>
"'self' data: blob: http: https:"
,
'manifest_src'
=>
"'self'"
,
'media_src'
=>
"'self'"
,
'media_src'
=>
"'self'
data:
"
,
'script_src'
=>
ContentSecurityPolicy
::
Directives
.
script_src
,
'style_src'
=>
"'self' 'unsafe-inline'"
,
'worker_src'
=>
"
#{
Gitlab
::
Utils
.
append_path
(
Gitlab
.
config
.
gitlab
.
url
,
'assets/'
)
}
blob: data:"
,
...
...
locale/gitlab.pot
View file @
7da260ce
...
...
@@ -4178,9 +4178,6 @@ msgstr ""
msgid "An error occurred while uploading the file. Please try again."
msgstr ""
msgid "An error occurred while uploading the image. Please try again."
msgstr ""
msgid "An error occurred while validating group path"
msgstr ""
...
...
spec/frontend/content_editor/components/wrappers/
image
_spec.js
→
spec/frontend/content_editor/components/wrappers/
media
_spec.js
View file @
7da260ce
import
{
GlLoadingIcon
}
from
'
@gitlab/ui
'
;
import
{
NodeViewWrapper
}
from
'
@tiptap/vue-2
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
ImageWrapper
from
'
~/content_editor/components/wrappers/image
.vue
'
;
import
MediaWrapper
from
'
~/content_editor/components/wrappers/media
.vue
'
;
describe
(
'
content/components/wrappers/
image
'
,
()
=>
{
describe
(
'
content/components/wrappers/
media
'
,
()
=>
{
let
wrapper
;
const
createWrapper
=
async
(
nodeAttrs
=
{})
=>
{
wrapper
=
shallowMountExtended
(
Image
Wrapper
,
{
wrapper
=
shallowMountExtended
(
Media
Wrapper
,
{
propsData
:
{
node
:
{
attrs
:
nodeAttrs
,
type
:
{
name
:
'
image
'
,
},
},
},
});
};
const
find
Image
=
()
=>
wrapper
.
findByTestId
(
'
image
'
);
const
find
Media
=
()
=>
wrapper
.
findByTestId
(
'
media
'
);
const
findLoadingIcon
=
()
=>
wrapper
.
findComponent
(
GlLoadingIcon
);
afterEach
(()
=>
{
...
...
@@ -33,7 +36,7 @@ describe('content/components/wrappers/image', () => {
createWrapper
({
src
});
expect
(
find
Image
().
attributes
().
src
).
toBe
(
src
);
expect
(
find
Media
().
attributes
().
src
).
toBe
(
src
);
});
describe
(
'
when uploading
'
,
()
=>
{
...
...
@@ -45,8 +48,8 @@ describe('content/components/wrappers/image', () => {
expect
(
findLoadingIcon
().
exists
()).
toBe
(
true
);
});
it
(
'
adds gl-opacity-5 class selector to
image
'
,
()
=>
{
expect
(
find
Image
().
classes
()).
toContain
(
'
gl-opacity-5
'
);
it
(
'
adds gl-opacity-5 class selector to
the media tag
'
,
()
=>
{
expect
(
find
Media
().
classes
()).
toContain
(
'
gl-opacity-5
'
);
});
});
...
...
@@ -59,8 +62,8 @@ describe('content/components/wrappers/image', () => {
expect
(
findLoadingIcon
().
exists
()).
toBe
(
false
);
});
it
(
'
does not add gl-opacity-5 class selector to
image
'
,
()
=>
{
expect
(
find
Image
().
classes
()).
not
.
toContain
(
'
gl-opacity-5
'
);
it
(
'
does not add gl-opacity-5 class selector to
the media tag
'
,
()
=>
{
expect
(
find
Media
().
classes
()).
not
.
toContain
(
'
gl-opacity-5
'
);
});
});
});
spec/frontend/content_editor/extensions/attachment_spec.js
View file @
7da260ce
import
axios
from
'
axios
'
;
import
MockAdapter
from
'
axios-mock-adapter
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
Attachment
from
'
~/content_editor/extensions/attachment
'
;
import
Image
from
'
~/content_editor/extensions/image
'
;
import
Audio
from
'
~/content_editor/extensions/audio
'
;
import
Video
from
'
~/content_editor/extensions/video
'
;
import
Link
from
'
~/content_editor/extensions/link
'
;
import
Loading
from
'
~/content_editor/extensions/loading
'
;
import
{
VARIANT_DANGER
}
from
'
~/flash
'
;
...
...
@@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="au
<img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">
</a>
</p>`
;
const
PROJECT_WIKI_ATTACHMENT_VIDEO_HTML
=
`<p data-sourcepos="1:1-1:132" dir="auto">
<span class="media-container video-container">
<video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4">
</video>
<a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a>
</span>
</p>`
;
const
PROJECT_WIKI_ATTACHMENT_AUDIO_HTML
=
`<p data-sourcepos="3:1-3:74" dir="auto">
<span class="media-container audio-container">
<audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3">
</audio>
<a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a>
</span>
</p>`
;
const
PROJECT_WIKI_ATTACHMENT_LINK_HTML
=
`<p data-sourcepos="1:1-1:26" dir="auto">
<a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
</p>`
;
...
...
@@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => {
let
doc
;
let
p
;
let
image
;
let
audio
;
let
video
;
let
loading
;
let
link
;
let
renderMarkdown
;
...
...
@@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => {
const
uploadsPath
=
'
/uploads/
'
;
const
imageFile
=
new
File
([
'
foo
'
],
'
test-file.png
'
,
{
type
:
'
image/png
'
});
const
audioFile
=
new
File
([
'
foo
'
],
'
test-file.mp3
'
,
{
type
:
'
audio/mpeg
'
});
const
videoFile
=
new
File
([
'
foo
'
],
'
test-file.mp4
'
,
{
type
:
'
video/mp4
'
});
const
attachmentFile
=
new
File
([
'
foo
'
],
'
test-file.zip
'
,
{
type
:
'
application/zip
'
});
const
expectDocumentAfterTransaction
=
({
number
,
expectedDoc
,
action
})
=>
{
return
new
Promise
((
resolve
)
=>
{
let
counter
=
1
;
const
handleTransaction
=
()
=>
{
const
handleTransaction
=
async
()
=>
{
if
(
counter
===
number
)
{
expect
(
tiptapEditor
.
state
.
doc
.
toJSON
()).
toEqual
(
expectedDoc
.
toJSON
());
tiptapEditor
.
off
(
'
update
'
,
handleTransaction
);
await
waitForPromises
();
resolve
();
}
...
...
@@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => {
Loading
,
Link
,
Image
,
Audio
,
Video
,
Attachment
.
configure
({
renderMarkdown
,
uploadsPath
,
eventHub
}),
],
});
({
builders
:
{
doc
,
p
,
image
,
loading
,
link
},
builders
:
{
doc
,
p
,
image
,
audio
,
video
,
loading
,
link
},
}
=
createDocBuilder
({
tiptapEditor
,
names
:
{
loading
:
{
markType
:
Loading
.
name
},
image
:
{
nodeType
:
Image
.
name
},
link
:
{
nodeType
:
Link
.
name
},
audio
:
{
nodeType
:
Audio
.
name
},
video
:
{
nodeType
:
Video
.
name
},
},
}));
...
...
@@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => {
tiptapEditor
.
commands
.
setContent
(
initialDoc
.
toJSON
());
});
describe
(
'
when the file has image mime type
'
,
()
=>
{
const
base64EncodedFile
=
'
data:image/png;base64,Zm9v
'
;
describe
.
each
`
nodeType | mimeType | html | file | mediaType
${
'
image
'
}
|
${
'
image/png
'
}
|
${
PROJECT_WIKI_ATTACHMENT_IMAGE_HTML
}
|
${
imageFile
}
|
${(
attrs
)
=>
image
(
attrs
)}
${
'
audio
'
}
|
${
'
audio/mpeg
'
}
|
${
PROJECT_WIKI_ATTACHMENT_AUDIO_HTML
}
|
${
audioFile
}
|
${(
attrs
)
=>
audio
(
attrs
)}
${
'
video
'
}
|
${
'
video/mp4
'
}
|
${
PROJECT_WIKI_ATTACHMENT_VIDEO_HTML
}
|
${
videoFile
}
|
${(
attrs
)
=>
video
(
attrs
)}
`
(
'
when the file has $nodeType mime type
'
,
({
mimeType
,
html
,
file
,
mediaType
})
=>
{
const
base64EncodedFile
=
`data:
${
mimeType
}
;base64,Zm9v`
;
beforeEach
(()
=>
{
renderMarkdown
.
mockResolvedValue
(
PROJECT_WIKI_ATTACHMENT_IMAGE_HTML
);
renderMarkdown
.
mockResolvedValue
(
html
);
});
describe
(
'
when uploading succeeds
'
,
()
=>
{
const
successResponse
=
{
link
:
{
markdown
:
'
![test-file](test-file.png)
'
,
markdown
:
`![test-file](
${
file
.
name
}
)`
,
},
};
...
...
@@ -121,21 +155,21 @@ describe('content_editor/extensions/attachment', () => {
mock
.
onPost
().
reply
(
httpStatus
.
OK
,
successResponse
);
});
it
(
'
inserts a
n image with src set to the encoded image file
and uploading true
'
,
async
()
=>
{
const
expectedDoc
=
doc
(
p
(
imag
e
({
uploading
:
true
,
src
:
base64EncodedFile
})));
it
(
'
inserts a
media content with src set to the encoded content
and uploading true
'
,
async
()
=>
{
const
expectedDoc
=
doc
(
p
(
mediaTyp
e
({
uploading
:
true
,
src
:
base64EncodedFile
})));
await
expectDocumentAfterTransaction
({
number
:
1
,
expectedDoc
,
action
:
()
=>
tiptapEditor
.
commands
.
uploadAttachment
({
file
:
imageFile
}),
action
:
()
=>
tiptapEditor
.
commands
.
uploadAttachment
({
file
}),
});
});
it
(
'
updates the inserted
image
with canonicalSrc when upload is successful
'
,
async
()
=>
{
it
(
'
updates the inserted
content
with canonicalSrc when upload is successful
'
,
async
()
=>
{
const
expectedDoc
=
doc
(
p
(
imag
e
({
canonicalSrc
:
'
test-file.png
'
,
mediaTyp
e
({
canonicalSrc
:
file
.
name
,
src
:
base64EncodedFile
,
alt
:
'
test-file
'
,
uploading
:
false
,
...
...
@@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => {
await
expectDocumentAfterTransaction
({
number
:
2
,
expectedDoc
,
action
:
()
=>
tiptapEditor
.
commands
.
uploadAttachment
({
file
:
imageFile
}),
action
:
()
=>
tiptapEditor
.
commands
.
uploadAttachment
({
file
}),
});
});
});
...
...
@@ -162,16 +196,16 @@ describe('content_editor/extensions/attachment', () => {
await
expectDocumentAfterTransaction
({
number
:
2
,
expectedDoc
,
action
:
()
=>
tiptapEditor
.
commands
.
uploadAttachment
({
file
:
imageFile
}),
action
:
()
=>
tiptapEditor
.
commands
.
uploadAttachment
({
file
}),
});
});
it
(
'
emits an alert event that includes an error message
'
,
(
done
)
=>
{
tiptapEditor
.
commands
.
uploadAttachment
({
file
:
imageFile
});
tiptapEditor
.
commands
.
uploadAttachment
({
file
});
eventHub
.
$on
(
'
alert
'
,
({
message
,
variant
})
=>
{
expect
(
variant
).
toBe
(
VARIANT_DANGER
);
expect
(
message
).
toBe
(
'
An error occurred while uploading the
imag
e. Please try again.
'
);
expect
(
message
).
toBe
(
'
An error occurred while uploading the
fil
e. Please try again.
'
);
done
();
});
});
...
...
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