Image.jsx 34.3 KB
Newer Older
yakoff94's avatar
yakoff94 committed
1
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
2
import React, { useEffect, useRef, useState, Fragment } from "react";
yakoff94's avatar
yakoff94 committed
3
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
update    
Яков committed
18
    const [isResizing, setIsResizing] = useState(false);
Яков's avatar
update    
Яков committed
19
20
    const [altModalVisible, setAltModalVisible] = useState(false);
    const [tempAlt, setTempAlt] = useState(node.attrs.alt || '');
Яков's avatar
update    
Яков committed
21
    const [tempFrontAlt, setTempFrontAlt] = useState(node.attrs.frontAlt || '');
Яков's avatar
Яков committed
22
23
    // 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
24

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

Яков's avatar
Яков committed
26
27
28
    // На десктопе вставляем \u200B после картинки, чтобы курсор можно было
    // поставить inline сразу после неё. На мобильном это не нужно — там курсор
    // всё равно встаёт на всю высоту картинки, выглядит некорректно.
Яков's avatar
fix    
Яков committed
29
    useEffect(() => {
Яков's avatar
Яков committed
30
        if (isMobile) return
Яков's avatar
Яков committed
31
        if (!editor || !getPos || editor.isDestroyed) return
Яков's avatar
fix    
Яков committed
32

Яков's avatar
Яков committed
33
        let pos
Яков's avatar
update    
Яков committed
34
        try {
Яков's avatar
Яков committed
35
36
37
            pos = getPos()
        } catch {
            return
Яков's avatar
update    
Яков committed
38
        }
Яков's avatar
Яков committed
39
        if (typeof pos !== 'number') return
Яков's avatar
update    
Яков committed
40

Яков's avatar
Яков committed
41
42
43
        const { doc } = editor.state
        const node = doc.nodeAt(pos)
        if (!node || node.type.name !== 'image') return
Яков's avatar
fix    
Яков committed
44

Яков's avatar
Яков committed
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
        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
60

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

Яков's avatar
fix    
Яков committed
62
63
    // Получаем текущую ширину редактора и доступное пространство
    const getEditorDimensions = () => {
Яков's avatar
Яков committed
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
        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
81
82
        }

Яков's avatar
Яков committed
83
84
85
86
87
88
89
90
91
        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
92
93
    };

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

Яков's avatar
fix    
Яков committed
95
    // Безопасное обновление атрибутов с учетом выравнивания и границ
Яков's avatar
Яков committed
96
97
98
99
100
101
102
103
104
105
    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
106
        }
Яков's avatar
Яков committed
107
        if (typeof pos !== 'number') return
Яков's avatar
fix    
Яков committed
108

Яков's avatar
Яков committed
109
110
111
112
113
114
115
116
117
118
119
120
        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
121
122
        }

Яков's avatar
Яков committed
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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
        // 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 });
    // };
174

Яков's avatar
fix    
Яков committed
175
    // Инициализация изображения
176
    useEffect(() => {
Яков's avatar
fix    
Яков committed
177
        if (!node.attrs['data-node-id']) {
Яков's avatar
fix    
Яков committed
178
            safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
179
180
181
                'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
            });
        }
Яков's avatar
fix    
Яков committed
182
    }, [node.attrs['data-node-id']]);
Яков's avatar
fix    
Яков committed
183

Яков's avatar
fix    
Яков committed
184
    // Обработка кликов вне изображения
Яков's avatar
fix    
Яков committed
185
186
187
    useEffect(() => {
        const handleClickOutside = (event) => {
            if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
Яков's avatar
update    
Яков committed
188
189
190
191
192
193
194
195
196
                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
197
198
            }
        };
Яков's avatar
fix    
Яков committed
199
200
201
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, [selected, editor, getPos]);
yakoff94's avatar
yakoff94 committed
202

Яков's avatar
fix    
Яков committed
203
    // Загрузка и инициализация изображения
yakoff94's avatar
yakoff94 committed
204
    useEffect(() => {
Яков's avatar
fix    
Яков committed
205
206
207
208
        if (!imgRef.current || isInitialized.current) return;

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

Яков's avatar
fix    
Яков committed
215
                const { width: editorWidth } = getEditorDimensions();
Яков's avatar
fix    
Яков committed
216
217
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;
Яков's avatar
update    
Яков committed
218

Яков's avatar
fix    
Яков committed
219
220
                if (naturalWidth <= 0 || naturalHeight <= 0) {
                    console.warn('Image has invalid natural dimensions, retrying...');
Яков's avatar
fix    
Яков committed
221
                    setTimeout(initImageSize, 100);
Яков's avatar
fix    
Яков committed
222
223
224
225
226
227
228
229
230
231
232
233
                    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
234
                safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
235
236
237
                    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
238
239
                });
                isInitialized.current = true;
Яков's avatar
fix    
Яков committed
240
241
242
243
244
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

Яков's avatar
fix    
Яков committed
245
        const handleLoad = () => {
Яков's avatar
fix    
Яков committed
246
247
248
249
250
            // Если размеры уже заданы в атрибутах, пропускаем инициализацию
            if (node.attrs.width && node.attrs.height) {
                isInitialized.current = true;
                return;
            }
Яков's avatar
fix    
Яков committed
251
252
253
            setTimeout(initImageSize, 50);
        };

Яков's avatar
fix    
Яков committed
254
        if (imgRef.current.complete) {
Яков's avatar
fix    
Яков committed
255
            handleLoad();
Яков's avatar
fix    
Яков committed
256
        } else {
Яков's avatar
fix    
Яков committed
257
            imgRef.current.addEventListener('load', handleLoad);
258
        }
Яков's avatar
fix    
Яков committed
259
260

        return () => {
Яков's avatar
fix    
Яков committed
261
262
263
            if (imgRef.current) {
                imgRef.current.removeEventListener('load', handleLoad);
            }
Яков's avatar
fix    
Яков committed
264
        };
Яков's avatar
fix    
Яков committed
265
    }, [node.attrs.width, node.attrs.height, node.attrs['data-node-id']]);
266

Яков's avatar
fix    
Яков committed
267
    // Обработка ресайза изображения
268
269
270
271
    const handleResizeStart = (direction) => (e) => {
        e.preventDefault();
        e.stopPropagation();

Яков's avatar
update    
Яков committed
272
        setIsResizing(true);
Яков's avatar
update    
Яков committed
273
274
275
276
277
278
279
280
281
        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
282

Яков's avatar
fix    
Яков committed
283
284
285
        const startWidth = node.attrs.width || imgRef.current.naturalWidth;
        const startHeight = node.attrs.height || imgRef.current.naturalHeight;
        const aspectRatio = startWidth / startHeight;
Яков's avatar
update    
Яков committed
286
287
288
289
290
291

        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
292
        const { width: initialEditorWidth, availableSpace: initialAvailableSpace } = getEditorDimensions();
293

Яков's avatar
update    
Яков committed
294
295
        const onMove = (e) => {
            if (e.cancelable) e.preventDefault();
Яков's avatar
Яков committed
296
297
298
            requestAnimationFrame(() => {
                const maxWidth = node.attrs.align === 'center' ? initialEditorWidth : initialAvailableSpace;

Яков's avatar
update    
Яков committed
299
300
                const deltaX = getClientX(e) - startX;
                const deltaY = getClientY(e) - startY;
Яков's avatar
Яков committed
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317

                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);
                    }
318
                } else {
Яков's avatar
Яков committed
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
                    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);
                    }
335
336
                }

Яков's avatar
Яков committed
337
338
                safeUpdateAttributes({ width: newWidth, height: newHeight });
            });
yakoff94's avatar
yakoff94 committed
339
340
        };

Яков's avatar
update    
Яков committed
341
342
343
344
345
        const onEnd = () => {
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('mouseup', onEnd);
            window.removeEventListener('touchmove', onMove);
            window.removeEventListener('touchend', onEnd);
Яков's avatar
update    
Яков committed
346
            setIsResizing(false);
Яков's avatar
update    
Яков committed
347
348
349
350
351
352
353
354
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
fix    
Яков committed
355
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
356
357
        };

Яков's avatar
update    
Яков committed
358
359
360
361
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onEnd);
        window.addEventListener('touchmove', onMove, { passive: false });
        window.addEventListener('touchend', onEnd);
362
    };
yakoff94's avatar
yakoff94 committed
363

Яков's avatar
fix    
Яков committed
364
    // Изменение выравнивания с автоматическим масштабированием
365
    const handleAlign = (align) => {
Яков's avatar
update    
Яков committed
366
        safeUpdateAttributes({ align });
Яков's avatar
Яков committed
367
        setTimeout(() => {
Яков's avatar
update    
Яков committed
368
369
370
371
372
373
374
375
376
            safeUpdateAttributes({ align });
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
Яков committed
377
        }, 50);
378
379
    };

Яков's avatar
Яков committed
380
381
382
383
384
    // Внешняя обёртка (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
385
        const noSelect = { userSelect: 'none', WebkitUserSelect: 'none', touchAction: 'manipulation' };
Яков's avatar
update    
Яков committed
386

Яков's avatar
Яков committed
387
        if (align === 'center') {
Яков's avatar
Яков committed
388
389
390
            // Используем float:left+width:100% чтобы не создавать block-in-inline внутри <p>
            // (иначе параграф получает лишнюю высоту). textAlign:center центрирует внутренний inline-block.
            return {
Яков's avatar
Яков committed
391
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
392
393
394
                display: 'inline-block', float: 'left', clear: 'both',
                width: '100%', textAlign: 'center',
            };
Яков's avatar
Яков committed
395
396
        }
        if (!wrap) {
Яков's avatar
update    
Яков committed
397
            return {
Яков's avatar
Яков committed
398
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
399
400
401
402
403
                display: 'inline-block',
                float: 'left',
                clear: 'both',
                width: '100%',
                ...(align === 'right' ? { textAlign: 'right' } : { textAlign: 'left' }),
Яков's avatar
update    
Яков committed
404
405
406
            };
        }
        return {
Яков's avatar
Яков committed
407
            ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
            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
427
428
            userSelect: 'none',
            WebkitUserSelect: 'none',
Яков's avatar
update    
Яков committed
429
        };
Яков's avatar
Яков committed
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
        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
445
    };
yakoff94's avatar
yakoff94 committed
446

Яков's avatar
fix    
Яков committed
447
    // Стили для самого изображения
448
449
    const getImageStyle = () => ({
        width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
Яков's avatar
fix    
Яков committed
450
        height: 'auto',
451
452
453
454
        maxWidth: '100%',
        display: 'block',
        cursor: 'default',
        userSelect: 'none',
Яков's avatar
Яков committed
455
        margin: node.attrs.align === 'center' ? '0 auto' : '0',
Яков's avatar
update    
Яков committed
456
        verticalAlign: node.attrs.align === 'text' ? 'middle' : 'top',
Яков's avatar
fix    
Яков committed
457
        objectFit: 'contain'
458
459
    });

Яков's avatar
Яков committed
460
461
462
    // Inner content shared between both rendering paths
    const imageContent = (
        <>
yakoff94's avatar
yakoff94 committed
463
            <img
Яков's avatar
Яков committed
464
465
466
467
                src={node.attrs.src}
                alt={node.attrs.alt || undefined}
                title={node.attrs.title || undefined}
                data-node-id={node.attrs['data-node-id'] || undefined}
468
                ref={imgRef}
Яков's avatar
Яков committed
469
                draggable={!isMobile}
470
                style={getImageStyle()}
yakoff94's avatar
yakoff94 committed
471
            />
Яков's avatar
Яков committed
472
            {node.attrs.frontAlt?.length > 0 && (
Яков's avatar
update    
Яков committed
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
                <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
491
            )}
Яков's avatar
update    
Яков committed
492
493
494
            <Button
                size="default"
                shape={'circle'}
Яков's avatar
update    
Яков committed
495
                type={node.attrs.alt?.length > 0 || node.attrs.frontAlt?.length ? 'primary' : 'default'}
Яков's avatar
update    
Яков committed
496
497
498
                onClick={(e) => {
                    e.stopPropagation();
                    setTempAlt(node.attrs.alt || '');
Яков's avatar
update    
Яков committed
499
                    setTempFrontAlt(node.attrs.frontAlt || '');
Яков's avatar
update    
Яков committed
500
501
                    setAltModalVisible(true);
                }}
Яков's avatar
Яков committed
502
                style={{ position: 'absolute', top: 4, right: '30px', zIndex: 15 }}
Яков's avatar
update    
Яков committed
503
504
505
506
507
508
509
510
511
            >
                <FontSizeOutlined />
            </Button>
            {selected && (
                <Button
                    type="text"
                    danger
                    size="small"
                    onClick={(e) => {
Яков's avatar
Яков committed
512
513
                        e.stopPropagation();
                        const pos = getPos?.();
Яков's avatar
update    
Яков committed
514
515
516
                        if (typeof pos === 'number') {
                            editor.view.dispatch(
                                editor.view.state.tr.delete(pos, pos + node.nodeSize)
Яков's avatar
Яков committed
517
                            );
Яков's avatar
update    
Яков committed
518
519
520
                        }
                    }}
                    style={{
Яков's avatar
Яков committed
521
522
523
524
                        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
525
                    }}
Яков's avatar
Яков committed
526
                >×</Button>
Яков's avatar
update    
Яков committed
527
            )}
Яков's avatar
Яков committed
528
            {(selected || isResizing) && !isMobile && (
529
530
531
532
533
                <Fragment>
                    {['nw', 'ne', 'sw', 'se'].map(dir => (
                        <div
                            key={dir}
                            onMouseDown={handleResizeStart(dir)}
Яков's avatar
update    
Яков committed
534
                            onTouchStart={handleResizeStart(dir)}
535
536
                            style={{
                                position: 'absolute',
Яков's avatar
Яков committed
537
                                width: 12, height: 12,
538
539
540
541
                                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
542
543
                                transform: node.attrs.align === 'center'
                                    ? `translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
544
545
546
547
                                cursor: `${dir}-resize`,
                                zIndex: 10
                            }}
                        />
yakoff94's avatar
yakoff94 committed
548
                    ))}
Яков's avatar
Яков committed
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
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
                    <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' }} />
599
                                <button
Яков's avatar
update    
Яков committed
600
                                    type="button"
Яков's avatar
Яков committed
601
602
603
604
605
606
607
608
609
610
611
                                    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 {}
                                        });
                                    }}
612
                                    style={{
Яков's avatar
Яков committed
613
614
615
616
617
                                        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,
618
619
                                    }}
                                >
Яков's avatar
Яков committed
620
621
622
623
624
625
626
627
                                    <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>
                                    Обтекание
628
                                </button>
Яков's avatar
Яков committed
629
630
631
                            </>
                        )}
                    </div>
632
                </Fragment>
yakoff94's avatar
yakoff94 committed
633
            )}
Яков's avatar
update    
Яков committed
634
            <Modal
Яков's avatar
update    
Яков committed
635
                title="Текст на картинке"
Яков's avatar
update    
Яков committed
636
                open={altModalVisible}
Яков's avatar
Яков committed
637
                onOk={() => { updateAttributes({ alt: tempAlt, frontAlt: tempFrontAlt }); setAltModalVisible(false); }}
Яков's avatar
update    
Яков committed
638
639
640
641
                onCancel={() => setAltModalVisible(false)}
                okText="Применить"
                cancelText="Отмена"
            >
Яков's avatar
update    
Яков committed
642
                <div style={{marginBottom: '5px'}}><Text>Лицевая сторона</Text></div>
Яков's avatar
Яков committed
643
                <TextArea value={tempFrontAlt} onChange={(e) => setTempFrontAlt(e.target.value)} rows={4} placeholder="Введите текст" />
Яков's avatar
update    
Яков committed
644
                <div style={{marginTop: '15px', marginBottom: '5px'}}><Text>Обратная сторона</Text></div>
Яков's avatar
Яков committed
645
                <TextArea value={tempAlt} onChange={(e) => setTempAlt(e.target.value)} rows={4} placeholder="Введите текст" />
Яков's avatar
update    
Яков committed
646
            </Modal>
Яков's avatar
Яков committed
647
648
649
650
651
652
653
        </>
    );

    // Единая структура NodeViewWrapper > div > content — img всегда на одной глубине,
    // поэтому при смене выравнивания React не размонтирует img и не перезагружает его.
    return (
        <NodeViewWrapper as="div" style={getOuterStyle()} contentEditable={false} data-image-wrapper>
Яков's avatar
Яков committed
654
655
656
657
            <div
                ref={wrapperRef}
                style={getInnerStyle()}
                onClick={handleNodeClick}
Яков's avatar
Яков committed
658
659
660
661
662
663
664
665
666
667
                onTouchEnd={(e) => {
                    e.preventDefault();
                    try {
                        const pos = getPos?.();
                        if (typeof pos === 'number') {
                            editor.view.focus();
                            editor.commands.setNodeSelection(pos);
                        }
                    } catch (err) {}
                }}
Яков's avatar
Яков committed
668
            >
Яков's avatar
Яков committed
669
670
                {imageContent}
            </div>
yakoff94's avatar
yakoff94 committed
671
672
673
674
675
676
677
678
        </NodeViewWrapper>
    );
};

const ResizableImageExtension = TipTapImage.extend({
    addAttributes() {
        return {
            ...this.parent?.(),
Яков's avatar
fix    
Яков committed
679
            src: { default: null },
Яков's avatar
update    
Яков committed
680
681
682
683
684
685
686
687
688
689
690
691
            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
692
693
694
695
696
697
698
699
700
701
702
703
            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
704
            title: { default: null },
705
706
            width: {
                default: null,
Яков's avatar
fix    
Яков committed
707
                parseHTML: element => parseInt(element.getAttribute('width'), 10) || null,
708
709
710
711
                renderHTML: attributes => attributes.width ? { width: attributes.width } : {}
            },
            height: {
                default: null,
Яков's avatar
fix    
Яков committed
712
                parseHTML: element => parseInt(element.getAttribute('height'), 10) || null,
713
714
715
716
717
718
                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
719
            },
Яков's avatar
Яков committed
720
721
722
723
724
            wrap: {
                default: false,
                parseHTML: element => element.getAttribute('data-wrap') === 'true',
                renderHTML: attributes => attributes.wrap ? { 'data-wrap': 'true' } : {}
            },
Яков's avatar
fix    
Яков committed
725
726
727
728
            'data-node-id': {
                default: null,
                parseHTML: element => element.getAttribute('data-node-id'),
                renderHTML: attributes => ({ 'data-node-id': attributes['data-node-id'] })
729
            }
yakoff94's avatar
yakoff94 committed
730
731
        };
    },
Яков's avatar
update    
Яков committed
732
733
734
735
736
737
738
739
740
741
742
    renderHTML({ node, HTMLAttributes }) {
        const {
            src,
            alt = '',
            title = '',
            width,
            height,
            ...rest
        } = HTMLAttributes;

        const align = node.attrs.align || 'left';
Яков's avatar
Яков committed
743
        const wrap  = node.attrs.wrap  || false;
Яков's avatar
update    
Яков committed
744
745
746
747
748
749

        const style = [];

        if (align === 'center') {
            style.push('display: block', 'margin-left: auto', 'margin-right: auto');
        } else if (align === 'left') {
Яков's avatar
Яков committed
750
751
752
            wrap
                ? style.push('float: left', 'margin-right: 1rem')
                : style.push('display: block', 'margin-right: auto');
Яков's avatar
update    
Яков committed
753
        } else if (align === 'right') {
Яков's avatar
Яков committed
754
755
756
            wrap
                ? style.push('float: right', 'margin-left: 1rem')
                : style.push('display: block', 'margin-left: auto');
Яков's avatar
update    
Яков committed
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
        }

        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,
            }
        ];
    },
776

yakoff94's avatar
yakoff94 committed
777
778
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
779
780
781
782
783
784
785
786
    },

    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' }),
        };
787
788
789
790
791
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
792
    selectable: true
793
});
yakoff94's avatar
yakoff94 committed
794
795

export default ResizableImageExtension;