Image.jsx 37.7 KB
Newer Older
yakoff94's avatar
yakoff94 committed
1
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
2
import React, { useEffect, useRef, useState, Fragment } from "react";
yakoff94's avatar
yakoff94 committed
3
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

Яков's avatar
update    
Яков committed
10
11
12
13
14
15
// Слои накладок узла — все ниже липкой панели инструментов
// (--atma-toolbar-z-index, по умолчанию 10): панель и накладки лежат в одном
// контексте наложения, и при равном z-index побеждает то, что ниже в DOM.
// Так кнопка подписи заезжала на панель, стоило прокрутить страницу.
const Z = { action: 6, resize: 7, bar: 8, remove: 9 };

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

Яков's avatar
fix    
Яков committed
20
const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, selected }) => {
yakoff94's avatar
yakoff94 committed
21
    const imgRef = useRef(null);
22
23
    const wrapperRef = useRef(null);
    const isInitialized = useRef(false);
Яков's avatar
update    
Яков committed
24
    const [isResizing, setIsResizing] = useState(false);
Яков's avatar
update    
Яков committed
25
26
    const [altModalVisible, setAltModalVisible] = useState(false);
    const [tempAlt, setTempAlt] = useState(node.attrs.alt || '');
Яков's avatar
update    
Яков committed
27
    const [tempFrontAlt, setTempFrontAlt] = useState(node.attrs.frontAlt || '');
Яков's avatar
Яков committed
28
29
    // 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
30

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

Яков's avatar
update    
Яков committed
32
33
34
35
36
37
    // Раньше сразу после картинки вставлялся \u200B — «чтобы курсор можно было
    // поставить inline сразу после неё». Убрано 29.08.2026: картинка занимает
    // всю ширину строки, и позиция «сразу после» — это верхний ПРАВЫЙ угол,
    // где тонкая каретка выглядит как сбой («курсор почему-то справа», замечание
    // владельца). Курсор после вставки теперь уводится в абзац под картинкой
    // (QEditor), а невидимый символ только мусорил в сохранённом HTML.
Яков's avatar
update    
Яков committed
38

Яков's avatar
fix    
Яков committed
39
40
    // Получаем текущую ширину редактора и доступное пространство
    const getEditorDimensions = () => {
Яков's avatar
Яков committed
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
        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
58
59
        }

Яков's avatar
Яков committed
60
61
62
63
64
65
66
67
68
        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
69
70
    };

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

Яков's avatar
fix    
Яков committed
72
    // Безопасное обновление атрибутов с учетом выравнивания и границ
Яков's avatar
Яков committed
73
74
75
76
77
78
79
80
81
82
    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
83
        }
Яков's avatar
Яков committed
84
        if (typeof pos !== 'number') return
Яков's avatar
fix    
Яков committed
85

Яков's avatar
Яков committed
86
87
88
89
90
91
92
93
94
95
96
97
        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
98
99
        }

Яков's avatar
Яков committed
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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
        // 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 });
    // };
151

Яков's avatar
fix    
Яков committed
152
    // Инициализация изображения
153
    useEffect(() => {
Яков's avatar
fix    
Яков committed
154
        if (!node.attrs['data-node-id']) {
Яков's avatar
fix    
Яков committed
155
            safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
156
157
158
                'data-node-id': `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
            });
        }
Яков's avatar
fix    
Яков committed
159
    }, [node.attrs['data-node-id']]);
Яков's avatar
fix    
Яков committed
160

Яков's avatar
fix    
Яков committed
161
    // Обработка кликов вне изображения
Яков's avatar
fix    
Яков committed
162
163
164
    useEffect(() => {
        const handleClickOutside = (event) => {
            if (wrapperRef.current && !wrapperRef.current.contains(event.target) && selected) {
Яков's avatar
update    
Яков committed
165
166
167
168
169
170
171
172
173
                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
174
175
            }
        };
Яков's avatar
fix    
Яков committed
176
177
178
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, [selected, editor, getPos]);
yakoff94's avatar
yakoff94 committed
179

Яков's avatar
fix    
Яков committed
180
    // Загрузка и инициализация изображения
yakoff94's avatar
yakoff94 committed
181
    useEffect(() => {
Яков's avatar
fix    
Яков committed
182
183
184
185
        if (!imgRef.current || isInitialized.current) return;

        const initImageSize = () => {
            try {
Яков's avatar
update    
Яков committed
186
187
188
189
190
191
192
193
194
195
196
                // Ширина задана автором — уважаем её и ничего не пересчитываем.
                //
                // Раньше проверялись ОБЕ величины (width && height). Но в сохранённых
                // уроках height есть далеко не всегда: по боевой базе width="N" стоит
                // в 305 уроках, а height — в 79. Для остальных условие не срабатывало,
                // и картинка при открытии урока молча получала натуральный размер
                // вместо авторского: в базе width="1000", в редакторе 80 px.
                // Дальше это уезжало в базу при первом же сохранении.
                //
                // Высота не нужна: она выводится из пропорций (height: auto).
                if (node.attrs.width) {
Яков's avatar
fix    
Яков committed
197
198
199
                    isInitialized.current = true;
                    return;
                }
Яков's avatar
fix    
Яков committed
200

Яков's avatar
fix    
Яков committed
201
                const { width: editorWidth } = getEditorDimensions();
Яков's avatar
fix    
Яков committed
202
203
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;
Яков's avatar
update    
Яков committed
204

Яков's avatar
fix    
Яков committed
205
206
                if (naturalWidth <= 0 || naturalHeight <= 0) {
                    console.warn('Image has invalid natural dimensions, retrying...');
Яков's avatar
fix    
Яков committed
207
                    setTimeout(initImageSize, 100);
Яков's avatar
fix    
Яков committed
208
209
210
211
212
213
214
215
216
217
218
219
                    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
220
                safeUpdateAttributes({
Яков's avatar
fix    
Яков committed
221
222
223
                    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
224
225
                });
                isInitialized.current = true;
Яков's avatar
fix    
Яков committed
226
227
228
229
230
            } catch (error) {
                console.warn('Error initializing image size:', error);
            }
        };

Яков's avatar
fix    
Яков committed
231
        const handleLoad = () => {
Яков's avatar
update    
Яков committed
232
233
234
235
236
            // Достаточно одной ширины — высота выводится из пропорций.
            // Условие «width && height» пропускало картинки, у которых сохранён
            // только width (в боевой базе таких большинство: 305 уроков против 79),
            // и они получали натуральный размер вместо авторского. См. initImageSize.
            if (node.attrs.width) {
Яков's avatar
fix    
Яков committed
237
238
239
                isInitialized.current = true;
                return;
            }
Яков's avatar
fix    
Яков committed
240
241
242
            setTimeout(initImageSize, 50);
        };

Яков's avatar
fix    
Яков committed
243
        if (imgRef.current.complete) {
Яков's avatar
fix    
Яков committed
244
            handleLoad();
Яков's avatar
fix    
Яков committed
245
        } else {
Яков's avatar
fix    
Яков committed
246
            imgRef.current.addEventListener('load', handleLoad);
247
        }
Яков's avatar
fix    
Яков committed
248
249

        return () => {
Яков's avatar
fix    
Яков committed
250
251
252
            if (imgRef.current) {
                imgRef.current.removeEventListener('load', handleLoad);
            }
Яков's avatar
fix    
Яков committed
253
        };
Яков's avatar
fix    
Яков committed
254
    }, [node.attrs.width, node.attrs.height, node.attrs['data-node-id']]);
255

Яков's avatar
fix    
Яков committed
256
    // Обработка ресайза изображения
257
258
259
260
    const handleResizeStart = (direction) => (e) => {
        e.preventDefault();
        e.stopPropagation();

Яков's avatar
update    
Яков committed
261
        setIsResizing(true);
Яков's avatar
update    
Яков committed
262
263
264
265
266
267
268
269
270
        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
271

Яков's avatar
fix    
Яков committed
272
273
274
        const startWidth = node.attrs.width || imgRef.current.naturalWidth;
        const startHeight = node.attrs.height || imgRef.current.naturalHeight;
        const aspectRatio = startWidth / startHeight;
Яков's avatar
update    
Яков committed
275
276
277
278
279
280

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

Яков's avatar
update    
Яков committed
283
284
        const onMove = (e) => {
            if (e.cancelable) e.preventDefault();
Яков's avatar
Яков committed
285
286
287
            requestAnimationFrame(() => {
                const maxWidth = node.attrs.align === 'center' ? initialEditorWidth : initialAvailableSpace;

Яков's avatar
update    
Яков committed
288
289
                const deltaX = getClientX(e) - startX;
                const deltaY = getClientY(e) - startY;
Яков's avatar
Яков committed
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306

                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);
                    }
307
                } else {
Яков's avatar
Яков committed
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
                    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);
                    }
324
325
                }

Яков's avatar
Яков committed
326
327
                safeUpdateAttributes({ width: newWidth, height: newHeight });
            });
yakoff94's avatar
yakoff94 committed
328
329
        };

Яков's avatar
update    
Яков committed
330
331
332
333
334
        const onEnd = () => {
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('mouseup', onEnd);
            window.removeEventListener('touchmove', onMove);
            window.removeEventListener('touchend', onEnd);
Яков's avatar
update    
Яков committed
335
            setIsResizing(false);
Яков's avatar
update    
Яков committed
336
337
338
339
340
341
342
343
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
fix    
Яков committed
344
            editor.commands.focus();
yakoff94's avatar
yakoff94 committed
345
346
        };

Яков's avatar
update    
Яков committed
347
348
349
350
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onEnd);
        window.addEventListener('touchmove', onMove, { passive: false });
        window.addEventListener('touchend', onEnd);
351
    };
yakoff94's avatar
yakoff94 committed
352

Яков's avatar
fix    
Яков committed
353
    // Изменение выравнивания с автоматическим масштабированием
354
    const handleAlign = (align) => {
Яков's avatar
update    
Яков committed
355
        safeUpdateAttributes({ align });
Яков's avatar
Яков committed
356
        setTimeout(() => {
Яков's avatar
update    
Яков committed
357
358
359
360
361
362
363
364
365
            safeUpdateAttributes({ align });
            try {
                const pos = getPos?.()
                if (typeof pos === 'number') {
                    editor.commands.setNodeSelection(pos)
                }
            } catch (e) {
                console.warn('getPos() failed:', e)
            }
Яков's avatar
Яков committed
366
        }, 50);
367
368
    };

Яков's avatar
Яков committed
369
    // Внешняя обёртка (NodeViewWrapper): управляет float/block-layout и отступами
Яков's avatar
update    
Яков committed
370
371
372
373
374
375
376
377
    // width: min(Npx, 100%) вместо Npx — намеренно.
    //
    // Фиксированная пиксельная ширина на обёртке делает её минимально-содержимым
    // блоком: в узком контейнере (ячейка таблицы) она не даёт ячейке ужаться,
    // и таблица растягивается — замерено 1495 px внутри контейнера на 832 px.
    // У учащегося тот же <img> с max-width: 100% спокойно ужимается до 229 px,
    // и вид расходится. min() сохраняет ширину, заданную автором, и при этом
    // позволяет сжаться. Проверяется docs/stand/media-parity.js.
Яков's avatar
Яков committed
378
379
    const getOuterStyle = () => {
        const { align, wrap, width } = node.attrs;
Яков's avatar
update    
Яков committed
380
        const w = width ? `min(${width}px, 100%)` : 'auto';
Яков's avatar
Яков committed
381
        const sharedMargin = { marginTop: '0.5rem', marginBottom: '0.5rem' };
Яков's avatar
Яков committed
382
        const noSelect = { userSelect: 'none', WebkitUserSelect: 'none', touchAction: 'manipulation' };
Яков's avatar
update    
Яков committed
383

Яков's avatar
update    
Яков committed
384
385
386
387
388
389
390
391
392
393
394
395
396
        // Без обтекания картинка остаётся В ПОТОКЕ строки: inline-block на всю
        // ширину, БЕЗ float.
        //
        // Раньше здесь стоял float:left+width:100% — «чтобы не создавать
        // block-in-inline внутри <p> (иначе параграф получает лишнюю высоту)».
        // Ценой была высота НУЛЕВАЯ: абзац с плавающей картинкой не содержит её
        // по высоте, картинка вылезала за поле ввода, курсор вставал сбоку от неё
        // на пустой строке, а клик по картинке ниже конца абзаца выделял узел —
        // следующая же набранная буква стирала картинку (воспроизведено на стенде).
        // Блока в инлайне тут нет и с inline-block: он инлайнового уровня.
        // verticalAlign: top убирает провал под базовую линию, ради которого
        // и брали float.
        if (align === 'center' || !wrap) {
Яков's avatar
update    
Яков committed
397
            return {
Яков's avatar
Яков committed
398
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
update    
Яков committed
399
                display: 'inline-block', verticalAlign: 'top',
Яков's avatar
Яков committed
400
                width: '100%',
Яков's avatar
update    
Яков committed
401
                textAlign: align === 'center' ? 'center' : (align === 'right' ? 'right' : 'left'),
Яков's avatar
update    
Яков committed
402
403
404
            };
        }
        return {
Яков's avatar
Яков committed
405
            ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
406
407
408
409
410
411
412
413
414
415
            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
416
        const w = width ? `min(${width}px, 100%)` : 'auto';
Яков's avatar
Яков committed
417
418
419
420
421
422
423
424
        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
425
426
            userSelect: 'none',
            WebkitUserSelect: 'none',
Яков's avatar
update    
Яков committed
427
        };
Яков's avatar
Яков committed
428
429
        if (align === 'center') {
            return { ...base, display: 'block', marginLeft: 'auto', marginRight: 'auto',
Яков's avatar
update    
Яков committed
430
                width: width ? `min(${width}px, 100%)` : 'fit-content' };
Яков's avatar
Яков committed
431
432
433
434
435
436
437
438
439
440
441
442
        }
        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
443
    };
yakoff94's avatar
yakoff94 committed
444

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

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

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

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

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

Яков's avatar
update    
Яков committed
746
747
748
749
750
751
752
753
754
755
756
        // Инлайновых стилей здесь БОЛЬШЕ НЕТ — намеренно.
        //
        // Раньше сюда писались '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
757
758
759
760
761
762
763
764
765
        return [
            'img',
            {
                src,
                alt,
                title,
                width,
                height,
                'data-align': align,
Яков's avatar
update    
Яков committed
766
                ...(wrap ? { 'data-wrap': 'true' } : {}),
Яков's avatar
update    
Яков committed
767
768
769
770
                ...rest,
            }
        ];
    },
771

yakoff94's avatar
yakoff94 committed
772
773
    addNodeView() {
        return ReactNodeViewRenderer(ResizableImageTemplate);
Яков's avatar
fix    
Яков committed
774
775
776
777
778
779
780
781
    },

    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' }),
        };
782
783
784
785
786
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
787
    selectable: true
788
});
yakoff94's avatar
yakoff94 committed
789
790

export default ResizableImageExtension;