Skip to content

Commit 7bbda34

Browse files
committed
added per item text inputs
1 parent fb5c5a2 commit 7bbda34

6 files changed

Lines changed: 349 additions & 43 deletions

File tree

src/app/components/upload-card/UploadCardRenderer.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode, useEffect } from 'react';
1+
import { ReactNode, useEffect, useState } from 'react';
22
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
33
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '$state/upload';
44
import { useMatrixClient } from '$hooks/useMatrixClient';
@@ -8,6 +8,7 @@ import { roomUploadAtomFamily, TUploadItem, TUploadMetadata } from '$state/room/
88
import { useObjectURL } from '$hooks/useObjectURL';
99
import { useMediaConfig } from '$hooks/useMediaConfig';
1010
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
11+
import { DescriptionEditor } from './UploadDescriptionEditor';
1112

1213
type PreviewImageProps = {
1314
fileItem: TUploadItem;
@@ -98,15 +99,15 @@ type UploadCardRendererProps = {
9899
isEncrypted?: boolean;
99100
fileItem: TUploadItem;
100101
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
101-
setDescription: (fileContent: TUploadContent) => void;
102+
setDesc: (fileItem: TUploadItem, body: string, formatted_body: string) => void;
102103
onRemove: (file: TUploadContent) => void;
103104
onComplete?: (upload: UploadSuccess) => void;
104105
};
105106
export function UploadCardRenderer({
106107
isEncrypted,
107108
fileItem,
108109
setMetadata,
109-
setDescription,
110+
setDesc,
110111
onRemove,
111112
onComplete,
112113
}: UploadCardRendererProps) {
@@ -120,6 +121,8 @@ export function UploadCardRenderer({
120121
const { file } = upload;
121122
const fileSizeExceeded = file.size >= allowSize;
122123

124+
const [isDescribed, setIsDescribed] = useState(false);
125+
123126
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
124127
startUpload();
125128
}
@@ -132,9 +135,6 @@ export function UploadCardRenderer({
132135
cancelUpload();
133136
onRemove(file);
134137
};
135-
const handleDescription = () => {
136-
setDescription(file);
137-
};
138138

139139
useEffect(() => {
140140
if (upload.status === UploadStatus.Success) {
@@ -160,13 +160,15 @@ export function UploadCardRenderer({
160160
</Chip>
161161
)}
162162
<IconButton
163-
onClick={handleDescription}
163+
onClick={() => {
164+
setIsDescribed(!isDescribed);
165+
}}
164166
aria-label="Add Upload Description"
165167
variant="SurfaceVariant"
166168
radii="Pill"
167169
size="300"
168170
>
169-
<Icon src={Icons.Pencil} size="50" />
171+
<Icon src={isDescribed ? Icons.ChevronBottom : Icons.ChevronTop} size="50" />
170172
</IconButton>
171173

172174
<IconButton
@@ -212,6 +214,14 @@ export function UploadCardRenderer({
212214
</Text>
213215
</UploadCardError>
214216
)}
217+
{isDescribed && (
218+
<DescriptionEditor
219+
value={fileItem.formatted_body || fileItem.body || fileItem.file.name || ''}
220+
onSave={(htmlBio) => {
221+
setDesc(fileItem, htmlBio, htmlBio);
222+
}}
223+
/>
224+
)}
215225
</>
216226
}
217227
>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { style, globalStyle } from '@vanilla-extract/css';
2+
import { config } from 'folds';
3+
4+
export const BioEditorContainer = style({
5+
backgroundColor: 'var(--sable-bg-container)',
6+
borderRadius: config.radii.R400,
7+
overflow: 'hidden',
8+
});
9+
10+
globalStyle(`${BioEditorContainer} div[class*="EditorTextarea"]`, {
11+
backgroundColor: 'var(--sable-bg-container) !important',
12+
border: 'none !important',
13+
});
14+
15+
globalStyle(`${BioEditorContainer} [class*="Toolbar"]`, {
16+
backgroundColor: 'var(--sable-bg-container) !important',
17+
padding: 'var(--space-S100) !important',
18+
borderTop: '1px solid var(--sable-outline-variant) !important',
19+
});
20+
21+
globalStyle(`${BioEditorContainer} [class*="Toolbar"] button`, {
22+
backgroundColor: 'transparent !important',
23+
boxShadow: 'none !important',
24+
});
25+
26+
globalStyle(`${BioEditorContainer} [class*="Toolbar"] button:hover`, {
27+
backgroundColor: 'var(--sable-surface-variant) !important',
28+
});
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { KeyboardEventHandler, useCallback, useEffect, useState, useRef } from 'react';
2+
import {
3+
Box,
4+
Chip,
5+
Icon,
6+
IconButton,
7+
Icons,
8+
Line,
9+
PopOut,
10+
RectCords,
11+
Spinner,
12+
Text,
13+
config,
14+
} from 'folds';
15+
import { Editor, Transforms } from 'slate';
16+
import { ReactEditor } from 'slate-react';
17+
import { isKeyHotkey } from 'is-hotkey';
18+
import {
19+
AutocompletePrefix,
20+
AutocompleteQuery,
21+
CustomEditor,
22+
EmoticonAutocomplete,
23+
Toolbar,
24+
createEmoticonElement,
25+
getAutocompleteQuery,
26+
getPrevWorldRange,
27+
htmlToEditorInput,
28+
plainToEditorInput,
29+
moveCursor,
30+
toMatrixCustomHTML,
31+
toPlainText,
32+
trimCustomHtml,
33+
useEditor,
34+
} from '$components/editor';
35+
import { useSetting } from '$state/hooks/settings';
36+
import { settingsAtom } from '$state/settings';
37+
import { UseStateProvider } from '$components/UseStateProvider';
38+
import { EmojiBoard } from '$components/emoji-board';
39+
import { mobileOrTablet } from '$utils/user-agent';
40+
import * as css from './UploadDescriptionEditor.css';
41+
42+
type BioEditorProps = {
43+
value?: string | any;
44+
isSaving?: boolean;
45+
imagePackRooms?: any[];
46+
onSave: (htmlContent: string) => void;
47+
};
48+
49+
export function DescriptionEditor({ value, isSaving, imagePackRooms, onSave }: BioEditorProps) {
50+
const editor = useEditor();
51+
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
52+
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
53+
const [toolbar, setToolbar] = useState(false);
54+
55+
const [autocompleteQuery, setAutocompleteQuery] =
56+
useState<AutocompleteQuery<AutocompletePrefix>>();
57+
const [hasChanged, setHasChanged] = useState(false);
58+
59+
const prevValue = useRef(value);
60+
const initialized = useRef(false);
61+
const handleSave = useCallback(() => {
62+
const plainText = toPlainText(editor.children, isMarkdown).trim();
63+
64+
const customHtml = trimCustomHtml(
65+
toMatrixCustomHTML(editor.children, {
66+
allowTextFormatting: true,
67+
allowBlockMarkdown: isMarkdown,
68+
allowInlineMarkdown: isMarkdown,
69+
})
70+
);
71+
72+
onSave(customHtml || plainText);
73+
setHasChanged(false);
74+
}, [editor, isMarkdown, onSave]);
75+
76+
useEffect(() => {
77+
const valueChanged = prevValue.current !== value;
78+
const isFirstValidLoad = !initialized.current && value !== undefined;
79+
80+
if (valueChanged || isFirstValidLoad) {
81+
prevValue.current = value;
82+
83+
let normalizedValue = value;
84+
if (
85+
typeof normalizedValue === 'object' &&
86+
normalizedValue !== null &&
87+
'formatted_body' in normalizedValue
88+
) {
89+
normalizedValue = normalizedValue.formatted_body;
90+
}
91+
92+
const safeValue = typeof normalizedValue === 'string' ? normalizedValue : '';
93+
94+
const incomingPlainText = toPlainText(
95+
htmlToEditorInput(safeValue, isMarkdown),
96+
isMarkdown
97+
).trim();
98+
const currentPlainText = toPlainText(editor.children, isMarkdown).trim();
99+
100+
if (currentPlainText === incomingPlainText && initialized.current) return;
101+
102+
const isLikelyHtml = safeValue.includes('<') || safeValue.includes('>');
103+
const initialValue = isLikelyHtml
104+
? htmlToEditorInput(safeValue, isMarkdown)
105+
: plainToEditorInput(safeValue, isMarkdown);
106+
107+
editor.children = initialValue;
108+
Editor.normalize(editor, { force: true });
109+
Transforms.select(editor, Editor.start(editor, []));
110+
111+
initialized.current = true;
112+
setHasChanged(false);
113+
}
114+
}, [value, editor, isMarkdown]);
115+
116+
const handleKeyDown: KeyboardEventHandler = useCallback(
117+
(evt) => {
118+
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
119+
evt.preventDefault();
120+
handleSave();
121+
}
122+
},
123+
[handleSave, enterForNewline]
124+
);
125+
126+
const handleKeyUp: KeyboardEventHandler = useCallback(
127+
(evt) => {
128+
if (isKeyHotkey('escape', evt)) {
129+
evt.preventDefault();
130+
return;
131+
}
132+
const prevWordRange = getPrevWorldRange(editor);
133+
const query = prevWordRange
134+
? getAutocompleteQuery(editor, prevWordRange, [AutocompletePrefix.Emoticon])
135+
: undefined;
136+
setAutocompleteQuery(query);
137+
},
138+
[editor]
139+
);
140+
141+
const handleCloseAutocomplete = useCallback(() => {
142+
ReactEditor.focus(editor);
143+
setAutocompleteQuery(undefined);
144+
}, [editor]);
145+
146+
const handleEmoticonSelect = (key: string, shortcode: string) => {
147+
editor.insertNode(createEmoticonElement(key, shortcode));
148+
moveCursor(editor);
149+
setHasChanged(true);
150+
};
151+
152+
return (
153+
<Box direction="Column" gap="100">
154+
<Box className={css.BioEditorContainer} direction="Column" style={{ position: 'relative' }}>
155+
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
156+
<EmoticonAutocomplete
157+
imagePackRooms={imagePackRooms || []}
158+
editor={editor}
159+
query={autocompleteQuery}
160+
requestClose={handleCloseAutocomplete}
161+
/>
162+
)}
163+
<CustomEditor
164+
editor={editor}
165+
placeholder="File Description..."
166+
onKeyDown={handleKeyDown}
167+
onKeyUp={handleKeyUp}
168+
maxHeight="200px"
169+
variant="Background"
170+
bottom={
171+
<Box direction="Column" style={{ backgroundColor: 'var(--sable-bg-container)' }}>
172+
<Box
173+
style={{ padding: config.space.S200, paddingTop: 0 }}
174+
alignItems="End"
175+
justifyContent="SpaceBetween"
176+
gap="100"
177+
>
178+
<Box gap="200" alignItems="Center">
179+
{hasChanged && (
180+
<Chip
181+
onClick={handleSave}
182+
variant="Primary"
183+
radii="Pill"
184+
outlined
185+
before={
186+
isSaving ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined
187+
}
188+
>
189+
<Text size="B300">{isSaving ? 'Saving' : 'Save'}</Text>
190+
</Chip>
191+
)}
192+
</Box>
193+
<Box gap="Inherit">
194+
<IconButton
195+
variant="Background"
196+
size="300"
197+
radii="300"
198+
onClick={() => setToolbar(!toolbar)}
199+
>
200+
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
201+
</IconButton>
202+
<UseStateProvider initial={undefined}>
203+
{(anchor: RectCords | undefined, setAnchor) => (
204+
<PopOut
205+
anchor={anchor}
206+
alignOffset={-8}
207+
position="Top"
208+
align="End"
209+
content={
210+
<EmojiBoard
211+
imagePackRooms={imagePackRooms ?? []}
212+
returnFocusOnDeactivate={false}
213+
onEmojiSelect={handleEmoticonSelect}
214+
onCustomEmojiSelect={handleEmoticonSelect}
215+
requestClose={() =>
216+
setAnchor((v) => {
217+
if (v) {
218+
if (!mobileOrTablet()) ReactEditor.focus(editor);
219+
return undefined;
220+
}
221+
return v;
222+
})
223+
}
224+
/>
225+
}
226+
>
227+
<IconButton
228+
aria-pressed={anchor !== undefined}
229+
variant="Background"
230+
size="300"
231+
radii="300"
232+
onClick={(evt) => setAnchor(evt.currentTarget.getBoundingClientRect())}
233+
>
234+
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
235+
</IconButton>
236+
</PopOut>
237+
)}
238+
</UseStateProvider>
239+
</Box>
240+
</Box>
241+
{toolbar && (
242+
<Box direction="Column">
243+
<Line variant="Surface" size="300" />
244+
<Toolbar />
245+
</Box>
246+
)}
247+
</Box>
248+
}
249+
/>
250+
</Box>
251+
</Box>
252+
);
253+
}

0 commit comments

Comments
 (0)