Commit 0a1a0a91 authored by Яков's avatar Яков
Browse files

update

parent bfd5fe15
{
"name": "react-ag-qeditor",
"version": "1.1.85",
"version": "1.1.86",
"description": "WYSIWYG html editor",
"author": "atma",
"license": "MIT",
......
......@@ -37,6 +37,7 @@ import Audio from './extensions/Audio'
import TableExtension from './extensions/TableExtension'
import ToggleBlock from './extensions/ToggleBlock'
import InteractiveImage from './extensions/InteractiveImage'
import TrailingNode from './extensions/TrailingNode'
import FontSize from './extensions/FontSize'
import BlockId from './extensions/BlockId'
import ActivityRef from './extensions/ActivityRef'
......@@ -909,6 +910,8 @@ const QEditor = ({
}),
WordNavigation,
ImageWrapEnter,
// Пустой абзац в конце: иначе после последней картинки писать негде
TrailingNode,
DragAndDrop.configure({
uploadUrl: uploadOptions.url,
allowedFileTypes: [
......
......@@ -7,6 +7,12 @@ import { isMobile } from 'react-device-detect';
const { TextArea } = Input;
const {Text} = Typography;
// Слои накладок узла — все ниже липкой панели инструментов
// (--atma-toolbar-z-index, по умолчанию 10): панель и накладки лежат в одном
// контексте наложения, и при равном z-index побеждает то, что ниже в DOM.
// Так кнопка подписи заезжала на панель, стоило прокрутить страницу.
const Z = { action: 6, resize: 7, bar: 8, remove: 9 };
const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right'];
......@@ -404,23 +410,24 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
const sharedMargin = { marginTop: '0.5rem', marginBottom: '0.5rem' };
const noSelect = { userSelect: 'none', WebkitUserSelect: 'none', touchAction: 'manipulation' };
if (align === 'center') {
// Используем float:left+width:100% чтобы не создавать block-in-inline внутри <p>
// (иначе параграф получает лишнюю высоту). textAlign:center центрирует внутренний inline-block.
return {
...sharedMargin, ...noSelect, lineHeight: 0,
display: 'inline-block', float: 'left', clear: 'both',
width: '100%', textAlign: 'center',
};
}
if (!wrap) {
// Без обтекания картинка остаётся В ПОТОКЕ строки: inline-block на всю
// ширину, БЕЗ float.
//
// Раньше здесь стоял float:left+width:100% — «чтобы не создавать
// block-in-inline внутри <p> (иначе параграф получает лишнюю высоту)».
// Ценой была высота НУЛЕВАЯ: абзац с плавающей картинкой не содержит её
// по высоте, картинка вылезала за поле ввода, курсор вставал сбоку от неё
// на пустой строке, а клик по картинке ниже конца абзаца выделял узел —
// следующая же набранная буква стирала картинку (воспроизведено на стенде).
// Блока в инлайне тут нет и с inline-block: он инлайнового уровня.
// verticalAlign: top убирает провал под базовую линию, ради которого
// и брали float.
if (align === 'center' || !wrap) {
return {
...sharedMargin, ...noSelect, lineHeight: 0,
display: 'inline-block',
float: 'left',
clear: 'both',
display: 'inline-block', verticalAlign: 'top',
width: '100%',
...(align === 'right' ? { textAlign: 'right' } : { textAlign: 'left' }),
textAlign: align === 'center' ? 'center' : (align === 'right' ? 'right' : 'left'),
};
}
return {
......@@ -519,7 +526,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
setTempFrontAlt(node.attrs.frontAlt || '');
setAltModalVisible(true);
}}
style={{ position: 'absolute', top: 4, right: '30px', zIndex: 15 }}
style={{ position: 'absolute', top: 4, right: '30px', zIndex: Z.action }}
>
<FontSizeOutlined />
</Button>
......@@ -538,7 +545,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
}
}}
style={{
position: 'absolute', top: 4, right: 4, zIndex: 30,
position: 'absolute', top: 4, right: 4, zIndex: Z.remove,
backgroundColor: 'white', border: '1px solid #d9d9d9',
borderRadius: '50%', width: 20, height: 20,
fontSize: 12, lineHeight: 1, padding: '0px 0px 2px 0px', cursor: 'pointer'
......@@ -562,7 +569,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
transform: node.attrs.align === 'center'
? `translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
cursor: `${dir}-resize`,
zIndex: 10
zIndex: Z.resize
}}
/>
))}
......@@ -571,7 +578,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
transform: 'translateX(-50%)',
backgroundColor: 'white',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
borderRadius: 4, padding: 4, zIndex: 20,
borderRadius: 4, padding: 4, zIndex: Z.bar,
display: 'flex', alignItems: 'center', gap: 2, whiteSpace: 'nowrap',
}}>
{ALIGN_OPTIONS.map(a => (
......
......@@ -9,6 +9,14 @@ const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right'];
// Слои накладок узла.
//
// Все ОБЯЗАНЫ быть ниже липкой панели инструментов (--atma-toolbar-z-index, по
// умолчанию 10): панель и накладки лежат в одном контексте наложения, и при
// равном z-index побеждает то, что ниже в DOM, — то есть накладка. Так кнопка
// «Редактировать» заезжала на панель, стоило прокрутить страницу.
const Z = { marker: 5, action: 6, resize: 7, bar: 8, remove: 9 };
const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected, extension }) => {
const [modalVisible, setModalVisible] = useState(false)
const [points, setPoints] = useState(node.attrs.points || [])
......@@ -85,6 +93,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const [editingText, setEditingText] = useState('')
const [editingTitle, setEditingTitle] = useState('')
const [isResizing, setIsResizing] = useState(false)
const [hovered, setHovered] = useState(false)
const imgRef = useRef(null)
const isInitialized = useRef(false)
const wrapperRef = useRef(null)
......@@ -317,25 +326,28 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
// ─── Стили (идентично Image.jsx) ─────────────────────────────────────────
// Без обтекания это обычный блок, и float ему противопоказан.
//
// Раньше здесь стоял float:left+width:100% — копия из Image.jsx, где он
// вынужденный: обычная картинка живёт ИНЛАЙНОВОЙ нодой внутри <p>, и блок
// туда иначе не положить. Интерактивная картинка объявлена group: 'block',
// то есть и так лежит отдельным блоком, а float выбивал её из потока:
// следующий абзац оказывался не под картинкой, а СПРАВА от неё, курсор
// ставился сбоку, и «написать текст после картинки» становилось нерешаемой
// задачей (жалоба владельца 29.08.2026).
const getOuterStyle = () => {
const { align, wrap, width } = node.attrs;
const w = width ? `${width}px` : 'auto';
const sharedMargin = { marginTop: '0.5rem', marginBottom: '0.5rem' };
if (align === 'center') {
return {
...sharedMargin, lineHeight: 0,
display: 'inline-block', float: 'left', clear: 'both',
width: '100%', textAlign: 'center',
};
}
if (!wrap) {
if (align === 'center' || !wrap) {
return {
...sharedMargin, lineHeight: 0,
display: 'inline-block', float: 'left', clear: 'both', width: '100%',
...(align === 'right' ? { textAlign: 'right' } : { textAlign: 'left' }),
display: 'block', clear: 'both', width: '100%',
textAlign: align === 'center' ? 'center' : (align === 'right' ? 'right' : 'left'),
};
}
// Обтекание — единственный случай, когда float нужен: текст обязан идти сбоку
return {
...sharedMargin, lineHeight: 0,
display: 'inline-block',
......@@ -390,6 +402,9 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
<div
ref={wrapperRef}
style={getInnerStyle()}
title="Двойной клик — редактировать точки"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={(e) => {
e.stopPropagation();
try {
......@@ -397,6 +412,9 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
if (typeof pos === 'number') editor.commands.setNodeSelection(pos);
} catch {}
}}
// «Нажимаешь на картинку — ничего не происходит»: одиночный клик
// только выделяет узел, а точки открывались единственной кнопкой
onDoubleClick={(e) => { e.stopPropagation(); setModalVisible(true); }}
>
<img
ref={imgRef}
......@@ -418,15 +436,18 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
}}
/>
{/* Кнопка редактирования точек */}
{/* Кнопка редактирования точек — только под курсором или у выделенной
картинки: постоянно висящая кнопка закрывала кусок изображения */}
{(hovered || selected || isResizing) && (
<Button
size="default"
type="primary"
onClick={(e) => { e.stopPropagation(); setModalVisible(true); }}
style={{ position: 'absolute', top: 4, right: 30, zIndex: 10 }}
style={{ position: 'absolute', top: 4, right: 30, zIndex: Z.action }}
>
Редактировать
</Button>
)}
{/* Маркеры точек */}
{points.map((point, idx) => (
......@@ -442,7 +463,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
borderRadius: '50%', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transform: 'translate(-50%, -50%)',
zIndex: 5,
zIndex: Z.marker,
backgroundColor: '#1677ff', border: 'none',
pointerEvents: 'none',
}}
......@@ -466,7 +487,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
}
}}
style={{
position: 'absolute', top: 4, right: 4, zIndex: 30,
position: 'absolute', top: 4, right: 4, zIndex: Z.remove,
backgroundColor: 'white', border: '1px solid #d9d9d9',
borderRadius: '50%', width: 20, height: 20,
fontSize: 12, lineHeight: 1, padding: '0px 0px 2px 0px', cursor: 'pointer'
......@@ -491,7 +512,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
transform: node.attrs.align === 'center'
? `translateX(${dir[1] === 'w' ? '-100%' : '0%'})` : 'none',
cursor: `${dir}-resize`,
zIndex: 10
zIndex: Z.resize
}}
/>
))}
......@@ -502,7 +523,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
transform: 'translateX(-50%)',
backgroundColor: 'white',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
borderRadius: 4, padding: 4, zIndex: 20,
borderRadius: 4, padding: 4, zIndex: Z.bar,
display: 'flex', alignItems: 'center', gap: 2, whiteSpace: 'nowrap',
}}>
{ALIGN_OPTIONS.map(a => (
......@@ -606,6 +627,10 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
<Popconfirm
icon={null}
open={true}
// Всплывашка живёт РЯДОМ С МАРКЕРОМ, а не в портале body:
// из портала она не знала о прокрутке картинки внутри окна
// и отрывалась от точки, повисая поверх инструкции
getPopupContainer={(trigger) => trigger.parentElement}
title={
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Input
......@@ -648,6 +673,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
icon={null}
key={idx}
open={editingIdx === idx}
getPopupContainer={(trigger) => trigger.parentElement}
title={
<div style={{ maxWidth: 250 }}>
<Input
......
import { Extension } from '@tiptap/core'
// prosemirror-state напрямую, как в остальных расширениях пакета: смешивать
// с '@tiptap/pm/state' нельзя — это разные экземпляры библиотеки
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
/**
* Пустой абзац в конце документа — чтобы после блока было куда встать курсору.
*
* Зачем. Картинка, видео, таблица, кнопка — это блочные узлы, и внутрь них
* курсор не ставится. Если такой узел последний в документе, писать после него
* физически некуда: клик по пустому месту под ним попадал в сам узел (он просто
* выделялся), а Enter вставлял абзац ПЕРЕД картинкой — она уезжала вниз, и
* выглядело это как «редактор сломался». Жалоба владельца 29.08.2026: «как мне
* сделать, чтобы текст под картинкой был — непонятно».
*
* Что делает:
* 1. как только документ изменился и последним оказался неТЕКСТОВЫЙ узел —
* дописывает в конец пустой абзац (вставил картинку — сразу есть строка
* под ней);
* 2. клик по пустому месту НИЖЕ последнего блока ставит туда курсор, при
* необходимости создавая абзац, — это для уже накопленных уроков, которые
* заканчиваются картинкой: их документ при открытии не меняется, поэтому
* трогать его без действия автора нельзя (иначе урок «испачкается»
* и предложит сохранить сам себя).
*
* Абзацы после заголовков и текста НЕ добавляем: там курсор ставится и так,
* а лишняя пустая строка уезжала бы в сохранённый HTML на каждом сохранении.
*/
export const TrailingNode = Extension.create({
name: 'trailingNode',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('trailingNode'),
appendTransaction: (transactions, oldState, newState) => {
if (!transactions.some(tr => tr.docChanged)) {
return null
}
const { doc, schema, tr } = newState
const last = doc.lastChild
if (!last || last.isTextblock || !schema.nodes.paragraph) {
return null
}
return tr.insert(doc.content.size, schema.nodes.paragraph.create())
},
props: {
handleClick: (view, pos, event) => {
const last = view.state.doc.lastChild
if (!last || last.isTextblock) {
return false
}
// Клик именно ниже содержимого, а не по самому блоку:
// иначе перехватывали бы обычное выделение картинки
const lastEl = view.dom.lastElementChild
if (!lastEl || event.clientY <= lastEl.getBoundingClientRect().bottom) {
return false
}
const paragraph = view.state.schema.nodes.paragraph
if (!paragraph) {
return false
}
const tr = view.state.tr.insert(view.state.doc.content.size, paragraph.create())
tr.setSelection(TextSelection.near(tr.doc.resolve(tr.doc.content.size - 1)))
view.dispatch(tr.scrollIntoView())
view.focus()
return true
},
},
}),
]
},
})
export default TrailingNode
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment