tiptap: improve resizable

This commit is contained in:
fantasticit 2022-04-27 22:24:30 +08:00
parent bf913734ea
commit 733a4910e9
6 changed files with 136 additions and 109 deletions

View File

@ -10,6 +10,7 @@ interface IProps {
width: number; width: number;
height: number; height: number;
maxWidth?: number; maxWidth?: number;
isEditable?: boolean;
onChange?: (arg: ISize) => void; onChange?: (arg: ISize) => void;
onChangeEnd?: (arg: ISize) => void; onChangeEnd?: (arg: ISize) => void;
className?: string; className?: string;
@ -18,26 +19,30 @@ interface IProps {
const MIN_WIDTH = 50; const MIN_WIDTH = 50;
const MIN_HEIGHT = 50; const MIN_HEIGHT = 50;
function clamp(val: number, min: number, max: number): number { function clamp(val: number, min: number, max: number): string {
if (val < min) { if (val < min) {
return min; return '' + min;
} }
if (val > max) { if (val > max) {
return max; return '' + max;
} }
return val; return '' + val;
} }
export const Resizeable: React.FC<IProps> = ({ export const Resizeable: React.FC<IProps> = ({
width, width,
height, height,
maxWidth, maxWidth,
isEditable = false,
className, className,
onChange, onChange,
onChangeEnd, onChangeEnd,
children, children,
}) => { }) => {
const $container = useRef<HTMLDivElement>(null); const $container = useRef<HTMLDivElement>(null);
const $cloneNode = useRef<HTMLDivElement>(null);
const $cloneNodeTip = useRef<HTMLDivElement>(null);
const $placeholderNode = useRef<HTMLDivElement>(null);
const $topLeft = useRef<HTMLDivElement>(null); const $topLeft = useRef<HTMLDivElement>(null);
const $topRight = useRef<HTMLDivElement>(null); const $topRight = useRef<HTMLDivElement>(null);
const $bottomLeft = useRef<HTMLDivElement>(null); const $bottomLeft = useRef<HTMLDivElement>(null);
@ -49,6 +54,8 @@ export const Resizeable: React.FC<IProps> = ({
}); });
useEffect(() => { useEffect(() => {
if (!isEditable) return;
interact($container.current).resizable({ interact($container.current).resizable({
edges: { edges: {
top: true, top: true,
@ -58,19 +65,24 @@ export const Resizeable: React.FC<IProps> = ({
}, },
listeners: { listeners: {
move: function (event) { move: function (event) {
let { x, y } = event.target.dataset; const placeholderNode = $placeholderNode.current;
x = (parseFloat(x) || 0) + event.deltaRect.left; Object.assign(placeholderNode.style, {
y = (parseFloat(y) || 0) + event.deltaRect.top; opacity: 0,
});
const cloneNode = $cloneNode.current;
let { width, height } = event.rect; let { width, height } = event.rect;
width = clamp(width, MIN_WIDTH, maxWidth || Infinity); width = parseInt(clamp(width, MIN_WIDTH, maxWidth || Infinity));
height = clamp(height, MIN_HEIGHT, Infinity); height = parseInt(clamp(height, MIN_HEIGHT, Infinity));
Object.assign(cloneNode.style, {
Object.assign(event.target.style, {
width: `${width}px`, width: `${width}px`,
height: `${height}px`, height: `${height}px`,
zIndex: 1000,
}); });
Object.assign(event.target.dataset, { x, y });
const tipNode = $cloneNodeTip.current;
tipNode.innerText = `${width}x${height}`;
onChange && onChange({ width, height }); onChange && onChange({ width, height });
}, },
end: function (event) { end: function (event) {
@ -78,11 +90,24 @@ export const Resizeable: React.FC<IProps> = ({
width = clamp(width, MIN_WIDTH, maxWidth || Infinity); width = clamp(width, MIN_WIDTH, maxWidth || Infinity);
height = clamp(height, MIN_HEIGHT, Infinity); height = clamp(height, MIN_HEIGHT, Infinity);
const cloneNode = $cloneNode.current;
Object.assign(cloneNode.style, {
zIndex: 0,
});
const tipNode = $cloneNodeTip.current;
tipNode.innerText = ``;
const placeholderNode = $placeholderNode.current;
Object.assign(placeholderNode.style, {
opacity: 1,
});
onChangeEnd && onChangeEnd({ width, height }); onChangeEnd && onChangeEnd({ width, height });
}, },
}, },
}); });
}, [maxWidth]); }, [maxWidth, isEditable]);
useEffect(() => { useEffect(() => {
Object.assign($container.current.style, { Object.assign($container.current.style, {
@ -98,10 +123,41 @@ export const Resizeable: React.FC<IProps> = ({
ref={$container} ref={$container}
style={{ width, height }} style={{ width, height }}
> >
<span className={styles.resizer + ' ' + styles.topLeft} ref={$topLeft} data-type={'topLeft'}></span> {isEditable && (
<span className={styles.resizer + ' ' + styles.topRight} ref={$topRight} data-type={'topRight'}></span> <>
<span className={styles.resizer + ' ' + styles.bottomLeft} ref={$bottomLeft} data-type={'bottomLeft'}></span> <div ref={$placeholderNode} className={styles.placeholderWrap} style={{ opacity: 1 }}>
<span className={styles.resizer + ' ' + styles.bottomRight} ref={$bottomRight} data-type={'bottomRight'}></span> <span className={styles.resizer + ' ' + styles.topLeft} ref={$topLeft} data-type={'topLeft'}></span>
<span className={styles.resizer + ' ' + styles.topRight} ref={$topRight} data-type={'topRight'}></span>
<span
className={styles.resizer + ' ' + styles.bottomLeft}
ref={$bottomLeft}
data-type={'bottomLeft'}
></span>
<span
className={styles.resizer + ' ' + styles.bottomRight}
ref={$bottomRight}
data-type={'bottomRight'}
></span>
</div>
<div ref={$cloneNode} className={styles.cloneNodeWrap} style={{ width, height, maxWidth }}>
<span className={styles.resizer + ' ' + styles.topLeft} ref={$topLeft} data-type={'topLeft'}></span>
<span className={styles.resizer + ' ' + styles.topRight} ref={$topRight} data-type={'topRight'}></span>
<span
className={styles.resizer + ' ' + styles.bottomLeft}
ref={$bottomLeft}
data-type={'bottomLeft'}
></span>
<span
className={styles.resizer + ' ' + styles.bottomRight}
ref={$bottomRight}
data-type={'bottomRight'}
></span>
<span ref={$cloneNodeTip}></span>
</div>
</>
)}
{children} {children}
</div> </div>
); );

View File

@ -6,6 +6,17 @@
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
.cloneNodeWrap {
position: absolute;
background-color: rgb(179 212 255 / 30%);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #333;
user-select: none;
}
.resizer { .resizer {
position: absolute; position: absolute;
z-index: 9999; z-index: 9999;

View File

@ -72,7 +72,6 @@
&::after { &::after {
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
background-color: rgb(179 212 255 / 30%);
border: 1px solid var(--node-selected-border-color) !important; border: 1px solid var(--node-selected-border-color) !important;
border-radius: var(--border-radius); border-radius: var(--border-radius);
content: ''; content: '';

View File

@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import cls from 'classnames'; import cls from 'classnames';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { NodeViewWrapper } from '@tiptap/react';
import { Typography } from '@douyinfe/semi-ui'; import { Typography } from '@douyinfe/semi-ui';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
import { getEditorContainerDOMSize } from '../../utils/editor'; import { getEditorContainerDOMSize } from '../../utils/editor';
@ -17,36 +17,21 @@ export const IframeWrapper = ({ editor, node, updateAttributes }) => {
updateAttributes({ width: size.width, height: size.height }); updateAttributes({ width: size.width, height: size.height });
}, []); }, []);
const content = useMemo(
() => (
<NodeViewContent as="div" className={cls(styles.wrap, 'render-wrapper')}>
{url ? (
<div className={styles.innerWrap} style={{ pointerEvents: !isEditable ? 'auto' : 'none' }}>
<iframe src={url}></iframe>
</div>
) : (
<div className={styles.emptyWrap}>
<Text></Text>
</div>
)}
</NodeViewContent>
),
[url, width, height]
);
if (!isEditable && !url) {
return null;
}
return ( return (
<NodeViewWrapper> <NodeViewWrapper>
{isEditable ? ( <Resizeable width={width} maxWidth={maxWidth} height={height} isEditable={isEditable} onChangeEnd={onResize}>
<Resizeable width={width || maxWidth} maxWidth={maxWidth} height={height} onChangeEnd={onResize}> <div className={cls(styles.wrap, 'render-wrapper')}>
<div style={{ width, height, maxWidth: '100%' }}>{content}</div> {url ? (
</Resizeable> <div className={styles.innerWrap} style={{ pointerEvents: !isEditable ? 'auto' : 'none' }}>
) : ( <iframe src={url}></iframe>
<div style={{ width, height, maxWidth: '100%' }}>{content}</div> </div>
)} ) : (
<div className={styles.emptyWrap}>
<Text></Text>
</div>
)}
</div>
</Resizeable>
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };

View File

@ -1,7 +1,6 @@
import cls from 'classnames'; import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Typography, Spin } from '@douyinfe/semi-ui'; import { Typography, Spin } from '@douyinfe/semi-ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { NodeViewWrapper } from '@tiptap/react';
import { LazyLoadImage } from 'react-lazy-load-image-component'; import { LazyLoadImage } from 'react-lazy-load-image-component';
import { Resizeable } from 'components/resizeable'; import { Resizeable } from 'components/resizeable';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
@ -58,53 +57,31 @@ export const ImageWrapper = ({ editor, node, updateAttributes }) => {
} }
}, [src, hasTrigger]); }, [src, hasTrigger]);
const content = useMemo(() => {
if (error) {
return (
<div className={cls(styles.wrap, 'render-wrapper')}>
<Text>{error}</Text>
</div>
);
}
if (!src) {
return (
<div className={cls(styles.wrap, 'render-wrapper')} onClick={selectFile}>
<Spin spinning={loading}>
<Text style={{ cursor: 'pointer' }}>{loading ? '正在上传中' : '请选择图片'}</Text>
<input ref={$upload} accept="image/*" type="file" hidden onChange={handleFile} />
</Spin>
</div>
);
}
const img = <LazyLoadImage src={src} alt={alt} width={width} height={height} />;
if (isEditable) {
return (
<Resizeable
className={cls('render-wrapper')}
width={width || maxWidth}
height={height}
maxWidth={maxWidth}
onChangeEnd={onResize}
>
{img}
</Resizeable>
);
}
return (
<div className={cls('render-wrapper')} style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>
{img}
</div>
);
}, [error, src, isEditable, width, height]);
return ( return (
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}> <NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
{content} <Resizeable
<NodeViewContent /> className={'render-wrapper'}
width={width || maxWidth}
height={height}
maxWidth={maxWidth}
isEditable={isEditable}
onChangeEnd={onResize}
>
{error ? (
<div className={styles.wrap}>
<Text>{error}</Text>
</div>
) : !src ? (
<div className={styles.wrap} onClick={selectFile}>
<Spin spinning={loading}>
<Text style={{ cursor: 'pointer' }}>{loading ? '正在上传中' : '请选择图片'}</Text>
<input ref={$upload} accept="image/*" type="file" hidden onChange={handleFile} />
</Spin>
</div>
) : (
<LazyLoadImage src={src} alt={alt} width={width} height={height} />
)}
</Resizeable>
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };

View File

@ -1,4 +1,4 @@
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'; import { NodeViewWrapper } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Spin, Typography } from '@douyinfe/semi-ui'; import { Spin, Typography } from '@douyinfe/semi-ui';
@ -15,6 +15,8 @@ import styles from './index.module.scss';
const { Text } = Typography; const { Text } = Typography;
const INHERIT_SIZE_STYLE = { width: '100%', height: '100%' };
export const MindWrapper = ({ editor, node, updateAttributes }) => { export const MindWrapper = ({ editor, node, updateAttributes }) => {
const $container = useRef(); const $container = useRef();
const $mind = useRef<any>(); const $mind = useRef<any>();
@ -28,14 +30,14 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
const content = useMemo(() => { const content = useMemo(() => {
if (error) { if (error) {
return ( return (
<div style={{ width: '100%', height: '100%' }}> <div style={INHERIT_SIZE_STYLE}>
<Text>{error.message || error}</Text> <Text>{error.message || error}</Text>
</div> </div>
); );
} }
if (loading) { if (loading) {
return <Spin spinning={loading} style={{ width: '100%', height: '100%' }}></Spin>; return <Spin spinning={loading} style={INHERIT_SIZE_STYLE}></Spin>;
} }
return ( return (
@ -43,10 +45,10 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
ref={$container} ref={$container}
className={cls(styles.renderWrap, 'render-wrapper')} className={cls(styles.renderWrap, 'render-wrapper')}
tabIndex={0} tabIndex={0}
style={{ width: '100%', height: '100%' }} style={INHERIT_SIZE_STYLE}
></div> ></div>
); );
}, [loading, error]); }, [loading, error, width, height]);
const onResize = useCallback( const onResize = useCallback(
(size) => { (size) => {
@ -203,15 +205,13 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
minder.execCommand('theme', theme); minder.execCommand('theme', theme);
}, [theme]); }, [theme]);
console.log(width, height);
return ( return (
<NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}> <NodeViewWrapper className={cls(styles.wrap, isActive && styles.isActive)}>
{isEditable ? ( <Resizeable isEditable={isEditable} width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}>
<Resizeable width={width} height={height} maxWidth={maxWidth} onChangeEnd={onResize}> {content}
{content} </Resizeable>
</Resizeable>
) : (
<div style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>{content}</div>
)}
<div className={styles.toolbarWrap}> <div className={styles.toolbarWrap}>
<Toolbar <Toolbar
isEditable={isEditable} isEditable={isEditable}
@ -226,7 +226,6 @@ export const MindWrapper = ({ editor, node, updateAttributes }) => {
setTheme={setTheme} setTheme={setTheme}
/> />
</div> </div>
<NodeViewContent />
</NodeViewWrapper> </NodeViewWrapper>
); );
}; };