Image.jsx 37 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
update    
Яков committed
209
210
211
212
213
214
215
216
217
218
219
                // Ширина задана автором — уважаем её и ничего не пересчитываем.
                //
                // Раньше проверялись ОБЕ величины (width && height). Но в сохранённых
                // уроках height есть далеко не всегда: по боевой базе width="N" стоит
                // в 305 уроках, а height — в 79. Для остальных условие не срабатывало,
                // и картинка при открытии урока молча получала натуральный размер
                // вместо авторского: в базе width="1000", в редакторе 80 px.
                // Дальше это уезжало в базу при первом же сохранении.
                //
                // Высота не нужна: она выводится из пропорций (height: auto).
                if (node.attrs.width) {
Яков's avatar
fix    
Яков committed
220
221
222
                    isInitialized.current = true;
                    return;
                }
Яков's avatar
fix    
Яков committed
223

Яков's avatar
fix    
Яков committed
224
                const { width: editorWidth } = getEditorDimensions();
Яков's avatar
fix    
Яков committed
225
226
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;
Яков's avatar
update    
Яков committed
227

Яков's avatar
fix    
Яков committed
228
229
                if (naturalWidth <= 0 || naturalHeight <= 0) {
                    console.warn('Image has invalid natural dimensions, retrying...');
Яков's avatar
fix    
Яков committed
230
                    setTimeout(initImageSize, 100);
Яков's avatar
fix    
Яков committed
231
232
233
234
235
236
237
238
239
240
241
242
                    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
243
                safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
244
245
246
                    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
247
248
                });
                isInitialized.current = true;
Яков's avatar
fix    
Яков committed
249
250
251
252
253
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

Яков's avatar
fix    
Яков committed
254
        const handleLoad = () => {
Яков's avatar
update    
Яков committed
255
256
257
258
259
            // Достаточно одной ширины — высота выводится из пропорций.
            // Условие «width && height» пропускало картинки, у которых сохранён
            // только width (в боевой базе таких большинство: 305 уроков против 79),
            // и они получали натуральный размер вместо авторского. См. initImageSize.
            if (node.attrs.width) {
Яков's avatar
fix    
Яков committed
260
261
262
                isInitialized.current = true;
                return;
            }
Яков's avatar
fix    
Яков committed
263
264
265
            setTimeout(initImageSize, 50);
        };

Яков's avatar
fix    
Яков committed
266
        if (imgRef.current.complete) {
Яков's avatar
fix    
Яков committed
267
            handleLoad();
Яков's avatar
fix    
Яков committed
268
        } else {
Яков's avatar
fix    
Яков committed
269
            imgRef.current.addEventListener('load', handleLoad);
270
        }
Яков's avatar
fix    
Яков committed
271
272

        return () => {
Яков's avatar
fix    
Яков committed
273
274
275
            if (imgRef.current) {
                imgRef.current.removeEventListener('load', handleLoad);
            }
Яков's avatar
fix    
Яков committed
276
        };
Яков's avatar
fix    
Яков committed
277
    }, [node.attrs.width, node.attrs.height, node.attrs['data-node-id']]);
278

Яков's avatar
fix    
Яков committed
279
    // Обработка ресайза изображения
280
281
282
283
    const handleResizeStart = (direction) => (e) => {
        e.preventDefault();
        e.stopPropagation();

Яков's avatar
update    
Яков committed
284
        setIsResizing(true);
Яков's avatar
update    
Яков committed
285
286
287
288
289
290
291
292
293
        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
294

Яков's avatar
fix    
Яков committed
295
296
297
        const startWidth = node.attrs.width || imgRef.current.naturalWidth;
        const startHeight = node.attrs.height || imgRef.current.naturalHeight;
        const aspectRatio = startWidth / startHeight;
Яков's avatar
update    
Яков committed
298
299
300
301
302
303

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

Яков's avatar
update    
Яков committed
306
307
        const onMove = (e) => {
            if (e.cancelable) e.preventDefault();
Яков's avatar
Яков committed
308
309
310
            requestAnimationFrame(() => {
                const maxWidth = node.attrs.align === 'center' ? initialEditorWidth : initialAvailableSpace;

Яков's avatar
update    
Яков committed
311
312
                const deltaX = getClientX(e) - startX;
                const deltaY = getClientY(e) - startY;
Яков's avatar
Яков committed
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329

                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);
                    }
330
                } else {
Яков's avatar
Яков committed
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
                    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);
                    }
347
348
                }

Яков's avatar
Яков committed
349
350
                safeUpdateAttributes({ width: newWidth, height: newHeight });
            });
yakoff94's avatar
yakoff94 committed
351
352
        };

Яков's avatar
update    
Яков committed
353
354
355
356
357
        const onEnd = () => {
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('mouseup', onEnd);
            window.removeEventListener('touchmove', onMove);
            window.removeEventListener('touchend', onEnd);
Яков's avatar
update    
Яков committed
358
            setIsResizing(false);
Яков's avatar
update    
Яков committed
359
360
361
362
363
364
365
366
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
fix    
Яков committed
367
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
368
369
        };

Яков's avatar
update    
Яков committed
370
371
372
373
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onEnd);
        window.addEventListener('touchmove', onMove, { passive: false });
        window.addEventListener('touchend', onEnd);
374
    };
yakoff94's avatar
yakoff94 committed
375

Яков's avatar
fix    
Яков committed
376
    // Изменение выравнивания с автоматическим масштабированием
377
    const handleAlign = (align) => {
Яков's avatar
update    
Яков committed
378
        safeUpdateAttributes({ align });
Яков's avatar
Яков committed
379
        setTimeout(() => {
Яков's avatar
update    
Яков committed
380
381
382
383
384
385
386
387
388
            safeUpdateAttributes({ align });
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
Яков committed
389
        }, 50);
390
391
    };

Яков's avatar
Яков committed
392
    // Внешняя обёртка (NodeViewWrapper): управляет float/block-layout и отступами
Яков's avatar
update    
Яков committed
393
394
395
396
397
398
399
400
    // width: min(Npx, 100%) вместо Npx — намеренно.
    //
    // Фиксированная пиксельная ширина на обёртке делает её минимально-содержимым
    // блоком: в узком контейнере (ячейка таблицы) она не даёт ячейке ужаться,
    // и таблица растягивается — замерено 1495 px внутри контейнера на 832 px.
    // У учащегося тот же <img> с max-width: 100% спокойно ужимается до 229 px,
    // и вид расходится. min() сохраняет ширину, заданную автором, и при этом
    // позволяет сжаться. Проверяется docs/stand/media-parity.js.
Яков's avatar
Яков committed
401
402
    const getOuterStyle = () => {
        const { align, wrap, width } = node.attrs;
Яков's avatar
update    
Яков committed
403
        const w = width ? `min(${width}px, 100%)` : 'auto';
Яков's avatar
Яков committed
404
        const sharedMargin = { marginTop: '0.5rem', marginBottom: '0.5rem' };
Яков's avatar
Яков committed
405
        const noSelect = { userSelect: 'none', WebkitUserSelect: 'none', touchAction: 'manipulation' };
Яков's avatar
update    
Яков committed
406

Яков's avatar
Яков committed
407
        if (align === 'center') {
Яков's avatar
Яков committed
408
409
410
            // Используем float:left+width:100% чтобы не создавать block-in-inline внутри <p>
            // (иначе параграф получает лишнюю высоту). textAlign:center центрирует внутренний inline-block.
            return {
Яков's avatar
Яков committed
411
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
412
413
414
                display: 'inline-block', float: 'left', clear: 'both',
                width: '100%', textAlign: 'center',
            };
Яков's avatar
Яков committed
415
416
        }
        if (!wrap) {
Яков's avatar
update    
Яков committed
417
            return {
Яков's avatar
Яков committed
418
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
419
420
421
422
423
                display: 'inline-block',
                float: 'left',
                clear: 'both',
                width: '100%',
                ...(align === 'right' ? { textAlign: 'right' } : { textAlign: 'left' }),
Яков's avatar
update    
Яков committed
424
425
426
            };
        }
        return {
Яков's avatar
Яков committed
427
            ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
428
429
430
431
432
433
434
435
436
437
            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;
Яков's avatar
update    
Яков committed
438
        const w = width ? `min(${width}px, 100%)` : 'auto';
Яков's avatar
Яков committed
439
440
441
442
443
444
445
446
        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
447
448
            userSelect: 'none',
            WebkitUserSelect: 'none',
Яков's avatar
update    
Яков committed
449
        };
Яков's avatar
Яков committed
450
451
        if (align === 'center') {
            return { ...base, display: 'block', marginLeft: 'auto', marginRight: 'auto',
Яков's avatar
update    
Яков committed
452
                width: width ? `min(${width}px, 100%)` : 'fit-content' };
Яков's avatar
Яков committed
453
454
455
456
457
458
459
460
461
462
463
464
        }
        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
465
    };
yakoff94's avatar
yakoff94 committed
466

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

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

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

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

        const align = node.attrs.align || 'left';
Яков's avatar
Яков committed
766
        const wrap  = node.attrs.wrap  || false;
Яков's avatar
update    
Яков committed
767

Яков's avatar
update    
Яков committed
768
769
770
771
772
773
774
775
776
777
778
        // Инлайновых стилей здесь БОЛЬШЕ НЕТ — намеренно.
        //
        // Раньше сюда писались 'width: 640px; height: 480px; float: left'. Из-за
        // пиксельной высоты картинка искажалась при сужении окна (замерено 26% на
        // 768 px и 42% на 600 px), а перебить инлайн из таблицы стилей можно было
        // только через !important.
        //
        // Теперь размеры едут обычными атрибутами width/height: браузер выводит из
        // них соотношение сторон, и height: auto в CSS работает без искажений.
        // Раскладку задают data-align и data-wrap — правила в
        // AtmaGuru/src/React/sass/_content.scss. См. §6.1 ТЗ.
Яков's avatar
update    
Яков committed
779
780
781
782
783
784
785
786
787
        return [
            'img',
            {
                src,
                alt,
                title,
                width,
                height,
                'data-align': align,
Яков's avatar
update    
Яков committed
788
                ...(wrap ? { 'data-wrap': 'true' } : {}),
Яков's avatar
update    
Яков committed
789
790
791
792
                ...rest,
            }
        ];
    },
793

yakoff94's avatar
yakoff94 committed
794
795
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
796
797
798
799
800
801
802
803
    },

    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' }),
        };
804
805
806
807
808
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
809
    selectable: true
810
});
yakoff94's avatar
yakoff94 committed
811
812

export default ResizableImageExtension;