feat: add ability to set focal points for media (#1303)

* feat: add ability to set focal points for media

fixes #739

* fix tests

* actually fix tests

* really really fix tests

* really really really fix tests pinkie swear
This commit is contained in:
Nolan Lawson 2019-07-07 00:14:19 -07:00 committed by GitHub
parent 994dda4806
commit 85b75900c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 763 additions and 101 deletions

View file

@ -52,5 +52,6 @@ module.exports = [
{ id: 'fa-clock', src: 'src/thirdparty/font-awesome-svg-png/white/svg/clock-o.svg' },
{ id: 'fa-refresh', src: 'src/thirdparty/font-awesome-svg-png/white/svg/refresh.svg' },
{ id: 'fa-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/plus.svg' },
{ id: 'fa-info-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/info-circle.svg' }
{ id: 'fa-info-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/info-circle.svg' },
{ id: 'fa-crosshairs', src: 'src/thirdparty/font-awesome-svg-png/white/svg/crosshairs.svg' }
]

View file

@ -46,6 +46,7 @@
"@babel/core": "^7.5.0",
"@gamestdio/websocket": "^0.3.2",
"@webcomponents/custom-elements": "^1.2.4",
"@wessberg/pointer-events": "^1.0.9",
"babel-loader": "^8.0.6",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"cheerio": "^1.0.0-rc.2",

View file

@ -4,7 +4,7 @@ import { postStatus as postStatusToServer } from '../_api/statuses'
import { addStatusOrNotification } from './addStatusOrNotification'
import { database } from '../_database/database'
import { emit } from '../_utils/eventBus'
import { putMediaDescription } from '../_api/media'
import { putMediaMetadata } from '../_api/media'
export async function insertHandleForReply (statusId) {
let { currentInstance } = store.get()
@ -22,7 +22,7 @@ export async function insertHandleForReply (statusId) {
export async function postStatus (realm, text, inReplyToId, mediaIds,
sensitive, spoilerText, visibility,
mediaDescriptions, inReplyToUuid, poll) {
mediaDescriptions, inReplyToUuid, poll, mediaFocalPoints) {
let { currentInstance, accessToken, online } = store.get()
if (!online) {
@ -31,17 +31,27 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
}
text = text || ''
mediaDescriptions = mediaDescriptions || []
store.set({
postingStatus: true
let mediaMetadata = (mediaIds || []).map((mediaId, idx) => {
return {
description: mediaDescriptions && mediaDescriptions[idx],
focalPoint: mediaFocalPoints && mediaFocalPoints[idx]
}
})
store.set({ postingStatus: true })
try {
await Promise.all(mediaDescriptions.map(async (description, i) => {
return description && putMediaDescription(currentInstance, accessToken, mediaIds[i], description)
await Promise.all(mediaMetadata.map(async ({ description, focalPoint }, i) => {
description = description || ''
focalPoint = focalPoint || [0, 0]
focalPoint[0] = focalPoint[0] || 0
focalPoint[1] = focalPoint[1] || 0
if (description || focalPoint[0] || focalPoint[1]) {
return putMediaMetadata(currentInstance, accessToken, mediaIds[i], description, focalPoint)
}
}))
let status = await postStatusToServer(currentInstance, accessToken, text,
inReplyToId, mediaIds, sensitive, spoilerText, visibility, poll)
inReplyToId, mediaIds, sensitive, spoilerText, visibility, poll, mediaFocalPoints)
addStatusOrNotification(currentInstance, 'home', status)
store.clearComposeData(realm)
emit('postedStatus', realm, inReplyToUuid)

View file

@ -11,7 +11,7 @@ export async function uploadMedia (instanceName, accessToken, file, description)
return post(url, formData, auth(accessToken), { timeout: MEDIA_WRITE_TIMEOUT })
}
export async function putMediaDescription (instanceName, accessToken, mediaId, description) {
export async function putMediaMetadata (instanceName, accessToken, mediaId, description, focus) {
let url = `${basename(instanceName)}/api/v1/media/${mediaId}`
return put(url, { description }, auth(accessToken), { timeout: WRITE_TIMEOUT })
return put(url, { description, focus: (focus && focus.join(',')) }, auth(accessToken), { timeout: WRITE_TIMEOUT })
}

View file

@ -0,0 +1,102 @@
<div class="draggable-area {draggableClass}"
on:pointermove="onPointerMove(event)"
on:pointerleave="onPointerLeave(event)"
on:click="onClick(event)"
ref:area
>
<div class="draggable-indicator {indicatorClass}"
style={indicatorStyle}
on:pointerdown="onPointerDown(event)"
on:pointerup="onPointerUp(event)"
ref:indicator
>
<div class="draggable-indicator-inner">
<slot></slot>
</div>
</div>
</div>
<style>
.draggable-area {
position: relative;
touch-action: none;
}
.draggable-indicator {
position: absolute;
cursor: pointer;
}
.draggable-indicator-inner {
pointer-events: none;
display: flex;
}
</style>
<script>
import { throttleRaf } from '../_utils/throttleRaf'
const clamp = x => Math.max(0, Math.min(1, x))
const throttledRaf = throttleRaf()
export default {
data: () => ({
draggableClass: '',
indicatorClass: '',
x: 0,
y: 0,
indicatorWidth: 0,
indicatorHeight: 0
}),
computed: {
indicatorStyle: ({ x, y, indicatorWidth, indicatorHeight }) => (
`left: calc(${x * 100}% - ${indicatorWidth / 2}px); top: calc(${y * 100}% - ${indicatorHeight / 2}px);`
)
},
methods: {
onPointerDown (e) {
e.preventDefault()
e.stopPropagation()
let rect = this.refs.indicator.getBoundingClientRect()
this.set({
dragging: true,
dragOffsetX: e.clientX - rect.left,
dragOffsetY: e.clientY - rect.top
})
},
onPointerMove (e) {
if (this.get().dragging) {
e.preventDefault()
e.stopPropagation()
let { indicatorWidth, indicatorHeight, dragOffsetX, dragOffsetY } = this.get()
throttledRaf(() => {
let rect = this.refs.area.getBoundingClientRect()
let offsetX = dragOffsetX - (indicatorWidth / 2)
let offsetY = dragOffsetY - (indicatorHeight / 2)
let x = clamp((e.clientX - rect.left - offsetX) / rect.width)
let y = clamp((e.clientY - rect.top - offsetY) / rect.height)
this.set({ x, y })
this.fire('change', { x, y })
})
}
},
onPointerUp (e) {
e.preventDefault()
e.stopPropagation()
this.set({ dragging: false })
},
onPointerLeave (e) {
e.preventDefault()
e.stopPropagation()
this.set({ dragging: false })
},
onClick (e) {
if (!e.target.classList.contains('draggable-indicator')) {
e.preventDefault()
e.stopPropagation()
let rect = this.refs.area.getBoundingClientRect()
let x = clamp((e.clientX - rect.left) / rect.width)
let y = clamp((e.clientY - rect.top) / rect.height)
this.set({ x, y })
this.fire('change', { x, y })
}
}
}
}
</script>

View file

@ -65,7 +65,9 @@
// Here we do a pure css version instead of using
// https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point
if (!focus) return 'background-position: center;'
if (!focus) {
return 'background-position: center;'
}
return `object-position: ${coordsToPercent(focus.x)}% ${100 - coordsToPercent(focus.y)}%;`
},
fillFixSize: ({ forceSize, $largeInlineMedia }) => !$largeInlineMedia && !forceSize,

View file

@ -193,6 +193,7 @@
let sensitive = media.length && !!contentWarning
let mediaIds = media.map(_ => _.data.id)
let mediaDescriptions = media.map(_ => _.description)
let mediaFocalPoints = media.map(_ => [_.focusX, _.focusY])
let inReplyTo = inReplyToId || ((realm === 'home' || realm === 'dialog') ? null : realm)
if (overLimit || (!text && !media.length)) {
@ -217,7 +218,8 @@
/* no await */
postStatus(realm, text, inReplyTo, mediaIds,
sensitive, contentWarning, postPrivacyKey,
mediaDescriptions, inReplyToUuid, pollToPost)
mediaDescriptions, inReplyToUuid, pollToPost,
mediaFocalPoints)
}
}
}

View file

@ -1,14 +1,24 @@
<li class="compose-media compose-media-realm-{realm}">
<li class="compose-media compose-media-realm-{realm}" aria-label={shortName}>
<img
alt=""
class="{type === 'audio' ? 'audio-preview' : ''}"
style="object-position: {objectPosition};"
src={previewSrc}
{alt}
aria-hidden="true"
/>
<div class="compose-media-delete">
<button class="compose-media-delete-button"
aria-label="Delete {shortName}"
<div class="compose-media-buttons">
<button class="compose-media-button compose-media-focal-button {type === 'audio' ? 'compose-media-hidden' : ''}"
aria-hidden={type === 'audio'}
aria-label="Change preview"
title="Change preview"
on:click="onSetFocalPoint()" >
<SvgIcon className="compose-media-button-svg" href="#fa-crosshairs" />
</button>
<button class="compose-media-button compose-media-delete-button"
aria-label="Delete"
title="Delete"
on:click="onDeleteMedia()" >
<SvgIcon className="compose-media-delete-button-svg" href="#fa-times" />
<SvgIcon className="compose-media-button-svg" href="#fa-times" />
</button>
</div>
<div class="compose-media-alt">
@ -18,7 +28,9 @@
ref:textarea
bind:value=rawText
></textarea>
<label for="compose-media-input-{uuid}" class="sr-only">{label}</label>
<label for="compose-media-input-{uuid}" class="sr-only">
Describe for the visually impaired (image, video) or auditorily impaired (audio, video)
</label>
</div>
</li>
<style>
@ -34,7 +46,6 @@
}
.compose-media img {
object-fit: contain;
object-position: center center;
flex: 1;
height: 100%;
width: 100%;
@ -60,33 +71,38 @@
.compose-media-alt-input:focus {
background: var(--main-bg);
}
.compose-media-delete {
.compose-media-buttons {
position: absolute;
z-index: 10;
top: 0;
right: 0;
left: 0;
display: flex;
justify-content: flex-end;
justify-content: space-between;
margin: 2px;
}
.compose-media-delete-button {
.compose-media-button {
padding: 7px 10px 5px;
background: var(--floating-button-bg);
border: 1px solid var(--button-border);
}
.compose-media-delete-button:hover {
.compose-media-button:hover {
background: var(--floating-button-bg-hover);
}
.compose-media-delete-button:active {
.compose-media-button:active {
background: var(--floating-button-bg-active);
}
:global(.compose-media-delete-button-svg) {
:global(.compose-media-button-svg) {
fill: var(--button-text);
width: 18px;
height: 18px;
}
.compose-media-hidden {
visibility: hidden;
pointer-events: none;
}
.audio-preview {
background: var(--audio-bg);
}
@ -113,6 +129,9 @@
import SvgIcon from '../SvgIcon.html'
import { autosize } from '../../_thirdparty/autosize/autosize'
import { ONE_TRANSPARENT_PIXEL } from '../../_static/media'
import { get } from '../../_utils/lodash-lite'
import { coordsToPercent } from '../../_utils/coordsToPercent'
import { importMediaFocalPointDialog } from '../dialog/asyncDialogs'
export default {
oncreate () {
@ -124,24 +143,27 @@
this.teardownAutosize()
},
data: () => ({
rawText: ''
rawText: '',
focusX: 0,
focusY: 0
}),
computed: {
filename: ({ mediaItem }) => mediaItem.file && mediaItem.file.name,
alt: ({ filename, mediaItem }) => (
type: ({ mediaItem }) => mediaItem.data.type,
shortName: ({ mediaItem }) => (
// sometimes we no longer have the file, e.g. in a delete and redraft situation,
// so fall back to the description if it was provided
filename || mediaItem.description || ''
get(mediaItem, ['file', 'name']) || get(mediaItem, ['description']) || 'media'
),
type: ({ mediaItem }) => mediaItem.data.type,
shortName: ({ filename }) => filename || 'media',
previewSrc: ({ mediaItem, type }) => (
type === 'audio' ? ONE_TRANSPARENT_PIXEL : mediaItem.data.preview_url
),
label: ({ shortName }) => (
`Describe ${shortName} for the visually impaired (image, video) or auditorily impaired (audio, video)`
),
uuid: ({ realm, mediaItem }) => `${realm}-${mediaItem.data.id}`
uuid: ({ realm, mediaItem }) => `${realm}-${mediaItem.data.id}`,
objectPosition: ({ focusX, focusY }) => {
if (!focusX && !focusY) {
return 'center center'
}
return `${coordsToPercent(focusX)}% ${100 - coordsToPercent(focusY)}%`
}
},
store: () => store,
methods: {
@ -150,10 +172,13 @@
this.observe('media', media => {
media = media || []
let { index, rawText } = this.get()
let text = (media[index] && media[index].description) || ''
let text = get(media, [index, 'description'], '')
if (rawText !== text) {
this.set({ rawText: text })
}
let focusX = get(media, [index, 'focusX'], 0)
let focusY = get(media, [index, 'focusY'], 0)
this.set({ focusX, focusY })
})
},
setupSyncToStore () {
@ -161,12 +186,11 @@
this.observe('rawText', rawText => {
let { realm, index, media } = this.get()
if (media[index].description === rawText) {
return
if (media[index].description !== rawText) {
media[index].description = rawText
this.store.setComposeData(realm, { media })
saveStore()
}
media[index].description = rawText
this.store.setComposeData(realm, { media })
saveStore()
}, { init: false })
},
setupAutosize () {
@ -176,11 +200,13 @@
autosize.destroy(this.refs.textarea)
},
onDeleteMedia () {
let {
realm,
index
} = this.get()
let { realm, index } = this.get()
deleteMedia(realm, index)
},
async onSetFocalPoint () {
let { realm, index } = this.get()
let showMediaFocalPointDialog = await importMediaFocalPointDialog()
showMediaFocalPointDialog(realm, index)
}
},
components: {

View file

@ -43,3 +43,7 @@ export const importShowMuteDialog = () => import(
export const importShowReportDialog = () => import(
/* webpackChunkName: 'showReportDialog' */ './creators/showReportDialog'
).then(getDefault)
export const importMediaFocalPointDialog = () => import(
/* webpackChunkName: 'mediaFocalPointDialog' */ './creators/mediaFocalPointDialog'
).then(getDefault)

View file

@ -0,0 +1,326 @@
<ModalDialog
{id}
{label}
{title}
background="var(--main-bg)"
className="media-focal-point-dialog"
on:show="measure()"
>
<form class="media-focal-point-container"
aria-label="Enter the focal point (X, Y) for this media"
on:resize="measure()"
>
<div class="media-focal-point-image-container" ref:container>
<img
{intrinsicsize}
class="media-focal-point-image"
src={previewSrc}
alt={shortName}
on:load="onImageLoad()"
/>
<div class="media-focal-point-backdrop"></div>
<div class="media-draggable-area"
style={draggableAreaStyle}
>
<!-- 52px == 32px icon width + 10px padding -->
<Draggable
draggableClass="media-draggable-area-inner"
indicatorClass="media-focal-point-indicator {imageLoaded ? '': 'hidden'}"
indicatorWidth={52}
indicatorHeight={52}
x={indicatorX}
y={indicatorY}
on:change="onDraggableChange(event)"
>
<SvgIcon
className="media-focal-point-indicator-svg"
href="#fa-crosshairs"
/>
</Draggable>
</div>
</div>
<div class="media-focal-point-inputs">
<div class="media-focal-point-input-pair">
<label for="media-focal-point-x-input-{realm}">
X coordinate
</label>
<input type="number"
step="0.01"
min="-1"
max="1"
inputmode="decimal"
placeholder="0"
id="media-focal-point-x-input-{realm}"
bind:value="rawFocusX"
/>
</div>
<div class="media-focal-point-input-pair">
<label for="media-focal-point-y-input-{realm}">
Y coordinate
</label>
<input type="number"
step="0.01"
min="-1"
max="1"
inputmode="decimal"
placeholder="0"
id="media-focal-point-y-input-{realm}"
bind:value="rawFocusY"
/>
</div>
</div>
</form>
</ModalDialog>
<style>
:global(.media-focal-point-dialog) {
max-width: calc(100%);
}
.media-focal-point-container {
height: calc(100% - 44px); /* 44px X button height */
width: calc(100vw - 40px);
padding-top: 10px;
display: flex;
flex-direction: column;
}
.media-focal-point-image-container {
flex: 1;
width: 100%;
position: relative;
min-height: 0;
}
.media-focal-point-image {
object-fit: contain;
width: 100%;
height: 100%;
}
.media-focal-point-backdrop {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
}
@supports (-webkit-backdrop-filter: blur(1px) saturate(1%)) or (backdrop-filter: blur(1px) saturate(1%)) {
.media-focal-point-backdrop {
-webkit-backdrop-filter: blur(2px) saturate(110%);
backdrop-filter: blur(2px) saturate(110%);
background-color: var(--focal-img-backdrop-filter);
}
}
@supports not ((-webkit-backdrop-filter: blur(1px) saturate(1%)) or (backdrop-filter: blur(1px) saturate(1%))) {
.media-focal-point-backdrop {
background-color: var(--focal-img-bg);
}
}
.media-focal-point-inputs {
display: flex;
padding: 20px 40px;
justify-content: space-around;
width: auto;
}
.media-focal-point-input-pair {
display: flex;
align-items: center;
}
.media-focal-point-input-pair input {
margin-left: 20px;
}
.media-draggable-area {
position: absolute;
}
:global(.media-focal-point-indicator) {
background: var(--focal-bg);
border-radius: 100%;
display: flex;
}
:global(.media-draggable-area-inner) {
width: 100%;
height: 100%;
}
:global(.media-focal-point-indicator-svg) {
width: 32px;
height: 32px;
padding: 10px;
fill: var(--focal-color);
}
@media (max-width: 767px) {
.media-focal-point-inputs {
padding: 10px 20px;
}
.media-focal-point-input-pair {
flex-direction: column;
justify-content: center;
margin: 0 5px;
}
.media-focal-point-input-pair input {
margin-left: 0;
width: 100px;
}
}
</style>
<script>
import ModalDialog from './ModalDialog.html'
import { show } from '../helpers/showDialog'
import { close } from '../helpers/closeDialog'
import { oncreate as onCreateDialog } from '../helpers/onCreateDialog'
import { store } from '../../../_store/store'
import { get } from '../../../_utils/lodash-lite'
import { observe } from 'svelte-extras'
import debounce from 'lodash-es/debounce'
import { scheduleIdleTask } from '../../../_utils/scheduleIdleTask'
import { coordsToPercent, percentToCoords } from '../../../_utils/coordsToPercent'
import SvgIcon from '../../SvgIcon.html'
import { intrinsicScale } from '../../../_thirdparty/intrinsic-scale/intrinsicScale'
import { resize } from '../../../_utils/events'
import Draggable from '../../Draggable.html'
const parseAndValidateFloat = rawText => {
let float = parseFloat(rawText)
if (Number.isNaN(float)) {
float = 0
}
float = Math.min(1, float)
float = Math.max(-1, float)
float = Math.round(float * 100) / 100
return float
}
export default {
oncreate () {
onCreateDialog.call(this)
this.setupSyncFromStore()
this.setupSyncToStore()
},
components: {
ModalDialog,
SvgIcon,
Draggable
},
data: () => ({
rawFocusX: '0',
rawFocusY: '0',
containerWidth: 0,
containerHeight: 0,
imageLoaded: false
}),
store: () => store,
computed: {
media: ({ $currentInstance, $composeData, realm }) => (
get($composeData, [$currentInstance, realm, 'media'])
),
mediaItem: ({ media, index }) => get(media, [index]),
focusX: ({ mediaItem }) => get(mediaItem, ['focusX'], 0),
focusY: ({ mediaItem }) => get(mediaItem, ['focusY'], 0),
previewSrc: ({ mediaItem }) => mediaItem.data.preview_url,
nativeWidth: ({ mediaItem }) => get(mediaItem, ['data', 'meta', 'original', 'width'], 300),
nativeHeight: ({ mediaItem }) => get(mediaItem, ['data', 'meta', 'original', 'height'], 200),
shortName: ({ mediaItem }) => (
// sometimes we no longer have the file, e.g. in a delete and redraft situation,
// so fall back to the description if it was provided
get(mediaItem, ['file', 'name']) || get(mediaItem, ['description']) || 'media'
),
intrinsicsize: ({ mediaItem }) => {
let width = get(mediaItem, ['data', 'meta', 'original', 'width'])
let height = get(mediaItem, ['data', 'meta', 'original', 'height'])
if (width && height) {
return `${width} x ${height}`
}
return '' // pleroma does not give us original width/height
},
scale: ({ nativeWidth, nativeHeight, containerWidth, containerHeight }) => (
intrinsicScale(containerWidth, containerHeight, nativeWidth, nativeHeight)
),
scaleWidth: ({ scale }) => scale.width,
scaleHeight: ({ scale }) => scale.height,
scaleX: ({ scale }) => scale.x,
scaleY: ({ scale }) => scale.y,
indicatorX: ({ focusX }) => (coordsToPercent(focusX) / 100),
indicatorY: ({ focusY }) => ((100 - coordsToPercent(focusY)) / 100),
draggableAreaStyle: ({ scaleWidth, scaleHeight, scaleX, scaleY }) => (
`top: ${scaleY}px; left: ${scaleX}px; width: ${scaleWidth}px; height: ${scaleHeight}px;`
)
},
methods: {
observe,
show,
close,
setupSyncFromStore () {
this.observe('mediaItem', mediaItem => {
let { rawFocusX, rawFocusY } = this.get()
const syncFromStore = (rawKey, rawFocus, key) => {
let focus = get(mediaItem, [key], 0) || 0
let focusAsString = focus.toString()
if (focusAsString !== rawFocus) {
this.set({ [rawKey]: focusAsString })
}
}
syncFromStore('rawFocusX', rawFocusX, 'focusX')
syncFromStore('rawFocusY', rawFocusY, 'focusY')
})
},
setupSyncToStore () {
const saveStore = debounce(() => scheduleIdleTask(() => this.store.save()), 1000)
const observeAndSync = (rawKey, key) => {
this.observe(rawKey, rawFocus => {
let { realm, index, media } = this.get()
let rawFocusDecimal = parseAndValidateFloat(rawFocus)
if (media[index][key] !== rawFocusDecimal) {
media[index][key] = rawFocusDecimal
this.store.setComposeData(realm, { media })
saveStore()
}
}, { init: false })
}
observeAndSync('rawFocusX', 'focusX')
observeAndSync('rawFocusY', 'focusY')
},
onDraggableChange ({ x, y }) {
const saveStore = debounce(() => scheduleIdleTask(() => this.store.save()), 1000)
scheduleIdleTask(() => {
let focusX = percentToCoords(x * 100)
let focusY = percentToCoords(100 - (y * 100))
let { realm, index, media } = this.get()
media[index].focusX = parseAndValidateFloat(focusX)
media[index].focusY = parseAndValidateFloat(focusY)
this.store.setComposeData(realm, { media })
saveStore()
})
},
measure () {
requestAnimationFrame(() => {
if (!this.refs.container) {
return
}
let rect = this.refs.container.getBoundingClientRect()
this.set({
containerWidth: rect.width,
containerHeight: rect.height
})
})
},
onImageLoad () {
this.measure()
this.set({ imageLoaded: true })
}
},
events: {
resize
}
}
</script>

View file

@ -69,6 +69,8 @@
}
</style>
<script>
import { get } from '../../../_utils/lodash-lite'
export default {
computed: {
type: ({ media }) => media.type,
@ -77,8 +79,9 @@
poster: ({ media }) => media.preview_url,
static_url: ({ media }) => media.static_url,
intrinsicsize: ({ media }) => {
if (media.meta && media.meta.original && media.meta.original.width && media.meta.original.height) {
let { width, height } = media.meta.original
let width = get(media, ['meta', 'original', 'width'])
let height = get(media, ['meta', 'original', 'height'])
if (width && height) {
return `${width} x ${height}`
}
return '' // pleroma does not give us original width/height

View file

@ -0,0 +1,11 @@
import MediaFocalPointDialog from '../components/MediaFocalPointDialog.html'
import { showDialog } from './showDialog'
export default function showMediaFocalPointDialog (realm, index) {
return showDialog(MediaFocalPointDialog, {
label: 'Change preview dialog',
title: 'Change preview (focal point)',
realm,
index
})
}

View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Federico Brigante <bfred-it@users.noreply.github.com> (twitter.com/bfred_it)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -0,0 +1,21 @@
// via https://github.com/bfred-it/intrinsic-scale/blob/3d058f79902653484092ad9a2f3e1d9a3d03f09e/index.js
export const intrinsicScale = (parentWidth, parentHeight, childWidth, childHeight) => {
const doRatio = childWidth / childHeight
const cRatio = parentWidth / parentHeight
let width = parentWidth
let height = parentHeight
if (doRatio > cRatio) {
height = width / doRatio
} else {
width = height * doRatio
}
return {
width,
height,
x: (parentWidth - width) / 2,
y: (parentHeight - height) / 2
}
}

View file

@ -13,3 +13,7 @@ export const importIndexedDBGetAllShim = () => import(
export const importCustomElementsPolyfill = () => import(
/* webpackChunkName: '$polyfill$-@webcomponents/custom-elements' */ '@webcomponents/custom-elements'
)
export const importPointerEventsPolyfill = () => import(
/* webpackChunkName: '$polyfill$-@wessberg/pointer-events' */ '@wessberg/pointer-events'
)

View file

@ -1 +1,3 @@
export const coordsToPercent = coord => (1 + coord) / 2 * 100
export const coordsToPercent = coord => ((1 + coord) / 2) * 100
export const percentToCoords = percent => ((percent / 100) * 2) - 1

View file

@ -1,3 +1,5 @@
import { registerResizeListener, unregisterResizeListener } from './resize'
export function mouseover (node, callback) {
function onMouseEnter () {
callback(true) // eslint-disable-line
@ -71,3 +73,13 @@ export function applyFocusStylesToParent (node) {
}
}
}
export function resize (node, callback) {
registerResizeListener(callback)
return {
destroy () {
unregisterResizeListener(callback)
}
}
}

View file

@ -2,7 +2,8 @@ import {
importCustomElementsPolyfill,
importIndexedDBGetAllShim,
importIntersectionObserver,
importRequestIdleCallback
importRequestIdleCallback,
importPointerEventsPolyfill
} from './asyncPolyfills'
export function loadPolyfills () {
@ -10,6 +11,7 @@ export function loadPolyfills () {
typeof IntersectionObserver === 'undefined' && importIntersectionObserver(),
typeof requestIdleCallback === 'undefined' && importRequestIdleCallback(),
!IDBObjectStore.prototype.getAll && importIndexedDBGetAllShim(),
typeof customElements === 'undefined' && importCustomElementsPolyfill()
typeof customElements === 'undefined' && importCustomElementsPolyfill(),
typeof PointerEvent === 'undefined' && importPointerEventsPolyfill()
])
}

View file

@ -0,0 +1,18 @@
// ensure callback is only executed once per raf
export const throttleRaf = () => {
let rafCallback
let rafQueued
return function throttledRaf (callback) {
rafCallback = callback
if (!rafQueued) {
rafQueued = true
requestAnimationFrame(() => {
let cb = rafCallback
rafCallback = null
rafQueued = false
cb()
})
}
}
}

View file

@ -120,4 +120,9 @@
--length-indicator-color: #{$main-theme-color};
--audio-bg: #{rgba(30, 30, 30, 0.8)};
--focal-img-backdrop-filter: #{rgba($main-bg-color, 0.5)};
--focal-img-bg: #{rgba($main-bg-color, 0.3)};
--focal-bg: #{rgba($toast-bg, 0.8)};
--focal-color: #{$secondary-text-color};
}

View file

@ -1,6 +1,13 @@
import {
composeInput, getNthDeleteMediaButton, getNthMedia, getNthMediaAltInput, homeNavButton, mediaButton,
settingsNavButton, sleep,
composeInput,
getNthDeleteMediaButton,
getNthMedia,
getNthMediaAltInput,
getNthMediaListItem,
homeNavButton,
mediaButton,
settingsNavButton,
sleep,
uploadKittenImage
} from '../utils'
import { loginAsFoobar } from '../roles'
@ -13,21 +20,21 @@ test('inserts media', async t => {
await t
.expect(mediaButton.hasAttribute('disabled')).notOk()
await (uploadKittenImage(1)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
await (uploadKittenImage(2)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.expect(mediaButton.hasAttribute('disabled')).notOk()
await (uploadKittenImage(3)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMedia(3).getAttribute('alt')).eql('kitten3.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.expect(getNthMediaListItem(3).getAttribute('aria-label')).eql('kitten3.jpg')
.expect(mediaButton.hasAttribute('disabled')).notOk()
await (uploadKittenImage(4)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMedia(3).getAttribute('alt')).eql('kitten3.jpg')
.expect(getNthMedia(4).getAttribute('alt')).eql('kitten4.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.expect(getNthMediaListItem(3).getAttribute('aria-label')).eql('kitten3.jpg')
.expect(getNthMediaListItem(4).getAttribute('aria-label')).eql('kitten4.jpg')
.expect(mediaButton.getAttribute('disabled')).eql('')
.click(getNthDeleteMediaButton(4))
.click(getNthDeleteMediaButton(3))
@ -41,10 +48,10 @@ test('removes media', async t => {
await t
.expect(mediaButton.exists).ok()
await (uploadKittenImage(1)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
await (uploadKittenImage(2)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.click(getNthDeleteMediaButton(2))
.expect(getNthMedia(2).exists).notOk()
.expect(getNthMedia(1).exists).ok()
@ -79,11 +86,11 @@ test('keeps media descriptions as media is removed', async t => {
.typeText(getNthMediaAltInput(2), 'kitten numero dos')
.expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
.expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.click(getNthDeleteMediaButton(1))
.expect(getNthMediaAltInput(1).value).eql('kitten numero dos')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten2.jpg')
})
test('keeps media in local storage', async t => {
@ -101,8 +108,8 @@ test('keeps media in local storage', async t => {
.expect(composeInput.value).eql('hello hello')
.expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
.expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
await sleep(1)
await t
.click(settingsNavButton)
@ -110,12 +117,12 @@ test('keeps media in local storage', async t => {
.expect(composeInput.value).eql('hello hello')
.expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
.expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.navigateTo('/')
.expect(composeInput.value).eql('hello hello')
.expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
.expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
})

View file

@ -1,7 +1,15 @@
import {
composeButton, composeInput, getNthDeleteMediaButton, getNthMedia, getNthMediaAltInput, getNthStatusAndImage, getUrl,
composeButton,
composeInput,
getNthDeleteMediaButton,
getNthMedia,
getNthMediaAltInput,
getNthMediaListItem,
getNthStatusAndImage,
getUrl,
homeNavButton,
mediaButton, notificationsNavButton,
mediaButton,
notificationsNavButton,
uploadKittenImage
} from '../utils'
import { loginAsFoobar } from '../roles'
@ -11,10 +19,10 @@ fixture`109-compose-media.js`
async function uploadTwoKittens (t) {
await (uploadKittenImage(1)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
await (uploadKittenImage(2)())
await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
await t.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
}
test('uploads alts for media', async t => {
@ -25,10 +33,10 @@ test('uploads alts for media', async t => {
await t.typeText(getNthMediaAltInput(2), 'kitten 2')
.typeText(getNthMediaAltInput(1), 'kitten 1')
.click(composeButton)
.expect(getNthStatusAndImage(1, 0).getAttribute('alt')).eql('kitten 1')
.expect(getNthStatusAndImage(1, 0).getAttribute('title')).eql('kitten 1')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('kitten 2')
.expect(getNthStatusAndImage(1, 1).getAttribute('title')).eql('kitten 2')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('kitten 1')
.expect(getNthStatusAndImage(1, 1).getAttribute('title')).eql('kitten 1')
.expect(getNthStatusAndImage(1, 2).getAttribute('alt')).eql('kitten 2')
.expect(getNthStatusAndImage(1, 2).getAttribute('title')).eql('kitten 2')
})
test('uploads alts when deleting and re-uploading media', async t => {
@ -41,10 +49,10 @@ test('uploads alts when deleting and re-uploading media', async t => {
.expect(getNthMedia(1).exists).notOk()
await (uploadKittenImage(2)())
await t.expect(getNthMediaAltInput(1).value).eql('')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten2.jpg')
.typeText(getNthMediaAltInput(1), 'this will not be deleted')
.click(composeButton)
.expect(getNthStatusAndImage(1, 0).getAttribute('alt')).eql('this will not be deleted')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('this will not be deleted')
})
test('uploads alts mixed with no-alts', async t => {
@ -54,8 +62,8 @@ test('uploads alts mixed with no-alts', async t => {
await uploadTwoKittens(t)
await t.typeText(getNthMediaAltInput(2), 'kitten numero dos')
.click(composeButton)
.expect(getNthStatusAndImage(1, 0).getAttribute('alt')).eql('')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('kitten numero dos')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('')
.expect(getNthStatusAndImage(1, 2).getAttribute('alt')).eql('kitten numero dos')
})
test('saves alts to local storage', async t => {
@ -69,13 +77,13 @@ test('saves alts to local storage', async t => {
.expect(getUrl()).contains('/notifications')
.click(homeNavButton)
.expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg')
.expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg')
.expect(getNthMediaListItem(1).getAttribute('aria-label')).eql('kitten1.jpg')
.expect(getNthMediaListItem(2).getAttribute('aria-label')).eql('kitten2.jpg')
.expect(getNthMediaAltInput(1).value).eql('kitten numero uno')
.expect(getNthMediaAltInput(2).value).eql('kitten numero dos')
.click(composeButton)
.expect(getNthStatusAndImage(1, 0).getAttribute('alt')).eql('kitten numero uno')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('kitten numero dos')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('kitten numero uno')
.expect(getNthStatusAndImage(1, 2).getAttribute('alt')).eql('kitten numero dos')
})
test('can post a status with empty content if there is media', async t => {
@ -87,5 +95,5 @@ test('can post a status with empty content if there is media', async t => {
await t
.typeText(getNthMediaAltInput(1), 'just an image!')
await t.click(composeButton)
.expect(getNthStatusAndImage(1, 0).getAttribute('alt')).eql('just an image!')
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('just an image!')
})

View file

@ -8,12 +8,11 @@ import {
composeModalInput,
getNthStatusMediaImg,
composeModalPostPrivacyButton,
getComposeModalNthMediaImg,
getComposeModalNthMediaAltInput,
getNthStatusSpoiler,
composeModalContentWarningInput,
dialogOptionsOption,
getNthReplyButton, getNthComposeReplyInput, getNthComposeReplyButton, getUrl, sleep
getNthReplyButton, getNthComposeReplyInput, getNthComposeReplyButton, getUrl, sleep, getComposeModalNthMediaListItem
} from '../utils'
import { postAs, postEmptyStatusWithMediaAs, postWithSpoilerAndPrivacyAs } from '../serverActions'
@ -48,7 +47,7 @@ test('image with empty text delete and redraft', async t => {
.expect(modalDialog.hasAttribute('aria-hidden')).notOk()
.expect(composeModalInput.value).eql('')
.expect(composeModalPostPrivacyButton.getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
.expect(getComposeModalNthMediaImg(1).getAttribute('alt')).eql('what a kitteh')
.expect(getComposeModalNthMediaListItem(1).getAttribute('aria-label')).eql('what a kitteh')
.expect(getComposeModalNthMediaAltInput(1).value).eql('what a kitteh')
.typeText(composeModalInput, 'I love this kitteh', { replace: true, paste: true })
.click(composeModalComposeButton)
@ -68,7 +67,7 @@ test('image with no alt delete and redraft', async t => {
.expect(modalDialog.hasAttribute('aria-hidden')).notOk()
.expect(composeModalInput.value).eql('')
.expect(composeModalPostPrivacyButton.getAttribute('aria-label')).eql('Adjust privacy (currently Public)')
.expect(getComposeModalNthMediaImg(1).getAttribute('alt')).eql('')
.expect(getComposeModalNthMediaListItem(1).getAttribute('aria-label')).eql('media')
.expect(getComposeModalNthMediaAltInput(1).value).eql('')
.typeText(composeModalInput, 'oops forgot an alt', { replace: true, paste: true })
.typeText(getComposeModalNthMediaAltInput(1), 'lovely kitteh', { replace: true, paste: true })

View file

@ -0,0 +1,59 @@
import {
sleep,
composeInput,
mediaButton,
uploadKittenImage,
getNthMediaAltInput,
getNthMediaFocalPointButton,
modalDialog,
focalPointXInput,
closeDialogButton,
composeButton,
focalPointYInput,
getNthStatusContent,
getNthStatusAndImage
} from '../utils'
import { loginAsFoobar } from '../roles'
fixture`130-focal-point.js`
.page`http://localhost:4002`
test('Can set a focal point', async t => {
await loginAsFoobar(t)
await t
.typeText(composeInput, 'here is a focal point')
.click(mediaButton)
await (uploadKittenImage(1)())
await (uploadKittenImage(2)())
await (uploadKittenImage(3)())
await t
.typeText(getNthMediaAltInput(1), 'kitten 1')
.typeText(getNthMediaAltInput(2), 'kitten 2')
.click(getNthMediaFocalPointButton(2))
.expect(modalDialog.hasAttribute('aria-hidden')).notOk({ timeout: 30000 })
.typeText(focalPointXInput, '0.5')
await sleep(1000)
await t
.click(closeDialogButton)
.expect(modalDialog.exists).notOk()
await sleep(1000)
await t
.click(getNthMediaFocalPointButton(3))
.expect(modalDialog.hasAttribute('aria-hidden')).notOk({ timeout: 30000 })
.typeText(focalPointXInput, '-0.25')
.typeText(focalPointYInput, '1')
await sleep(1000)
await t
.click(closeDialogButton)
.expect(modalDialog.exists).notOk()
await sleep(1000)
await t
.click(composeButton)
.expect(getNthStatusContent(1).innerText).contains('here is a focal point', { timeout: 30000 })
.expect(getNthStatusAndImage(1, 1).getAttribute('alt')).eql('kitten 1')
.expect(getNthStatusAndImage(1, 2).getAttribute('alt')).eql('kitten 2')
.expect(getNthStatusAndImage(1, 3).getAttribute('alt')).eql('')
.expect(getNthStatusAndImage(1, 1).getAttribute('style')).eql('object-position: 50% 50%;')
.expect(getNthStatusAndImage(1, 2).getAttribute('style')).eql('object-position: 75% 50%;')
.expect(getNthStatusAndImage(1, 3).getAttribute('style')).eql('object-position: 62.5% 0%;')
})

View file

@ -85,12 +85,15 @@ export const instanceSettingNotificationMentions = $('#instance-option-notificat
export const notificationBadge = $('#main-nav li:nth-child(2) .nav-link-badge')
export const focalPointXInput = $('.media-focal-point-inputs *:nth-child(1) input')
export const focalPointYInput = $('.media-focal-point-inputs *:nth-child(2) input')
export function getComposeModalNthMediaAltInput (n) {
return $(`.modal-dialog .compose-media:nth-child(${n}) .compose-media-alt textarea`)
}
export function getComposeModalNthMediaImg (n) {
return $(`.modal-dialog .compose-media:nth-child(${n}) img`)
export function getComposeModalNthMediaListItem (n) {
return $(`.modal-dialog .compose-media:nth-child(${n})`)
}
export const favoritesCountElement = $('.status-favs').addCustomDOMProperties({
@ -278,6 +281,10 @@ export function getNthSearchResult (n) {
return $(`.search-result:nth-child(${n}) a`)
}
export function getNthMediaListItem (n) {
return $(`.compose-media:nth-child(${n})`)
}
export function getNthMedia (n) {
return $(`.compose-media:nth-child(${n}) img`)
}
@ -286,6 +293,10 @@ export function getNthDeleteMediaButton (n) {
return $(`.compose-media:nth-child(${n}) .compose-media-delete-button`)
}
export function getNthMediaFocalPointButton (n) {
return $(`.compose-media:nth-child(${n}) .compose-media-focal-button`)
}
export function getAriaSetSize () {
return getNthStatus(1 + 0).getAttribute('aria-setsize')
}
@ -331,7 +342,7 @@ export function getNthStatusHeader (n) {
}
export function getNthStatusAndImage (nStatus, nImage) {
return $(`${getNthStatusSelector(nStatus)} .status-media .show-image-button:nth-child(${nImage + 1}) img`)
return $(`${getNthStatusSelector(nStatus)} .status-media .show-image-button:nth-child(${nImage}) img`)
}
export function getFirstVisibleStatus () {

View file

@ -352,6 +352,11 @@
resolved "https://registry.yarnpkg.com/@webcomponents/custom-elements/-/custom-elements-1.2.4.tgz#7074543155396114617722724d6f6cb7b3800a14"
integrity sha512-WiTlgz6/kuwajYIcgyq64rSlCtb2AvbxwwrExP3wr6rKbJ72I3hi/sb4KdGUumfC+isDn2F0btZGk4MnWpyO1Q==
"@wessberg/pointer-events@^1.0.9":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@wessberg/pointer-events/-/pointer-events-1.0.9.tgz#9591f2326e3592cdfa52d630217b97ecf7c5d817"
integrity sha512-fTOBNzakyi3wBXMmQ1mpAibU3lCbXVWSwgM9nsjtlfbJ5MH1yvteCZgZF+TDQWNtTPg8JWuQUjBF7+8cKoj92A==
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"