feat: add carousel for media modal (#928)

This commit is contained in:
Nolan Lawson 2019-02-02 23:03:40 -08:00 committed by GitHub
parent 2ef4743b3c
commit 9d594f0bac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 334 additions and 199 deletions

View file

@ -24,7 +24,7 @@ const builders = [
rebuild: buildInlineScript
},
{
watch: 'src/bin/svgs.js',
watch: 'bin/svgs.js',
comment: '<!-- inline SVG -->',
rebuild: buildSvg
}

View file

@ -36,5 +36,10 @@ module.exports = [
{ id: 'fa-times', src: 'src/thirdparty/font-awesome-svg-png/white/svg/times.svg' },
{ id: 'fa-volume-off', src: 'src/thirdparty/font-awesome-svg-png/white/svg/volume-off.svg' },
{ id: 'fa-volume-up', src: 'src/thirdparty/font-awesome-svg-png/white/svg/volume-up.svg' },
{ id: 'fa-link', src: 'src/thirdparty/font-awesome-svg-png/white/svg/link.svg' }
{ id: 'fa-link', src: 'src/thirdparty/font-awesome-svg-png/white/svg/link.svg' },
{ id: 'fa-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/circle.svg' },
{ id: 'fa-circle-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/circle-o.svg' },
{ id: 'fa-angle-left', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-left.svg' },
{ id: 'fa-angle-right', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-right.svg' }
]

View file

@ -99,6 +99,22 @@
fill: var(--action-button-deemphasized-fill-color-pressed-active);
}
/*
* disable the separate press color (noPressColor)
*/
.icon-button.pressed.no-press-color .icon-button-svg {
fill: var(--action-button-fill-color);
}
.icon-button.pressed.no-press-color:hover .icon-button-svg {
fill: var(--action-button-fill-color-hover);
}
.icon-button.pressed.no-press-color:active .icon-button-svg {
fill: var(--action-button-fill-color-active);
}
</style>
<script>
import { classname } from '../_utils/classname'
@ -114,17 +130,19 @@
pressable: false,
pressed: false,
className: void 0,
delegateKey: void 0
delegateKey: void 0,
noPressColor: false
}),
store: () => store,
computed: {
computedClass: ({ pressable, pressed, big, muted, className }) => {
computedClass: ({ pressable, pressed, big, muted, noPressColor, className }) => {
return classname(
'icon-button',
!pressable && 'not-pressable',
pressed && 'pressed',
big && 'big-icon',
muted && 'muted-style',
noPressColor && 'no-press-color',
className
)
}

View file

@ -1,39 +1,37 @@
const getDefault = mod => mod.default
export const importShowAccountProfileOptionsDialog = () => import(
/* webpackChunkName: 'showAccountProfileOptionsDialog' */ './creators/showAccountProfileOptionsDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowComposeDialog = () => import(
/* webpackChunkName: 'showComposeDialog' */ './creators/showComposeDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowConfirmationDialog = () => import(
/* webpackChunkName: 'showConfirmationDialog' */ './creators/showConfirmationDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowEmojiDialog = () => import(
/* webpackChunkName: 'showEmojiDialog' */ './creators/showEmojiDialog'
).then(mod => mod.default)
export const importShowImageDialog = () => import(
/* webpackChunkName: 'showImageDialog' */ './creators/showImageDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowPostPrivacyDialog = () => import(
/* webpackChunkName: 'showPostPrivacyDialog' */ './creators/showPostPrivacyDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowStatusOptionsDialog = () => import(
/* webpackChunkName: 'showStatusOptionsDialog' */ './creators/showStatusOptionsDialog'
).then(mod => mod.default)
export const importShowVideoDialog = () => import(
/* webpackChunkName: 'showVideoDialog' */ './creators/showVideoDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowCopyDialog = () => import(
/* webpackChunkName: 'showCopyDialog' */ './creators/showCopyDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowShortcutHelpDialog = () => import(
/* webpackChunkName: 'showShortcutHelpDialog' */ './creators/showShortcutHelpDialog'
).then(mod => mod.default)
).then(getDefault)
export const importShowMediaDialog = () => import(
/* webpackChunkName: 'showMediaDialog' */ './creators/showMediaDialog'
).then(getDefault)

View file

@ -1,66 +0,0 @@
<ModalDialog
{id}
{label}
background="var(--muted-modal-bg)"
muted="true"
className="image-modal-dialog"
>
{#if type === 'gifv'}
<video
class="image-modal-dialog-autoplay-video"
aria-label="Animated GIF: {description || ''}"
style="{videoStyle}"
{src}
autoplay
muted
loop
webkit-playsinline
playsinline
/>
{:else}
<img
{src}
{style}
alt={description || ''}
title={description || ''}
/>
{/if}
</ModalDialog>
<style>
:global(.image-modal-dialog img, .image-modal-dialog video) {
object-fit: contain;
max-width: calc(100vw - 20px);
max-height: calc(100% - 20px);
overflow: hidden;
}
.image-modal-dialog-autoplay-video {
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import { show } from '../helpers/showDialog'
import { oncreate } from '../helpers/onCreateDialog'
export default {
oncreate,
components: {
ModalDialog
},
computed: {
style: ({ width, height }) => `
width: ${width ? width + 'px' : 'auto'};
height: ${height ? height + 'px' : 'auto'};`,
videoStyle: ({ style, poster }) => `
${style}
background-image: url(${poster});`
},
methods: {
show
}
}
</script>

View file

@ -0,0 +1,214 @@
<ModalDialog
{id}
{label}
background="var(--muted-modal-bg)"
muted="true"
className="media-modal-dialog"
>
<div class="media-container">
<div class="media-scroll" ref:scroller>
{#each mediaItems as media}
<div class="media-scroll-item">
<div class="media-scroll-item-inner">
<div class="media-scroll-item-inner-inner">
<MediaInDialog {media} />
</div>
</div>
</div>
{/each}
</div>
{#if dots.length > 1}
<div class="media-controls">
<IconButton
className="media-control-button"
disabled={scrolledItem === 0}
label="Show previous media"
href="#fa-angle-left"
on:click="onClick(scrolledItem - 1)"
/>
{#each dots as dot, i (dot.i)}
<IconButton
className="media-control-button"
pressable={true}
label="Show {nth(i)} media"
pressed={i === scrolledItem}
href={i === scrolledItem ? '#fa-circle' : '#fa-circle-o'}
noPressColor={true}
on:click="onClick(i)"
/>
{/each}
<IconButton
className="media-control-button"
disabled={scrolledItem === length - 1}
label="Show next media"
href="#fa-angle-right"
on:click="onClick(scrolledItem + 1)"
/>
</div>
{/if}
</div>
</ModalDialog>
<style>
:global(.media-modal-dialog) {
max-width: calc(100vw);
}
.media-container {
height: calc(100% - 64px); /* 44px X button height + 20px padding */
width: calc(100vw);
padding-top: 10px;
display: flex;
flex-direction: column;
}
.media-scroll {
-webkit-overflow-scrolling: touch;
display: flex;
align-items: center;
overflow-x: auto;
width: 100%;
flex: 1;
scrollbar-width: none;
}
.media-scroll::-webkit-scrollbar {
display: none;
}
.media-scroll-item {
height: 100%;
}
.media-scroll-item-inner {
width: 100vw;
height: 100%;
overflow: hidden;
}
.media-scroll-item-inner-inner {
height: calc(100% - 10px);
width: calc(100% - 10px);
padding: 5px;
}
.media-controls {
display: flex;
justify-content: space-between;
margin: 10px auto;
}
:global(.media-control-button) {
margin: 0 5px;
}
@supports (scroll-snap-align: start) {
/* modern scroll snap points */
.media-scroll {
scroll-snap-type: x mandatory;
}
.media-scroll-item {
scroll-snap-align: center;
}
}
@supports not (scroll-snap-align: start) {
/* old scroll snap points spec */
.media-scroll {
-webkit-scroll-snap-type: mandatory;
scroll-snap-type: mandatory;
-webkit-scroll-snap-destination: 0% center;
scroll-snap-destination: 0% center;
-webkit-scroll-snap-points-x: repeat(100%);
scroll-snap-points-x: repeat(100%);
}
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import MediaInDialog from './MediaInDialog.html'
import IconButton from '../../IconButton.html'
import { show } from '../helpers/showDialog'
import { oncreate as onCreateDialog } from '../helpers/onCreateDialog'
import debounce from 'lodash-es/debounce'
import times from 'lodash-es/times'
import { smoothScroll } from '../../../_utils/smoothScroll'
import { doubleRAF } from '../../../_utils/doubleRAF'
import { store } from '../../../_store/store'
export default {
oncreate () {
onCreateDialog.call(this)
this.onScroll = debounce(this.onScroll.bind(this), 100, { leading: false, trailing: true })
let { scrolledItem } = this.get()
if (scrolledItem) {
doubleRAF(() => {
this.scrollToItem(scrolledItem, false)
this.setupScroll()
})
} else {
this.setupScroll()
}
},
ondestroy () {
this.teardownScroll()
},
store: () => store,
computed: {
length: ({ mediaItems }) => mediaItems.length,
originalWidths: ({ mediaItems }) => mediaItems.map(_ => _.meta.original.width),
maxWidth: ({ originalWidths }) => Math.max.apply(Math, originalWidths),
dots: ({ length }) => times(length, i => ({ i }))
},
components: {
ModalDialog,
MediaInDialog,
IconButton
},
helpers: {
nth (i) {
switch (i) {
case 0:
return 'first'
case 1:
return 'second'
case 2:
return 'third'
case 3:
return 'fourth'
}
}
},
methods: {
show,
setupScroll () {
this.refs.scroller.addEventListener('scroll', this.onScroll)
},
teardownScroll () {
this.refs.scroller.removeEventListener('scroll', this.onScroll)
},
onScroll () {
let { length } = this.get()
let { scrollWidth, scrollLeft } = this.refs.scroller
let scrolledItem = Math.floor((scrollLeft / scrollWidth) * length)
this.set({ scrolledItem })
},
onClick (i) {
let { scrolledItem } = this.get()
if (scrolledItem !== i) {
this.scrollToItem(i, true)
}
},
scrollToItem (i, smooth) {
let { length } = this.get()
let { scroller } = this.refs
let { scrollWidth } = scroller
let scrollLeft = Math.floor(scrollWidth * (i / length))
if (smooth) {
smoothScroll(scroller, scrollLeft, true)
} else {
scroller.scrollLeft = scrollLeft
}
}
}
}
</script>

View file

@ -0,0 +1,52 @@
{#if type === 'video'}
<video
class="media-fit"
aria-label={description}
src={url}
{poster}
controls
ref:video
/>
{:elseif type === 'gifv'}
<video
class="media-fit"
style="background-image:url({static_url});"
aria-label={description}
src={url}
autoplay
muted
loop
webkit-playsinline
playsinline
/>
{:else}
<img
class="media-fit"
alt={description}
title={description}
src={url}
/>
{/if}
<style>
.media-fit {
object-fit: contain;
width: 100%;
height: 100%;
}
</style>
<script>
export default {
computed: {
type: ({ media }) => media.type,
url: ({ media }) => media.url,
description: ({ media }) => media.description || '',
poster: ({ media }) => media.poster,
static_url: ({ media }) => media.static_url
},
ondestroy () {
if (this.refs.video && !this.refs.video.paused) {
this.refs.video.pause()
}
}
}
</script>

View file

@ -1,49 +0,0 @@
<ModalDialog
{id}
{label}
background="var(--muted-modal-bg)"
muted="true"
className="video-modal-dialog"
on:close="onClose()"
>
<video {poster}
{src}
{style}
aria-label="Video: {description || ''}"
controls
ref:video
/>
</ModalDialog>
<style>
:global(.video-modal-dialog video) {
object-fit: contain;
max-width: calc(100vw - 20px);
max-height: calc(100% - 20px);
overflow: hidden;
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import { show } from '../helpers/showDialog'
import { oncreate } from '../helpers/onCreateDialog'
export default {
oncreate,
components: {
ModalDialog
},
computed: {
style: ({ width, height }) => `
width: ${width ? width + 'px' : 'auto'};
height: ${height ? height + 'px' : 'auto'};`
},
methods: {
show,
onClose () {
if (this.refs.video && !this.refs.video.paused) {
this.refs.video.pause()
}
}
}
}
</script>

View file

@ -1,20 +0,0 @@
import ImageDialog from '../components/ImageDialog.html'
import { createDialogElement } from '../helpers/createDialogElement'
import { createDialogId } from '../helpers/createDialogId'
export default function showImageDialog (poster, src, type, width, height, description) {
let imageDialog = new ImageDialog({
target: createDialogElement(),
data: {
id: createDialogId(),
label: 'Image dialog',
poster,
src,
type,
width,
height,
description
}
})
imageDialog.show()
}

View file

@ -0,0 +1,16 @@
import MediaDialog from '../components/MediaDialog.html'
import { createDialogElement } from '../helpers/createDialogElement'
import { createDialogId } from '../helpers/createDialogId'
export default function showMediaDialog (mediaItems, scrolledItem) {
let dialog = new MediaDialog({
target: createDialogElement(),
data: {
id: createDialogId(),
label: 'Media dialog',
mediaItems,
scrolledItem
}
})
dialog.show()
}

View file

@ -1,19 +0,0 @@
import VideoDialog from '../components/VideoDialog.html'
import { createDialogElement } from '../helpers/createDialogElement'
import { createDialogId } from '../helpers/createDialogId'
export default function showVideoDialog (poster, src, width, height, description) {
let videoDialog = new VideoDialog({
target: createDialogElement(),
data: {
id: createDialogId(),
label: 'Video dialog',
poster,
src,
width,
height,
description
}
})
videoDialog.show()
}

View file

@ -79,7 +79,7 @@
</style>
<script>
import { DEFAULT_MEDIA_WIDTH, DEFAULT_MEDIA_HEIGHT, ONE_TRANSPARENT_PIXEL } from '../../_static/media'
import { importShowVideoDialog, importShowImageDialog } from '../dialog/asyncDialogs'
import { importShowMediaDialog } from '../dialog/asyncDialogs'
import { mouseover } from '../../_utils/events'
import NonAutoplayGifv from '../NonAutoplayGifv.html'
import PlayVideoIcon from '../PlayVideoIcon.html'
@ -91,14 +91,7 @@
export default {
oncreate () {
let { delegateKey } = this.get()
registerClickDelegate(this, delegateKey, () => {
let { type } = this.get()
if (type === 'video') {
this.onClickPlayVideoButton()
} else {
this.onClickShowImageButton()
}
})
registerClickDelegate(this, delegateKey, () => this.onClick())
},
computed: {
focus: ({ meta }) => meta && meta.focus,
@ -133,17 +126,10 @@
type: ({ media }) => media.type
},
methods: {
async onClickPlayVideoButton () {
let { previewUrl, url, modalWidth, modalHeight, description } = this.get()
let showVideoDialog = await importShowVideoDialog()
showVideoDialog(previewUrl, url,
modalWidth, modalHeight, description)
},
async onClickShowImageButton () {
let { previewUrl, url, modalWidth, modalHeight, description, type } = this.get()
let showImageDialog = await importShowImageDialog()
showImageDialog(previewUrl, url, type,
modalWidth, modalHeight, description)
async onClick () {
let { mediaAttachments, index } = this.get()
let showMediaDialog = await importShowMediaDialog()
showMediaDialog(mediaAttachments, index)
}
},
data: () => ({

View file

@ -1,7 +1,7 @@
<div class={computedClass}
style="grid-template-columns: repeat({nCols}, 1fr);" >
{#each mediaAttachments as media}
<Media {media} {uuid} />
{#each mediaAttachments as media, index}
<Media {media} {uuid} {mediaAttachments} {index} />
{/each}
</div>
<style>

View file

@ -61,13 +61,13 @@ function testSupportsSmoothScroll () {
const smoothScrollSupported = process.browser && testSupportsSmoothScroll()
export function smoothScroll (node, top) {
export function smoothScroll (node, topOrLeft, horizontal) {
if (smoothScrollSupported) {
return node.scrollTo({
top: top,
[horizontal ? 'left' : 'top']: topOrLeft,
behavior: 'smooth'
})
} else {
return smoothScrollPolyfill(node, 'scrollTop', top)
return smoothScrollPolyfill(node, horizontal ? 'scrollLeft' : 'scrollTop', topOrLeft)
}
}