Image.jsx 38.2 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
Яков committed
32
33
34
    // На десктопе вставляем \u200B после картинки, чтобы курсор можно было
    // поставить inline сразу после неё. На мобильном это не нужно — там курсор
    // всё равно встаёт на всю высоту картинки, выглядит некорректно.
Яков's avatar
fix    
Яков committed
35
    useEffect(() => {
Яков's avatar
Яков committed
36
        if (isMobile) return
Яков's avatar
Яков committed
37
        if (!editor || !getPos || editor.isDestroyed) return
Яков's avatar
fix    
Яков committed
38

Яков's avatar
Яков committed
39
        let pos
Яков's avatar
update    
Яков committed
40
        try {
Яков's avatar
Яков committed
41
42
43
            pos = getPos()
        } catch {
            return
Яков's avatar
update    
Яков committed
44
        }
Яков's avatar
Яков committed
45
        if (typeof pos !== 'number') return
Яков's avatar
update    
Яков committed
46

Яков's avatar
Яков committed
47
48
49
        const { doc } = editor.state
        const node = doc.nodeAt(pos)
        if (!node || node.type.name !== 'image') return
Яков's avatar
fix    
Яков committed
50

Яков's avatar
Яков committed
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
        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
66

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

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

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

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

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

Яков's avatar
Яков committed
115
116
117
118
119
120
121
122
123
124
125
126
        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
127
128
        }

Яков's avatar
Яков committed
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
174
175
176
177
178
179
        // 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 });
    // };
180

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

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

Яков's avatar
fix    
Яков committed
209
    // Загрузка и инициализация изображения
yakoff94's avatar
yakoff94 committed
210
    useEffect(() => {
Яков's avatar
fix    
Яков committed
211
212
213
214
        if (!imgRef.current || isInitialized.current) return;

        const initImageSize = () => {
            try {
Яков's avatar
update    
Яков committed
215
216
217
218
219
220
221
222
223
224
225
                // Ширина задана автором — уважаем её и ничего не пересчитываем.
                //
                // Раньше проверялись ОБЕ величины (width && height). Но в сохранённых
                // уроках height есть далеко не всегда: по боевой базе width="N" стоит
                // в 305 уроках, а height — в 79. Для остальных условие не срабатывало,
                // и картинка при открытии урока молча получала натуральный размер
                // вместо авторского: в базе width="1000", в редакторе 80 px.
                // Дальше это уезжало в базу при первом же сохранении.
                //
                // Высота не нужна: она выводится из пропорций (height: auto).
                if (node.attrs.width) {
Яков's avatar
fix    
Яков committed
226
227
228
                    isInitialized.current = true;
                    return;
                }
Яков's avatar
fix    
Яков committed
229

Яков's avatar
fix    
Яков committed
230
                const { width: editorWidth } = getEditorDimensions();
Яков's avatar
fix    
Яков committed
231
232
                const naturalWidth = imgRef.current.naturalWidth;
                const naturalHeight = imgRef.current.naturalHeight;
Яков's avatar
update    
Яков committed
233

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

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

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

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

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

Яков's avatar
update    
Яков committed
290
        setIsResizing(true);
Яков's avatar
update    
Яков committed
291
292
293
294
295
296
297
298
299
        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
300

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

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

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

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

                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);
                    }
336
                } else {
Яков's avatar
Яков committed
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
                    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);
                    }
353
354
                }

Яков's avatar
Яков committed
355
356
                safeUpdateAttributes({ width: newWidth, height: newHeight });
            });
yakoff94's avatar
yakoff94 committed
357
358
        };

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

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

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

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

Яков's avatar
update    
Яков committed
413
414
415
416
417
418
419
420
421
422
423
424
425
        // Без обтекания картинка остаётся В ПОТОКЕ строки: inline-block на всю
        // ширину, БЕЗ float.
        //
        // Раньше здесь стоял float:left+width:100% — «чтобы не создавать
        // block-in-inline внутри <p> (иначе параграф получает лишнюю высоту)».
        // Ценой была высота НУЛЕВАЯ: абзац с плавающей картинкой не содержит её
        // по высоте, картинка вылезала за поле ввода, курсор вставал сбоку от неё
        // на пустой строке, а клик по картинке ниже конца абзаца выделял узел —
        // следующая же набранная буква стирала картинку (воспроизведено на стенде).
        // Блока в инлайне тут нет и с inline-block: он инлайнового уровня.
        // verticalAlign: top убирает провал под базовую линию, ради которого
        // и брали float.
        if (align === 'center' || !wrap) {
Яков's avatar
update    
Яков committed
426
            return {
Яков's avatar
Яков committed
427
                ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
update    
Яков committed
428
                display: 'inline-block', verticalAlign: 'top',
Яков's avatar
Яков committed
429
                width: '100%',
Яков's avatar
update    
Яков committed
430
                textAlign: align === 'center' ? 'center' : (align === 'right' ? 'right' : 'left'),
Яков's avatar
update    
Яков committed
431
432
433
            };
        }
        return {
Яков's avatar
Яков committed
434
            ...sharedMargin, ...noSelect, lineHeight: 0,
Яков's avatar
Яков committed
435
436
437
438
439
440
441
442
443
444
            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
445
        const w = width ? `min(${width}px, 100%)` : 'auto';
Яков's avatar
Яков committed
446
447
448
449
450
451
452
453
        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
454
455
            userSelect: 'none',
            WebkitUserSelect: 'none',
Яков's avatar
update    
Яков committed
456
        };
Яков's avatar
Яков committed
457
458
        if (align === 'center') {
            return { ...base, display: 'block', marginLeft: 'auto', marginRight: 'auto',
Яков's avatar
update    
Яков committed
459
                width: width ? `min(${width}px, 100%)` : 'fit-content' };
Яков's avatar
Яков committed
460
461
462
463
464
465
466
467
468
469
470
471
        }
        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
472
    };
yakoff94's avatar
yakoff94 committed
473

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

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

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

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

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

Яков's avatar
update    
Яков committed
775
776
777
778
779
780
781
782
783
784
785
        // Инлайновых стилей здесь БОЛЬШЕ НЕТ — намеренно.
        //
        // Раньше сюда писались '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
786
787
788
789
790
791
792
793
794
        return [
            'img',
            {
                src,
                alt,
                title,
                width,
                height,
                'data-align': align,
Яков's avatar
update    
Яков committed
795
                ...(wrap ? { 'data-wrap': 'true' } : {}),
Яков's avatar
update    
Яков committed
796
797
798
799
                ...rest,
            }
        ];
    },
800

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

    addKeyboardShortcuts() {
        return {
            'Mod-ArrowLeft': () => this.editor.commands.updateAttributes(this.type.name, { align: 'left' }),
            'Mod-ArrowRight': () => this.editor.commands.updateAttributes(this.type.name, { align: 'right' }),
            'Mod-ArrowDown': () => this.editor.commands.updateAttributes(this.type.name, { align: 'center' }),
        };
811
812
813
814
815
    }
}).configure({
    inline: true,
    group: 'inline',
    draggable: true,
Яков's avatar
fix    
Яков committed
816
    selectable: true
817
});
yakoff94's avatar
yakoff94 committed
818
819

export default ResizableImageExtension;