Pinafore-Web-Client-Frontend/src/routes/_components/dialog/components/MediaFocalPointEditor.html

325 lines
9.5 KiB
HTML

<form class="media-focal-point-container {className}"
aria-label="{intl.enterFocalPoint}"
on:resize="measure()"
>
<div class="media-focal-point-image-container" ref:container>
<img
width={intrinsicWidth}
height={intrinsicHeight}
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'} {dragging ? 'dragging' : ''}"
indicatorWidth={52}
indicatorHeight={52}
x={indicatorX}
y={indicatorY}
on:dragStart="onDragStart()"
on:dragEnd="onDragEnd()"
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
</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
</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>
<style>
.media-focal-point-container {
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(1px) saturate(105%);
backdrop-filter: blur(1px) saturate(105%);
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: 10px;
justify-content: space-around;
width: auto;
}
.media-focal-point-input-pair {
display: flex;
align-items: center;
}
.media-focal-point-input-pair:first-child {
margin-right: 10px;
}
.media-focal-point-input-pair input {
margin-left: 10px;
}
.media-draggable-area {
position: absolute;
}
:global(.media-focal-point-indicator) {
background: var(--focal-bg);
border-radius: 100%;
display: flex;
}
:global(.media-focal-point-indicator:hover) {
background: var(--focal-bg-hover);
}
:global(.media-focal-point-indicator.dragging) {
background: var(--focal-bg-drag);
}
:global(.media-draggable-area-inner) {
width: 100%;
height: 100%;
}
:global(.media-focal-point-indicator-svg) {
width: 32px;
height: 32px;
padding: 15px;
fill: var(--focal-color);
}
@media (max-width: 767px) {
.media-focal-point-inputs {
padding: 5px 20px;
justify-content: space-around;
}
:global(.media-focal-point-indicator-svg) {
width: 32px;
height: 32px;
padding: 12px;
fill: var(--focal-color);
}
.media-focal-point-input-pair label {
font-size: 1.1em;
}
.media-focal-point-input-pair input {
font-size: 1.1em;
}
}
</style>
<script>
import { store } from '../../../_store/store'
import { get } from '../../../_utils/lodash-lite'
import { observe } from 'svelte-extras'
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 () {
this.setupSyncFromStore()
this.setupSyncToStore()
},
components: {
SvgIcon,
Draggable
},
data: () => ({
dragging: false,
rawFocusX: '0',
rawFocusY: '0',
containerWidth: 0,
containerHeight: 0,
imageLoaded: false,
className: ''
}),
store: () => store,
computed: {
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) // TODO: Pleroma placeholder
),
nativeHeight: ({ mediaItem }) => (
get(mediaItem, ['data', 'meta', 'original', 'height'], 200) // TODO: Pleroma placeholder
),
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'
),
// intrinsic width/height to avoid layout shifting https://chromestatus.com/feature/5695266130755584
// note pleroma does not give us intrinsic width/height
intrinsicWidth: ({ mediaItem }) => get(mediaItem, ['data', 'meta', 'original', 'width']),
intrinsicHeight: ({ mediaItem }) => get(mediaItem, ['data', 'meta', 'original', '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,
setupSyncFromStore () {
this.observe('mediaItem', mediaItem => {
const { rawFocusX, rawFocusY } = this.get()
const syncFromStore = (rawKey, rawFocus, key) => {
const focus = get(mediaItem, [key], 0) || 0
const focusAsString = focus.toString()
if (focusAsString !== rawFocus) {
this.set({ [rawKey]: focusAsString })
}
}
syncFromStore('rawFocusX', rawFocusX, 'focusX')
syncFromStore('rawFocusY', rawFocusY, 'focusY')
})
},
setupSyncToStore () {
const observeAndSync = (rawKey, key) => {
this.observe(rawKey, rawFocus => {
const { realm, index, media } = this.get()
const rawFocusDecimal = parseAndValidateFloat(rawFocus)
if (media[index][key] !== rawFocusDecimal) {
media[index][key] = rawFocusDecimal
this.store.setComposeData(realm, { media })
scheduleIdleTask(() => this.store.save())
}
}, { init: false })
}
observeAndSync('rawFocusX', 'focusX')
observeAndSync('rawFocusY', 'focusY')
},
onDraggableChange ({ x, y }) {
scheduleIdleTask(() => {
const focusX = parseAndValidateFloat(percentToCoords(x * 100))
const focusY = parseAndValidateFloat(percentToCoords(100 - (y * 100)))
const { realm, index, media } = this.get()
if (media[index].focusX !== focusX || media[index].focusY !== focusY) {
media[index].focusX = focusX
media[index].focusY = focusY
this.store.setComposeData(realm, { media })
scheduleIdleTask(() => this.store.save())
}
})
},
onDragStart () {
this.set({ dragging: true })
},
onDragEnd () {
this.set({ dragging: false })
},
measure () {
requestAnimationFrame(() => {
if (!this.refs.container) {
return
}
const rect = this.refs.container.getBoundingClientRect()
this.set({
containerWidth: rect.width,
containerHeight: rect.height
})
})
},
onImageLoad () {
this.measure()
this.set({ imageLoaded: true })
}
},
events: {
resize
}
}
</script>