Change media reordering design in the compose form in web UI (#32093)

pull/2860/head
Eugen Rochko 2024-09-27 17:09:39 +02:00 committed by GitHub
parent cdd7526531
commit 11a12e56b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 423 additions and 154 deletions

View File

@ -1,81 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import spring from 'react-motion/lib/spring';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose, initMediaEditModal } from 'mastodon/actions/compose';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import Motion from 'mastodon/features/ui/util/optional_motion';
export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => {
const dispatch = useDispatch();
const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id));
const sensitive = useSelector(state => state.getIn(['compose', 'spoiler']));
const handleUndoClick = useCallback(() => {
dispatch(undoUploadCompose(id));
}, [dispatch, id]);
const handleFocalPointClick = useCallback(() => {
dispatch(initMediaEditModal(id));
}, [dispatch, id]);
const handleDragStart = useCallback(() => {
onDragStart(id);
}, [onDragStart, id]);
const handleDragEnter = useCallback(() => {
onDragEnter(id);
}, [onDragEnter, id]);
if (!media) {
return null;
}
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const missingDescription = (media.get('description') || '').length === 0;
return (
<div className='compose-form__upload' draggable onDragStart={handleDragStart} onDragEnter={handleDragEnter} onDragEnd={onDragEnd}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
{sensitive && <Blurhash
hash={media.get('blurhash')}
className='compose-form__upload__preview'
/>}
<div className='compose-form__upload__actions'>
<button type='button' className='icon-button compose-form__upload__delete' onClick={handleUndoClick}><Icon icon={CloseIcon} /></button>
<button type='button' className='icon-button' onClick={handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div>
<div className='compose-form__upload__warning'>
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
</div>
</div>
)}
</Motion>
</div>
);
};
Upload.propTypes = {
id: PropTypes.string,
onDragEnter: PropTypes.func,
onDragStart: PropTypes.func,
onDragEnd: PropTypes.func,
};

View File

@ -0,0 +1,130 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import {
undoUploadCompose,
initMediaEditModal,
} from 'mastodon/actions/compose';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
export const Upload: React.FC<{
id: string;
dragging?: boolean;
overlay?: boolean;
tall?: boolean;
wide?: boolean;
}> = ({ id, dragging, overlay, tall, wide }) => {
const dispatch = useAppDispatch();
const media = useAppSelector(
(state) =>
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
| MediaAttachment
| undefined,
);
const sensitive = useAppSelector(
(state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
);
const handleUndoClick = useCallback(() => {
dispatch(undoUploadCompose(id));
}, [dispatch, id]);
const handleFocalPointClick = useCallback(() => {
dispatch(initMediaEditModal(id));
}, [dispatch, id]);
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
if (!media) {
return null;
}
const focusX = media.getIn(['meta', 'focus', 'x']) as number;
const focusY = media.getIn(['meta', 'focus', 'y']) as number;
const x = (focusX / 2 + 0.5) * 100;
const y = (focusY / -2 + 0.5) * 100;
const missingDescription =
((media.get('description') as string | undefined) ?? '').length === 0;
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
className={classNames('compose-form__upload media-gallery__item', {
dragging,
overlay,
'media-gallery__item--tall': tall,
'media-gallery__item--wide': wide,
})}
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
<div
className='compose-form__upload__thumbnail'
style={{
backgroundImage: !sensitive
? `url(${media.get('preview_url') as string})`
: undefined,
backgroundPosition: `${x}% ${y}%`,
}}
>
{sensitive && (
<Blurhash
hash={media.get('blurhash') as string}
className='compose-form__upload__preview'
/>
)}
<div className='compose-form__upload__actions'>
<button
type='button'
className='icon-button compose-form__upload__delete'
onClick={handleUndoClick}
>
<Icon id='close' icon={CloseIcon} />
</button>
<button
type='button'
className='icon-button'
onClick={handleFocalPointClick}
>
<Icon id='edit' icon={EditIcon} />{' '}
<FormattedMessage id='upload_form.edit' defaultMessage='Edit' />
</button>
</div>
<div className='compose-form__upload__warning'>
<button
type='button'
className={classNames('icon-button', {
active: missingDescription,
})}
onClick={handleFocalPointClick}
>
{missingDescription && <Icon id='warning' icon={WarningIcon} />} ALT
</button>
</div>
</div>
</div>
);
};

View File

@ -1,53 +0,0 @@
import { useRef, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { changeMediaOrder } from 'mastodon/actions/compose';
import { Upload } from './upload';
import { UploadProgress } from './upload_progress';
export const UploadForm = () => {
const dispatch = useDispatch();
const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id')));
const active = useSelector(state => state.getIn(['compose', 'is_uploading']));
const progress = useSelector(state => state.getIn(['compose', 'progress']));
const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing']));
const dragItem = useRef();
const dragOverItem = useRef();
const handleDragStart = useCallback(id => {
dragItem.current = id;
}, [dragItem]);
const handleDragEnter = useCallback(id => {
dragOverItem.current = id;
}, [dragOverItem]);
const handleDragEnd = useCallback(() => {
dispatch(changeMediaOrder(dragItem.current, dragOverItem.current));
dragItem.current = null;
dragOverItem.current = null;
}, [dispatch, dragItem, dragOverItem]);
return (
<>
<UploadProgress active={active} progress={progress} isProcessing={isProcessing} />
{mediaIds.size > 0 && (
<div className='compose-form__uploads'>
{mediaIds.map(id => (
<Upload
key={id}
id={id}
onDragStart={handleDragStart}
onDragEnter={handleDragEnter}
onDragEnd={handleDragEnd}
/>
))}
</div>
)}
</>
);
};

View File

@ -0,0 +1,185 @@
import { useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import type { List } from 'immutable';
import type {
DragStartEvent,
DragEndEvent,
UniqueIdentifier,
Announcements,
ScreenReaderInstructions,
} from '@dnd-kit/core';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { changeMediaOrder } from 'mastodon/actions/compose';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { Upload } from './upload';
import { UploadProgress } from './upload_progress';
const messages = defineMessages({
screenReaderInstructions: {
id: 'upload_form.drag_and_drop.instructions',
defaultMessage:
'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.',
},
onDragStart: {
id: 'upload_form.drag_and_drop.on_drag_start',
defaultMessage: 'Picked up media attachment {item}.',
},
onDragOver: {
id: 'upload_form.drag_and_drop.on_drag_over',
defaultMessage: 'Media attachment {item} was moved.',
},
onDragEnd: {
id: 'upload_form.drag_and_drop.on_drag_end',
defaultMessage: 'Media attachment {item} was dropped.',
},
onDragCancel: {
id: 'upload_form.drag_and_drop.on_drag_cancel',
defaultMessage:
'Dragging was cancelled. Media attachment {item} was dropped.',
},
});
export const UploadForm: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const mediaIds = useAppSelector(
(state) =>
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
);
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
);
const progress = useAppSelector(
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
);
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
);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = useCallback(
(e: DragStartEvent) => {
const { active } = e;
setActiveId(active.id);
},
[setActiveId],
);
const handleDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (over && active.id !== over.id) {
dispatch(changeMediaOrder(active.id, over.id));
}
setActiveId(null);
},
[dispatch, setActiveId],
);
const accessibility: {
screenReaderInstructions: ScreenReaderInstructions;
announcements: Announcements;
} = useMemo(
() => ({
screenReaderInstructions: {
draggable: intl.formatMessage(messages.screenReaderInstructions),
},
announcements: {
onDragStart({ active }) {
return intl.formatMessage(messages.onDragStart, { item: active.id });
},
onDragOver({ active }) {
return intl.formatMessage(messages.onDragOver, { item: active.id });
},
onDragEnd({ active }) {
return intl.formatMessage(messages.onDragEnd, { item: active.id });
},
onDragCancel({ active }) {
return intl.formatMessage(messages.onDragCancel, { item: active.id });
},
},
}),
[intl],
);
return (
<>
<UploadProgress
active={active}
progress={progress}
isProcessing={isProcessing}
/>
{mediaIds.size > 0 && (
<div
className={`compose-form__uploads media-gallery media-gallery--layout-${mediaIds.size}`}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
accessibility={accessibility}
>
<SortableContext
items={mediaIds.toArray()}
strategy={rectSortingStrategy}
>
{mediaIds.map((id, idx) => (
<Upload
key={id}
id={id}
dragging={id === activeId}
tall={mediaIds.size < 3 || (mediaIds.size === 3 && idx === 0)}
wide={mediaIds.size === 1}
/>
))}
</SortableContext>
<DragOverlay>
{activeId ? <Upload id={activeId as string} overlay /> : null}
</DragOverlay>
</DndContext>
</div>
)}
</>
);
};

View File

@ -852,6 +852,11 @@
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
"upload_form.description": "Describe for people who are blind or have low vision",
"upload_form.drag_and_drop.instructions": "To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.",
"upload_form.drag_and_drop.on_drag_cancel": "Dragging was cancelled. Media attachment {item} was dropped.",
"upload_form.drag_and_drop.on_drag_end": "Media attachment {item} was dropped.",
"upload_form.drag_and_drop.on_drag_over": "Media attachment {item} was moved.",
"upload_form.drag_and_drop.on_drag_start": "Picked up media attachment {item}.",
"upload_form.edit": "Edit",
"upload_form.thumbnail": "Change thumbnail",
"upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",

View File

@ -0,0 +1,2 @@
// Temporary until we type it correctly
export type MediaAttachment = Immutable.Map<string, unknown>;

View File

@ -653,19 +653,39 @@ body > [data-popper-placement] {
}
&__uploads {
display: flex;
gap: 8px;
padding: 0 12px;
flex-wrap: wrap;
align-self: stretch;
align-items: flex-start;
align-content: flex-start;
justify-content: center;
aspect-ratio: 3/2;
}
.media-gallery {
gap: 8px;
}
&__upload {
flex: 1 1 0;
min-width: calc(50% - 8px);
position: relative;
cursor: grab;
&.dragging {
opacity: 0;
}
&.overlay {
height: 100%;
border-radius: 8px;
pointer-events: none;
}
&__drag-handle {
position: absolute;
top: 50%;
inset-inline-start: 0;
transform: translateY(-50%);
color: $white;
background: transparent;
border: 0;
padding: 8px 3px;
cursor: grab;
}
&__actions {
display: flex;
@ -686,8 +706,7 @@ body > [data-popper-placement] {
&__thumbnail {
width: 100%;
height: 144px;
border-radius: 6px;
height: 100%;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
@ -7098,30 +7117,30 @@ a.status-card {
gap: 2px;
&--layout-2 {
.media-gallery__item:nth-child(1) {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
.media-gallery__item:nth-child(2) {
& > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
&--layout-3 {
.media-gallery__item:nth-child(1) {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
.media-gallery__item:nth-child(2) {
& > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.media-gallery__item:nth-child(3) {
& > .media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
@ -7129,26 +7148,26 @@ a.status-card {
}
&--layout-4 {
.media-gallery__item:nth-child(1) {
& > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
}
.media-gallery__item:nth-child(2) {
& > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.media-gallery__item:nth-child(3) {
& > .media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.media-gallery__item:nth-child(4) {
& > .media-gallery__item:nth-child(4) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;

View File

@ -42,6 +42,9 @@
"@babel/preset-react": "^7.22.3",
"@babel/preset-typescript": "^7.21.5",
"@babel/runtime": "^7.22.3",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@formatjs/intl-pluralrules": "^5.2.2",
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",

View File

@ -2010,6 +2010,55 @@ __metadata:
languageName: node
linkType: hard
"@dnd-kit/accessibility@npm:^3.1.0":
version: 3.1.0
resolution: "@dnd-kit/accessibility@npm:3.1.0"
dependencies:
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: 10c0/4f9d24e801d66d4fbb551ec389ed90424dd4c5bbdf527000a618e9abb9833cbd84d9a79e362f470ccbccfbd6d00217a9212c92f3cef66e01c951c7f79625b9d7
languageName: node
linkType: hard
"@dnd-kit/core@npm:^6.1.0":
version: 6.1.0
resolution: "@dnd-kit/core@npm:6.1.0"
dependencies:
"@dnd-kit/accessibility": "npm:^3.1.0"
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10c0/c793eb97cb59285ca8937ebcdfcd27cff09d750ae06722e36ca5ed07925e41abc36a38cff98f9f6056f7a07810878d76909826142a2968330e7e22060e6be584
languageName: node
linkType: hard
"@dnd-kit/sortable@npm:^8.0.0":
version: 8.0.0
resolution: "@dnd-kit/sortable@npm:8.0.0"
dependencies:
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
"@dnd-kit/core": ^6.1.0
react: ">=16.8.0"
checksum: 10c0/a6066c652b892c6a11320c7d8f5c18fdf723e721e8eea37f4ab657dee1ac5e7ca710ac32ce0712a57fe968bc07c13bcea5d5599d90dfdd95619e162befd4d2fb
languageName: node
linkType: hard
"@dnd-kit/utilities@npm:^3.2.2":
version: 3.2.2
resolution: "@dnd-kit/utilities@npm:3.2.2"
dependencies:
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0
languageName: node
linkType: hard
"@dual-bundle/import-meta-resolve@npm:^4.1.0":
version: 4.1.0
resolution: "@dual-bundle/import-meta-resolve@npm:4.1.0"
@ -2753,6 +2802,9 @@ __metadata:
"@babel/preset-react": "npm:^7.22.3"
"@babel/preset-typescript": "npm:^7.21.5"
"@babel/runtime": "npm:^7.22.3"
"@dnd-kit/core": "npm:^6.1.0"
"@dnd-kit/sortable": "npm:^8.0.0"
"@dnd-kit/utilities": "npm:^3.2.2"
"@formatjs/cli": "npm:^6.1.1"
"@formatjs/intl-pluralrules": "npm:^5.2.2"
"@gamestdio/websocket": "npm:^0.3.2"
@ -17205,6 +17257,13 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.0.0":
version: 2.7.0
resolution: "tslib@npm:2.7.0"
checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6
languageName: node
linkType: hard
"tslib@npm:^2.4.0, tslib@npm:^2.6.2":
version: 2.6.3
resolution: "tslib@npm:2.6.3"