pinafore/routes/_components/compose/ComposeBox.html
Nolan Lawson 2c6576e09d
Fix compose button covering content (#95)
Fixes #43 for browsers that support fit-content
2018-04-12 20:23:38 -07:00

254 lines
8.8 KiB
HTML

{{#if realm === 'home'}}
<h1 class="sr-only">Compose toot</h1>
{{/if}}
<div class="{{computedClassName}} {{hideAndFadeIn}}">
<ComposeAuthor />
{{#if contentWarningShown}}
<div class="compose-content-warning-wrapper"
transition:slide="{duration: 333}">
<ComposeContentWarning :realm :contentWarning />
</div>
{{/if}}
<ComposeInput :realm :text :autoFocus on:postAction="doPostStatus()" />
<ComposeLengthGauge :length :overLimit />
<ComposeToolbar :realm :postPrivacy :media :contentWarningShown :text />
<ComposeLengthIndicator :length :overLimit />
<ComposeMedia :realm :media :mediaDescriptions />
</div>
<div class="compose-box-button-sentinel {{hideAndFadeIn}}" ref:sentinel></div>
<div class="compose-box-button-wrapper {{realm === 'home' ? 'compose-button-sticky' : ''}} {{hideAndFadeIn}}" >
<ComposeButton :length :overLimit :sticky on:click="onClickPostButton()" />
</div>
{{#if !hideBottomBorder}}
<div class="compose-box-border-bottom {{hideAndFadeIn}}"></div>
{{/if}}
<style>
.compose-box {
border-radius: 4px;
padding: 20px 20px 0 20px;
display: grid;
align-items: flex-start;
grid-template-areas:
"avatar name handle handle"
"avatar cw cw cw"
"avatar input input input"
"avatar gauge gauge gauge"
"avatar toolbar toolbar length"
"avatar media media media";
grid-template-columns: min-content minmax(0, max-content) 1fr 1fr;
width: 560px;
max-width: calc(100vw - 40px);
}
.compose-box.direct-reply {
background-color: var(--status-direct-background);
}
.compose-box.slim-size {
width: 540px;
max-width: calc(100vw - 60px);
}
.compose-box-fade-in {
transition: opacity 0.2s linear; /* main page reveal */
}
.compose-box-border-bottom {
height: 1px;
background: var(--main-border);
width: 100%;
}
.compose-box-button-wrapper {
/*
* We want pointer-events only for the sticky button, so use fit-content so that
* the element doesn't take up the full width, and then set its left margin to
* auto so that it sticks to the right. fit-content doesn't work in Edge, but
* that just means that content that is level with the button is not clickable.
*
* Before this, I tried a wrapper element with pointer-events:none and then
* pointer-events:auto on the button itself, but in Chrome that made it so
* that occasionally hit detection failed and a click on the button would fall
* through and click something underneath, which is a worse bug.
*/
width: -moz-fit-content;
width: fit-content;
margin-left: auto;
display: flex;
justify-content: flex-end;
}
.compose-box-button-wrapper.compose-button-sticky {
position: -webkit-sticky;
position: sticky;
top: 10px;
z-index: 5000;
}
.compose-content-warning-wrapper {
grid-area: cw;
}
@media (max-width: 767px) {
.compose-box {
padding: 10px 10px 0 10px;
max-width: calc(100vw - 20px);
width: 580px;
}
.compose-box.slim-size {
width: 560px;
max-width: calc(100vw - 40px);
}
.compose-box-button-wrapper.compose-button-sticky {
top: 5px;
}
}
</style>
<script>
import ComposeToolbar from './ComposeToolbar.html'
import ComposeLengthGauge from './ComposeLengthGauge.html'
import ComposeLengthIndicator from './ComposeLengthIndicator.html'
import ComposeAuthor from './ComposeAuthor.html'
import ComposeInput from './ComposeInput.html'
import ComposeButton from './ComposeButton.html'
import ComposeMedia from './ComposeMedia.html'
import ComposeContentWarning from './ComposeContentWarning.html'
import { measureText } from '../../_utils/measureText'
import { CHAR_LIMIT, POST_PRIVACY_OPTIONS } from '../../_static/statuses'
import { store } from '../../_store/store'
import { slide } from 'svelte-transitions'
import { postStatus, insertHandleForReply, setReplySpoiler, setReplyVisibility } from '../../_actions/compose'
import { importDialogs } from '../../_utils/asyncModules'
import { classname } from '../../_utils/classname'
export default {
oncreate() {
let realm = this.get('realm')
if (realm === 'home') {
this.setupStickyObserver()
} else if (realm !== 'dialog') {
// if this is a reply, populate the handle immediately
insertHandleForReply(realm)
}
let replySpoiler = this.get('replySpoiler')
if (replySpoiler) {
// default spoiler is same as the replied-to status
setReplySpoiler(realm, replySpoiler)
}
let replyVisibility = this.get('replyVisibility')
if (replyVisibility) {
// make sure the visibility is consistent with the replied-to status
setReplyVisibility(realm, replyVisibility)
}
},
ondestroy() {
this.teardownStickyObserver()
},
components: {
ComposeAuthor,
ComposeToolbar,
ComposeLengthGauge,
ComposeLengthIndicator,
ComposeInput,
ComposeButton,
ComposeMedia,
ComposeContentWarning
},
store: () => store,
computed: {
computedClassName: (overLimit, realm, size, postPrivacyKey, isReply) => {
return classname(
'compose-box',
overLimit && 'over-char-limit',
size === 'slim' && 'slim-size',
isReply && postPrivacyKey === 'direct' && 'direct-reply'
)
},
hideAndFadeIn: (hidden) => {
return classname(
'compose-box-fade-in',
hidden && 'hidden'
)
},
composeData: ($currentComposeData, realm) => $currentComposeData[realm] || {},
text: (composeData) => composeData.text || '',
media: (composeData) => composeData.media || [],
postPrivacy: (postPrivacyKey) => POST_PRIVACY_OPTIONS.find(_ => _.key === postPrivacyKey),
defaultPostPrivacyKey: ($currentVerifyCredentials) => $currentVerifyCredentials.source.privacy,
postPrivacyKey: (composeData, defaultPostPrivacyKey) => composeData.postPrivacy || defaultPostPrivacyKey,
textLength: (text) => measureText(text),
contentWarningLength: (contentWarning) => measureText(contentWarning),
length: (textLength, contentWarningLength, contentWarningShown) => {
return textLength + (contentWarningShown ? contentWarningLength : 0)
},
overLimit: (length) => length > CHAR_LIMIT,
contentWarningShown: (composeData) => composeData.contentWarningShown,
contentWarning: (composeData) => composeData.contentWarning || '',
timelineInitialized: ($timelineInitialized) => $timelineInitialized,
mediaDescriptions: (composeData) => composeData.mediaDescriptions || []
},
transitions: {
slide
},
methods: {
async onClickPostButton() {
if (this.get('sticky')) {
// when the button is sticky, we're scrolled down the home timeline,
// so we should launch a new compose dialog
let dialogs = await importDialogs()
dialogs.showComposeDialog()
} else {
// else we're actually posting a new toot
this.doPostStatus();
}
},
doPostStatus() {
let text = this.get('text')
let media = this.get('media')
let postPrivacyKey = this.get('postPrivacyKey')
let contentWarning = this.get('contentWarning')
let sensitive = media.length && !!contentWarning
let realm = this.get('realm')
let mediaIds = media.map(_ => _.data.id)
let inReplyTo = (realm === 'home' || realm === 'dialog') ? null : realm
let overLimit = this.get('overLimit')
let mediaDescriptions = this.get('mediaDescriptions')
if (!text || overLimit) {
return // do nothing if invalid
}
/* no await */
postStatus(realm, text, inReplyTo, mediaIds,
sensitive, contentWarning, postPrivacyKey, mediaDescriptions)
},
setupStickyObserver() {
this.__stickyObserver = new IntersectionObserver(entries => {
this.set({sticky: !entries[0].isIntersecting})
})
this.__stickyObserver.observe(this.refs.sentinel)
// also create a one-shot observer for the $timelineInitialized event,
// due to a bug in Firefox where when the scrollTop is set
// manually, the other observer doesn't necessarily fire
this.observe('timelineInitialized', timelineInitialized => {
if (timelineInitialized) {
let observer = new IntersectionObserver(entries => {
this.set({sticky: !entries[0].isIntersecting})
observer.disconnect()
})
observer.observe(this.refs.sentinel)
}
}, {init: false})
},
teardownStickyObserver() {
if (this.__stickyObserver) {
this.__stickyObserver.disconnect()
}
}
}
}
</script>