Image.jsx 18.2 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
    const [isResizing, setIsResizing] = useState(false);

Яков's avatar
fix    
Яков committed
16
17
    // Получаем текущую ширину редактора и доступное пространство
    const getEditorDimensions = () => {
Яков's avatar
update    
Яков committed
18
        const editorElement = editor?.options?.element?.closest('.atma-editor-content');
Яков's avatar
fix    
Яков committed
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
        if (!editorElement) return { width: Infinity, availableSpace: Infinity };

        const editorWidth = editorElement.clientWidth;
        const imgElement = imgRef.current;
        let availableSpace = editorWidth;

        if (imgElement) {
            const imgRect = imgElement.getBoundingClientRect();
            const editorRect = editorElement.getBoundingClientRect();

            if (node.attrs.align === 'center') {
                const leftSpace = imgRect.left - editorRect.left;
                const rightSpace = editorRect.right - imgRect.right;
                availableSpace = Math.min(editorWidth, (leftSpace + rightSpace + imgRect.width));
                console.log(leftSpace, rightSpace, availableSpace);
            } else if (node.attrs.align === 'right') {
                availableSpace = imgRect.left - editorRect.left + node.attrs.width;
            } else if (node.attrs.align === 'left' || node.attrs.align === 'text') {
                availableSpace = editorRect.right - imgRect.left;
            }
        }

        return { width: editorWidth, availableSpace };
    };

    // Безопасное обновление атрибутов с учетом выравнивания и границ
    const safeUpdateAttributes = (newAttrs) => {
        const { width: editorWidth, availableSpace } = getEditorDimensions();
        let { width, height, align } = { ...node.attrs, ...newAttrs };
        const newAlign = newAttrs.align || align;

        // При изменении выравнивания проверяем доступное пространство
        if (newAlign && newAlign !== align) {
            const maxWidth = availableSpace;
            if (width > maxWidth) {
                const ratio = maxWidth / width;
                width = maxWidth;
                height = Math.round(height * ratio);
            }
        } else {
            // Для обычного обновления размеров
            const maxWidth = availableSpace;
            if (width > maxWidth) {
                const ratio = maxWidth / width;
                width = maxWidth;
                height = Math.round(height * ratio);
            }
        }

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

        updateAttributes({ width, height, ...newAttrs });
Яков's avatar
update    
Яков committed
76
    };
77

Яков's avatar
fix    
Яков committed
78
    // Инициализация изображения
79
    useEffect(() => {
Яков's avatar
fix    
Яков committed
80
        if (!node.attrs['data-node-id']) {
Яков's avatar
fix    
Яков committed
81
            safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
82
83
84
                'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
            });
        }
Яков's avatar
fix    
Яков committed
85
    }, [node.attrs['data-node-id']]);
Яков's avatar
fix    
Яков committed
86

Яков's avatar
fix    
Яков committed
87
    // Обработка кликов вне изображения
Яков's avatar
fix    
Яков committed
88
89
90
91
    useEffect(() => {
        const handleClickOutside = (event) => {
            if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
                editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
92
93
            }
        };
Яков's avatar
fix    
Яков committed
94
95
96
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, [selected, editor, getPos]);
yakoff94's avatar
yakoff94 committed
97

Яков's avatar
fix    
Яков committed
98
    // Загрузка и инициализация изображения
yakoff94's avatar
yakoff94 committed
99
    useEffect(() => {
Яков's avatar
fix    
Яков committed
100
101
102
103
        if (!imgRef.current || isInitialized.current) return;

        const initImageSize = () => {
            try {
Яков's avatar
fix    
Яков committed
104
                const { width: editorWidth } = getEditorDimensions();
Яков's avatar
update    
Яков committed
105
106
107
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;

Яков's avatar
fix    
Яков committed
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
                // Проверяем, что изображение загружено и имеет корректные размеры
                if (naturalWidth <= 0 || naturalHeight <= 0) {
                    console.warn('Image has invalid natural dimensions, retrying...');
                    setTimeout(initImageSize, 100); // Повторная попытка через 100 мс
                    return;
                }

                // Рассчитываем начальные размеры с учетом максимальной ширины редактора
                let initialWidth = naturalWidth;
                let initialHeight = naturalHeight;

                if (initialWidth > editorWidth) {
                    const ratio = editorWidth / initialWidth;
                    initialWidth = editorWidth;
                    initialHeight = Math.round(initialHeight * ratio);
                }

Яков's avatar
fix    
Яков committed
125
                safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
126
127
128
                    width: initialWidth,
                    height: initialHeight,
                    'data-node-id': node.attrs['data-node-id'] || `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
Яков's avatar
fix    
Яков committed
129
130
                });
                isInitialized.current = true;
Яков's avatar
fix    
Яков committed
131
132
133
134
135
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

Яков's avatar
fix    
Яков committed
136
137
138
139
140
        const handleLoad = () => {
            // Добавляем небольшую задержку для гарантированного получения размеров
            setTimeout(initImageSize, 50);
        };

Яков's avatar
fix    
Яков committed
141
        if (imgRef.current.complete) {
Яков's avatar
fix    
Яков committed
142
            handleLoad();
Яков's avatar
fix    
Яков committed
143
        } else {
Яков's avatar
fix    
Яков committed
144
            imgRef.current.addEventListener('load', handleLoad);
145
        }
Яков's avatar
fix    
Яков committed
146
147

        return () => {
Яков's avatar
fix    
Яков committed
148
149
150
            if (imgRef.current) {
                imgRef.current.removeEventListener('load', handleLoad);
            }
Яков's avatar
fix    
Яков committed
151
        };
Яков's avatar
fix    
Яков committed
152
    }, [node.attrs.width, node.attrs.height, node.attrs['data-node-id']]);
153

Яков's avatar
fix    
Яков committed
154
    // Обработка ресайза изображения
155
156
157
158
    const handleResizeStart = (direction) => (e) => {
        e.preventDefault();
        e.stopPropagation();

Яков's avatar
update    
Яков committed
159
        setIsResizing(true);
Яков's avatar
update    
Яков committed
160
161
        editor.commands.setNodeSelection(getPos());

Яков's avatar
fix    
Яков committed
162
163
164
165
166
        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;
Яков's avatar
fix    
Яков committed
167
        const { width: editorWidth, availableSpace } = getEditorDimensions();
168
169
170
171
172
173

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

            let newWidth, newHeight;
Яков's avatar
fix    
Яков committed
174
            const maxWidth = node.attrs.align === 'center' ? editorWidth : availableSpace;
175
176
177
178
179

            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);
Яков's avatar
fix    
Яков committed
180
181
                    newWidth = Math.min(Math.round(newHeight * aspectRatio), maxWidth);
                    newHeight = Math.round(newWidth / aspectRatio);
182
183
                } else {
                    const scale = direction.includes('e') ? 1 : -1;
Яков's avatar
fix    
Яков committed
184
185
186
187
                    newWidth = Math.min(
                        Math.max(startWidth + deltaX * scale, MIN_WIDTH),
                        maxWidth
                    );
188
189
190
191
192
                    newHeight = Math.round(newWidth / aspectRatio);
                }
            } else {
                if (direction.includes('e') || direction.includes('w')) {
                    const scale = direction.includes('e') ? 1 : -1;
Яков's avatar
fix    
Яков committed
193
194
195
196
                    newWidth = Math.min(
                        Math.max(startWidth + deltaX * scale, MIN_WIDTH),
                        maxWidth
                    );
197
198
199
200
                    newHeight = Math.round(newWidth / aspectRatio);
                } else {
                    const scale = direction.includes('s') ? 1 : -1;
                    newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
Яков's avatar
fix    
Яков committed
201
202
203
204
205
                    newWidth = Math.min(
                        Math.round(newHeight * aspectRatio),
                        maxWidth
                    );
                    newHeight = Math.round(newWidth / aspectRatio);
206
207
208
                }
            }

Яков's avatar
fix    
Яков committed
209
            safeUpdateAttributes({ width: newWidth, height: newHeight });
yakoff94's avatar
yakoff94 committed
210
211
        };

212
213
214
        const onMouseUp = () => {
            window.removeEventListener('mousemove', onMouseMove);
            window.removeEventListener('mouseup', onMouseUp);
Яков's avatar
update    
Яков committed
215
            setIsResizing(false);
Яков's avatar
update    
Яков committed
216
            editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
217
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
218
219
        };

220
221
222
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
    };
yakoff94's avatar
yakoff94 committed
223

Яков's avatar
fix    
Яков committed
224
    // Изменение выравнивания с автоматическим масштабированием
225
    const handleAlign = (align) => {
Яков's avatar
fix    
Яков committed
226
227
        safeUpdateAttributes({ align });
        safeUpdateAttributes({ align });
228
        setShowAlignMenu(false);
Яков's avatar
fix    
Яков committed
229
        editor.commands.focus();
230
231
    };

Яков's avatar
fix    
Яков committed
232
    // Стили для обертки изображения
Яков's avatar
update    
Яков committed
233
234
    const getWrapperStyle = () => {
        const baseStyle = {
235
            display: 'inline-block',
Яков's avatar
update    
Яков committed
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
271
272
273
274
275
            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
276

Яков's avatar
fix    
Яков committed
277
    // Стили для самого изображения
278
279
    const getImageStyle = () => ({
        width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
Яков's avatar
fix    
Яков committed
280
        height: 'auto',
281
282
283
284
        maxWidth: '100%',
        display: 'block',
        cursor: 'default',
        userSelect: 'none',
Яков's avatar
Яков committed
285
        margin: node.attrs.align === 'center' ? '0 auto' : '0',
Яков's avatar
update    
Яков committed
286
        verticalAlign: node.attrs.align === 'text' ? 'middle' : 'top',
Яков's avatar
fix    
Яков committed
287
        objectFit: 'contain'
288
289
    });

yakoff94's avatar
yakoff94 committed
290
291
    return (
        <NodeViewWrapper
292
            as="div"
293
294
295
296
            style={getWrapperStyle()}
            ref={wrapperRef}
            onClick={(e) => {
                e.stopPropagation();
Яков's avatar
fix    
Яков committed
297
                editor.commands.setNodeSelection(getPos());
yakoff94's avatar
yakoff94 committed
298
            }}
299
300
            contentEditable={false}
            data-image-wrapper
yakoff94's avatar
yakoff94 committed
301
302
        >
            <img
303
304
                {...node.attrs}
                ref={imgRef}
Яков's avatar
update    
Яков committed
305
                draggable={true}
306
                style={getImageStyle()}
307
                onLoad={() => {
308
                    if (imgRef.current && !isInitialized.current) {
Яков's avatar
fix    
Яков committed
309
                        const { width: editorWidth } = getEditorDimensions();
Яков's avatar
update    
Яков committed
310
311
312
                        const naturalWidth = imgRef.current.naturalWidth;
                        const naturalHeight = imgRef.current.naturalHeight;

Яков's avatar
fix    
Яков committed
313
314
315
                        safeUpdateAttributes({
                            width: naturalWidth,
                            height: naturalHeight,
Яков's avatar
fix    
Яков committed
316
                            'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
317
318
319
                        });
                        isInitialized.current = true;
                    }
yakoff94's avatar
yakoff94 committed
320
321
                }}
            />
322

Яков's avatar
update    
Яков committed
323
            {(selected || isResizing) && (
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
                <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
343
                    ))}
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363

                    {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',
364
                                        padding: '10px 8px',
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
                                        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,
390
                            padding: '8px 8px',
391
392
393
394
395
396
397
398
                            cursor: 'pointer',
                            fontSize: 12,
                            zIndex: 10
                        }}
                    >
                        Align
                    </button>
                </Fragment>
yakoff94's avatar
yakoff94 committed
399
400
401
402
403
404
405
406
407
            )}
        </NodeViewWrapper>
    );
};

const ResizableImageExtension = TipTapImage.extend({
    addAttributes() {
        return {
            ...this.parent?.(),
Яков's avatar
fix    
Яков committed
408
409
410
            src: { default: null },
            alt: { default: null },
            title: { default: null },
411
412
            width: {
                default: null,
Яков's avatar
fix    
Яков committed
413
                parseHTML: element => parseInt(element.getAttribute('width'), 10) || null,
414
415
416
417
                renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
            },
            height: {
                default: null,
Яков's avatar
fix    
Яков committed
418
                parseHTML: element => parseInt(element.getAttribute('height'), 10) || null,
419
420
421
422
423
424
                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
425
426
427
428
429
            },
            'data-node-id': {
                default: null,
                parseHTML: element => element.getAttribute('data-node-id'),
                renderHTML: attributes => ({ 'data-node-id': attributes['data-node-id'] })
430
            }
yakoff94's avatar
yakoff94 committed
431
432
        };
    },
433

yakoff94's avatar
yakoff94 committed
434
435
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
436
437
438
439
440
441
442
443
    },

    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' }),
        };
444
445
446
447
448
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
449
    selectable: true
450
});
yakoff94's avatar
yakoff94 committed
451
452

export default ResizableImageExtension;