Merge branch 'main' into workspace-invite

This commit is contained in:
Zack Fu Zi Xiang 2024-03-09 12:47:12 +08:00
commit 65a6b394c4
No known key found for this signature in database
20 changed files with 290 additions and 168 deletions

View File

@ -20,7 +20,6 @@ const List<FieldType> _supportedFieldTypes = [
FieldType.URL, FieldType.URL,
FieldType.LastEditedTime, FieldType.LastEditedTime,
FieldType.CreatedTime, FieldType.CreatedTime,
FieldType.Relation,
]; ];
class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {

View File

@ -67,13 +67,13 @@ class _ChecklistItemsState extends State<ChecklistItems> {
(index, task) => Padding( (index, task) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ChecklistItem( child: ChecklistItem(
key: ValueKey(task.data.id),
task: task, task: task,
autofocus: widget.state.newTask && index == tasks.length - 1, autofocus: widget.state.newTask && index == tasks.length - 1,
onSubmitted: () { onSubmitted: index == tasks.length - 1
if (index == tasks.length - 1) { ? () => widget.bloc
widget.bloc.add(const ChecklistCellEvent.createNewTask("")); .add(const ChecklistCellEvent.createNewTask(""))
} : null,
},
), ),
), ),
) )

View File

@ -136,14 +136,6 @@ class _SelectTaskIntent extends Intent {
const _SelectTaskIntent(); const _SelectTaskIntent();
} }
class _DeleteTaskIntent extends Intent {
const _DeleteTaskIntent();
}
class _StartEditingTaskIntent extends Intent {
const _StartEditingTaskIntent();
}
class _EndEditingTaskIntent extends Intent { class _EndEditingTaskIntent extends Intent {
const _EndEditingTaskIntent(); const _EndEditingTaskIntent();
} }
@ -168,7 +160,9 @@ class ChecklistItem extends StatefulWidget {
class _ChecklistItemState extends State<ChecklistItem> { class _ChecklistItemState extends State<ChecklistItem> {
late final TextEditingController _textController; late final TextEditingController _textController;
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode(skipTraversal: true);
final FocusNode _textFieldFocusNode = FocusNode();
bool _isHovered = false; bool _isHovered = false;
bool _isFocused = false; bool _isFocused = false;
Timer? _debounceOnChanged; Timer? _debounceOnChanged;
@ -184,6 +178,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
_debounceOnChanged?.cancel(); _debounceOnChanged?.cancel();
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
_textFieldFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -200,6 +195,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FocusableActionDetector( return FocusableActionDetector(
focusNode: _focusNode,
onShowHoverHighlight: (isHovered) { onShowHoverHighlight: (isHovered) {
setState(() => _isHovered = isHovered); setState(() => _isHovered = isHovered);
}, },
@ -212,116 +208,90 @@ class _ChecklistItemState extends State<ChecklistItem> {
.read<ChecklistCellBloc>() .read<ChecklistCellBloc>()
.add(ChecklistCellEvent.selectTask(widget.task.data.id)), .add(ChecklistCellEvent.selectTask(widget.task.data.id)),
), ),
_DeleteTaskIntent: CallbackAction<_DeleteTaskIntent>(
onInvoke: (_DeleteTaskIntent intent) => context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.deleteTask(widget.task.data.id)),
),
_StartEditingTaskIntent: CallbackAction<_StartEditingTaskIntent>(
onInvoke: (_StartEditingTaskIntent intent) =>
_focusNode.requestFocus(),
),
_EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>(
onInvoke: (_EndEditingTaskIntent intent) => _focusNode.unfocus(), onInvoke: (_EndEditingTaskIntent intent) =>
_textFieldFocusNode.unfocus(),
), ),
}, },
shortcuts: { shortcuts: {
const SingleActivator(LogicalKeyboardKey.space): SingleActivator(
const _SelectTaskIntent(), LogicalKeyboardKey.enter,
const SingleActivator(LogicalKeyboardKey.delete): meta: Platform.isMacOS,
const _DeleteTaskIntent(), control: !Platform.isMacOS,
const SingleActivator(LogicalKeyboardKey.enter): ): const _SelectTaskIntent(),
const _StartEditingTaskIntent(),
if (Platform.isMacOS)
const SingleActivator(LogicalKeyboardKey.enter, meta: true):
const _SelectTaskIntent()
else
const SingleActivator(LogicalKeyboardKey.enter, control: true):
const _SelectTaskIntent(),
const SingleActivator(LogicalKeyboardKey.arrowUp):
const PreviousFocusIntent(),
const SingleActivator(LogicalKeyboardKey.arrowDown):
const NextFocusIntent(),
}, },
descendantsAreTraversable: false,
child: Container( child: Container(
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isHovered || _isFocused || _focusNode.hasFocus color: _isHovered || _isFocused || _textFieldFocusNode.hasFocus
? AFThemeExtension.of(context).lightGreyHover ? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent, : Colors.transparent,
borderRadius: Corners.s6Border, borderRadius: Corners.s6Border,
), ),
child: Row( child: Row(
children: [ children: [
FlowyIconButton( ExcludeFocus(
width: 32, child: FlowyIconButton(
icon: FlowySvg( width: 32,
widget.task.isSelected icon: FlowySvg(
? FlowySvgs.check_filled_s widget.task.isSelected
: FlowySvgs.uncheck_s, ? FlowySvgs.check_filled_s
blendMode: BlendMode.dst, : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
),
hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.selectTask(widget.task.data.id),
),
), ),
hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.selectTask(widget.task.data.id),
),
), ),
Expanded( Expanded(
child: Shortcuts( child: Shortcuts(
shortcuts: { shortcuts: const {
const SingleActivator(LogicalKeyboardKey.space): SingleActivator(LogicalKeyboardKey.escape):
const DoNothingAndStopPropagationIntent(), _EndEditingTaskIntent(),
const SingleActivator(LogicalKeyboardKey.delete):
const DoNothingAndStopPropagationIntent(),
if (Platform.isMacOS)
LogicalKeySet(
LogicalKeyboardKey.fn,
LogicalKeyboardKey.backspace,
): const DoNothingAndStopPropagationIntent(),
const SingleActivator(LogicalKeyboardKey.enter):
const DoNothingAndStopPropagationIntent(),
const SingleActivator(LogicalKeyboardKey.escape):
const _EndEditingTaskIntent(),
const SingleActivator(LogicalKeyboardKey.arrowUp):
const DoNothingAndStopPropagationIntent(),
const SingleActivator(LogicalKeyboardKey.arrowDown):
const DoNothingAndStopPropagationIntent(),
}, },
child: TextField( child: Builder(
controller: _textController, builder: (context) {
focusNode: _focusNode, return TextField(
autofocus: widget.autofocus, controller: _textController,
style: Theme.of(context).textTheme.bodyMedium, focusNode: _textFieldFocusNode,
decoration: InputDecoration( autofocus: widget.autofocus,
border: InputBorder.none, style: Theme.of(context).textTheme.bodyMedium,
isCollapsed: true, decoration: InputDecoration(
contentPadding: EdgeInsets.only( border: InputBorder.none,
top: 8.0, isCollapsed: true,
bottom: 8.0, contentPadding: EdgeInsets.only(
left: 2.0, top: 8.0,
right: _isHovered ? 2.0 : 8.0, bottom: 8.0,
), left: 2.0,
hintText: LocaleKeys.grid_checklist_taskHint.tr(), right: _isHovered ? 2.0 : 8.0,
), ),
onChanged: (text) { hintText: LocaleKeys.grid_checklist_taskHint.tr(),
if (_textController.value.composing.isCollapsed) { ),
_debounceOnChangedText(text); textInputAction: widget.onSubmitted == null
} ? TextInputAction.next
}, : null,
onSubmitted: (description) { onChanged: (text) {
_submitUpdateTaskDescription(description); if (_textController.value.composing.isCollapsed) {
widget.onSubmitted?.call(); _debounceOnChangedText(text);
}
},
onSubmitted: (description) {
_submitUpdateTaskDescription(description);
if (widget.onSubmitted != null) {
widget.onSubmitted?.call();
} else {
Actions.invoke(context, const NextFocusIntent());
}
},
);
}, },
), ),
), ),
), ),
if (_isHovered || _isFocused || _focusNode.hasFocus) if (_isHovered || _isFocused || _textFieldFocusNode.hasFocus)
FlowyIconButton( _DeleteTaskButton(
width: 32,
icon: const FlowySvg(FlowySvgs.delete_s),
hoverColor: Colors.transparent,
iconColorOnHover: Theme.of(context).colorScheme.error,
onPressed: () => context.read<ChecklistCellBloc>().add( onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data.id), ChecklistCellEvent.deleteTask(widget.task.data.id),
), ),
@ -440,3 +410,56 @@ class _NewTaskItemState extends State<NewTaskItem> {
); );
} }
} }
class _DeleteTaskButton extends StatefulWidget {
const _DeleteTaskButton({
required this.onPressed,
});
final VoidCallback onPressed;
@override
State<_DeleteTaskButton> createState() => _DeleteTaskButtonState();
}
class _DeleteTaskButtonState extends State<_DeleteTaskButton> {
final _materialStatesController = MaterialStatesController();
@override
void dispose() {
_materialStatesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: widget.onPressed,
onHover: (_) => setState(() {}),
onFocusChange: (_) => setState(() {}),
style: ButtonStyle(
fixedSize: const MaterialStatePropertyAll(Size.square(32)),
minimumSize: const MaterialStatePropertyAll(Size.square(32)),
maximumSize: const MaterialStatePropertyAll(Size.square(32)),
overlayColor: MaterialStateProperty.resolveWith((state) {
if (state.contains(MaterialState.focused)) {
return AFThemeExtension.of(context).greyHover;
}
return Colors.transparent;
}),
shape: const MaterialStatePropertyAll(
RoundedRectangleBorder(borderRadius: Corners.s6Border),
),
),
statesController: _materialStatesController,
child: FlowySvg(
FlowySvgs.delete_s,
color: _materialStatesController.value
.contains(MaterialState.hovered) ||
_materialStatesController.value.contains(MaterialState.focused)
? Theme.of(context).colorScheme.error
: null,
),
);
}
}

View File

@ -38,7 +38,7 @@ class FlowyIconButton extends StatelessWidget {
this.preferBelow = true, this.preferBelow = true,
this.isSelected, this.isSelected,
required this.icon, required this.icon,
}) : assert((richTooltipText != null && tooltipText == null) || }) : assert((richTooltipText != null && tooltipText == null) ||
(richTooltipText == null && tooltipText != null) || (richTooltipText == null && tooltipText != null) ||
(richTooltipText == null && tooltipText == null)); (richTooltipText == null && tooltipText == null));

View File

@ -3,7 +3,7 @@ import TextField from '@mui/material/TextField';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
const urlPattern = /^https?:\/\/.+/; const urlPattern = /^(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm|.webp|.svg)(\?[^\s[",><]*)?$/;
export function EmbedLink({ export function EmbedLink({
onDone, onDone,

View File

@ -22,8 +22,10 @@ export const LocalImage = forwardRef<
const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs'); const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs');
try { try {
const svg = src.endsWith('.svg');
const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData }); const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData });
const blob = new Blob([buffer]); const blob = new Blob([buffer], { type: svg ? 'image/svg+xml' : 'image' });
setImageURL(URL.createObjectURL(blob)); setImageURL(URL.createObjectURL(blob));
} catch (e) { } catch (e) {

View File

@ -50,6 +50,7 @@ export interface KeyboardNavigationProps<T> {
onBlur?: () => void; onBlur?: () => void;
itemClassName?: string; itemClassName?: string;
itemStyle?: React.CSSProperties; itemStyle?: React.CSSProperties;
renderNoResult?: () => React.ReactNode;
} }
function KeyboardNavigation<T>({ function KeyboardNavigation<T>({
@ -69,6 +70,7 @@ function KeyboardNavigation<T>({
onFocus, onFocus,
itemClassName, itemClassName,
itemStyle, itemStyle,
renderNoResult,
}: KeyboardNavigationProps<T>) { }: KeyboardNavigationProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -301,6 +303,8 @@ function KeyboardNavigation<T>({
> >
{options.length > 0 ? ( {options.length > 0 ? (
options.map(renderOption) options.map(renderOption)
) : renderNoResult ? (
renderNoResult()
) : ( ) : (
<Typography variant='body1' className={'p-3 text-xs text-text-caption'}> <Typography variant='body1' className={'p-3 text-xs text-text-caption'}>
{t('findAndReplace.noResult')} {t('findAndReplace.noResult')}

View File

@ -4,7 +4,17 @@ import { useViewId } from '$app/hooks';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function AddNewOption({ rowId, fieldId, onClose }: { rowId: string; fieldId: string; onClose: () => void }) { function AddNewOption({
rowId,
fieldId,
onClose,
onFocus,
}: {
rowId: string;
fieldId: string;
onClose: () => void;
onFocus: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const viewId = useViewId(); const viewId = useViewId();
@ -18,6 +28,7 @@ function AddNewOption({ rowId, fieldId, onClose }: { rowId: string; fieldId: str
return ( return (
<div className={'flex items-center justify-between p-2 px-2 text-sm'}> <div className={'flex items-center justify-between p-2 px-2 text-sm'}>
<input <input
onFocus={onFocus}
placeholder={t('grid.checklist.addNew')} placeholder={t('grid.checklist.addNew')}
className={'flex-1 px-2'} className={'flex-1 px-2'}
autoFocus={true} autoFocus={true}

View File

@ -19,26 +19,15 @@ function ChecklistCellActions({
const { fieldId, rowId } = cell; const { fieldId, rowId } = cell;
const { percentage, selectedOptions = [], options = [] } = cell.data; const { percentage, selectedOptions = [], options = [] } = cell.data;
const [hoverId, setHoverId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
return ( return (
<Popover <Popover {...props} disableRestoreFocus={true}>
{...props}
disableRestoreFocus={true}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
props.onClose?.({}, 'escapeKeyDown');
}
}}
>
<div <div
style={{ style={{
maxHeight: maxHeight, maxHeight: maxHeight,
maxWidth: maxWidth, maxWidth: maxWidth,
}} }}
onMouseLeave={() => setHoverId(null)}
className={'flex h-full w-full flex-col overflow-hidden'} className={'flex h-full w-full flex-col overflow-hidden'}
> >
{options.length > 0 && ( {options.length > 0 && (
@ -56,10 +45,10 @@ function ChecklistCellActions({
<ChecklistItem <ChecklistItem
fieldId={fieldId} fieldId={fieldId}
rowId={rowId} rowId={rowId}
isHovered={hoverId === option.id} isSelected={focusedId === option.id}
onMouseEnter={() => setHoverId(option.id)}
key={option.id} key={option.id}
option={option} option={option}
onFocus={() => setFocusedId(option.id)}
onClose={() => props.onClose?.({}, 'escapeKeyDown')} onClose={() => props.onClose?.({}, 'escapeKeyDown')}
checked={selectedOptions?.includes(option.id) || false} checked={selectedOptions?.includes(option.id) || false}
/> />
@ -71,7 +60,14 @@ function ChecklistCellActions({
</> </>
)} )}
<AddNewOption onClose={() => props.onClose?.({}, 'escapeKeyDown')} fieldId={fieldId} rowId={rowId} /> <AddNewOption
onFocus={() => {
setFocusedId(null);
}}
onClose={() => props.onClose?.({}, 'escapeKeyDown')}
fieldId={fieldId}
rowId={rowId}
/>
</div> </div>
</Popover> </Popover>
); );

View File

@ -18,17 +18,18 @@ function ChecklistItem({
rowId, rowId,
fieldId, fieldId,
onClose, onClose,
isHovered, isSelected,
onMouseEnter, onFocus,
}: { }: {
checked: boolean; checked: boolean;
option: SelectOption; option: SelectOption;
rowId: string; rowId: string;
fieldId: string; fieldId: string;
onClose: () => void; onClose: () => void;
isHovered: boolean; isSelected: boolean;
onMouseEnter: () => void; onFocus: () => void;
}) { }) {
const inputRef = React.useRef<HTMLInputElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(option.name); const [value, setValue] = useState(option.name);
const viewId = useViewId(); const viewId = useViewId();
@ -61,17 +62,23 @@ function ChecklistItem({
return ( return (
<div <div
onMouseEnter={onMouseEnter} style={{
className={`flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-active`} backgroundColor: isSelected ? 'var(--fill-list-active)' : undefined,
}}
className={`checklist-item ${
isSelected ? 'selected' : ''
} flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-hover`}
> >
<div className={'cursor-pointer select-none text-content-blue-400'} onClick={onCheckedChange}> <div className={'relative cursor-pointer select-none text-content-blue-400'} onClick={onCheckedChange}>
{checked ? <CheckboxCheckSvg className={'h-5 w-5'} /> : <CheckboxUncheckSvg className={'h-5 w-5'} />} {checked ? <CheckboxCheckSvg className={'h-5 w-5'} /> : <CheckboxUncheckSvg className={'h-5 w-5'} />}
</div> </div>
<input <input
className={'flex-1 truncate'} className={'flex-1 truncate'}
ref={inputRef}
onBlur={updateText} onBlur={updateText}
value={value} value={value}
onFocus={onFocus}
placeholder={t('grid.checklist.taskHint')} placeholder={t('grid.checklist.taskHint')}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@ -99,14 +106,7 @@ function ChecklistItem({
}} }}
/> />
<div className={'w-10'}> <div className={'w-10'}>
<IconButton <IconButton size={'small'} className={`delete-option-button z-10 mx-2`} onClick={deleteOption}>
size={'small'}
style={{
display: isHovered ? 'block' : 'none',
}}
className={`z-10 mx-2`}
onClick={deleteOption}
>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</div> </div>

View File

@ -118,6 +118,9 @@ export const SelectOptionModifyMenu: FC<SelectOptionMenuProps> = ({ fieldId, opt
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
onMouseDown={(e) => {
e.stopPropagation();
}}
autoFocus={true} autoFocus={true}
placeholder={t('grid.selectOption.tagName')} placeholder={t('grid.selectOption.tagName')}
size='small' size='small'

View File

@ -54,7 +54,7 @@ function SelectCellActions({
), ),
})); }));
if (result.length === 0) { if (result.length === 0 && newOptionName) {
result.push({ result.push({
key: CREATE_OPTION_KEY, key: CREATE_OPTION_KEY,
content: <Tag size='small' label={newOptionName} />, content: <Tag size='small' label={newOptionName} />,
@ -69,8 +69,7 @@ function SelectCellActions({
const updateCell = useCallback( const updateCell = useCallback(
async (optionIds: string[]) => { async (optionIds: string[]) => {
if (!cell || !rowId) return; if (!cell || !rowId) return;
const prev = selectedOptionIds; const deleteOptionIds = selectedOptionIds?.filter((id) => optionIds.find((cur) => cur === id) === undefined);
const deleteOptionIds = prev?.filter((id) => optionIds.find((cur) => cur === id) === undefined);
await cellService.updateSelectCell(viewId, rowId, field.id, { await cellService.updateSelectCell(viewId, rowId, field.id, {
insertOptionIds: optionIds, insertOptionIds: optionIds,
@ -136,9 +135,12 @@ function SelectCellActions({
<div className={'flex h-full flex-col overflow-hidden'}> <div className={'flex h-full flex-col overflow-hidden'}>
<SearchInput inputRef={inputRef} setNewOptionName={setNewOptionName} newOptionName={newOptionName} /> <SearchInput inputRef={inputRef} setNewOptionName={setNewOptionName} newOptionName={newOptionName} />
<div className='mx-4 mb-2 mt-4 text-xs'> {filteredOptions.length > 0 && (
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')} <div className='mx-4 mb-2 mt-4 text-xs'>
</div> {shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
</div>
)}
<div ref={scrollRef} className={'mx-1 flex-1 overflow-y-auto overflow-x-hidden px-1'}> <div ref={scrollRef} className={'mx-1 flex-1 overflow-y-auto overflow-x-hidden px-1'}>
<KeyboardNavigation <KeyboardNavigation
scrollRef={scrollRef} scrollRef={scrollRef}
@ -150,6 +152,7 @@ function SelectCellActions({
itemStyle={{ itemStyle={{
borderRadius: '4px', borderRadius: '4px',
}} }}
renderNoResult={() => null}
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useCallback } from 'react';
import { Popover, TextareaAutosize } from '@mui/material'; import { Popover, TextareaAutosize } from '@mui/material';
interface Props { interface Props {
@ -8,6 +8,7 @@ interface Props {
text: string; text: string;
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void; onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void;
} }
function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props) { function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props) {
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const shift = e.shiftKey; const shift = e.shiftKey;
@ -20,6 +21,13 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
} }
}; };
const setRef = useCallback((e: HTMLTextAreaElement | null) => {
if (!e) return;
const selectionStart = e.value.length;
e.setSelectionRange(selectionStart, selectionStart);
}, []);
return ( return (
<Popover <Popover
open={editing} open={editing}
@ -47,6 +55,7 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
<TextareaAutosize <TextareaAutosize
className='w-full resize-none whitespace-break-spaces break-all text-sm' className='w-full resize-none whitespace-break-spaces break-all text-sm'
autoFocus autoFocus
ref={setRef}
spellCheck={false} spellCheck={false}
autoCorrect='off' autoCorrect='off'
value={text} value={text}

View File

@ -4,3 +4,16 @@
height: 0px; height: 0px;
} }
} }
.checklist-item {
@apply my-1;
.delete-option-button {
display: none;
}
&:hover, &.selected {
background-color: var(--fill-list-hover);
.delete-option-button {
display: block;
}
}
}

View File

@ -8,6 +8,11 @@ import { CustomEditor } from '$app/components/editor/command';
import { useSlateStatic } from 'slate-react'; import { useSlateStatic } from 'slate-react';
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions'; import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
import { LocalImage } from '$app/components/_shared/image_upload'; import { LocalImage } from '$app/components/_shared/image_upload';
import debounce from 'lodash-es/debounce';
const MIN_WIDTH = 100;
const DELAY = 300;
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) { function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -21,14 +26,22 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [initialWidth, setInitialWidth] = useState<number | null>(null); const [initialWidth, setInitialWidth] = useState<number | null>(null);
const [newWidth, setNewWidth] = useState<number | null>(imageWidth ?? null);
const handleWidthChange = useCallback( const debounceSubmitWidth = useMemo(() => {
(newWidth: number) => { return debounce((newWidth: number) => {
CustomEditor.setImageBlockData(editor, node, { CustomEditor.setImageBlockData(editor, node, {
width: newWidth, width: newWidth,
}); });
}, DELAY);
}, [editor, node]);
const handleWidthChange = useCallback(
(newWidth: number) => {
setNewWidth(newWidth);
debounceSubmitWidth(newWidth);
}, },
[editor, node] [debounceSubmitWidth]
); );
useEffect(() => { useEffect(() => {
@ -38,7 +51,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
}, [hasError, initialWidth, loading]); }, [hasError, initialWidth, loading]);
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => { const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
return { return {
style: { width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 }, style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 },
className: 'object-cover', className: 'object-cover',
ref: imgRef, ref: imgRef,
src: url, src: url,
@ -52,7 +65,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
setLoading(false); setLoading(false);
}, },
}; };
}, [url, imageWidth, loading, hasError, selected]); }, [url, newWidth, loading, hasError, selected]);
const renderErrorNode = useCallback(() => { const renderErrorNode = useCallback(() => {
return ( return (
@ -75,7 +88,13 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
onMouseLeave={() => { onMouseLeave={() => {
setShowActions(false); setShowActions(false);
}} }}
className={`relative min-h-[48px] ${hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''}`} style={{
minWidth: MIN_WIDTH,
width: 'fit-content',
}}
className={`image-render relative min-h-[48px] ${
hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''
}`}
> >
{source === ImageType.Local ? ( {source === ImageType.Local ? (
<LocalImage <LocalImage
@ -90,7 +109,17 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
<img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} /> <img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} />
)} )}
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />} {initialWidth && (
<>
<ImageResizer
isLeft
minWidth={MIN_WIDTH}
width={imageWidth ?? initialWidth}
onWidthChange={handleWidthChange}
/>
<ImageResizer minWidth={MIN_WIDTH} width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />
</>
)}
{showActions && <ImageActions node={node} />} {showActions && <ImageActions node={node} />}
{hasError ? ( {hasError ? (
renderErrorNode() renderErrorNode()

View File

@ -1,24 +1,32 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useRef } from 'react';
const MIN_WIDTH = 80; function ImageResizer({
minWidth,
function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange: (newWidth: number) => void }) { width,
onWidthChange,
isLeft,
}: {
isLeft?: boolean;
minWidth: number;
width: number;
onWidthChange: (newWidth: number) => void;
}) {
const originalWidth = useRef(width); const originalWidth = useRef(width);
const startX = useRef(0); const startX = useRef(0);
const onResize = useCallback( const onResize = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
const diff = e.clientX - startX.current; const diff = isLeft ? startX.current - e.clientX : e.clientX - startX.current;
const newWidth = originalWidth.current + diff; const newWidth = originalWidth.current + diff;
if (newWidth < MIN_WIDTH) { if (newWidth < minWidth) {
return; return;
} }
onWidthChange(newWidth); onWidthChange(newWidth);
}, },
[onWidthChange] [isLeft, minWidth, onWidthChange]
); );
const onResizeEnd = useCallback(() => { const onResizeEnd = useCallback(() => {
@ -40,7 +48,8 @@ function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange:
<div <div
onMouseDown={onResizeStart} onMouseDown={onResizeStart}
style={{ style={{
right: '2px', right: isLeft ? 'auto' : '2px',
left: isLeft ? '-2px' : 'auto',
}} }}
className={'image-resizer'} className={'image-resizer'}
> >

View File

@ -44,6 +44,16 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
if (!input) return; if (!input) return;
let isComposing = false;
const handleCompositionUpdate = () => {
isComposing = true;
};
const handleCompositionEnd = () => {
isComposing = false;
};
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -69,16 +79,22 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
return; return;
} }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { if (!isComposing && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
notify.clear(); notify.clear();
notify.info(`Press Tab to focus on the menu`); notify.info(`Press Tab to focus on the menu`);
return; return;
} }
}; };
input.addEventListener('compositionstart', handleCompositionUpdate);
input.addEventListener('compositionend', handleCompositionEnd);
input.addEventListener('compositionupdate', handleCompositionUpdate);
input.addEventListener('keydown', handleKeyDown); input.addEventListener('keydown', handleKeyDown);
return () => { return () => {
input.removeEventListener('keydown', handleKeyDown); input.removeEventListener('keydown', handleKeyDown);
input.removeEventListener('compositionstart', handleCompositionUpdate);
input.removeEventListener('compositionend', handleCompositionEnd);
input.removeEventListener('compositionupdate', handleCompositionUpdate);
}; };
}, [link, onClose, setNodeMark]); }, [link, onClose, setNodeMark]);

View File

@ -138,19 +138,24 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
height: 0; height: 0;
} }
.image-resizer { .image-render {
@apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end; .image-resizer {
.resize-handle { @apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end;
@apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0; .resize-handle {
background: var(--fill-toolbar); @apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0;
background: var(--fill-toolbar);
}
} }
&:hover { &:hover {
.resize-handle { .image-resizer{
@apply opacity-90; .resize-handle {
@apply opacity-90;
}
} }
} }
} }
.image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block { .image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block {
::selection { ::selection {
@apply bg-transparent; @apply bg-transparent;

View File

@ -1,5 +1,5 @@
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png']; export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
export const IMAGE_DIR = 'images'; export const IMAGE_DIR = 'images';
export function getFileName(url: string) { export function getFileName(url: string) {

View File

@ -921,7 +921,7 @@
"error": { "error": {
"invalidImage": "Invalid image", "invalidImage": "Invalid image",
"invalidImageSize": "Image size must be less than 5MB", "invalidImageSize": "Image size must be less than 5MB",
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG", "invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP",
"invalidImageUrl": "Invalid image URL", "invalidImageUrl": "Invalid image URL",
"noImage": "No such file or directory" "noImage": "No such file or directory"
}, },