Skip to content

Commit

Permalink
fix(ImgSize): added ResizableImage (#338)
Browse files Browse the repository at this point in the history
  • Loading branch information
makhnatkin authored Sep 10, 2024
1 parent 20b6e31 commit 66ca04d
Show file tree
Hide file tree
Showing 7 changed files with 373 additions and 81 deletions.
50 changes: 50 additions & 0 deletions src/extensions/behavior/Resizable/Resizable.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
body :has(.g-md-resizable_resizing) {
cursor: col-resize;
}

.g-md-resizable {
position: relative;

&_resizing &__resizer-wrapper,
&_hover &__resizer-wrapper {
position: absolute;
z-index: 1;
top: 0;

display: flex;
justify-content: center;
align-items: center;

width: 20px;
height: 100%;

cursor: col-resize;
pointer-events: auto;

&_left {
left: 0;
}

&_right {
right: 0;
}
}

&__resizer {
opacity: 0;
}

&_resizing &__resizer,
&_hover &__resizer {
box-sizing: content-box;
width: 4px;
height: 50px;
max-height: 50%;

opacity: 1;
border-radius: 6px;
background: rgba(127, 127, 127, 0.8);

transition: opacity 300ms ease-in 0s;
}
}
44 changes: 44 additions & 0 deletions src/extensions/behavior/Resizable/Resizable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';

import {cn} from '../../../classname';

import './Resizable.scss';

const b = cn('resizable');

interface ResizerProps {
onMouseDown: (event: React.MouseEvent<HTMLElement>) => void;
direction: 'left' | 'right';
}
const Resizer: React.FC<ResizerProps> = ({onMouseDown, direction}) => (
<div
className={b('resizer-wrapper', {[direction]: true})}
role="button"
tabIndex={0}
onMouseDown={onMouseDown}
>
<div className={b('resizer')} />
</div>
);

export interface ResizableProps {
children: React.ReactNode;
onResizeLeft: (event: React.MouseEvent<HTMLElement>) => void;
onResizeRight: (event: React.MouseEvent<HTMLElement>) => void;
hover?: boolean;
resizing?: boolean;
}

export const Resizable: React.FC<ResizableProps> = ({
hover,
resizing,
children,
onResizeLeft,
onResizeRight,
}) => (
<div className={b({hover, resizing})}>
{children}
<Resizer onMouseDown={onResizeLeft} direction="left" />
<Resizer onMouseDown={onResizeRight} direction="right" />
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.g-md-img-settings-button {
position: absolute;
z-index: 2;
top: 3px;
right: 3px;
}
Original file line number Diff line number Diff line change
@@ -1,96 +1,95 @@
import React, {RefObject, useEffect, useRef} from 'react';
import React, {RefObject, useRef} from 'react';

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

import {cn} from '../../../../../classname';
import {i18n as i18nCommon} from '../../../../../i18n/common';
import {useBooleanState} from '../../../../../react-utils/hooks';
import {useNodeEditing} from '../../../../../react-utils/useNodeEditing';
import {useNodeHovered} from '../../../../../react-utils/useNodeHovered';
import {removeNode} from '../../../../../utils/remove-node';
import {imageRendererKey} from '../../const';

import {ImageForm} from './ImageForm';

import './ImgSettingsButton.scss';

const b = cn('img-settings-button');

export const ImgSettingsButton: React.FC<{
node: Node;
view: EditorView;
getPos: () => number | undefined;
nodeRef: RefObject<HTMLElement>;
updateAttributes: (o: object) => void;
}> = function ({node, view, getPos, nodeRef, updateAttributes}) {
nodeRef: RefObject<HTMLDivElement>;
visible: boolean;
toggleEdit: () => void;
edit: boolean;
unsetEdit: () => void;
onDelete: () => void;
}> = function ({
node,
view,
updateAttributes,
visible,
edit,
toggleEdit,
nodeRef,
unsetEdit,
onDelete,
}) {
const [popupOpen, setPopupOpen, unsetPopupOpen] = useBooleanState(false);
const placement: PopupPlacement = ['bottom-end', 'bottom-start'];
const buttonRef = useRef<HTMLDivElement>(null);

const isNodeHovered = useNodeHovered(nodeRef);
const isButtonHovered = useNodeHovered(buttonRef);

const [edit, setEditing, unsetEdit, toggleEdit] = useNodeEditing({nodeRef, view});
const visible = (isNodeHovered || isButtonHovered || popupOpen) && !edit;
const handleEdit = () => {
toggleEdit();
unsetPopupOpen();
};

useEffect(() => {
if (imageRendererKey.getState(view.state)?.linkAdded) {
setEditing();
}
}, [view, setEditing]);
const isVisibleImageForm = edit;
const isVisibleEditButton = !edit && (visible || popupOpen);
const isVisiblePopup = !edit && popupOpen;

if (edit)
return (
<ImageForm
node={node}
view={view}
updateAttributes={updateAttributes}
dom={nodeRef}
unsetEdit={unsetEdit}
/>
);
const handleEditButtonClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
setPopupOpen();
};

return visible ? (
return (
<>
<Button
onClick={setPopupOpen}
ref={buttonRef}
size="s"
view={'raised'}
style={{position: 'absolute', right: '3px', top: '3px'}}
>
<Icon data={Ellipsis} />
</Button>
{isVisibleImageForm && (
<ImageForm
node={node}
view={view}
updateAttributes={updateAttributes}
dom={nodeRef}
unsetEdit={unsetEdit}
/>
)}

{isVisibleEditButton && (
<Button
onClick={handleEditButtonClick}
ref={buttonRef}
size="s"
view={'raised'}
className={b()}
>
<Icon data={Ellipsis} />
</Button>
)}

<Popup
open={popupOpen}
open={isVisiblePopup}
anchorRef={buttonRef}
onClose={unsetPopupOpen}
placement={placement}
>
<Menu>
<Menu.Item
onClick={() => {
toggleEdit();
unsetPopupOpen();
}}
>
{i18nCommon('edit')}
</Menu.Item>
<Menu.Item
onClick={() => {
const pos = getPos();
if (pos === undefined) return;
removeNode({
node,
pos,
tr: view.state.tr,
dispatch: view.dispatch,
});
view.focus();
}}
>
{i18nCommon('delete')}
</Menu.Item>
<Menu.Item onClick={handleEdit}>{i18nCommon('edit')}</Menu.Item>
<Menu.Item onClick={onDelete}>{i18nCommon('delete')}</Menu.Item>
</Menu>
</Popup>
</>
) : null;
);
};
105 changes: 91 additions & 14 deletions src/extensions/yfm/ImgSize/plugins/ImgSizeNodeView/NodeView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React, {useRef} from 'react';
import React, {useCallback, useEffect, useRef} from 'react';

import {cn} from '../../../../../classname';
import {ReactNodeViewProps} from '../../../../../react-utils/react-node-view';
import {ReactNodeViewProps, useNodeEditing, useNodeHovered} from '../../../../../react-utils';
import {ResizeDirection, useNodeResizing} from '../../../../../react-utils/useNodeResizing';
import {removeNode} from '../../../../../utils';
import {Resizable} from '../../../../behavior/Resizable/Resizable';
import {ImgSizeAttr} from '../../ImgSizeSpecs';
import {imageRendererKey} from '../../const';

import {ImgSettingsButton} from './ImgSettingsButton';

Expand All @@ -15,19 +20,91 @@ export const ImageNodeView: React.FC<ReactNodeViewProps> = ({
getPos,
updateAttributes,
}) => {
const ref = useRef<HTMLImageElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);

const alt = node.attrs[ImgSizeAttr.Alt] || '';
const initialHeight = node.attrs[ImgSizeAttr.Height];
const initialWidth = node.attrs[ImgSizeAttr.Width];
const src = node.attrs[ImgSizeAttr.Src] || '';
const title = node.attrs[ImgSizeAttr.Title] || '';

const isNodeHovered = useNodeHovered(imageContainerRef);
const [edit, setEditing, unsetEdit, toggleEdit] = useNodeEditing({
nodeRef: imageContainerRef,
view,
});

const handleResize = useCallback(
({width, height}: {width?: number; height?: number}) => {
updateAttributes({
width: width === undefined ? undefined : String(Math.round(width)),
height: height === undefined ? undefined : String(Math.round(height)),
name: title,
alt,
});
},
[alt, title, updateAttributes],
);

const {state, startResizing} = useNodeResizing({
width: initialWidth,
height: initialHeight,
ref: imageRef,
onResize: handleResize,
});

const style = {
width: state.width ? `${state.width}px` : '',
height: state.height ? `${state.height}px` : '',
transition: 'width 0.15s ease-out, height 0.15s ease-out',
};

const handleDelete = useCallback(() => {
const pos = getPos();
if (pos === undefined) return;
removeNode({
node,
pos,
tr: view.state.tr,
dispatch: view.dispatch,
});
view.focus();
}, [getPos, node, view]);

const createHandleResize =
(direction: ResizeDirection) => (event: React.MouseEvent<HTMLElement>) => {
startResizing(event, direction);
};

useEffect(() => {
if (imageRendererKey.getState(view.state)?.linkAdded) {
setEditing();
}
}, [view, setEditing]);

return (
<>
<ImgSettingsButton
node={node}
view={view}
getPos={getPos}
updateAttributes={updateAttributes}
nodeRef={ref}
/>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img {...node.attrs} ref={ref} />
</>
<div ref={imageContainerRef}>
<Resizable
hover={isNodeHovered}
resizing={state.resizing}
onResizeLeft={createHandleResize('left')}
onResizeRight={createHandleResize('right')}
>
<ImgSettingsButton
node={node}
view={view}
getPos={getPos}
updateAttributes={updateAttributes}
visible={isNodeHovered && !edit && !state.resizing}
edit={edit}
toggleEdit={toggleEdit}
nodeRef={imageRef}
onDelete={handleDelete}
unsetEdit={unsetEdit}
/>
<img ref={imageRef} src={src} alt={alt} style={style} />
</Resizable>
</div>
);
};
9 changes: 4 additions & 5 deletions src/react-utils/useNodeEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import {EditorView} from 'prosemirror-view';

import {useBooleanState} from './hooks';

export const useNodeEditing = ({
nodeRef,
view,
}: {
export interface UseNodeEditingArgs {
nodeRef: RefObject<HTMLElement>;
view: EditorView;
}) => {
}

export const useNodeEditing = ({nodeRef, view}: UseNodeEditingArgs) => {
const state = useBooleanState(false);
const [, , unsetEdit, toggleEdit] = state;

Expand Down
Loading

0 comments on commit 66ca04d

Please sign in to comment.