mirror of https://github.com/fantasticit/think.git
tiptap: improve resizable
parent
bf913734ea
commit
733a4910e9
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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: '';
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue