[Glitch] Change design of edit media modal in web UI
Port 11786f1114f9dfb5a17810d14477a94162f94064 to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
05e5db91c7
commit
5e7c079787
@ -448,7 +448,7 @@ export function initMediaEditModal(id) {
|
|||||||
|
|
||||||
dispatch(openModal({
|
dispatch(openModal({
|
||||||
modalType: 'FOCAL_POINT',
|
modalType: 'FOCAL_POINT',
|
||||||
modalProps: { id },
|
modalProps: { mediaId: id },
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
70
app/javascript/flavours/glitch/actions/compose_typed.ts
Normal file
70
app/javascript/flavours/glitch/actions/compose_typed.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
|
||||||
|
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
||||||
|
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||||
|
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
|
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||||
|
unattached?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulateModifiedApiResponse = (
|
||||||
|
media: MediaAttachment,
|
||||||
|
params: { description?: string; focus?: string },
|
||||||
|
): SimulatedMediaAttachmentJSON => {
|
||||||
|
const [x, y] = (params.focus ?? '').split(',');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
...media.toJS(),
|
||||||
|
...params,
|
||||||
|
meta: {
|
||||||
|
focus: {
|
||||||
|
x: parseFloat(x ?? '0'),
|
||||||
|
y: parseFloat(y ?? '0'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as SimulatedMediaAttachmentJSON;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeUploadCompose = createDataLoadingThunk(
|
||||||
|
'compose/changeUpload',
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
...params
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
focus: string;
|
||||||
|
},
|
||||||
|
{ getState },
|
||||||
|
) => {
|
||||||
|
const media = (
|
||||||
|
(getState().compose as ImmutableMap<string, unknown>).get(
|
||||||
|
'media_attachments',
|
||||||
|
) as ImmutableList<MediaAttachment>
|
||||||
|
).find((item) => item.get('id') === id);
|
||||||
|
|
||||||
|
// Editing already-attached media is deferred to editing the post itself.
|
||||||
|
// For simplicity's sake, fake an API reply.
|
||||||
|
if (media && !media.get('unattached')) {
|
||||||
|
return new Promise<SimulatedMediaAttachmentJSON>((resolve) => {
|
||||||
|
resolve(simulateModifiedApiResponse(media, params));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiUpdateMedia(id, params);
|
||||||
|
},
|
||||||
|
(media: SimulatedMediaAttachmentJSON) => {
|
||||||
|
return {
|
||||||
|
media,
|
||||||
|
attached: typeof media.unattached !== 'undefined' && !media.unattached,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
useLoadingBar: false,
|
||||||
|
},
|
||||||
|
);
|
@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS;
|
|||||||
interface OpenModalPayload {
|
interface OpenModalPayload {
|
||||||
modalType: ModalType;
|
modalType: ModalType;
|
||||||
modalProps: ModalProps;
|
modalProps: ModalProps;
|
||||||
|
previousModalProps?: ModalProps;
|
||||||
}
|
}
|
||||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||||
|
|
||||||
|
7
app/javascript/flavours/glitch/api/compose.ts
Normal file
7
app/javascript/flavours/glitch/api/compose.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { apiRequestPut } from 'flavours/glitch/api';
|
||||||
|
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
||||||
|
|
||||||
|
export const apiUpdateMedia = (
|
||||||
|
id: string,
|
||||||
|
params?: { description?: string; focus?: string },
|
||||||
|
) => apiRequestPut<ApiMediaAttachmentJSON>(`v1/media/${id}`, params);
|
@ -7,6 +7,7 @@ interface BaseProps
|
|||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,6 +28,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
block,
|
block,
|
||||||
secondary,
|
secondary,
|
||||||
|
compact,
|
||||||
dangerous,
|
dangerous,
|
||||||
className,
|
className,
|
||||||
title,
|
title,
|
||||||
@ -47,6 +49,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
<button
|
<button
|
||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
'button-secondary': secondary,
|
'button-secondary': secondary,
|
||||||
|
'button--compact': compact,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
'button--dangerous': dangerous,
|
'button--dangerous': dangerous,
|
||||||
})}
|
})}
|
||||||
|
@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react';
|
|||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useIdentity } from '@/flavours/glitch/identity_context';
|
import { useIdentity } from '@/flavours/glitch/identity_context';
|
||||||
import {
|
import {
|
||||||
fetchRelationships,
|
fetchRelationships,
|
||||||
@ -22,7 +24,8 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
export const FollowButton: React.FC<{
|
export const FollowButton: React.FC<{
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
}> = ({ accountId }) => {
|
compact?: boolean;
|
||||||
|
}> = ({ accountId, compact }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { signedIn } = useIdentity();
|
const { signedIn } = useIdentity();
|
||||||
@ -89,7 +92,9 @@ export const FollowButton: React.FC<{
|
|||||||
href='/settings/profile'
|
href='/settings/profile'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener'
|
rel='noopener'
|
||||||
className='button button-secondary'
|
className={classNames('button button-secondary', {
|
||||||
|
'button--compact': compact,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
@ -106,6 +111,7 @@ export const FollowButton: React.FC<{
|
|||||||
(account?.suspended || !!account?.moved))
|
(account?.suspended || !!account?.moved))
|
||||||
}
|
}
|
||||||
secondary={following}
|
secondary={following}
|
||||||
|
compact={compact}
|
||||||
className={following ? 'button--destructive' : undefined}
|
className={following ? 'button--destructive' : undefined}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
@ -1,46 +1,39 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState, forwardRef } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: string;
|
src: string;
|
||||||
key: string;
|
|
||||||
alt?: string;
|
alt?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
width: number;
|
width?: number;
|
||||||
height: number;
|
height?: number;
|
||||||
onClick?: () => void;
|
onClick?: React.MouseEventHandler;
|
||||||
|
onMouseDown?: React.MouseEventHandler;
|
||||||
|
onTouchStart?: React.TouchEventHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GIFV: React.FC<Props> = ({
|
export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
src,
|
(
|
||||||
alt,
|
{ src, alt, lang, width, height, onClick, onMouseDown, onTouchStart },
|
||||||
lang,
|
ref,
|
||||||
width,
|
) => {
|
||||||
height,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> =
|
const handleLoadedData = useCallback(() => {
|
||||||
useCallback(() => {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [setLoading]);
|
}, [setLoading]);
|
||||||
|
|
||||||
const handleClick: React.MouseEventHandler = useCallback(
|
const handleClick = useCallback(
|
||||||
(e) => {
|
(e: React.MouseEvent) => {
|
||||||
if (onClick) {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick();
|
onClick?.(e);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onClick],
|
[onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='gifv' style={{ position: 'relative' }}>
|
<div className='gifv'>
|
||||||
{loading && (
|
{loading && (
|
||||||
<canvas
|
<canvas
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
@ -51,20 +44,27 @@ export const GIFV: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<video
|
<video
|
||||||
|
ref={ref}
|
||||||
src={src}
|
src={src}
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
title={alt}
|
title={alt}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onLoadedData={handleLoadedData}
|
onLoadedData={handleLoadedData}
|
||||||
style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
GIFV.displayName = 'GIFV';
|
||||||
|
531
app/javascript/flavours/glitch/features/alt_text_modal/index.tsx
Normal file
531
app/javascript/flavours/glitch/features/alt_text_modal/index.tsx
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
import { length } from 'stringz';
|
||||||
|
// eslint-disable-next-line import/extensions
|
||||||
|
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
|
||||||
|
|
||||||
|
import { showAlertForError } from 'flavours/glitch/actions/alerts';
|
||||||
|
import { uploadThumbnail } from 'flavours/glitch/actions/compose';
|
||||||
|
import { changeUploadCompose } from 'flavours/glitch/actions/compose_typed';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { GIFV } from 'flavours/glitch/components/gifv';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||||
|
import Audio from 'flavours/glitch/features/audio';
|
||||||
|
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
|
||||||
|
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
|
||||||
|
import Video, { getPointerPosition } from 'flavours/glitch/features/video';
|
||||||
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
import { assetHost } from 'flavours/glitch/utils/config';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholderVisual: {
|
||||||
|
id: 'alt_text_modal.describe_for_people_with_visual_impairments',
|
||||||
|
defaultMessage: 'Describe this for people with visual impairments…',
|
||||||
|
},
|
||||||
|
placeholderHearing: {
|
||||||
|
id: 'alt_text_modal.describe_for_people_with_hearing_impairments',
|
||||||
|
defaultMessage: 'Describe this for people with hearing impairments…',
|
||||||
|
},
|
||||||
|
discardMessage: {
|
||||||
|
id: 'confirmations.discard_edit_media.message',
|
||||||
|
defaultMessage:
|
||||||
|
'You have unsaved changes to the media description or preview, discard them anyway?',
|
||||||
|
},
|
||||||
|
discardConfirm: {
|
||||||
|
id: 'confirmations.discard_edit_media.confirm',
|
||||||
|
defaultMessage: 'Discard',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_LENGTH = 1500;
|
||||||
|
|
||||||
|
type FocalPoint = [number, number];
|
||||||
|
|
||||||
|
const UploadButton: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelectFile: (arg0: File) => void;
|
||||||
|
mimeTypes: string;
|
||||||
|
}> = ({ children, onSelectFile, mimeTypes }) => {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
fileRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
onSelectFile(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<Button onClick={handleClick}>{children}</Button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id='upload-modal__thumbnail'
|
||||||
|
ref={fileRef}
|
||||||
|
type='file'
|
||||||
|
accept={mimeTypes}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Preview: React.FC<{
|
||||||
|
mediaId: string;
|
||||||
|
position: FocalPoint;
|
||||||
|
onPositionChange: (arg0: FocalPoint) => void;
|
||||||
|
}> = ({ mediaId, position, onPositionChange }) => {
|
||||||
|
const media = useAppSelector((state) =>
|
||||||
|
(
|
||||||
|
(state.compose as ImmutableMap<string, unknown>).get(
|
||||||
|
'media_attachments',
|
||||||
|
) as ImmutableList<MediaAttachment>
|
||||||
|
).find((x) => x.get('id') === mediaId),
|
||||||
|
);
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
me ? state.accounts.get(me) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [x, y] = position;
|
||||||
|
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
|
||||||
|
const draggingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const setRef = useCallback(
|
||||||
|
(e: HTMLImageElement | HTMLVideoElement | null) => {
|
||||||
|
nodeRef.current = e;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||||
|
setDragging(true);
|
||||||
|
draggingRef.current = true;
|
||||||
|
onPositionChange([x, y]);
|
||||||
|
},
|
||||||
|
[setDragging, onPositionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||||
|
setDragging(true);
|
||||||
|
draggingRef.current = true;
|
||||||
|
onPositionChange([x, y]);
|
||||||
|
},
|
||||||
|
[setDragging, onPositionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDragging(false);
|
||||||
|
draggingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (draggingRef.current) {
|
||||||
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||||
|
onPositionChange([x, y]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
setDragging(false);
|
||||||
|
draggingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (draggingRef.current) {
|
||||||
|
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||||
|
onPositionChange([x, y]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
|
document.addEventListener('touchmove', handleTouchMove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
};
|
||||||
|
}, [setDragging, onPositionChange]);
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.get('type') === 'image') {
|
||||||
|
return (
|
||||||
|
<div className={classNames('focal-point', { dragging })}>
|
||||||
|
<img
|
||||||
|
ref={setRef}
|
||||||
|
draggable={false}
|
||||||
|
src={media.get('url') as string}
|
||||||
|
alt=''
|
||||||
|
role='presentation'
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className='focal-point__reticle'
|
||||||
|
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (media.get('type') === 'gifv') {
|
||||||
|
return (
|
||||||
|
<div className={classNames('focal-point', { dragging })}>
|
||||||
|
<GIFV
|
||||||
|
ref={setRef}
|
||||||
|
src={media.get('url') as string}
|
||||||
|
alt=''
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className='focal-point__reticle'
|
||||||
|
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (media.get('type') === 'video') {
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
preview={media.get('preview_url') as string}
|
||||||
|
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
|
||||||
|
blurhash={media.get('blurhash') as string}
|
||||||
|
src={media.get('url') as string}
|
||||||
|
detailed
|
||||||
|
inline
|
||||||
|
editable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (media.get('type') === 'audio') {
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={media.get('url') as string}
|
||||||
|
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||||
|
poster={
|
||||||
|
(media.get('preview_url') as string | undefined) ??
|
||||||
|
account?.avatar_static
|
||||||
|
}
|
||||||
|
backgroundColor={
|
||||||
|
media.getIn(['meta', 'colors', 'background']) as string
|
||||||
|
}
|
||||||
|
foregroundColor={
|
||||||
|
media.getIn(['meta', 'colors', 'foreground']) as string
|
||||||
|
}
|
||||||
|
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
|
||||||
|
editable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RestoreProps {
|
||||||
|
previousDescription: string;
|
||||||
|
previousPosition: FocalPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mediaId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmationMessage {
|
||||||
|
message: string;
|
||||||
|
confirm: string;
|
||||||
|
props?: RestoreProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalRef {
|
||||||
|
getCloseConfirmationMessage: () => null | ConfirmationMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||||
|
({ mediaId, previousDescription, previousPosition, onClose }, ref) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const media = useAppSelector((state) =>
|
||||||
|
(
|
||||||
|
(state.compose as ImmutableMap<string, unknown>).get(
|
||||||
|
'media_attachments',
|
||||||
|
) as ImmutableList<MediaAttachment>
|
||||||
|
).find((x) => x.get('id') === mediaId),
|
||||||
|
);
|
||||||
|
const lang = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
||||||
|
);
|
||||||
|
const focusX =
|
||||||
|
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
||||||
|
const focusY =
|
||||||
|
(media?.getIn(['meta', 'focus', 'y'], 0) as number | undefined) ?? 0;
|
||||||
|
const [description, setDescription] = useState(
|
||||||
|
previousDescription ??
|
||||||
|
(media?.get('description') as string | undefined) ??
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const [position, setPosition] = useState<FocalPoint>(
|
||||||
|
previousPosition ?? [focusX / 2 + 0.5, focusY / -2 + 0.5],
|
||||||
|
);
|
||||||
|
const [isDetecting, setIsDetecting] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const dirtyRef = useRef(
|
||||||
|
previousDescription || previousPosition ? true : false,
|
||||||
|
);
|
||||||
|
const type = media?.get('type') as string;
|
||||||
|
const valid = length(description) <= MAX_LENGTH;
|
||||||
|
|
||||||
|
const handleDescriptionChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
dirtyRef.current = true;
|
||||||
|
},
|
||||||
|
[setDescription],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleThumbnailChange = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
dispatch(uploadThumbnail(mediaId, file));
|
||||||
|
},
|
||||||
|
[dispatch, mediaId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePositionChange = useCallback(
|
||||||
|
(position: FocalPoint) => {
|
||||||
|
setPosition(position);
|
||||||
|
dirtyRef.current = true;
|
||||||
|
},
|
||||||
|
[setPosition],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
changeUploadCompose({
|
||||||
|
id: mediaId,
|
||||||
|
description,
|
||||||
|
focus: `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
setIsSaving(false);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
onClose();
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setIsSaving(false);
|
||||||
|
dispatch(showAlertForError(err));
|
||||||
|
});
|
||||||
|
}, [dispatch, setIsSaving, mediaId, onClose, position, description]);
|
||||||
|
|
||||||
|
const handleKeyUp = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmit, valid],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDetectClick = useCallback(() => {
|
||||||
|
setIsDetecting(true);
|
||||||
|
|
||||||
|
fetchTesseract()
|
||||||
|
.then(async ({ createWorker }) => {
|
||||||
|
const worker = await createWorker('eng', 1, {
|
||||||
|
workerPath: tesseractWorkerPath as string,
|
||||||
|
corePath: tesseractCorePath as string,
|
||||||
|
langPath: `${assetHost}/ocr/lang-data`,
|
||||||
|
cacheMethod: 'write',
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = URL.createObjectURL(media?.get('file') as File);
|
||||||
|
const result = await worker.recognize(image);
|
||||||
|
|
||||||
|
setDescription(result.data.text);
|
||||||
|
setIsDetecting(false);
|
||||||
|
|
||||||
|
await worker.terminate();
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsDetecting(false);
|
||||||
|
});
|
||||||
|
}, [setDescription, setIsDetecting, media]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
getCloseConfirmationMessage: () => {
|
||||||
|
if (dirtyRef.current) {
|
||||||
|
return {
|
||||||
|
message: intl.formatMessage(messages.discardMessage),
|
||||||
|
confirm: intl.formatMessage(messages.discardConfirm),
|
||||||
|
props: {
|
||||||
|
previousDescription: description,
|
||||||
|
previousPosition: position,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[intl, description, position],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal dialog-modal'>
|
||||||
|
<div className='dialog-modal__header'>
|
||||||
|
<Button onClick={handleSubmit} disabled={!valid}>
|
||||||
|
{isSaving ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='alt_text_modal.done'
|
||||||
|
defaultMessage='Done'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className='dialog-modal__header__title'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='alt_text_modal.add_alt_text'
|
||||||
|
defaultMessage='Add alt text'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button secondary onClick={onClose}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='alt_text_modal.cancel'
|
||||||
|
defaultMessage='Cancel'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='dialog-modal__content'>
|
||||||
|
<div className='dialog-modal__content__preview'>
|
||||||
|
<Preview
|
||||||
|
mediaId={mediaId}
|
||||||
|
position={position}
|
||||||
|
onPositionChange={handlePositionChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(type === 'audio' || type === 'video') && (
|
||||||
|
<UploadButton
|
||||||
|
onSelectFile={handleThumbnailChange}
|
||||||
|
mimeTypes='image/jpeg,image/png,image/gif,image/heic,image/heif,image/webp,image/avif'
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='alt_text_modal.change_thumbnail'
|
||||||
|
defaultMessage='Change thumbnail'
|
||||||
|
/>
|
||||||
|
</UploadButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className='dialog-modal__content__form simple_form'
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div className='input'>
|
||||||
|
<div className='label_input'>
|
||||||
|
<Textarea
|
||||||
|
id='description'
|
||||||
|
value={isDetecting ? ' ' : description}
|
||||||
|
onChange={handleDescriptionChange}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
lang={lang}
|
||||||
|
placeholder={intl.formatMessage(
|
||||||
|
type === 'audio'
|
||||||
|
? messages.placeholderHearing
|
||||||
|
: messages.placeholderVisual,
|
||||||
|
)}
|
||||||
|
minRows={3}
|
||||||
|
disabled={isDetecting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isDetecting && (
|
||||||
|
<div className='label_input__loading-indicator'>
|
||||||
|
<Skeleton width='100%' />
|
||||||
|
<Skeleton width='100%' />
|
||||||
|
<Skeleton width='61%' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='input__toolbar'>
|
||||||
|
<button
|
||||||
|
className='link-button'
|
||||||
|
onClick={handleDetectClick}
|
||||||
|
disabled={type !== 'image' || isDetecting}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='alt_text_modal.add_text_from_image'
|
||||||
|
defaultMessage='Add text from image'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CharacterCounter
|
||||||
|
max={MAX_LENGTH}
|
||||||
|
text={isDetecting ? '' : description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AltTextModal.displayName = 'AltTextModal';
|
@ -587,10 +587,14 @@ class Audio extends PureComponent {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
<div className='video-player__buttons right'>
|
||||||
{!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>}
|
{!editable && (
|
||||||
|
<>
|
||||||
|
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
|
||||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
||||||
<Icon id={'download'} icon={DownloadIcon} />
|
<Icon id='download' icon={DownloadIcon} />
|
||||||
</a>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { length } from 'stringz';
|
|
||||||
|
|
||||||
export const CharacterCounter = ({ text, max }) => {
|
|
||||||
const diff = max - length(text);
|
|
||||||
|
|
||||||
if (diff < 0) {
|
|
||||||
return <span className='character-counter character-counter--over'>{diff}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span className='character-counter'>{diff}</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
CharacterCounter.propTypes = {
|
|
||||||
text: PropTypes.string.isRequired,
|
|
||||||
max: PropTypes.number.isRequired,
|
|
||||||
};
|
|
@ -0,0 +1,16 @@
|
|||||||
|
import { length } from 'stringz';
|
||||||
|
|
||||||
|
export const CharacterCounter: React.FC<{
|
||||||
|
text: string;
|
||||||
|
max: number;
|
||||||
|
}> = ({ text, max }) => {
|
||||||
|
const diff = max - length(text);
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
return (
|
||||||
|
<span className='character-counter character-counter--over'>{diff}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className='character-counter'>{diff}</span>;
|
||||||
|
};
|
@ -335,6 +335,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
|
compact
|
||||||
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
||||||
disabled={!this.canSubmit()}
|
disabled={!this.canSubmit()}
|
||||||
/>
|
/>
|
||||||
|
@ -4,16 +4,16 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||||
import {
|
import { undoUploadCompose } from 'flavours/glitch/actions/compose';
|
||||||
undoUploadCompose,
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
initMediaEditModal,
|
|
||||||
} from 'flavours/glitch/actions/compose';
|
|
||||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||||
@ -27,16 +27,15 @@ export const Upload: React.FC<{
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
}> = ({ id, dragging, overlay, tall, wide }) => {
|
}> = ({ id, dragging, overlay, tall, wide }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const media = useAppSelector(
|
const media = useAppSelector((state) =>
|
||||||
(state) =>
|
(
|
||||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
(state.compose as ImmutableMap<string, unknown>).get(
|
||||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
'media_attachments',
|
||||||
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
) as ImmutableList<MediaAttachment>
|
||||||
| MediaAttachment
|
).find((item) => item.get('id') === id),
|
||||||
| undefined,
|
|
||||||
);
|
);
|
||||||
const sensitive = useAppSelector(
|
const sensitive = useAppSelector(
|
||||||
(state) => state.compose.get('sensitive') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
(state) => state.compose.get('sensitive') as boolean,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUndoClick = useCallback(() => {
|
const handleUndoClick = useCallback(() => {
|
||||||
@ -44,7 +43,9 @@ export const Upload: React.FC<{
|
|||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleFocalPointClick = useCallback(() => {
|
const handleFocalPointClick = useCallback(() => {
|
||||||
dispatch(initMediaEditModal(id));
|
dispatch(
|
||||||
|
openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
|
||||||
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
|
@ -2,7 +2,11 @@ import { useState, useCallback, useMemo } from 'react';
|
|||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import type { List } from 'immutable';
|
import type {
|
||||||
|
List,
|
||||||
|
Map as ImmutableMap,
|
||||||
|
List as ImmutableList,
|
||||||
|
} from 'immutable';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
@ -64,18 +68,20 @@ export const UploadForm: React.FC = () => {
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const mediaIds = useAppSelector(
|
const mediaIds = useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
(
|
||||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
(state.compose as ImmutableMap<string, unknown>).get(
|
||||||
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
'media_attachments',
|
||||||
|
) as ImmutableList<MediaAttachment>
|
||||||
|
).map((item: MediaAttachment) => item.get('id')) as List<string>,
|
||||||
);
|
);
|
||||||
const active = useAppSelector(
|
const active = useAppSelector(
|
||||||
(state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
(state) => state.compose.get('is_uploading') as boolean,
|
||||||
);
|
);
|
||||||
const progress = useAppSelector(
|
const progress = useAppSelector(
|
||||||
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
(state) => state.compose.get('progress') as number,
|
||||||
);
|
);
|
||||||
const isProcessing = useAppSelector(
|
const isProcessing = useAppSelector(
|
||||||
(state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
(state) => state.compose.get('is_processing') as boolean,
|
||||||
);
|
);
|
||||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
@ -65,7 +65,9 @@ export const NotificationFollow: React.FC<{
|
|||||||
const account = notification.sampleAccountIds[0];
|
const account = notification.sampleAccountIds[0];
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
actions = <FollowButton accountId={notification.sampleAccountIds[0]} />;
|
actions = (
|
||||||
|
<FollowButton compact accountId={notification.sampleAccountIds[0]} />
|
||||||
|
);
|
||||||
additionalContent = <FollowerCount accountId={account} />;
|
additionalContent = <FollowerCount accountId={account} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,6 @@ export const BoostModal: React.FC<{
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const defaultPrivacy = useAppSelector(
|
const defaultPrivacy = useAppSelector(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
||||||
(state) => state.compose.get('default_privacy') as StatusVisibility,
|
(state) => state.compose.get('default_privacy') as StatusVisibility,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,438 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import Textarea from 'react-textarea-autosize';
|
|
||||||
import { length } from 'stringz';
|
|
||||||
// eslint-disable-next-line import/extensions
|
|
||||||
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
|
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
||||||
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
|
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { GIFV } from 'flavours/glitch/components/gifv';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import Audio from 'flavours/glitch/features/audio';
|
|
||||||
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
|
|
||||||
import { UploadProgress } from 'flavours/glitch/features/compose/components/upload_progress';
|
|
||||||
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
|
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
|
||||||
import { assetHost } from 'flavours/glitch/utils/config';
|
|
||||||
|
|
||||||
import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
|
|
||||||
import Video, { getPointerPosition } from '../../video';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
|
|
||||||
applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
|
|
||||||
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
|
|
||||||
chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
|
|
||||||
discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
|
|
||||||
discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
|
||||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
|
||||||
account: state.getIn(['accounts', me]),
|
|
||||||
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
|
|
||||||
description: state.getIn(['compose', 'media_modal', 'description']),
|
|
||||||
lang: state.getIn(['compose', 'language']),
|
|
||||||
focusX: state.getIn(['compose', 'media_modal', 'focusX']),
|
|
||||||
focusY: state.getIn(['compose', 'media_modal', 'focusY']),
|
|
||||||
dirty: state.getIn(['compose', 'media_modal', 'dirty']),
|
|
||||||
is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
|
||||||
|
|
||||||
onSave: (description, x, y) => {
|
|
||||||
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
|
||||||
},
|
|
||||||
|
|
||||||
onChangeDescription: (description) => {
|
|
||||||
dispatch(onChangeMediaDescription(description));
|
|
||||||
},
|
|
||||||
|
|
||||||
onChangeFocus: (focusX, focusY) => {
|
|
||||||
dispatch(onChangeMediaFocus(focusX, focusY));
|
|
||||||
},
|
|
||||||
|
|
||||||
onSelectThumbnail: files => {
|
|
||||||
dispatch(uploadThumbnail(id, files[0]));
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
|
|
||||||
.replace(/\n/g, ' ')
|
|
||||||
.replace(/\*\*\*\*\*\*/g, '\n\n');
|
|
||||||
|
|
||||||
class ImageLoader extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
src: PropTypes.string.isRequired,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
loading: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const image = new Image();
|
|
||||||
image.addEventListener('load', () => this.setState({ loading: false }));
|
|
||||||
image.src = this.props.src;
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { loading } = this.state;
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <canvas width={this.props.width} height={this.props.height} />;
|
|
||||||
} else {
|
|
||||||
return <img {...this.props} alt='' />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class FocalPointModal extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
isUploadingThumbnail: PropTypes.bool,
|
|
||||||
onSave: PropTypes.func.isRequired,
|
|
||||||
onChangeDescription: PropTypes.func.isRequired,
|
|
||||||
onChangeFocus: PropTypes.func.isRequired,
|
|
||||||
onSelectThumbnail: PropTypes.func.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
dragging: false,
|
|
||||||
dirty: false,
|
|
||||||
progress: 0,
|
|
||||||
loading: true,
|
|
||||||
ocrStatus: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseDown = e => {
|
|
||||||
document.addEventListener('mousemove', this.handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', this.handleMouseUp);
|
|
||||||
|
|
||||||
this.updatePosition(e);
|
|
||||||
this.setState({ dragging: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTouchStart = e => {
|
|
||||||
document.addEventListener('touchmove', this.handleMouseMove);
|
|
||||||
document.addEventListener('touchend', this.handleTouchEnd);
|
|
||||||
|
|
||||||
this.updatePosition(e);
|
|
||||||
this.setState({ dragging: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseMove = e => {
|
|
||||||
this.updatePosition(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
||||||
|
|
||||||
this.setState({ dragging: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTouchEnd = () => {
|
|
||||||
document.removeEventListener('touchmove', this.handleMouseMove);
|
|
||||||
document.removeEventListener('touchend', this.handleTouchEnd);
|
|
||||||
|
|
||||||
this.setState({ dragging: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePosition = e => {
|
|
||||||
const { x, y } = getPointerPosition(this.node, e);
|
|
||||||
const focusX = (x - .5) * 2;
|
|
||||||
const focusY = (y - .5) * -2;
|
|
||||||
|
|
||||||
this.props.onChangeFocus(focusX, focusY);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = e => {
|
|
||||||
this.props.onChangeDescription(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
|
||||||
this.props.onChangeDescription(e.target.value);
|
|
||||||
this.handleSubmit(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
|
|
||||||
};
|
|
||||||
|
|
||||||
getCloseConfirmationMessage = () => {
|
|
||||||
const { intl, dirty } = this.props;
|
|
||||||
|
|
||||||
if (dirty) {
|
|
||||||
return {
|
|
||||||
message: intl.formatMessage(messages.discardMessage),
|
|
||||||
confirm: intl.formatMessage(messages.discardConfirm),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTextDetection = () => {
|
|
||||||
this._detectText();
|
|
||||||
};
|
|
||||||
|
|
||||||
_detectText = (refreshCache = false) => {
|
|
||||||
const { media } = this.props;
|
|
||||||
|
|
||||||
this.setState({ detecting: true });
|
|
||||||
|
|
||||||
fetchTesseract().then(({ createWorker }) => {
|
|
||||||
const worker = createWorker({
|
|
||||||
workerPath: tesseractWorkerPath,
|
|
||||||
corePath: tesseractCorePath,
|
|
||||||
langPath: `${assetHost}/ocr/lang-data`,
|
|
||||||
logger: ({ status, progress }) => {
|
|
||||||
if (status === 'recognizing text') {
|
|
||||||
this.setState({ ocrStatus: 'detecting', progress });
|
|
||||||
} else {
|
|
||||||
this.setState({ ocrStatus: 'preparing', progress });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cacheMethod: refreshCache ? 'refresh' : 'write',
|
|
||||||
});
|
|
||||||
|
|
||||||
let media_url = media.get('url');
|
|
||||||
|
|
||||||
if (window.URL && URL.createObjectURL) {
|
|
||||||
try {
|
|
||||||
media_url = URL.createObjectURL(media.get('file'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (async () => {
|
|
||||||
await worker.load();
|
|
||||||
await worker.loadLanguage('eng');
|
|
||||||
await worker.initialize('eng');
|
|
||||||
const { data: { text } } = await worker.recognize(media_url);
|
|
||||||
this.setState({ detecting: false });
|
|
||||||
this.props.onChangeDescription(removeExtraLineBreaks(text));
|
|
||||||
await worker.terminate();
|
|
||||||
})().catch((e) => {
|
|
||||||
if (refreshCache) {
|
|
||||||
throw e;
|
|
||||||
} else {
|
|
||||||
this._detectText(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
this.setState({ detecting: false });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleThumbnailChange = e => {
|
|
||||||
if (e.target.files.length > 0) {
|
|
||||||
this.props.onSelectThumbnail(e.target.files);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setFileInputRef = c => {
|
|
||||||
this.fileInput = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFileInputClick = () => {
|
|
||||||
this.fileInput.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props;
|
|
||||||
const { dragging, detecting, progress, ocrStatus } = this.state;
|
|
||||||
const x = (focusX / 2) + .5;
|
|
||||||
const y = (focusY / -2) + .5;
|
|
||||||
|
|
||||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
|
||||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
|
||||||
const focals = ['image', 'gifv'].includes(media.get('type'));
|
|
||||||
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
|
|
||||||
|
|
||||||
const previewRatio = 16/9;
|
|
||||||
const previewWidth = 200;
|
|
||||||
const previewHeight = previewWidth / previewRatio;
|
|
||||||
|
|
||||||
let descriptionLabel = null;
|
|
||||||
|
|
||||||
if (media.get('type') === 'audio') {
|
|
||||||
descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
|
|
||||||
} else if (media.get('type') === 'video') {
|
|
||||||
descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
|
|
||||||
} else {
|
|
||||||
descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ocrMessage = '';
|
|
||||||
if (ocrStatus === 'detecting') {
|
|
||||||
ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />;
|
|
||||||
} else {
|
|
||||||
ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
|
|
||||||
<div className='report-modal__target'>
|
|
||||||
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
|
||||||
<FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='report-modal__container'>
|
|
||||||
<form className='report-modal__comment' onSubmit={this.handleSubmit} >
|
|
||||||
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
|
|
||||||
|
|
||||||
{thumbnailable && (
|
|
||||||
<>
|
|
||||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
|
||||||
|
|
||||||
<Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
|
||||||
|
|
||||||
<input
|
|
||||||
id='upload-modal__thumbnail'
|
|
||||||
ref={this.setFileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept='image/png,image/jpeg'
|
|
||||||
onChange={this.handleThumbnailChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
disabled={isUploadingThumbnail || is_changing_upload}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<hr className='setting-divider' />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className='setting-text-label' htmlFor='upload-modal__description'>
|
|
||||||
{descriptionLabel}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className='setting-text__wrapper'>
|
|
||||||
<Textarea
|
|
||||||
id='upload-modal__description'
|
|
||||||
className='setting-text light'
|
|
||||||
value={detecting ? '…' : description}
|
|
||||||
lang={lang}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
disabled={detecting || is_changing_upload}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='setting-text__modifiers'>
|
|
||||||
<UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='setting-text__toolbar'>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
disabled={detecting || media.get('type') !== 'image' || is_changing_upload}
|
|
||||||
className='link-button'
|
|
||||||
onClick={this.handleTextDetection}
|
|
||||||
>
|
|
||||||
<FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' />
|
|
||||||
</button>
|
|
||||||
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='submit'
|
|
||||||
disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload}
|
|
||||||
text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className='focal-point-modal__content'>
|
|
||||||
{focals && (
|
|
||||||
<div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
|
|
||||||
{media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
|
|
||||||
{media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />}
|
|
||||||
|
|
||||||
<div className='focal-point__preview'>
|
|
||||||
<strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
|
|
||||||
<div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
|
|
||||||
<div className='focal-point__overlay' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{media.get('type') === 'video' && (
|
|
||||||
<Video
|
|
||||||
preview={media.get('preview_url')}
|
|
||||||
frameRate={media.getIn(['meta', 'original', 'frame_rate'])}
|
|
||||||
blurhash={media.get('blurhash')}
|
|
||||||
src={media.get('url')}
|
|
||||||
detailed
|
|
||||||
inline
|
|
||||||
editable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{media.get('type') === 'audio' && (
|
|
||||||
<Audio
|
|
||||||
src={media.get('url')}
|
|
||||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
|
||||||
height={150}
|
|
||||||
poster={media.get('preview_url') || account.get('avatar_static')}
|
|
||||||
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
|
||||||
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
|
||||||
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
|
||||||
editable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps, null, {
|
|
||||||
forwardRef: true,
|
|
||||||
})(injectIntl(FocalPointModal, { forwardRef: true }));
|
|
@ -4,6 +4,7 @@ import { PureComponent } from 'react';
|
|||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
import Base from 'flavours/glitch/components/modal_root';
|
import Base from 'flavours/glitch/components/modal_root';
|
||||||
|
import { AltTextModal } from 'flavours/glitch/features/alt_text_modal';
|
||||||
import {
|
import {
|
||||||
MuteModal,
|
MuteModal,
|
||||||
BlockModal,
|
BlockModal,
|
||||||
@ -41,7 +42,6 @@ import {
|
|||||||
import DeprecatedSettingsModal from './deprecated_settings_modal';
|
import DeprecatedSettingsModal from './deprecated_settings_modal';
|
||||||
import DoodleModal from './doodle_modal';
|
import DoodleModal from './doodle_modal';
|
||||||
import { FavouriteModal } from './favourite_modal';
|
import { FavouriteModal } from './favourite_modal';
|
||||||
import FocalPointModal from './focal_point_modal';
|
|
||||||
import ImageModal from './image_modal';
|
import ImageModal from './image_modal';
|
||||||
import MediaModal from './media_modal';
|
import MediaModal from './media_modal';
|
||||||
import { ModalPlaceholder } from './modal_placeholder';
|
import { ModalPlaceholder } from './modal_placeholder';
|
||||||
@ -72,7 +72,7 @@ export const MODAL_COMPONENTS = {
|
|||||||
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
|
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
|
||||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||||
'EMBED': EmbedModal,
|
'EMBED': EmbedModal,
|
||||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
|
||||||
'LIST_ADDER': ListAdder,
|
'LIST_ADDER': ListAdder,
|
||||||
'COMPARE_HISTORY': CompareHistoryModal,
|
'COMPARE_HISTORY': CompareHistoryModal,
|
||||||
'FILTER': FilterModal,
|
'FILTER': FilterModal,
|
||||||
@ -146,8 +146,7 @@ export default class ModalRoot extends PureComponent {
|
|||||||
<>
|
<>
|
||||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
|
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
|
||||||
{(SpecificComponent) => {
|
{(SpecificComponent) => {
|
||||||
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />;
|
||||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />;
|
|
||||||
}}
|
}}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ const mapDispatchToProps = dispatch => ({
|
|||||||
if (confirmationMessage) {
|
if (confirmationMessage) {
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal({
|
openModal({
|
||||||
|
previousModalProps: confirmationMessage.props,
|
||||||
modalType: 'CONFIRM',
|
modalType: 'CONFIRM',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
message: confirmationMessage.message,
|
message: confirmationMessage.message,
|
||||||
@ -24,7 +25,8 @@ const mapDispatchToProps = dispatch => ({
|
|||||||
modalType: undefined,
|
modalType: undefined,
|
||||||
ignoreFocus: { ignoreFocus },
|
ignoreFocus: { ignoreFocus },
|
||||||
})),
|
})),
|
||||||
} }),
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(closeModal({
|
dispatch(closeModal({
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||||
|
|
||||||
|
import { changeUploadCompose } from 'flavours/glitch/actions/compose_typed';
|
||||||
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -38,18 +39,12 @@ import {
|
|||||||
COMPOSE_COMPOSING_CHANGE,
|
COMPOSE_COMPOSING_CHANGE,
|
||||||
COMPOSE_CONTENT_TYPE_CHANGE,
|
COMPOSE_CONTENT_TYPE_CHANGE,
|
||||||
COMPOSE_EMOJI_INSERT,
|
COMPOSE_EMOJI_INSERT,
|
||||||
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
|
||||||
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
|
||||||
COMPOSE_UPLOAD_CHANGE_FAIL,
|
|
||||||
COMPOSE_DOODLE_SET,
|
COMPOSE_DOODLE_SET,
|
||||||
COMPOSE_RESET,
|
COMPOSE_RESET,
|
||||||
COMPOSE_POLL_ADD,
|
COMPOSE_POLL_ADD,
|
||||||
COMPOSE_POLL_REMOVE,
|
COMPOSE_POLL_REMOVE,
|
||||||
COMPOSE_POLL_OPTION_CHANGE,
|
COMPOSE_POLL_OPTION_CHANGE,
|
||||||
COMPOSE_POLL_SETTINGS_CHANGE,
|
COMPOSE_POLL_SETTINGS_CHANGE,
|
||||||
INIT_MEDIA_EDIT_MODAL,
|
|
||||||
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
|
|
||||||
COMPOSE_CHANGE_MEDIA_FOCUS,
|
|
||||||
COMPOSE_CHANGE_MEDIA_ORDER,
|
COMPOSE_CHANGE_MEDIA_ORDER,
|
||||||
COMPOSE_SET_STATUS,
|
COMPOSE_SET_STATUS,
|
||||||
COMPOSE_FOCUS,
|
COMPOSE_FOCUS,
|
||||||
@ -102,13 +97,6 @@ const initialState = ImmutableMap({
|
|||||||
resetFileKey: Math.floor((Math.random() * 0x10000)),
|
resetFileKey: Math.floor((Math.random() * 0x10000)),
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
tagHistory: ImmutableList(),
|
tagHistory: ImmutableList(),
|
||||||
media_modal: ImmutableMap({
|
|
||||||
id: null,
|
|
||||||
description: '',
|
|
||||||
focusX: 0,
|
|
||||||
focusY: 0,
|
|
||||||
dirty: false,
|
|
||||||
}),
|
|
||||||
doodle: ImmutableMap({
|
doodle: ImmutableMap({
|
||||||
fg: 'rgb( 0, 0, 0)',
|
fg: 'rgb( 0, 0, 0)',
|
||||||
bg: 'rgb(255, 255, 255)',
|
bg: 'rgb(255, 255, 255)',
|
||||||
@ -370,7 +358,24 @@ const updatePoll = (state, index, value, maxOptions) => state.updateIn(['poll',
|
|||||||
return tmp;
|
return tmp;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function compose(state = initialState, action) {
|
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||||
|
export const composeReducer = (state = initialState, action) => {
|
||||||
|
if (changeUploadCompose.fulfilled.match(action)) {
|
||||||
|
return state
|
||||||
|
.set('is_changing_upload', false)
|
||||||
|
.update('media_attachments', list => list.map(item => {
|
||||||
|
if (item.get('id') === action.payload.media.id) {
|
||||||
|
return fromJS(action.payload.media).set('unattached', !action.payload.attached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
} else if (changeUploadCompose.pending.match(action)) {
|
||||||
|
return state.set('is_changing_upload', true);
|
||||||
|
} else if (changeUploadCompose.rejected.match(action)) {
|
||||||
|
return state.set('is_changing_upload', false);
|
||||||
|
}
|
||||||
|
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
case STORE_HYDRATE:
|
||||||
return hydrate(state, action.state.get('compose'));
|
return hydrate(state, action.state.get('compose'));
|
||||||
@ -476,14 +481,10 @@ export default function compose(state = initialState, action) {
|
|||||||
});
|
});
|
||||||
case COMPOSE_SUBMIT_REQUEST:
|
case COMPOSE_SUBMIT_REQUEST:
|
||||||
return state.set('is_submitting', true);
|
return state.set('is_submitting', true);
|
||||||
case COMPOSE_UPLOAD_CHANGE_REQUEST:
|
|
||||||
return state.set('is_changing_upload', true);
|
|
||||||
case COMPOSE_SUBMIT_SUCCESS:
|
case COMPOSE_SUBMIT_SUCCESS:
|
||||||
return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
|
return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
|
||||||
case COMPOSE_SUBMIT_FAIL:
|
case COMPOSE_SUBMIT_FAIL:
|
||||||
return state.set('is_submitting', false);
|
return state.set('is_submitting', false);
|
||||||
case COMPOSE_UPLOAD_CHANGE_FAIL:
|
|
||||||
return state.set('is_changing_upload', false);
|
|
||||||
case COMPOSE_UPLOAD_REQUEST:
|
case COMPOSE_UPLOAD_REQUEST:
|
||||||
return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
|
return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
|
||||||
case COMPOSE_UPLOAD_PROCESSING:
|
case COMPOSE_UPLOAD_PROCESSING:
|
||||||
@ -512,20 +513,6 @@ export default function compose(state = initialState, action) {
|
|||||||
|
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
case INIT_MEDIA_EDIT_MODAL: {
|
|
||||||
const media = state.get('media_attachments').find(item => item.get('id') === action.id);
|
|
||||||
return state.set('media_modal', ImmutableMap({
|
|
||||||
id: action.id,
|
|
||||||
description: media.get('description') || '',
|
|
||||||
focusX: media.getIn(['meta', 'focus', 'x'], 0),
|
|
||||||
focusY: media.getIn(['meta', 'focus', 'y'], 0),
|
|
||||||
dirty: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
case COMPOSE_CHANGE_MEDIA_DESCRIPTION:
|
|
||||||
return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true);
|
|
||||||
case COMPOSE_CHANGE_MEDIA_FOCUS:
|
|
||||||
return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true);
|
|
||||||
case COMPOSE_MENTION:
|
case COMPOSE_MENTION:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
|
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
|
||||||
@ -563,17 +550,6 @@ export default function compose(state = initialState, action) {
|
|||||||
}
|
}
|
||||||
case COMPOSE_EMOJI_INSERT:
|
case COMPOSE_EMOJI_INSERT:
|
||||||
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
|
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
|
||||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
|
||||||
return state
|
|
||||||
.set('is_changing_upload', false)
|
|
||||||
.setIn(['media_modal', 'dirty'], false)
|
|
||||||
.update('media_attachments', list => list.map(item => {
|
|
||||||
if (item.get('id') === action.media.id) {
|
|
||||||
return fromJS(action.media).set('unattached', !action.attached);
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}));
|
|
||||||
case COMPOSE_DOODLE_SET:
|
case COMPOSE_DOODLE_SET:
|
||||||
return state.mergeIn(['doodle'], action.options);
|
return state.mergeIn(['doodle'], action.options);
|
||||||
case REDRAFT: {
|
case REDRAFT: {
|
||||||
@ -671,4 +647,4 @@ export default function compose(state = initialState, action) {
|
|||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
@ -7,7 +7,7 @@ import { accountsReducer } from './accounts';
|
|||||||
import accounts_map from './accounts_map';
|
import accounts_map from './accounts_map';
|
||||||
import alerts from './alerts';
|
import alerts from './alerts';
|
||||||
import announcements from './announcements';
|
import announcements from './announcements';
|
||||||
import compose from './compose';
|
import { composeReducer } from './compose';
|
||||||
import contexts from './contexts';
|
import contexts from './contexts';
|
||||||
import conversations from './conversations';
|
import conversations from './conversations';
|
||||||
import custom_emojis from './custom_emojis';
|
import custom_emojis from './custom_emojis';
|
||||||
@ -61,7 +61,7 @@ const reducers = {
|
|||||||
push_notifications,
|
push_notifications,
|
||||||
server,
|
server,
|
||||||
contexts,
|
contexts,
|
||||||
compose,
|
compose: composeReducer,
|
||||||
search: searchReducer,
|
search: searchReducer,
|
||||||
media_attachments,
|
media_attachments,
|
||||||
notifications,
|
notifications,
|
||||||
|
@ -3,7 +3,6 @@ import { Record as ImmutableRecord, Stack } from 'immutable';
|
|||||||
|
|
||||||
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
||||||
|
|
||||||
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
|
|
||||||
import type { ModalType } from '../actions/modal';
|
import type { ModalType } from '../actions/modal';
|
||||||
import { openModal, closeModal } from '../actions/modal';
|
import { openModal, closeModal } from '../actions/modal';
|
||||||
|
|
||||||
@ -53,12 +52,36 @@ const pushModal = (
|
|||||||
state: State,
|
state: State,
|
||||||
modalType: ModalType,
|
modalType: ModalType,
|
||||||
modalProps: ModalProps,
|
modalProps: ModalProps,
|
||||||
|
previousModalProps?: ModalProps,
|
||||||
): State => {
|
): State => {
|
||||||
return state.withMutations((record) => {
|
return state.withMutations((record) => {
|
||||||
record.set('ignoreFocus', false);
|
record.set('ignoreFocus', false);
|
||||||
record.update('stack', (stack) =>
|
record.update('stack', (stack) => {
|
||||||
stack.unshift(Modal({ modalType, modalProps })),
|
let tmp = stack;
|
||||||
|
|
||||||
|
// With this option, we update the previously opened modal, so that when the
|
||||||
|
// current (new) modal is closed, the previous modal is re-opened with different
|
||||||
|
// props. Specifically, this is useful for the confirmation modal.
|
||||||
|
if (previousModalProps) {
|
||||||
|
const previousModal = tmp.first() as Modal | undefined;
|
||||||
|
|
||||||
|
if (previousModal) {
|
||||||
|
tmp = tmp.shift().unshift(
|
||||||
|
Modal({
|
||||||
|
modalType: previousModal.modalType,
|
||||||
|
modalProps: {
|
||||||
|
...previousModal.modalProps,
|
||||||
|
...previousModalProps,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp = tmp.unshift(Modal({ modalType, modalProps }));
|
||||||
|
|
||||||
|
return tmp;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,11 +91,10 @@ export const modalReducer: Reducer<State> = (state = initialState, action) => {
|
|||||||
state,
|
state,
|
||||||
action.payload.modalType,
|
action.payload.modalType,
|
||||||
action.payload.modalProps,
|
action.payload.modalProps,
|
||||||
|
action.payload.previousModalProps,
|
||||||
);
|
);
|
||||||
else if (closeModal.match(action)) return popModal(state, action.payload);
|
else if (closeModal.match(action)) return popModal(state, action.payload);
|
||||||
// TODO: type those actions
|
// TODO: type those actions
|
||||||
else if (action.type === COMPOSE_UPLOAD_CHANGE_SUCCESS)
|
|
||||||
return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
|
|
||||||
else if (timelineDelete.match(action))
|
else if (timelineDelete.match(action))
|
||||||
return state.update('stack', (stack) =>
|
return state.update('stack', (stack) =>
|
||||||
stack.filterNot(
|
stack.filterNot(
|
||||||
|
@ -85,6 +85,14 @@
|
|||||||
outline: $ui-button-icon-focus-outline;
|
outline: $ui-button-icon-focus-outline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
&--dangerous {
|
&--dangerous {
|
||||||
background-color: var(--error-background-color);
|
background-color: var(--error-background-color);
|
||||||
color: var(--on-error-color);
|
color: var(--on-error-color);
|
||||||
@ -3811,58 +3819,6 @@ input.glitch-setting-text {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-text {
|
|
||||||
display: block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
color: $primary-text-color;
|
|
||||||
background: $ui-base-color;
|
|
||||||
padding: 7px 10px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 22px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--background-border-color);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__wrapper {
|
|
||||||
background: $ui-base-color;
|
|
||||||
border: 1px solid var(--background-border-color);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
.setting-text {
|
|
||||||
border: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__modifiers {
|
|
||||||
color: $inverted-text-color;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: $white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (width <= 600px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -6442,6 +6398,35 @@ a.status-card {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: #000;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
.gifv video {
|
||||||
|
outline: 1px solid var(--media-outline-color);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
.gifv video,
|
||||||
|
.video-player,
|
||||||
|
.audio-player {
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 45vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-paste-text {
|
.copy-paste-text {
|
||||||
@ -6790,62 +6775,6 @@ a.status-card {
|
|||||||
margin-bottom: 29px;
|
margin-bottom: 29px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-modal__comment {
|
|
||||||
padding: 20px;
|
|
||||||
border-inline-end: 1px solid var(--background-border-color);
|
|
||||||
max-width: 320px;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-text-label {
|
|
||||||
display: block;
|
|
||||||
color: $secondary-text-color;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-text {
|
|
||||||
width: 100%;
|
|
||||||
resize: none;
|
|
||||||
min-height: 100px;
|
|
||||||
max-height: 50vh;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
@media screen and (height <= 600px) {
|
|
||||||
max-height: 20vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: $no-columns-breakpoint) {
|
|
||||||
max-height: 20vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-toggle {
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
color: $inverted-text-color;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (width <= 480px) {
|
|
||||||
padding: 10px;
|
|
||||||
max-width: 100%;
|
|
||||||
order: 2;
|
|
||||||
|
|
||||||
.setting-toggle {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-modal {
|
.actions-modal {
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
max-width: 80vw;
|
max-width: 80vw;
|
||||||
@ -7388,11 +7317,6 @@ img.modal-warning {
|
|||||||
outline: 1px solid var(--media-outline-color);
|
outline: 1px solid var(--media-outline-color);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
|
|
||||||
&.editable {
|
|
||||||
border-radius: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.inactive {
|
&.inactive {
|
||||||
audio,
|
audio,
|
||||||
.video-player__controls {
|
.video-player__controls {
|
||||||
@ -7461,11 +7385,6 @@ img.modal-warning {
|
|||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
&.editable {
|
|
||||||
border-radius: 0;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailed-status & {
|
.detailed-status & {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -7788,6 +7707,14 @@ img.modal-warning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gifv {
|
.gifv {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
video {
|
video {
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@ -8091,24 +8018,14 @@ noscript {
|
|||||||
|
|
||||||
.focal-point {
|
.focal-point {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: move;
|
cursor: grab;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: $base-shadow-color;
|
|
||||||
|
|
||||||
img,
|
&.dragging {
|
||||||
video,
|
cursor: grabbing;
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
max-height: 80vh;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
margin: 0;
|
|
||||||
object-fit: contain;
|
|
||||||
background: $base-shadow-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__reticle {
|
&__reticle {
|
||||||
@ -8116,54 +8033,10 @@ noscript {
|
|||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
background: url('~images/reticle.png') no-repeat 0 0;
|
border: 2px solid #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
|
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
|
||||||
}
|
pointer-events: none;
|
||||||
|
|
||||||
&__overlay {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
top: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__preview {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
inset-inline-end: 10px;
|
|
||||||
z-index: 2;
|
|
||||||
cursor: move;
|
|
||||||
transition: opacity 0.1s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: $primary-text-color;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (width <= 480px) {
|
|
||||||
img,
|
|
||||||
video {
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__preview {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10885,12 +10758,7 @@ noscript {
|
|||||||
.compose-form__actions {
|
.compose-form__actions {
|
||||||
.button {
|
.button {
|
||||||
display: block; // Otherwise text-ellipsis doesn't work
|
display: block; // Otherwise text-ellipsis doesn't work
|
||||||
font-size: 14px;
|
|
||||||
line-height: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,6 +76,18 @@ code {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -541,6 +553,7 @@ code {
|
|||||||
.actions {
|
.actions {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
&.actions--top {
|
&.actions--top {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@ -553,9 +566,7 @@ code {
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button:not(.button, .link-button) {
|
||||||
.button,
|
|
||||||
.block-button {
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
@ -630,6 +641,18 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label_input {
|
.label_input {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__loading-indicator {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 10px 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
&__wrapper {
|
&__wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
@ -127,9 +127,7 @@
|
|||||||
.actions-modal ul li:not(:empty) a:focus button,
|
.actions-modal ul li:not(:empty) a:focus button,
|
||||||
.actions-modal ul li:not(:empty) a:hover,
|
.actions-modal ul li:not(:empty) a:hover,
|
||||||
.actions-modal ul li:not(:empty) a:hover button,
|
.actions-modal ul li:not(:empty) a:hover button,
|
||||||
.simple_form .block-button,
|
.simple_form button:not(.button, .link-button) {
|
||||||
.simple_form .button,
|
|
||||||
.simple_form button {
|
|
||||||
color: $white;
|
color: $white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,6 +140,11 @@
|
|||||||
border-top-color: lighten($ui-base-color, 4%);
|
border-top-color: lighten($ui-base-color, 4%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-modal__content__preview {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--modal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.reactions-bar__item:hover,
|
.reactions-bar__item:hover,
|
||||||
.reactions-bar__item:focus,
|
.reactions-bar__item:focus,
|
||||||
.reactions-bar__item:active {
|
.reactions-bar__item:active {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user