Image.jsx 12.7 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
14
    const wrapperRef = useRef(null);
    const [showAlignMenu, setShowAlignMenu] = useState(false);
    const isInitialized = useRef(false);

Яков's avatar
fix    
Яков committed
15
    // Генерация уникального ID при создании
16
    useEffect(() => {
Яков's avatar
fix    
Яков committed
17
18
19
20
21
22
        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
23

Яков's avatar
fix    
Яков committed
24
25
26
27
    useEffect(() => {
        const handleClickOutside = (event) => {
            if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
                editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
28
29
            }
        };
Яков's avatar
fix    
Яков committed
30
31
32
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, [selected, editor, getPos]);
yakoff94's avatar
yakoff94 committed
33
34

    useEffect(() => {
Яков's avatar
fix    
Яков committed
35
36
37
38
39
40
41
42
43
        if (!imgRef.current || isInitialized.current) return;

        const initImageSize = () => {
            try {
                const width = node.attrs.width || imgRef.current.naturalWidth;
                const height = node.attrs.height || imgRef.current.naturalHeight;
                if (width > 0 && height > 0) {
                    updateAttributes({
                        width: Math.round(width),
Яков's avatar
fix    
Яков committed
44
45
                        height: Math.round(height),
                        'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
Яков's avatar
fix    
Яков committed
46
47
48
49
50
51
52
53
54
55
56
57
                    });
                    isInitialized.current = true;
                }
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

        if (imgRef.current.complete) {
            initImageSize();
        } else {
            imgRef.current.onload = initImageSize;
58
        }
Яков's avatar
fix    
Яков committed
59
60

        return () => {
Яков's avatar
fix    
Яков committed
61
            if (imgRef.current) imgRef.current.onload = null;
Яков's avatar
fix    
Яков committed
62
        };
Яков's avatar
fix    
Яков committed
63
    }, [node.attrs.width, node.attrs.height, updateAttributes, node.attrs['data-node-id']]);
64
65
66
67
68

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

Яков's avatar
update    
Яков committed
69
70
71
        // Явно устанавливаем выделение перед началом ресайза
        editor.commands.setNodeSelection(getPos());

Яков's avatar
fix    
Яков committed
72
73
74
75
76
        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;
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105

        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
106
            updateAttributes({ width: newWidth, height: newHeight });
yakoff94's avatar
yakoff94 committed
107
108
        };

109
110
111
        const onMouseUp = () => {
            window.removeEventListener('mousemove', onMouseMove);
            window.removeEventListener('mouseup', onMouseUp);
Яков's avatar
update    
Яков committed
112
113
            // Явно восстанавливаем выделение после ресайза
            editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
114
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
115
116
        };

117
118
119
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
    };
yakoff94's avatar
yakoff94 committed
120

121
122
123
    const handleAlign = (align) => {
        updateAttributes({ align });
        setShowAlignMenu(false);
Яков's avatar
fix    
Яков committed
124
        editor.commands.focus();
125
126
    };

Яков's avatar
fix    
Яков committed
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
    const getWrapperStyle = () => ({
        display: 'inline-block',
        lineHeight: 0,
        margin: '0.5rem 0',
        position: 'relative',
        outline: selected ? `1px dashed ${BORDER_COLOR}` : 'none',
        verticalAlign: 'top',
        ...(node.attrs.align === 'left' && { float: 'left', marginRight: '1rem' }),
        ...(node.attrs.align === 'right' && { float: 'right', marginLeft: '1rem' }),
        ...(node.attrs.align === 'center' && {
            display: 'block',
            marginLeft: 'auto',
            marginRight: 'auto',
            textAlign: 'center'
        }),
        ...(node.attrs.align === 'text' && {
143
            display: 'inline-block',
Яков's avatar
fix    
Яков committed
144
145
146
147
148
            float: 'none',
            margin: '0 0.2rem',
            verticalAlign: 'middle'
        })
    });
yakoff94's avatar
yakoff94 committed
149

150
151
152
153
154
155
156
    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',
Яков's avatar
Яков committed
157
158
        margin: node.attrs.align === 'center' ? '0 auto' : '0',
        verticalAlign: node.attrs.align === 'text' ? 'middle' : 'top'
159
160
    });

yakoff94's avatar
yakoff94 committed
161
162
    return (
        <NodeViewWrapper
163
            as="div"
164
165
166
167
            style={getWrapperStyle()}
            ref={wrapperRef}
            onClick={(e) => {
                e.stopPropagation();
Яков's avatar
fix    
Яков committed
168
                editor.commands.setNodeSelection(getPos());
yakoff94's avatar
yakoff94 committed
169
            }}
170
171
            contentEditable={false}
            data-image-wrapper
yakoff94's avatar
yakoff94 committed
172
173
        >
            <img
174
175
                {...node.attrs}
                ref={imgRef}
Яков's avatar
fix    
Яков committed
176
                draggable={true} // обязательно true для работы dragstart
177
                style={getImageStyle()}
178
                onLoad={() => {
179
                    if (imgRef.current && !isInitialized.current) {
180
181
182
183
                        const width = imgRef.current.naturalWidth;
                        const height = imgRef.current.naturalHeight;
                        updateAttributes({
                            width: Math.round(width),
Яков's avatar
fix    
Яков committed
184
185
                            height: Math.round(height),
                            'data-node-id': node.attrs['data-node-id'] || Math.random().toString(36).substr(2, 9)
186
187
188
                        });
                        isInitialized.current = true;
                    }
yakoff94's avatar
yakoff94 committed
189
190
                }}
            />
191

Яков's avatar
fix    
Яков committed
192
            {selected && (
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
                <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
212
                    ))}
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232

                    {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',
233
                                        padding: '10px 8px',
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
                                        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,
259
                            padding: '8px 8px',
260
261
262
263
264
265
266
267
                            cursor: 'pointer',
                            fontSize: 12,
                            zIndex: 10
                        }}
                    >
                        Align
                    </button>
                </Fragment>
yakoff94's avatar
yakoff94 committed
268
269
270
271
272
273
274
275
276
            )}
        </NodeViewWrapper>
    );
};

const ResizableImageExtension = TipTapImage.extend({
    addAttributes() {
        return {
            ...this.parent?.(),
Яков's avatar
fix    
Яков committed
277
278
279
            src: { default: null },
            alt: { default: null },
            title: { default: null },
280
281
            width: {
                default: null,
Яков's avatar
fix    
Яков committed
282
                parseHTML: element => parseInt(element.getAttribute('width'), 10) || null,
283
284
285
286
                renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
            },
            height: {
                default: null,
Яков's avatar
fix    
Яков committed
287
                parseHTML: element => parseInt(element.getAttribute('height'), 10) || null,
288
289
290
291
292
293
                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
294
295
296
297
298
            },
            'data-node-id': {
                default: null,
                parseHTML: element => element.getAttribute('data-node-id'),
                renderHTML: attributes => ({ 'data-node-id': attributes['data-node-id'] })
299
            }
yakoff94's avatar
yakoff94 committed
300
301
        };
    },
302

yakoff94's avatar
yakoff94 committed
303
304
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
305
306
307
308
309
310
311
312
    },

    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' }),
        };
313
314
315
316
317
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
318
    selectable: true
319
});
yakoff94's avatar
yakoff94 committed
320
321

export default ResizableImageExtension;