Image.jsx 15.4 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';
Яков's avatar
Яков committed
7
const ALIGN_OPTIONS = ['left', 'center', 'right', 'text'];
yakoff94's avatar
yakoff94 committed
8

Яков's avatar
fix    
Яков committed
9
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, selected }) => {
yakoff94's avatar
yakoff94 committed
10
    const imgRef = useRef(null);
11
12
13
    const wrapperRef = useRef(null);
    const [showAlignMenu, setShowAlignMenu] = useState(false);
    const isInitialized = useRef(false);
Яков's avatar
update    
Яков committed
14
15
16
17
18
19
20
    const [isResizing, setIsResizing] = useState(false);

    // Получаем ширину редактора для масштабирования изображений
    const getEditorWidth = () => {
        const editorElement = editor?.options?.element?.closest('.atma-editor-content');
        return editorElement ? editorElement.clientWidth : null;
    };
21

Яков's avatar
fix    
Яков committed
22
    // Генерация уникального ID при создании
23
    useEffect(() => {
Яков's avatar
fix    
Яков committed
24
25
26
27
28
29
        if (!node.attrs['data-node-id']) {
            updateAttributes({
                'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
            });
        }
    }, [node.attrs['data-node-id'], updateAttributes]);
Яков's avatar
fix    
Яков committed
30

Яков's avatar
fix    
Яков committed
31
32
33
34
    useEffect(() => {
        const handleClickOutside = (event) => {
            if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
                editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
35
36
            }
        };
Яков's avatar
fix    
Яков committed
37
38
39
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, [selected, editor, getPos]);
yakoff94's avatar
yakoff94 committed
40
41

    useEffect(() => {
Яков's avatar
fix    
Яков committed
42
43
44
45
        if (!imgRef.current || isInitialized.current) return;

        const initImageSize = () => {
            try {
Яков's avatar
update    
Яков committed
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
                const editorWidth = getEditorWidth();
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;

                let width = node.attrs.width || naturalWidth;
                let height = node.attrs.height || naturalHeight;

                // Масштабируем изображение, если оно шире редактора
                if (editorWidth && width > editorWidth) {
                    const ratio = editorWidth / width;
                    width = editorWidth;
                    height = Math.round(height * ratio);
                }

                // Проверяем минимальный размер
                if (width < MIN_WIDTH) {
                    const ratio = MIN_WIDTH / width;
                    width = MIN_WIDTH;
                    height = Math.round(height * ratio);
                }

Яков's avatar
fix    
Яков committed
67
68
69
                if (width > 0 && height > 0) {
                    updateAttributes({
                        width: Math.round(width),
Яков's avatar
fix    
Яков committed
70
71
                        height: Math.round(height),
                        'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
Яков's avatar
fix    
Яков committed
72
73
74
75
76
77
78
79
80
81
82
83
                    });
                    isInitialized.current = true;
                }
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

        if (imgRef.current.complete) {
            initImageSize();
        } else {
            imgRef.current.onload = initImageSize;
84
        }
Яков's avatar
fix    
Яков committed
85
86

        return () => {
Яков's avatar
fix    
Яков committed
87
            if (imgRef.current) imgRef.current.onload = null;
Яков's avatar
fix    
Яков committed
88
        };
Яков's avatar
fix    
Яков committed
89
    }, [node.attrs.width, node.attrs.height, updateAttributes, node.attrs['data-node-id']]);
90
91
92
93
94

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

Яков's avatar
update    
Яков committed
95
        setIsResizing(true);
Яков's avatar
update    
Яков committed
96
97
        editor.commands.setNodeSelection(getPos());

Яков's avatar
fix    
Яков committed
98
99
100
101
102
        const startWidth = node.attrs.width || imgRef.current.naturalWidth;
        const startHeight = node.attrs.height || imgRef.current.naturalHeight;
        const aspectRatio = startWidth / startHeight;
        const startX = e.clientX;
        const startY = e.clientY;
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131

        const onMouseMove = (e) => {
            const deltaX = e.clientX - startX;
            const deltaY = e.clientY - startY;

            let newWidth, newHeight;

            if (node.attrs.align === 'center') {
                if (direction.includes('n') || direction.includes('s')) {
                    const scale = direction.includes('s') ? 1 : -1;
                    newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
                    newWidth = Math.round(newHeight * aspectRatio);
                } else {
                    const scale = direction.includes('e') ? 1 : -1;
                    newWidth = Math.max(startWidth + deltaX * scale, MIN_WIDTH);
                    newHeight = Math.round(newWidth / aspectRatio);
                }
            } else {
                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);
                }
            }

Яков's avatar
fix    
Яков committed
132
            updateAttributes({ width: newWidth, height: newHeight });
yakoff94's avatar
yakoff94 committed
133
134
        };

135
136
137
        const onMouseUp = () => {
            window.removeEventListener('mousemove', onMouseMove);
            window.removeEventListener('mouseup', onMouseUp);
Яков's avatar
update    
Яков committed
138
            setIsResizing(false);
Яков's avatar
update    
Яков committed
139
            editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
140
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
141
142
        };

143
144
145
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
    };
yakoff94's avatar
yakoff94 committed
146

147
148
149
    const handleAlign = (align) => {
        updateAttributes({ align });
        setShowAlignMenu(false);
Яков's avatar
fix    
Яков committed
150
        editor.commands.focus();
151
152
    };

Яков's avatar
update    
Яков committed
153
154
    const getWrapperStyle = () => {
        const baseStyle = {
155
            display: 'inline-block',
Яков's avatar
update    
Яков committed
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
            lineHeight: 0,
            position: 'relative',
            outline: (selected || isResizing) ? `1px dashed ${BORDER_COLOR}` : 'none',
            verticalAlign: 'top',
            margin: '0.5rem 0',
        };

        // Для выравнивания по центру
        if (node.attrs.align === 'center') {
            return {
                ...baseStyle,
                display: 'block',
                marginLeft: 'auto',
                marginRight: 'auto',
                width: node.attrs.width ? `${node.attrs.width}px` : 'fit-content',
                maxWidth: '100%',
                textAlign: 'center'
            };
        }

        // Для других вариантов выравнивания
        return {
            ...baseStyle,
            ...(node.attrs.align === 'left' && {
                float: 'left',
                marginRight: '1rem',
                width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
            }),
            ...(node.attrs.align === 'right' && {
                float: 'right',
                marginLeft: '1rem',
                width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
            }),
            ...(node.attrs.align === 'text' && {
                display: 'inline-block',
                float: 'none',
                margin: '0 0.2rem',
                verticalAlign: 'middle',
                width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
            }),
        };
    };
yakoff94's avatar
yakoff94 committed
198

199
200
    const getImageStyle = () => ({
        width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
Яков's avatar
update    
Яков committed
201
        height: 'auto', // Автоматическая высота для сохранения пропорций
202
203
204
205
        maxWidth: '100%',
        display: 'block',
        cursor: 'default',
        userSelect: 'none',
Яков's avatar
Яков committed
206
        margin: node.attrs.align === 'center' ? '0 auto' : '0',
Яков's avatar
update    
Яков committed
207
208
        verticalAlign: node.attrs.align === 'text' ? 'middle' : 'top',
        objectFit: 'contain' // Сохраняем пропорции изображения
209
210
    });

yakoff94's avatar
yakoff94 committed
211
212
    return (
        <NodeViewWrapper
213
            as="div"
214
215
216
217
            style={getWrapperStyle()}
            ref={wrapperRef}
            onClick={(e) => {
                e.stopPropagation();
Яков's avatar
fix    
Яков committed
218
                editor.commands.setNodeSelection(getPos());
yakoff94's avatar
yakoff94 committed
219
            }}
220
221
            contentEditable={false}
            data-image-wrapper
yakoff94's avatar
yakoff94 committed
222
223
        >
            <img
224
225
                {...node.attrs}
                ref={imgRef}
Яков's avatar
update    
Яков committed
226
                draggable={true}
227
                style={getImageStyle()}
228
                onLoad={() => {
229
                    if (imgRef.current && !isInitialized.current) {
Яков's avatar
update    
Яков committed
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
                        const editorWidth = getEditorWidth();
                        const naturalWidth = imgRef.current.naturalWidth;
                        const naturalHeight = imgRef.current.naturalHeight;

                        let width = naturalWidth;
                        let height = naturalHeight;

                        // Масштабируем изображение, если оно шире редактора
                        if (editorWidth && width > editorWidth) {
                            const ratio = editorWidth / width;
                            width = editorWidth;
                            height = Math.round(height * ratio);
                        }

                        // Проверяем минимальный размер
                        if (width < MIN_WIDTH) {
                            const ratio = MIN_WIDTH / width;
                            width = MIN_WIDTH;
                            height = Math.round(height * ratio);
                        }

251
252
                        updateAttributes({
                            width: Math.round(width),
Яков's avatar
fix    
Яков committed
253
254
                            height: Math.round(height),
                            'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
255
256
257
                        });
                        isInitialized.current = true;
                    }
yakoff94's avatar
yakoff94 committed
258
259
                }}
            />
260

Яков's avatar
update    
Яков committed
261
            {(selected || isResizing) && (
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
                <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
281
                    ))}
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301

                    {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',
302
                                        padding: '10px 8px',
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
                                        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,
328
                            padding: '8px 8px',
329
330
331
332
333
334
335
336
                            cursor: 'pointer',
                            fontSize: 12,
                            zIndex: 10
                        }}
                    >
                        Align
                    </button>
                </Fragment>
yakoff94's avatar
yakoff94 committed
337
338
339
340
341
342
343
344
345
            )}
        </NodeViewWrapper>
    );
};

const ResizableImageExtension = TipTapImage.extend({
    addAttributes() {
        return {
            ...this.parent?.(),
Яков's avatar
fix    
Яков committed
346
347
348
            src: { default: null },
            alt: { default: null },
            title: { default: null },
349
350
            width: {
                default: null,
Яков's avatar
fix    
Яков committed
351
                parseHTML: element => parseInt(element.getAttribute('width'), 10) || null,
352
353
354
355
                renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
            },
            height: {
                default: null,
Яков's avatar
fix    
Яков committed
356
                parseHTML: element => parseInt(element.getAttribute('height'), 10) || null,
357
358
359
360
361
362
                renderHTML: attributes => attributes.height ? { height: attributes.height } : {}
            },
            align: {
                default: 'left',
                parseHTML: element => element.getAttribute('data-align') || 'left',
                renderHTML: attributes => ({ 'data-align': attributes.align })
Яков's avatar
fix    
Яков committed
363
364
365
366
367
            },
            'data-node-id': {
                default: null,
                parseHTML: element => element.getAttribute('data-node-id'),
                renderHTML: attributes => ({ 'data-node-id': attributes['data-node-id'] })
368
            }
yakoff94's avatar
yakoff94 committed
369
370
        };
    },
371

yakoff94's avatar
yakoff94 committed
372
373
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
374
375
376
377
378
379
380
381
    },

    addKeyboardShortcuts() {
        return {
            'Mod-ArrowLeft': () => this.editor.commands.updateAttributes(this.type.name, { align: 'left' }),
            'Mod-ArrowRight': () => this.editor.commands.updateAttributes(this.type.name, { align: 'right' }),
            'Mod-ArrowDown': () => this.editor.commands.updateAttributes(this.type.name, { align: 'center' }),
        };
382
383
384
385
386
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
387
    selectable: true
388
});
yakoff94's avatar
yakoff94 committed
389
390

export default ResizableImageExtension;