tiptap: improve resizable

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

View File

@ -6,6 +6,17 @@
max-width: 100%;
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 {
position: absolute;
z-index: 9999;

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import cls from 'classnames';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
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 { Resizeable } from 'components/resizeable';
import { useToggle } from 'hooks/use-toggle';
@ -58,53 +57,31 @@ export const ImageWrapper = ({ editor, node, updateAttributes }) => {
}
}, [src, hasTrigger]);
const content = useMemo(() => {
if (error) {
return (
<div className={cls(styles.wrap, 'render-wrapper')}>
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
<Resizeable
className={'render-wrapper'}
width={width || maxWidth}
height={height}
maxWidth={maxWidth}
isEditable={isEditable}
onChangeEnd={onResize}
>
{error ? (
<div className={styles.wrap}>
<Text>{error}</Text>
</div>
);
}
if (!src) {
return (
<div className={cls(styles.wrap, 'render-wrapper')} onClick={selectFile}>
) : !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>
);
}
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}
) : (
<LazyLoadImage src={src} alt={alt} width={width} height={height} />
)}
</Resizeable>
);
}
return (
<div className={cls('render-wrapper')} style={{ display: 'inline-block', width, height, maxWidth: '100%' }}>
{img}
</div>
);
}, [error, src, isEditable, width, height]);
return (
<NodeViewWrapper as="div" style={{ textAlign, fontSize: 0, maxWidth: '100%' }}>
{content}
<NodeViewContent />
</NodeViewWrapper>
);
};

View File

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