Skip to content

Commit 66ca04d

Browse files
authored
fix(ImgSize): added ResizableImage (#338)
1 parent 20b6e31 commit 66ca04d

File tree

7 files changed

+373
-81
lines changed

7 files changed

+373
-81
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
body :has(.g-md-resizable_resizing) {
2+
cursor: col-resize;
3+
}
4+
5+
.g-md-resizable {
6+
position: relative;
7+
8+
&_resizing &__resizer-wrapper,
9+
&_hover &__resizer-wrapper {
10+
position: absolute;
11+
z-index: 1;
12+
top: 0;
13+
14+
display: flex;
15+
justify-content: center;
16+
align-items: center;
17+
18+
width: 20px;
19+
height: 100%;
20+
21+
cursor: col-resize;
22+
pointer-events: auto;
23+
24+
&_left {
25+
left: 0;
26+
}
27+
28+
&_right {
29+
right: 0;
30+
}
31+
}
32+
33+
&__resizer {
34+
opacity: 0;
35+
}
36+
37+
&_resizing &__resizer,
38+
&_hover &__resizer {
39+
box-sizing: content-box;
40+
width: 4px;
41+
height: 50px;
42+
max-height: 50%;
43+
44+
opacity: 1;
45+
border-radius: 6px;
46+
background: rgba(127, 127, 127, 0.8);
47+
48+
transition: opacity 300ms ease-in 0s;
49+
}
50+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
3+
import {cn} from '../../../classname';
4+
5+
import './Resizable.scss';
6+
7+
const b = cn('resizable');
8+
9+
interface ResizerProps {
10+
onMouseDown: (event: React.MouseEvent<HTMLElement>) => void;
11+
direction: 'left' | 'right';
12+
}
13+
const Resizer: React.FC<ResizerProps> = ({onMouseDown, direction}) => (
14+
<div
15+
className={b('resizer-wrapper', {[direction]: true})}
16+
role="button"
17+
tabIndex={0}
18+
onMouseDown={onMouseDown}
19+
>
20+
<div className={b('resizer')} />
21+
</div>
22+
);
23+
24+
export interface ResizableProps {
25+
children: React.ReactNode;
26+
onResizeLeft: (event: React.MouseEvent<HTMLElement>) => void;
27+
onResizeRight: (event: React.MouseEvent<HTMLElement>) => void;
28+
hover?: boolean;
29+
resizing?: boolean;
30+
}
31+
32+
export const Resizable: React.FC<ResizableProps> = ({
33+
hover,
34+
resizing,
35+
children,
36+
onResizeLeft,
37+
onResizeRight,
38+
}) => (
39+
<div className={b({hover, resizing})}>
40+
{children}
41+
<Resizer onMouseDown={onResizeLeft} direction="left" />
42+
<Resizer onMouseDown={onResizeRight} direction="right" />
43+
</div>
44+
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.g-md-img-settings-button {
2+
position: absolute;
3+
z-index: 2;
4+
top: 3px;
5+
right: 3px;
6+
}
Lines changed: 61 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,95 @@
1-
import React, {RefObject, useEffect, useRef} from 'react';
1+
import React, {RefObject, useRef} from 'react';
22

33
import {Ellipsis} from '@gravity-ui/icons';
44
import {Button, Icon, Menu, Popup, PopupPlacement} from '@gravity-ui/uikit';
55
import {Node} from 'prosemirror-model';
66
import {EditorView} from 'prosemirror-view';
77

8+
import {cn} from '../../../../../classname';
89
import {i18n as i18nCommon} from '../../../../../i18n/common';
910
import {useBooleanState} from '../../../../../react-utils/hooks';
10-
import {useNodeEditing} from '../../../../../react-utils/useNodeEditing';
11-
import {useNodeHovered} from '../../../../../react-utils/useNodeHovered';
12-
import {removeNode} from '../../../../../utils/remove-node';
13-
import {imageRendererKey} from '../../const';
1411

1512
import {ImageForm} from './ImageForm';
1613

14+
import './ImgSettingsButton.scss';
15+
16+
const b = cn('img-settings-button');
17+
1718
export const ImgSettingsButton: React.FC<{
1819
node: Node;
1920
view: EditorView;
2021
getPos: () => number | undefined;
21-
nodeRef: RefObject<HTMLElement>;
2222
updateAttributes: (o: object) => void;
23-
}> = function ({node, view, getPos, nodeRef, updateAttributes}) {
23+
nodeRef: RefObject<HTMLDivElement>;
24+
visible: boolean;
25+
toggleEdit: () => void;
26+
edit: boolean;
27+
unsetEdit: () => void;
28+
onDelete: () => void;
29+
}> = function ({
30+
node,
31+
view,
32+
updateAttributes,
33+
visible,
34+
edit,
35+
toggleEdit,
36+
nodeRef,
37+
unsetEdit,
38+
onDelete,
39+
}) {
2440
const [popupOpen, setPopupOpen, unsetPopupOpen] = useBooleanState(false);
2541
const placement: PopupPlacement = ['bottom-end', 'bottom-start'];
2642
const buttonRef = useRef<HTMLDivElement>(null);
2743

28-
const isNodeHovered = useNodeHovered(nodeRef);
29-
const isButtonHovered = useNodeHovered(buttonRef);
30-
31-
const [edit, setEditing, unsetEdit, toggleEdit] = useNodeEditing({nodeRef, view});
32-
const visible = (isNodeHovered || isButtonHovered || popupOpen) && !edit;
44+
const handleEdit = () => {
45+
toggleEdit();
46+
unsetPopupOpen();
47+
};
3348

34-
useEffect(() => {
35-
if (imageRendererKey.getState(view.state)?.linkAdded) {
36-
setEditing();
37-
}
38-
}, [view, setEditing]);
49+
const isVisibleImageForm = edit;
50+
const isVisibleEditButton = !edit && (visible || popupOpen);
51+
const isVisiblePopup = !edit && popupOpen;
3952

40-
if (edit)
41-
return (
42-
<ImageForm
43-
node={node}
44-
view={view}
45-
updateAttributes={updateAttributes}
46-
dom={nodeRef}
47-
unsetEdit={unsetEdit}
48-
/>
49-
);
53+
const handleEditButtonClick = (event: React.MouseEvent<HTMLElement>) => {
54+
event.preventDefault();
55+
setPopupOpen();
56+
};
5057

51-
return visible ? (
58+
return (
5259
<>
53-
<Button
54-
onClick={setPopupOpen}
55-
ref={buttonRef}
56-
size="s"
57-
view={'raised'}
58-
style={{position: 'absolute', right: '3px', top: '3px'}}
59-
>
60-
<Icon data={Ellipsis} />
61-
</Button>
60+
{isVisibleImageForm && (
61+
<ImageForm
62+
node={node}
63+
view={view}
64+
updateAttributes={updateAttributes}
65+
dom={nodeRef}
66+
unsetEdit={unsetEdit}
67+
/>
68+
)}
69+
70+
{isVisibleEditButton && (
71+
<Button
72+
onClick={handleEditButtonClick}
73+
ref={buttonRef}
74+
size="s"
75+
view={'raised'}
76+
className={b()}
77+
>
78+
<Icon data={Ellipsis} />
79+
</Button>
80+
)}
81+
6282
<Popup
63-
open={popupOpen}
83+
open={isVisiblePopup}
6484
anchorRef={buttonRef}
6585
onClose={unsetPopupOpen}
6686
placement={placement}
6787
>
6888
<Menu>
69-
<Menu.Item
70-
onClick={() => {
71-
toggleEdit();
72-
unsetPopupOpen();
73-
}}
74-
>
75-
{i18nCommon('edit')}
76-
</Menu.Item>
77-
<Menu.Item
78-
onClick={() => {
79-
const pos = getPos();
80-
if (pos === undefined) return;
81-
removeNode({
82-
node,
83-
pos,
84-
tr: view.state.tr,
85-
dispatch: view.dispatch,
86-
});
87-
view.focus();
88-
}}
89-
>
90-
{i18nCommon('delete')}
91-
</Menu.Item>
89+
<Menu.Item onClick={handleEdit}>{i18nCommon('edit')}</Menu.Item>
90+
<Menu.Item onClick={onDelete}>{i18nCommon('delete')}</Menu.Item>
9291
</Menu>
9392
</Popup>
9493
</>
95-
) : null;
94+
);
9695
};
Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import React, {useRef} from 'react';
1+
import React, {useCallback, useEffect, useRef} from 'react';
22

33
import {cn} from '../../../../../classname';
4-
import {ReactNodeViewProps} from '../../../../../react-utils/react-node-view';
4+
import {ReactNodeViewProps, useNodeEditing, useNodeHovered} from '../../../../../react-utils';
5+
import {ResizeDirection, useNodeResizing} from '../../../../../react-utils/useNodeResizing';
6+
import {removeNode} from '../../../../../utils';
7+
import {Resizable} from '../../../../behavior/Resizable/Resizable';
8+
import {ImgSizeAttr} from '../../ImgSizeSpecs';
9+
import {imageRendererKey} from '../../const';
510

611
import {ImgSettingsButton} from './ImgSettingsButton';
712

@@ -15,19 +20,91 @@ export const ImageNodeView: React.FC<ReactNodeViewProps> = ({
1520
getPos,
1621
updateAttributes,
1722
}) => {
18-
const ref = useRef<HTMLImageElement>(null);
23+
const imageContainerRef = useRef<HTMLDivElement>(null);
24+
const imageRef = useRef<HTMLImageElement>(null);
25+
26+
const alt = node.attrs[ImgSizeAttr.Alt] || '';
27+
const initialHeight = node.attrs[ImgSizeAttr.Height];
28+
const initialWidth = node.attrs[ImgSizeAttr.Width];
29+
const src = node.attrs[ImgSizeAttr.Src] || '';
30+
const title = node.attrs[ImgSizeAttr.Title] || '';
31+
32+
const isNodeHovered = useNodeHovered(imageContainerRef);
33+
const [edit, setEditing, unsetEdit, toggleEdit] = useNodeEditing({
34+
nodeRef: imageContainerRef,
35+
view,
36+
});
37+
38+
const handleResize = useCallback(
39+
({width, height}: {width?: number; height?: number}) => {
40+
updateAttributes({
41+
width: width === undefined ? undefined : String(Math.round(width)),
42+
height: height === undefined ? undefined : String(Math.round(height)),
43+
name: title,
44+
alt,
45+
});
46+
},
47+
[alt, title, updateAttributes],
48+
);
49+
50+
const {state, startResizing} = useNodeResizing({
51+
width: initialWidth,
52+
height: initialHeight,
53+
ref: imageRef,
54+
onResize: handleResize,
55+
});
56+
57+
const style = {
58+
width: state.width ? `${state.width}px` : '',
59+
height: state.height ? `${state.height}px` : '',
60+
transition: 'width 0.15s ease-out, height 0.15s ease-out',
61+
};
62+
63+
const handleDelete = useCallback(() => {
64+
const pos = getPos();
65+
if (pos === undefined) return;
66+
removeNode({
67+
node,
68+
pos,
69+
tr: view.state.tr,
70+
dispatch: view.dispatch,
71+
});
72+
view.focus();
73+
}, [getPos, node, view]);
74+
75+
const createHandleResize =
76+
(direction: ResizeDirection) => (event: React.MouseEvent<HTMLElement>) => {
77+
startResizing(event, direction);
78+
};
79+
80+
useEffect(() => {
81+
if (imageRendererKey.getState(view.state)?.linkAdded) {
82+
setEditing();
83+
}
84+
}, [view, setEditing]);
1985

2086
return (
21-
<>
22-
<ImgSettingsButton
23-
node={node}
24-
view={view}
25-
getPos={getPos}
26-
updateAttributes={updateAttributes}
27-
nodeRef={ref}
28-
/>
29-
{/* eslint-disable-next-line jsx-a11y/alt-text */}
30-
<img {...node.attrs} ref={ref} />
31-
</>
87+
<div ref={imageContainerRef}>
88+
<Resizable
89+
hover={isNodeHovered}
90+
resizing={state.resizing}
91+
onResizeLeft={createHandleResize('left')}
92+
onResizeRight={createHandleResize('right')}
93+
>
94+
<ImgSettingsButton
95+
node={node}
96+
view={view}
97+
getPos={getPos}
98+
updateAttributes={updateAttributes}
99+
visible={isNodeHovered && !edit && !state.resizing}
100+
edit={edit}
101+
toggleEdit={toggleEdit}
102+
nodeRef={imageRef}
103+
onDelete={handleDelete}
104+
unsetEdit={unsetEdit}
105+
/>
106+
<img ref={imageRef} src={src} alt={alt} style={style} />
107+
</Resizable>
108+
</div>
32109
);
33110
};

src/react-utils/useNodeEditing.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import {EditorView} from 'prosemirror-view';
44

55
import {useBooleanState} from './hooks';
66

7-
export const useNodeEditing = ({
8-
nodeRef,
9-
view,
10-
}: {
7+
export interface UseNodeEditingArgs {
118
nodeRef: RefObject<HTMLElement>;
129
view: EditorView;
13-
}) => {
10+
}
11+
12+
export const useNodeEditing = ({nodeRef, view}: UseNodeEditingArgs) => {
1413
const state = useBooleanState(false);
1514
const [, , unsetEdit, toggleEdit] = state;
1615

0 commit comments

Comments
 (0)