Image.jsx 13.3 KB
Newer Older
yakoff94's avatar
yakoff94 committed
1
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
2
import React, { useEffect, useRef, useState, Fragment } from "react";
yakoff94's avatar
yakoff94 committed
3
4
5
6
import TipTapImage from "@tiptap/extension-image";

const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
7
const ALIGN_OPTIONS = ['left', 'center', 'right'];
yakoff94's avatar
yakoff94 committed
8

9
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos }) => {
yakoff94's avatar
yakoff94 committed
10
    const imgRef = useRef(null);
11
    const wrapperRef = useRef(null);
yakoff94's avatar
yakoff94 committed
12
    const [editing, setEditing] = useState(false);
13
14
15
16
17
18
19
20
21
22
    const [showAlignMenu, setShowAlignMenu] = useState(false);
    const isInitialized = useRef(false);
    const resizeData = useRef({
        startWidth: 0,
        startHeight: 0,
        startX: 0,
        startY: 0,
        aspectRatio: 1
    });

23
    // Добавляем прозрачный нулевой пробел после изображения
24
25
26
27
28
29
30
31
32
    useEffect(() => {
        if (!editor || !getPos) return;

        const pos = getPos() + 1;
        const doc = editor.state.doc;

        if (doc.nodeSize > pos && doc.nodeAt(pos)?.textContent !== '\u200B') {
            editor.commands.insertContentAt(pos, {
                type: 'text',
33
                text: '\u200B' // Невидимый нулевой пробел
34
35
36
            });
        }
    }, [editor, getPos]);
yakoff94's avatar
yakoff94 committed
37

38
    // Инициализация размеров
yakoff94's avatar
yakoff94 committed
39
    useEffect(() => {
40
        if (imgRef.current && !isInitialized.current) {
41
42
            const width = node.attrs.width || imgRef.current.naturalWidth;
            const height = node.attrs.height || imgRef.current.naturalHeight;
43
44
45
46
47
48
49
50
51
52
53
54
            updateAttributes({
                width: Math.round(width),
                height: Math.round(height)
            });
            isInitialized.current = true;
        }
    }, [node.attrs.width, node.attrs.height, updateAttributes]);

    const handleResizeStart = (direction) => (e) => {
        e.preventDefault();
        e.stopPropagation();

55
56
        const currentWidth = node.attrs.width || imgRef.current.naturalWidth;
        const currentHeight = node.attrs.height || imgRef.current.naturalHeight;
57
58
59
60
61
62
63
64

        resizeData.current = {
            startWidth: currentWidth,
            startHeight: currentHeight,
            startX: e.clientX,
            startY: e.clientY,
            aspectRatio: currentWidth / currentHeight,
            direction
yakoff94's avatar
yakoff94 committed
65
        };
66
67
68
69
70
71
72
73
74
75

        const onMouseMove = (e) => {
            const { startWidth, startHeight, startX, startY, aspectRatio, direction } = resizeData.current;

            const deltaX = e.clientX - startX;
            const deltaY = e.clientY - startY;

            let newWidth, newHeight;

            if (node.attrs.align === 'center') {
76
                // Особый случай для центрированного изображения
77
                if (direction.includes('n') || direction.includes('s')) {
78
                    // Только вертикальный ресайз с сохранением пропорций
79
80
81
82
                    const scale = direction.includes('s') ? 1 : -1;
                    newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
                    newWidth = Math.round(newHeight * aspectRatio);
                } else {
83
                    // Горизонтальный ресайз с сохранением пропорций
84
85
86
87
88
                    const scale = direction.includes('e') ? 1 : -1;
                    newWidth = Math.max(startWidth + deltaX * scale, MIN_WIDTH);
                    newHeight = Math.round(newWidth / aspectRatio);
                }
            } else {
89
                // Обычный ресайз для других выравниваний
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
                if (direction.includes('e') || direction.includes('w')) {
                    const scale = direction.includes('e') ? 1 : -1;
                    newWidth = Math.max(startWidth + deltaX * scale, MIN_WIDTH);
                    newHeight = Math.round(newWidth / aspectRatio);
                } else {
                    const scale = direction.includes('s') ? 1 : -1;
                    newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
                    newWidth = Math.round(newHeight * aspectRatio);
                }
            }

            updateAttributes({
                width: newWidth,
                height: newHeight
            });
yakoff94's avatar
yakoff94 committed
105
106
        };

107
108
109
110
        const onMouseUp = () => {
            window.removeEventListener('mousemove', onMouseMove);
            window.removeEventListener('mouseup', onMouseUp);
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
111
112
        };

113
114
115
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
    };
yakoff94's avatar
yakoff94 committed
116

117
118
119
120
121
122
123
124
125
    const handleAlign = (align) => {
        updateAttributes({ align });
        setShowAlignMenu(false);
        setTimeout(() => editor.commands.focus(), 100);
    };

    const getWrapperStyle = () => {
        const baseStyle = {
            display: 'inline-block',
126
127
            lineHeight: 0,
            margin: '0.5rem 0',
128
129
130
131
132
133
134
135
            position: 'relative',
            outline: editing ? `1px dashed ${BORDER_COLOR}` : 'none',
            verticalAlign: 'top',
            zIndex: 1
        };

        switch(node.attrs.align) {
            case 'left':
136
                return { ...baseStyle, float: 'left', marginRight: '1rem' };
137
            case 'right':
138
                return { ...baseStyle, float: 'right', marginLeft: '1rem' };
139
140
141
142
            case 'center':
                return {
                    ...baseStyle,
                    display: 'block',
143
144
                    marginLeft: 'auto',
                    marginRight: 'auto',
145
146
                    textAlign: 'center'
                };
147
148
149
150
            case 'wrap-left':
                return { ...baseStyle, float: 'left', margin: '0 1rem 1rem 0', shapeOutside: 'margin-box' };
            case 'wrap-right':
                return { ...baseStyle, float: 'right', margin: '0 0 1rem 1rem', shapeOutside: 'margin-box' };
151
152
153
154
            default:
                return baseStyle;
        }
    };
yakoff94's avatar
yakoff94 committed
155

156
157
158
159
160
161
162
163
164
165
    const getImageStyle = () => ({
        width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
        height: node.attrs.height ? `${node.attrs.height}px` : 'auto',
        maxWidth: '100%',
        display: 'block',
        cursor: 'default',
        userSelect: 'none',
        margin: node.attrs.align === 'center' ? '0 auto' : '0'
    });

yakoff94's avatar
yakoff94 committed
166
167
    return (
        <NodeViewWrapper
168
            as="div"
169
170
171
172
173
            style={getWrapperStyle()}
            ref={wrapperRef}
            onClick={(e) => {
                e.stopPropagation();
                setEditing(true);
yakoff94's avatar
yakoff94 committed
174
            }}
175
176
            contentEditable={false}
            data-image-wrapper
yakoff94's avatar
yakoff94 committed
177
178
        >
            <img
179
180
                {...node.attrs}
                ref={imgRef}
181
                style={getImageStyle()}
182
                onLoad={() => {
183
                    if (imgRef.current && !isInitialized.current) {
184
185
186
187
188
189
190
191
                        const width = imgRef.current.naturalWidth;
                        const height = imgRef.current.naturalHeight;
                        updateAttributes({
                            width: Math.round(width),
                            height: Math.round(height)
                        });
                        isInitialized.current = true;
                    }
yakoff94's avatar
yakoff94 committed
192
193
                }}
            />
194

yakoff94's avatar
yakoff94 committed
195
            {editing && (
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
                <Fragment>
                    {['nw', 'ne', 'sw', 'se'].map(dir => (
                        <div
                            key={dir}
                            onMouseDown={handleResizeStart(dir)}
                            style={{
                                position: 'absolute',
                                width: 12,
                                height: 12,
                                backgroundColor: BORDER_COLOR,
                                border: '1px solid white',
                                [dir[0] === 'n' ? 'top' : 'bottom']: -6,
                                [dir[1] === 'w' ? 'left' : 'right']: node.attrs.align === 'center' ? '50%' : -6,
                                transform: node.attrs.align === 'center' ?
                                    `translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
                                cursor: `${dir}-resize`,
                                zIndex: 10
                            }}
                        />
yakoff94's avatar
yakoff94 committed
215
                    ))}
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270

                    {showAlignMenu && (
                        <div style={{
                            position: 'absolute',
                            top: -40,
                            left: '50%',
                            transform: 'translateX(-50%)',
                            backgroundColor: 'white',
                            boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
                            borderRadius: 4,
                            padding: 4,
                            zIndex: 20,
                            display: 'flex'
                        }}>
                            {ALIGN_OPTIONS.map(align => (
                                <button
                                    key={align}
                                    onClick={() => handleAlign(align)}
                                    style={{
                                        margin: '0 2px',
                                        padding: '4px 8px',
                                        background: node.attrs.align === align ? '#e6f7ff' : 'transparent',
                                        border: '1px solid #d9d9d9',
                                        borderRadius: 2,
                                        cursor: 'pointer'
                                    }}
                                >
                                    {align}
                                </button>
                            ))}
                        </div>
                    )}

                    <button
                        onClick={(e) => {
                            e.stopPropagation();
                            setShowAlignMenu(!showAlignMenu);
                        }}
                        style={{
                            position: 'absolute',
                            top: -30,
                            left: '50%',
                            transform: 'translateX(-50%)',
                            backgroundColor: 'white',
                            border: `1px solid ${BORDER_COLOR}`,
                            borderRadius: 4,
                            padding: '2px 8px',
                            cursor: 'pointer',
                            fontSize: 12,
                            zIndex: 10
                        }}
                    >
                        Align
                    </button>
                </Fragment>
yakoff94's avatar
yakoff94 committed
271
272
273
274
275
276
277
278
279
            )}
        </NodeViewWrapper>
    );
};

const ResizableImageExtension = TipTapImage.extend({
    addAttributes() {
        return {
            ...this.parent?.(),
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
            src: {
                default: null,
            },
            alt: {
                default: null,
            },
            title: {
                default: null,
            },
            width: {
                default: null,
                parseHTML: element => {
                    const width = element.getAttribute('width');
                    return width ? parseInt(width, 10) : null;
                },
                renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
            },
            height: {
                default: null,
                parseHTML: element => {
                    const height = element.getAttribute('height');
                    return height ? parseInt(height, 10) : null;
                },
                renderHTML: attributes => attributes.height ? { height: attributes.height } : {}
            },
            align: {
                default: 'left',
                parseHTML: element => element.getAttribute('data-align') || 'left',
                renderHTML: attributes => ({ 'data-align': attributes.align })
            }
yakoff94's avatar
yakoff94 committed
310
311
        };
    },
312
313
314

    renderHTML({ HTMLAttributes }) {
        const align = HTMLAttributes.align || 'left';
315
316
        const isWrap = ['wrap-left', 'wrap-right'].includes(align);
        const floatDirection = isWrap ? align.split('-')[1] : ['left', 'right'].includes(align) ? align : 'none';
317
318
319
320
321
322

        return ['span', {
            'data-type': 'resizable-image',
            'data-image-wrapper': true,
            style: `
                display: ${align === 'center' ? 'block' : 'inline-block'};
323
                float: ${floatDirection};
324
325
                margin: ${align === 'left' ? '0 1rem 1rem 0' :
                align === 'right' ? '0 0 1rem 1rem' :
326
327
328
329
                    align === 'wrap-left' ? '0 1rem 1rem 0' :
                        align === 'wrap-right' ? '0 0 1rem 1rem' :
                            align === 'center' ? '0.5rem auto' : '0'};
                shape-outside: ${isWrap ? 'margin-box' : 'none'};
330
331
332
333
334
                vertical-align: top;
                position: relative;
                z-index: 1;
            `,
            'data-align': align
335
        }, ['img', HTMLAttributes]];
336
337
    },

yakoff94's avatar
yakoff94 committed
338
339
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
340
341
342
343
344
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
345
    selectable: false // Важно отключить выделение изображения
346
});
yakoff94's avatar
yakoff94 committed
347
348

export default ResizableImageExtension;