Image.jsx 35.5 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
import TipTapImage from "@tiptap/extension-image";
Яков's avatar
update    
Яков committed
4
import { Button, Modal, Input, Typography } from 'antd';
Яков's avatar
update    
Яков committed
5
import {FontSizeOutlined} from "@ant-design/icons";
Яков's avatar
Яков committed
6
import { isMobile } from 'react-device-detect';
Яков's avatar
update    
Яков committed
7
const { TextArea } = Input;
Яков's avatar
update    
Яков committed
8
const {Text} = Typography;
yakoff94's avatar
yakoff94 committed
9
10
11

const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
Яков's avatar
Яков committed
12
const ALIGN_OPTIONS = ['left', 'center', 'right'];
yakoff94's avatar
yakoff94 committed
13

Яков's avatar
fix    
Яков committed
14
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, selected }) => {
yakoff94's avatar
yakoff94 committed
15
    const imgRef = useRef(null);
16
17
    const wrapperRef = useRef(null);
    const isInitialized = useRef(false);
Яков's avatar
Яков committed
18
19
    const prevPosRef = useRef(null);
    const prevWrapRef = useRef(node.attrs.wrap);
Яков's avatar
update    
Яков committed
20
    const [isResizing, setIsResizing] = useState(false);
Яков's avatar
Яков committed
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

    // ── DEBUG: отслеживаем смещение ноды и изменения wrap ──────────────────
    useEffect(() => {
        const pos = getPos?.()
        const src = node.attrs.src?.split('/').pop()

        if (prevPosRef.current !== null && prevPosRef.current !== pos) {
            console.warn(
                `[ImageDebug] ПОЗИЦИЯ ИЗМЕНИЛАСЬ  src=${src}  было=${prevPosRef.current}  стало=${pos}`,
                new Error().stack.split('\n').slice(1, 4).join(' | ')
            )
        }
        if (prevWrapRef.current !== node.attrs.wrap) {
            console.log(`[ImageDebug] wrap изменился  src=${src}  wrap=${node.attrs.wrap}  pos=${pos}`)
        }

        prevPosRef.current = pos
        prevWrapRef.current = node.attrs.wrap
    })
Яков's avatar
update    
Яков committed
40
41
    const [altModalVisible, setAltModalVisible] = useState(false);
    const [tempAlt, setTempAlt] = useState(node.attrs.alt || '');
Яков's avatar
update    
Яков committed
42
    const [tempFrontAlt, setTempFrontAlt] = useState(node.attrs.frontAlt || '');
Яков's avatar
Яков committed
43
44
    // wrap=false + left/right: outer wrapper is full-width block, inner div holds image+handles
    const isNoWrap = !node.attrs.wrap && (node.attrs.align === 'left' || node.attrs.align === 'right');
Яков's avatar
update    
Яков committed
45

Яков's avatar
update    
Яков committed
46

Яков's avatar
Яков committed
47
48
49
    // На десктопе вставляем \u200B после картинки, чтобы курсор можно было
    // поставить inline сразу после неё. На мобильном это не нужно — там курсор
    // всё равно встаёт на всю высоту картинки, выглядит некорректно.
Яков's avatar
fix    
Яков committed
50
    useEffect(() => {
Яков's avatar
Яков committed
51
        if (isMobile) return
Яков's avatar
Яков committed
52
        if (!editor || !getPos || editor.isDestroyed) return
Яков's avatar
fix    
Яков committed
53

Яков's avatar
Яков committed
54
        let pos
Яков's avatar
update    
Яков committed
55
        try {
Яков's avatar
Яков committed
56
57
58
            pos = getPos()
        } catch {
            return
Яков's avatar
update    
Яков committed
59
        }
Яков's avatar
Яков committed
60
        if (typeof pos !== 'number') return
Яков's avatar
update    
Яков committed
61

Яков's avatar
Яков committed
62
63
64
        const { doc } = editor.state
        const node = doc.nodeAt(pos)
        if (!node || node.type.name !== 'image') return
Яков's avatar
fix    
Яков committed
65

Яков's avatar
Яков committed
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
        const next = doc.nodeAt(pos + node.nodeSize)
        if (next?.isText && next.text === '\u200B') return

        requestAnimationFrame(() => {
            if (editor.isDestroyed) return

            try {
                const p = getPos()
                const n = editor.state.doc.nodeAt(p)
                if (!n || n.type.name !== 'image') return

                editor.commands.insertContentAt(p + n.nodeSize, '\u200B')
            } catch {}
        })
    }, [])
Яков's avatar
fix    
Яков committed
81

Яков's avatar
update    
Яков committed
82

Яков's avatar
fix    
Яков committed
83
84
    // Получаем текущую ширину редактора и доступное пространство
    const getEditorDimensions = () => {
Яков's avatar
Яков committed
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
        const editorContent = editor?.options?.element?.closest('.atma-editor-content');
        if (!editorContent) return { width: Infinity, availableSpace: Infinity };

        const fullEditorWidth = editorContent.clientWidth;
        const editorStyles = window.getComputedStyle(editorContent);
        const paddingLeft = parseFloat(editorStyles.paddingLeft) || 0;
        const paddingRight = parseFloat(editorStyles.paddingRight) || 0;
        const availableEditorWidth = fullEditorWidth - paddingLeft - paddingRight;

        let container;

        // при center — всегда редактор
        if (node.attrs.align === 'center') {
            container = editorContent;
        } else {
            // при других выравниваниях — ближайший блок
            container = imgRef.current?.closest('li, blockquote, td, p, div') || editorContent;
Яков's avatar
fix    
Яков committed
102
103
        }

Яков's avatar
Яков committed
104
105
106
107
108
109
110
111
112
        const containerStyles = window.getComputedStyle(container);
        const containerPaddingLeft = parseFloat(containerStyles.paddingLeft) || 0;
        const containerPaddingRight = parseFloat(containerStyles.paddingRight) || 0;
        const containerWidth = container.clientWidth - containerPaddingLeft - containerPaddingRight;

        return {
            width: containerWidth,            // текущая ширина контейнера
            availableSpace: availableEditorWidth // фиксированная доступная ширина
        };
Яков's avatar
fix    
Яков committed
113
114
    };

Яков's avatar
Яков committed
115

Яков's avatar
fix    
Яков committed
116
    // Безопасное обновление атрибутов с учетом выравнивания и границ
Яков's avatar
Яков committed
117
118
119
120
121
122
123
124
125
126
    const clamp = (v, min, max) => Math.min(max, Math.max(min, v))

    const safeUpdateAttributes = (patch) => {
        if (!editor || editor.isDestroyed) return

        let pos
        try {
            pos = getPos()
        } catch {
            return
Яков's avatar
fix    
Яков committed
127
        }
Яков's avatar
Яков committed
128
        if (typeof pos !== 'number') return
Яков's avatar
fix    
Яков committed
129

Яков's avatar
Яков committed
130
131
132
133
134
135
136
137
138
139
140
141
        const currentNode = editor.state.doc.nodeAt(pos)
        if (!currentNode || currentNode.type.name !== 'image') return

        const base = currentNode.attrs || {}

        // 1) сохраняем то, что не хотим потерять
        const keep = {
            align: base.align,
            border: base.border,
            borderColor: base.borderColor,
            borderWidth: base.borderWidth,
            borderRadius: base.borderRadius,
Яков's avatar
fix    
Яков committed
142
143
        }

Яков's avatar
Яков committed
144
145
146
147
148
149
150
151
152
153
154
155
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
        // 2) кандидат на апдейт
        let next = { ...keep, ...base, ...patch }

        // 3) нормализуем размеры (границы)
        const minW = 80
        const maxW = 1600
        const minH = 40
        const maxH = 2000

        if (next.width != null) next.width = clamp(Number(next.width) || 0, minW, maxW)
        if (next.height != null) next.height = clamp(Number(next.height) || 0, minH, maxH)

        // 4) нормализуем align (чтобы не улетало в мусор)
        const allowedAlign = new Set(['left', 'center', 'right', 'full'])
        if (next.align && !allowedAlign.has(next.align)) next.align = base.align || 'center'

        updateAttributes(next)
    }
    //
    // 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 });
    // };
195

Яков's avatar
fix    
Яков committed
196
    // Инициализация изображения
197
    useEffect(() => {
Яков's avatar
fix    
Яков committed
198
        if (!node.attrs['data-node-id']) {
Яков's avatar
fix    
Яков committed
199
            safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
200
201
202
                'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
            });
        }
Яков's avatar
fix    
Яков committed
203
    }, [node.attrs['data-node-id']]);
Яков's avatar
fix    
Яков committed
204

Яков's avatar
fix    
Яков committed
205
    // Обработка кликов вне изображения
Яков's avatar
fix    
Яков committed
206
207
208
    useEffect(() => {
        const handleClickOutside = (event) => {
            if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
Яков's avatar
update    
Яков committed
209
210
211
212
213
214
215
216
217
                try {
                    const pos = getPos?.()
                    if (typeof pos === 'number') {
                        editor.commands.setNodeSelection(pos)
                    }
                } catch (e) {
                    console.warn('getPos() failed:', e)
                }
                // editor.commands.setNodeSelection(getPos());
Яков's avatar
fix    
Яков committed
218
219
            }
        };
Яков's avatar
fix    
Яков committed
220
221
222
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, [selected, editor, getPos]);
yakoff94's avatar
yakoff94 committed
223

Яков's avatar
fix    
Яков committed
224
    // Загрузка и инициализация изображения
yakoff94's avatar
yakoff94 committed
225
    useEffect(() => {
Яков's avatar
fix    
Яков committed
226
227
228
229
        if (!imgRef.current || isInitialized.current) return;

        const initImageSize = () => {
            try {
Яков's avatar
fix    
Яков committed
230
231
232
233
234
                // Если размеры уже заданы в атрибутах - используем их сразу
                if (node.attrs.width && node.attrs.height) {
                    isInitialized.current = true;
                    return;
                }
Яков's avatar
fix    
Яков committed
235

Яков's avatar
fix    
Яков committed
236
                const { width: editorWidth } = getEditorDimensions();
Яков's avatar
fix    
Яков committed
237
238
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;
Яков's avatar
update    
Яков committed
239

Яков's avatar
fix    
Яков committed
240
241
                if (naturalWidth <= 0 || naturalHeight <= 0) {
                    console.warn('Image has invalid natural dimensions, retrying...');
Яков's avatar
fix    
Яков committed
242
                    setTimeout(initImageSize, 100);
Яков's avatar
fix    
Яков committed
243
244
245
246
247
248
249
250
251
252
253
254
                    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
255
                safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
256
257
258
                    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
259
260
                });
                isInitialized.current = true;
Яков's avatar
fix    
Яков committed
261
262
263
264
265
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

Яков's avatar
fix    
Яков committed
266
        const handleLoad = () => {
Яков's avatar
fix    
Яков committed
267
268
269
270
271
            // Если размеры уже заданы в атрибутах, пропускаем инициализацию
            if (node.attrs.width && node.attrs.height) {
                isInitialized.current = true;
                return;
            }
Яков's avatar
fix    
Яков committed
272
273
274
            setTimeout(initImageSize, 50);
        };

Яков's avatar
fix    
Яков committed
275
        if (imgRef.current.complete) {
Яков's avatar
fix    
Яков committed
276
            handleLoad();
Яков's avatar
fix    
Яков committed
277
        } else {
Яков's avatar
fix    
Яков committed
278
            imgRef.current.addEventListener('load', handleLoad);
279
        }
Яков's avatar
fix    
Яков committed
280
281

        return () => {
Яков's avatar
fix    
Яков committed
282
283
284
            if (imgRef.current) {
                imgRef.current.removeEventListener('load', handleLoad);
            }
Яков's avatar
fix    
Яков committed
285
        };
Яков's avatar
fix    
Яков committed
286
    }, [node.attrs.width, node.attrs.height, node.attrs['data-node-id']]);
287

Яков's avatar
fix    
Яков committed
288
    // Обработка ресайза изображения
289
290
291
292
    const handleResizeStart = (direction) => (e) => {
        e.preventDefault();
        e.stopPropagation();

Яков's avatar
update    
Яков committed
293
        setIsResizing(true);
Яков's avatar
update    
Яков committed
294
295
296
297
298
299
300
301
302
        try {
            const pos = getPos?.()
            if (typeof pos === 'number') {
                editor.commands.setNodeSelection(pos)
            }
        } catch (e) {
            console.warn('getPos() failed:', e)
        }
        // editor.commands.setNodeSelection(getPos());
Яков's avatar
update    
Яков committed
303

Яков's avatar
fix    
Яков committed
304
305
306
        const startWidth = node.attrs.width || imgRef.current.naturalWidth;
        const startHeight = node.attrs.height || imgRef.current.naturalHeight;
        const aspectRatio = startWidth / startHeight;
Яков's avatar
update    
Яков committed
307
308
309
310
311
312

        const getClientX = (e) => e.touches ? e.touches[0].clientX : e.clientX;
        const getClientY = (e) => e.touches ? e.touches[0].clientY : e.clientY;

        const startX = getClientX(e);
        const startY = getClientY(e);
Яков's avatar
Яков committed
313
        const { width: initialEditorWidth, availableSpace: initialAvailableSpace } = getEditorDimensions();
314

Яков's avatar
update    
Яков committed
315
316
        const onMove = (e) => {
            if (e.cancelable) e.preventDefault();
Яков's avatar
Яков committed
317
318
319
            requestAnimationFrame(() => {
                const maxWidth = node.attrs.align === 'center' ? initialEditorWidth : initialAvailableSpace;

Яков's avatar
update    
Яков committed
320
321
                const deltaX = getClientX(e) - startX;
                const deltaY = getClientY(e) - startY;
Яков's avatar
Яков committed
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338

                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.min(Math.round(newHeight * aspectRatio), maxWidth);
                        newHeight = Math.round(newWidth / aspectRatio);
                    } else {
                        const scale = direction.includes('e') ? 1 : -1;
                        newWidth = Math.min(
                            Math.max(startWidth + deltaX * scale, MIN_WIDTH),
                            maxWidth
                        );
                        newHeight = Math.round(newWidth / aspectRatio);
                    }
339
                } else {
Яков's avatar
Яков committed
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
                    if (direction.includes('e') || direction.includes('w')) {
                        const scale = direction.includes('e') ? 1 : -1;
                        newWidth = Math.min(
                            Math.max(startWidth + deltaX * scale, MIN_WIDTH),
                            maxWidth
                        );
                        newHeight = Math.round(newWidth / aspectRatio);
                    } else {
                        const scale = direction.includes('s') ? 1 : -1;
                        newHeight = Math.max(startHeight + deltaY * scale, MIN_WIDTH);
                        newWidth = Math.min(
                            Math.round(newHeight * aspectRatio),
                            maxWidth
                        );
                        newHeight = Math.round(newWidth / aspectRatio);
                    }
356
357
                }

Яков's avatar
Яков committed
358
359
                safeUpdateAttributes({ width: newWidth, height: newHeight });
            });
yakoff94's avatar
yakoff94 committed
360
361
        };

Яков's avatar
update    
Яков committed
362
363
364
365
366
        const onEnd = () => {
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('mouseup', onEnd);
            window.removeEventListener('touchmove', onMove);
            window.removeEventListener('touchend', onEnd);
Яков's avatar
update    
Яков committed
367
            setIsResizing(false);
Яков's avatar
update    
Яков committed
368
369
370
371
372
373
374
375
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
fix    
Яков committed
376
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
377
378
        };

Яков's avatar
update    
Яков committed
379
380
381
382
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onEnd);
        window.addEventListener('touchmove', onMove, { passive: false });
        window.addEventListener('touchend', onEnd);
383
    };
yakoff94's avatar
yakoff94 committed
384

Яков's avatar
fix    
Яков committed
385
    // Изменение выравнивания с автоматическим масштабированием
386
    const handleAlign = (align) => {
Яков's avatar
update    
Яков committed
387
        safeUpdateAttributes({ align });
Яков's avatar
Яков committed
388
        setTimeout(() => {
Яков's avatar
update    
Яков committed
389
390
391
392
393
394
395
396
397
            safeUpdateAttributes({ align });
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
Яков committed
398
        }, 50);
399
400
    };

Яков's avatar
Яков committed
401
402
403
404
405
    // Внешняя обёртка (NodeViewWrapper): управляет float/block-layout и отступами
    const getOuterStyle = () => {
        const { align, wrap, width } = node.attrs;
        const w = width ? `${width}px` : 'auto';
        const sharedMargin = { marginTop: '0.5rem', marginBottom: '0.5rem' };
Яков's avatar
Яков committed
406
        const noSelect = { userSelect: 'none', WebkitUserSelect: 'none', touchAction: 'manipulation' };
Яков's avatar
update    
Яков committed
407

Яков's avatar
Яков committed
408
        if (align === 'center') {
Яков's avatar
Яков committed
409
410
411
            // Используем float:left+width:100% чтобы не создавать block-in-inline внутри <p>
            // (иначе параграф получает лишнюю высоту). textAlign:center центрирует внутренний inline-block.
            return {
Яков's avatar
Яков committed
412
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
413
414
415
                display: 'inline-block', float: 'left', clear: 'both',
                width: '100%', textAlign: 'center',
            };
Яков's avatar
Яков committed
416
417
        }
        if (!wrap) {
Яков's avatar
update    
Яков committed
418
            return {
Яков's avatar
Яков committed
419
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
420
421
422
423
424
                display: 'inline-block',
                float: 'left',
                clear: 'both',
                width: '100%',
                ...(align === 'right' ? { textAlign: 'right' } : { textAlign: 'left' }),
Яков's avatar
update    
Яков committed
425
426
427
            };
        }
        return {
Яков's avatar
Яков committed
428
            ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
            display: 'inline-block',
            float: align === 'left' ? 'left' : 'right',
            ...(align === 'left' ? { marginRight: '1rem' } : { marginLeft: '1rem' }),
            width: w, maxWidth: '100%',
        };
    };

    // Внутренний контейнер: всегда inline-block — надёжно получает высоту от дочернего img
    const getInnerStyle = () => {
        const { align, width } = node.attrs;
        const w = width ? `${width}px` : 'auto';
        const base = {
            position: 'relative',
            display: 'inline-block',
            verticalAlign: 'top',
            lineHeight: 0,
            outline: (selected || isResizing) ? `1px dashed ${BORDER_COLOR}` : 'none',
            width: w,
            maxWidth: '100%',
Яков's avatar
Яков committed
448
449
            userSelect: 'none',
            WebkitUserSelect: 'none',
Яков's avatar
update    
Яков committed
450
        };
Яков's avatar
Яков committed
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
        if (align === 'center') {
            return { ...base, display: 'block', marginLeft: 'auto', marginRight: 'auto',
                width: width ? `${width}px` : 'fit-content' };
        }
        return base;
    };

    const handleNodeClick = (e) => {
        e.stopPropagation();
        try {
            const pos = getPos?.();
            if (typeof pos === 'number') editor.commands.setNodeSelection(pos);
        } catch (err) {
            console.warn('getPos() failed:', err);
        }
Яков's avatar
update    
Яков committed
466
    };
yakoff94's avatar
yakoff94 committed
467

Яков's avatar
fix    
Яков committed
468
    // Стили для самого изображения
469
470
    const getImageStyle = () => ({
        width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
Яков's avatar
fix    
Яков committed
471
        height: 'auto',
472
473
474
475
        maxWidth: '100%',
        display: 'block',
        cursor: 'default',
        userSelect: 'none',
Яков's avatar
Яков committed
476
        margin: node.attrs.align === 'center' ? '0 auto' : '0',
Яков's avatar
update    
Яков committed
477
        verticalAlign: node.attrs.align === 'text' ? 'middle' : 'top',
Яков's avatar
fix    
Яков committed
478
        objectFit: 'contain'
479
480
    });

Яков's avatar
Яков committed
481
482
483
    // Inner content shared between both rendering paths
    const imageContent = (
        <>
yakoff94's avatar
yakoff94 committed
484
            <img
Яков's avatar
Яков committed
485
486
487
488
                src={node.attrs.src}
                alt={node.attrs.alt || undefined}
                title={node.attrs.title || undefined}
                data-node-id={node.attrs['data-node-id'] || undefined}
489
                ref={imgRef}
Яков's avatar
Яков committed
490
                draggable={!isMobile}
491
                style={getImageStyle()}
yakoff94's avatar
yakoff94 committed
492
            />
Яков's avatar
Яков committed
493
            {node.attrs.frontAlt?.length > 0 && (
Яков's avatar
update    
Яков committed
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
                <div
                    style={{
                        backgroundColor: '#FDE674',
                        borderRadius: '35px',
                        padding: '5px 25px',
                        textAlign: 'center',
                        color: '#000000D9',
                        position: 'absolute',
                        left: '50%',
                        transform: 'translateX(-50%)',
                        fontSize: '12px',
                        lineHeight: '16px',
                        letterSpacing: '2%',
                        fontWeight: '500',
                        bottom: '10px',
                        whiteSpace: 'pre-line'
                    }}
                >{node.attrs.frontAlt}</div>
Яков's avatar
Яков committed
512
            )}
Яков's avatar
update    
Яков committed
513
514
515
            <Button
                size="default"
                shape={'circle'}
Яков's avatar
update    
Яков committed
516
                type={node.attrs.alt?.length > 0 || node.attrs.frontAlt?.length ? 'primary' : 'default'}
Яков's avatar
update    
Яков committed
517
518
519
                onClick={(e) => {
                    e.stopPropagation();
                    setTempAlt(node.attrs.alt || '');
Яков's avatar
update    
Яков committed
520
                    setTempFrontAlt(node.attrs.frontAlt || '');
Яков's avatar
update    
Яков committed
521
522
                    setAltModalVisible(true);
                }}
Яков's avatar
Яков committed
523
                style={{ position: 'absolute', top: 4, right: '30px', zIndex: 15 }}
Яков's avatar
update    
Яков committed
524
525
526
527
528
529
530
531
532
            >
                <FontSizeOutlined />
            </Button>
            {selected && (
                <Button
                    type="text"
                    danger
                    size="small"
                    onClick={(e) => {
Яков's avatar
Яков committed
533
534
                        e.stopPropagation();
                        const pos = getPos?.();
Яков's avatar
update    
Яков committed
535
536
537
                        if (typeof pos === 'number') {
                            editor.view.dispatch(
                                editor.view.state.tr.delete(pos, pos + node.nodeSize)
Яков's avatar
Яков committed
538
                            );
Яков's avatar
update    
Яков committed
539
540
541
                        }
                    }}
                    style={{
Яков's avatar
Яков committed
542
543
544
545
                        position: 'absolute', top: 4, right: 4, zIndex: 30,
                        backgroundColor: 'white', border: '1px solid #d9d9d9',
                        borderRadius: '50%', width: 20, height: 20,
                        fontSize: 12, lineHeight: 1, padding: '0px 0px 2px 0px', cursor: 'pointer'
Яков's avatar
update    
Яков committed
546
                    }}
Яков's avatar
Яков committed
547
                >×</Button>
Яков's avatar
update    
Яков committed
548
            )}
Яков's avatar
Яков committed
549
            {(selected || isResizing) && (
550
551
552
553
554
                <Fragment>
                    {['nw', 'ne', 'sw', 'se'].map(dir => (
                        <div
                            key={dir}
                            onMouseDown={handleResizeStart(dir)}
Яков's avatar
update    
Яков committed
555
                            onTouchStart={handleResizeStart(dir)}
556
557
                            style={{
                                position: 'absolute',
Яков's avatar
Яков committed
558
                                width: 12, height: 12,
559
560
561
562
                                backgroundColor: BORDER_COLOR,
                                border: '1px solid white',
                                [dir[0] === 'n' ? 'top' : 'bottom']: -6,
                                [dir[1] === 'w' ? 'left' : 'right']: node.attrs.align === 'center' ? '50%' : -6,
Яков's avatar
Яков committed
563
564
                                transform: node.attrs.align === 'center'
                                    ? `translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
565
566
567
568
                                cursor: `${dir}-resize`,
                                zIndex: 10
                            }}
                        />
yakoff94's avatar
yakoff94 committed
569
                    ))}
Яков's avatar
Яков committed
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
                    <div style={{
                        position: 'absolute', top: -36, 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', alignItems: 'center', gap: 2, whiteSpace: 'nowrap',
                    }}>
                        {ALIGN_OPTIONS.map(a => (
                            <button
                                type="button"
                                key={a}
                                title={a === 'left' ? 'По левому краю' : a === 'center' ? 'По центру' : 'По правому краю'}
                                onClick={() => handleAlign(a)}
                                style={{
                                    padding: '4px 6px',
                                    background: node.attrs.align === a ? '#e6f7ff' : 'transparent',
                                    border: `1px solid ${node.attrs.align === a ? BORDER_COLOR : '#d9d9d9'}`,
                                    borderRadius: 2, cursor: 'pointer', display: 'flex', alignItems: 'center',
                                }}
                            >
                                {a === 'left' && (
                                    <svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
                                        <rect x="0" y="0"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="4"  width="10" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="8"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="12" width="10" height="2" rx="1" fill="currentColor"/>
                                    </svg>
                                )}
                                {a === 'center' && (
                                    <svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
                                        <rect x="0" y="0"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="3" y="4"  width="10" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="8"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="3" y="12" width="10" height="2" rx="1" fill="currentColor"/>
                                    </svg>
                                )}
                                {a === 'right' && (
                                    <svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
                                        <rect x="0" y="0"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="6" y="4"  width="10" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="8"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="6" y="12" width="10" height="2" rx="1" fill="currentColor"/>
                                    </svg>
                                )}
                            </button>
                        ))}
                        {node.attrs.align !== 'center' && (
                            <>
                                <div style={{ width: 1, background: '#d9d9d9', alignSelf: 'stretch', margin: '0 2px' }} />
620
                                <button
Яков's avatar
update    
Яков committed
621
                                    type="button"
Яков's avatar
Яков committed
622
623
624
625
626
627
628
629
630
631
632
                                    title={node.attrs.wrap ? 'Обтекание включено' : 'Обтекание выключено'}
                                    onClick={(e) => {
                                        e.stopPropagation();
                                        safeUpdateAttributes({ wrap: !node.attrs.wrap });
                                        requestAnimationFrame(() => {
                                            try {
                                                const pos = getPos?.();
                                                if (typeof pos === 'number') editor.commands.setNodeSelection(pos);
                                            } catch {}
                                        });
                                    }}
633
                                    style={{
Яков's avatar
Яков committed
634
635
636
637
638
                                        padding: '4px 6px',
                                        background: node.attrs.wrap ? '#e6f7ff' : 'transparent',
                                        border: `1px solid ${node.attrs.wrap ? BORDER_COLOR : '#d9d9d9'}`,
                                        borderRadius: 2, cursor: 'pointer', fontSize: 11,
                                        display: 'flex', alignItems: 'center', gap: 3,
639
640
                                    }}
                                >
Яков's avatar
Яков committed
641
642
643
644
645
646
647
648
                                    <svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
                                        <rect x="0" y="0" width="7" height="7" rx="1" fill="currentColor" opacity="0.5"/>
                                        <rect x="9" y="0"  width="7" height="2" rx="1" fill="currentColor"/>
                                        <rect x="9" y="4"  width="5" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="9"  width="16" height="2" rx="1" fill="currentColor"/>
                                        <rect x="0" y="12" width="12" height="2" rx="1" fill="currentColor"/>
                                    </svg>
                                    Обтекание
649
                                </button>
Яков's avatar
Яков committed
650
651
652
                            </>
                        )}
                    </div>
653
                </Fragment>
yakoff94's avatar
yakoff94 committed
654
            )}
Яков's avatar
update    
Яков committed
655
            <Modal
Яков's avatar
update    
Яков committed
656
                title="Текст на картинке"
Яков's avatar
update    
Яков committed
657
                open={altModalVisible}
Яков's avatar
Яков committed
658
                onOk={() => { updateAttributes({ alt: tempAlt, frontAlt: tempFrontAlt }); setAltModalVisible(false); }}
Яков's avatar
update    
Яков committed
659
660
661
662
                onCancel={() => setAltModalVisible(false)}
                okText="Применить"
                cancelText="Отмена"
            >
Яков's avatar
update    
Яков committed
663
                <div style={{marginBottom: '5px'}}><Text>Лицевая сторона</Text></div>
Яков's avatar
Яков committed
664
                <TextArea value={tempFrontAlt} onChange={(e) => setTempFrontAlt(e.target.value)} rows={4} placeholder="Введите текст" />
Яков's avatar
update    
Яков committed
665
                <div style={{marginTop: '15px', marginBottom: '5px'}}><Text>Обратная сторона</Text></div>
Яков's avatar
Яков committed
666
                <TextArea value={tempAlt} onChange={(e) => setTempAlt(e.target.value)} rows={4} placeholder="Введите текст" />
Яков's avatar
update    
Яков committed
667
            </Modal>
Яков's avatar
Яков committed
668
669
670
671
672
673
674
        </>
    );

    // Единая структура NodeViewWrapper > div > content — img всегда на одной глубине,
    // поэтому при смене выравнивания React не размонтирует img и не перезагружает его.
    return (
        <NodeViewWrapper as="div" style={getOuterStyle()} contentEditable={false} data-image-wrapper>
Яков's avatar
Яков committed
675
676
677
678
            <div
                ref={wrapperRef}
                style={getInnerStyle()}
                onClick={handleNodeClick}
Яков's avatar
Яков committed
679
                onTouchEnd={(e) => {
Яков's avatar
Яков committed
680
681
682
                    // Если тап на кнопке/интерактивном элементе — не перехватываем,
                    // иначе click на кнопках не сработает.
                    if (e.target.closest('button, a, input, [role="button"]')) return;
Яков's avatar
Яков committed
683
684
685
686
687
688
689
690
691
                    e.preventDefault();
                    try {
                        const pos = getPos?.();
                        if (typeof pos === 'number') {
                            editor.view.focus();
                            editor.commands.setNodeSelection(pos);
                        }
                    } catch (err) {}
                }}
Яков's avatar
Яков committed
692
            >
Яков's avatar
Яков committed
693
694
                {imageContent}
            </div>
yakoff94's avatar
yakoff94 committed
695
696
697
698
699
700
701
702
        </NodeViewWrapper>
    );
};

const ResizableImageExtension = TipTapImage.extend({
    addAttributes() {
        return {
            ...this.parent?.(),
Яков's avatar
fix    
Яков committed
703
            src: { default: null },
Яков's avatar
update    
Яков committed
704
705
706
707
708
709
710
711
712
713
714
715
            alt: {
                default: null,
                parseHTML: element => {
                    const raw = element.getAttribute('alt')
                    return raw?.replace(/&#10;/g, '\n') || null
                },
                renderHTML: attributes => {
                    return attributes.alt
                        ? { alt: attributes.alt.replace(/\n/g, '&#10;') }
                        : {}
                }
            },
Яков's avatar
update    
Яков committed
716
717
718
719
720
721
722
723
724
725
726
727
            frontAlt: {
                default: null,
                parseHTML: element => {
                    const raw = element.getAttribute('frontAlt')
                    return raw?.replace(/&#10;/g, '\n') || null
                },
                renderHTML: attributes => {
                    return attributes.frontAlt
                        ? { frontAlt: attributes.frontAlt.replace(/\n/g, '&#10;') }
                        : {}
                }
            },
Яков's avatar
fix    
Яков committed
728
            title: { default: null },
729
730
            width: {
                default: null,
Яков's avatar
fix    
Яков committed
731
                parseHTML: element => parseInt(element.getAttribute('width'), 10) || null,
732
733
734
735
                renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
            },
            height: {
                default: null,
Яков's avatar
fix    
Яков committed
736
                parseHTML: element => parseInt(element.getAttribute('height'), 10) || null,
737
738
739
740
741
742
                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
743
            },
Яков's avatar
Яков committed
744
745
746
747
748
            wrap: {
                default: false,
                parseHTML: element => element.getAttribute('data-wrap') === 'true',
                renderHTML: attributes => attributes.wrap ? { 'data-wrap': 'true' } : {}
            },
Яков's avatar
fix    
Яков committed
749
750
751
752
            'data-node-id': {
                default: null,
                parseHTML: element => element.getAttribute('data-node-id'),
                renderHTML: attributes => ({ 'data-node-id': attributes['data-node-id'] })
753
            }
yakoff94's avatar
yakoff94 committed
754
755
        };
    },
Яков's avatar
update    
Яков committed
756
757
758
759
760
761
762
763
764
765
766
    renderHTML({ node, HTMLAttributes }) {
        const {
            src,
            alt = '',
            title = '',
            width,
            height,
            ...rest
        } = HTMLAttributes;

        const align = node.attrs.align || 'left';
Яков's avatar
Яков committed
767
        const wrap  = node.attrs.wrap  || false;
Яков's avatar
update    
Яков committed
768
769
770
771
772
773

        const style = [];

        if (align === 'center') {
            style.push('display: block', 'margin-left: auto', 'margin-right: auto');
        } else if (align === 'left') {
Яков's avatar
Яков committed
774
775
776
            wrap
                ? style.push('float: left', 'margin-right: 1rem')
                : style.push('display: block', 'margin-right: auto');
Яков's avatar
update    
Яков committed
777
        } else if (align === 'right') {
Яков's avatar
Яков committed
778
779
780
            wrap
                ? style.push('float: right', 'margin-left: 1rem')
                : style.push('display: block', 'margin-left: auto');
Яков's avatar
update    
Яков committed
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
        }

        if (width) style.push(`width: ${width}px`);
        if (height) style.push(`height: ${height}px`);

        return [
            'img',
            {
                src,
                alt,
                title,
                width,
                height,
                'data-align': align,
                style: style.join('; '),
                ...rest,
            }
        ];
    },
800

yakoff94's avatar
yakoff94 committed
801
802
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
803
804
805
806
807
808
809
810
    },

    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' }),
        };
811
812
813
814
815
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
816
    selectable: true
817
});
yakoff94's avatar
yakoff94 committed
818
819

export default ResizableImageExtension;