Commit 42cd3cf9 authored by Яков's avatar Яков
Browse files

update

parent cf69071b
{ {
"name": "react-ag-qeditor", "name": "react-ag-qeditor",
"version": "1.1.87", "version": "1.1.89",
"description": "WYSIWYG html editor", "description": "WYSIWYG html editor",
"author": "atma", "author": "atma",
"license": "MIT", "license": "MIT",
......
...@@ -1797,23 +1797,31 @@ const QEditor = ({ ...@@ -1797,23 +1797,31 @@ const QEditor = ({
}) })
.command(({ tr, state }) => { .command(({ tr, state }) => {
// После setImage курсор — NodeSelection на картинке. // После setImage курсор — NodeSelection на картинке.
// На десктопе: TextSelection сразу после картинки (inline). // Уводим его в пустой абзац ПОД картинкой, при
// На мобильном: курсор в следующий параграф — иначе он // необходимости создавая его.
// растягивается на всю высоту картинки. //
// Раньше на десктопе курсор ставился «сразу после
// картинки» — а она занимает всю ширину строки, и это
// верхний ПРАВЫЙ угол: тонкая каретка сбоку от картинки
// выглядит как сбой, печатать «под картинкой» нельзя,
// текст прыгает на следующую строку сам (замечание
// владельца 29.08.2026). На мобильном курсор туда же
// уводили и раньше — теперь поведение одно на всех.
const { $to } = state.selection const { $to } = state.selection
if (isMobile) { const paragraph = state.schema.nodes.paragraph
const afterPara = $to.after(Math.min($to.depth, 1))
const target = afterPara < tr.doc.nodeSize - 1
? afterPara + 1
: afterPara
try { try {
tr.setSelection(TextSelection.create(tr.doc, target)) const after = $to.after(Math.min($to.depth, 1))
const следом = tr.doc.nodeAt(after)
const пустойАбзац = следом
&& следом.type === paragraph
&& следом.content.size === 0
if (!пустойАбзац) {
tr.insert(after, paragraph.create())
}
tr.setSelection(TextSelection.create(tr.doc, after + 1))
} catch { } catch {
tr.setSelection(TextSelection.create(tr.doc, $to.pos)) tr.setSelection(TextSelection.create(tr.doc, $to.pos))
} }
} else {
tr.setSelection(TextSelection.create(tr.doc, $to.pos))
}
return true return true
}) })
.run() .run()
......
import React from 'react' import React, { useState } from 'react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
/** /**
...@@ -21,18 +21,66 @@ import { Tooltip } from 'antd' ...@@ -21,18 +21,66 @@ import { Tooltip } from 'antd'
* Именно она давала ощущение «панель из 2003 года», с которого началась вся * Именно она давала ощущение «панель из 2003 года», с которого началась вся
* работа над редактором. Здесь — название и строка «что я получу». * работа над редактором. Здесь — название и строка «что я получу».
*/ */
/**
* Насколько опустить подсказку, чтобы она не легла на саму панель.
*
* Когда кнопки не помещаются в один ряд (узкое окно, панель 70–80 px вместо 46),
* подсказка кнопки ВЕРХНЕГО ряда всплывала под этой кнопкой — то есть поверх
* нижнего ряда: иконки закрыты, а сама подсказка читается вперемешку с ними.
* Поэтому опускаем её до нижнего края панели: расстояние считаем от кнопки,
* над которой курсор, до низа панели.
*
* Кнопку ищем через :hover, а не по ref: подсказка оборачивает и обычные
* кнопки, и чужие компоненты (выпадающие списки, эмодзи), и не каждый из них
* пробрасывает ref наружу. Вне панели (меню над выделением) поиск ничего
* не находит, и поведение остаётся прежним.
*/
const смещениеПодПанель = () => {
if (typeof document === 'undefined') {
return null
}
const панель = document.querySelector('.atma-editor-toolbar')
if (!панель) {
return null
}
// Без spread по NodeList: сборка пакета превращает [...nodeList] в
// [].concat(nodeList), а это кладёт в массив сам список, а не его элементы —
// обработчик падал с TypeError, и подсказка оставалась на месте.
const подКурсором = панель.querySelectorAll(':hover')
const кнопка = подКурсором.length ? подКурсором[подКурсором.length - 1] : null
if (!кнопка) {
return null
}
const сдвиг = Math.round(панель.getBoundingClientRect().bottom - кнопка.getBoundingClientRect().bottom)
return сдвиг > 1 ? [0, сдвиг] : null
}
const Tip = ({ info, placement = 'bottom', children }) => { const Tip = ({ info, placement = 'bottom', children }) => {
const [offset, setOffset] = useState(null)
// Нет названия — нет и подсказки: пустое чёрное окошко хуже, чем ничего // Нет названия — нет и подсказки: пустое чёрное окошко хуже, чем ничего
if (!info || !info.title) { if (!info || !info.title) {
return children return children
} }
const onOpenChange = (open) => {
if (!open) {
setOffset(null)
return
}
setOffset(placement === 'bottom' ? смещениеПодПанель() : null)
}
return ( return (
<Tooltip <Tooltip
placement={placement} placement={placement}
mouseEnterDelay={0.3} mouseEnterDelay={0.3}
mouseLeaveDelay={0} mouseLeaveDelay={0}
overlayClassName="atma-editor-tip" overlayClassName="atma-editor-tip"
onOpenChange={onOpenChange}
align={offset ? { offset } : undefined}
title={ title={
<span className="atma-editor-tip-body"> <span className="atma-editor-tip-body">
<b className="atma-editor-tip-title">{info.title}</b> <b className="atma-editor-tip-title">{info.title}</b>
......
import React, { Fragment, useEffect, useRef, useState } from 'react'; import React, { Fragment, useEffect, useRef, useState } from 'react';
import { Tooltip } from 'antd';
import { Icon } from '../icons'; import { Icon } from '../icons';
import Tip from './Tip'; import Tip from './Tip';
...@@ -559,14 +558,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -559,14 +558,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
const undoInfo = infoFor(type, item); const undoInfo = infoFor(type, item);
return ( return (
<Tooltip <Tip key={idx} info={undoInfo}>
key={idx}
placement="bottom"
mouseEnterDelay={0.3}
mouseLeaveDelay={0}
overlayClassName="atma-editor-tip"
title={<span className="atma-editor-tip-title">{undoInfo.title}</span>}
>
<div <div
onClick={(e) => { onClick={(e) => {
item.onClick(); item.onClick();
...@@ -582,27 +574,13 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -582,27 +574,13 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
> >
<Icon type={type} /> <Icon type={type} />
</div> </div>
</Tooltip> </Tip>
) )
} }
const info = infoFor(type, item); const info = infoFor(type, item);
return ( return (
<Tooltip <Tip key={idx} info={info}>
key={idx}
placement="bottom"
// 300 мс: не мешает быстрому клику, но не заставляет ждать,
// как нативный title (там задержка около секунды и её не настроить)
mouseEnterDelay={0.3}
mouseLeaveDelay={0}
overlayClassName="atma-editor-tip"
title={
<span className="atma-editor-tip-body">
<b className="atma-editor-tip-title">{info.title}</b>
{info.hint && <span className="atma-editor-tip-hint">{info.hint}</span>}
</span>
}
>
<div <div
onClick={(e) => { onClick={(e) => {
item.onClick(); item.onClick();
...@@ -618,7 +596,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -618,7 +596,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
> >
<Icon type={type} /> <Icon type={type} />
</div> </div>
</Tooltip> </Tip>
) )
} }
...@@ -703,18 +681,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -703,18 +681,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
: (activeType === false || section.toggle === false ? section.items[0] : activeType); : (activeType === false || section.toggle === false ? section.items[0] : activeType);
return ( return (
<Tooltip <Tip info={tip}>
placement="bottom"
mouseEnterDelay={0.3}
mouseLeaveDelay={0}
overlayClassName="atma-editor-tip"
title={
<span className="atma-editor-tip-body">
<b className="atma-editor-tip-title">{tip.title}</b>
{tip.hint && <span className="atma-editor-tip-hint">{tip.hint}</span>}
</span>
}
>
<div className={'qicon' + (activeType !== false && section.toggle !== false ? ' active' : '')} <div className={'qicon' + (activeType !== false && section.toggle !== false ? ' active' : '')}
role="button" role="button"
tabIndex={0} tabIndex={0}
...@@ -722,7 +689,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -722,7 +689,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
aria-haspopup="true"> aria-haspopup="true">
<Icon type={iconType} /> <Icon type={iconType} />
</div> </div>
</Tooltip> </Tip>
); );
})() })()
} }
......
...@@ -29,41 +29,12 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -29,41 +29,12 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
const isNoWrap = !node.attrs.wrap && (node.attrs.align === 'left' || node.attrs.align === 'right'); const isNoWrap = !node.attrs.wrap && (node.attrs.align === 'left' || node.attrs.align === 'right');
// На десктопе вставляем \u200B после картинки, чтобы курсор можно было // Раньше сразу после картинки вставлялся \u200B — «чтобы курсор можно было
// поставить inline сразу после неё. На мобильном это не нужно — там курсор // поставить inline сразу после неё». Убрано 29.08.2026: картинка занимает
// всё равно встаёт на всю высоту картинки, выглядит некорректно. // всю ширину строки, и позиция «сразу после» — это верхний ПРАВЫЙ угол,
useEffect(() => { // где тонкая каретка выглядит как сбой («курсор почему-то справа», замечание
if (isMobile) return // владельца). Курсор после вставки теперь уводится в абзац под картинкой
if (!editor || !getPos || editor.isDestroyed) return // (QEditor), а невидимый символ только мусорил в сохранённом HTML.
let pos
try {
pos = getPos()
} catch {
return
}
if (typeof pos !== 'number') return
const { doc } = editor.state
const node = doc.nodeAt(pos)
if (!node || node.type.name !== 'image') return
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 {}
})
}, [])
// Получаем текущую ширину редактора и доступное пространство // Получаем текущую ширину редактора и доступное пространство
const getEditorDimensions = () => { const getEditorDimensions = () => {
......
...@@ -26,6 +26,28 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state' ...@@ -26,6 +26,28 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
* Абзацы после заголовков и текста НЕ добавляем: там курсор ставится и так, * Абзацы после заголовков и текста НЕ добавляем: там курсор ставится и так,
* а лишняя пустая строка уезжала бы в сохранённый HTML на каждом сохранении. * а лишняя пустая строка уезжала бы в сохранённый HTML на каждом сохранении.
*/ */
/**
* Есть ли в узле место, куда встанет курсор.
*
* Абзац — текстовый узел, но абзац, в котором лежит ОДНА картинка и больше
* ничего, для автора такой же «блок», как видео или таблица: картинка занимает
* всю ширину строки, и печатать в этом абзаце можно только сбоку от неё.
* Поэтому документ, заканчивающийся таким абзацем, тоже требует пустой строки
* следом. (Обычная картинка — инлайновый узел внутри <p>, в отличие
* от интерактивной, которая сама по себе блок.)
*/
const местоДляКурсора = (узел) => {
if (!узел.isTextblock) {
return false
}
if (узел.childCount === 0) {
return true
}
// \u200B — невидимый символ, который прежние версии редактора ставили
// после картинки; текстом он не является
return узел.textContent.replace(/\u200B/g, '').trim() !== ''
}
export const TrailingNode = Extension.create({ export const TrailingNode = Extension.create({
name: 'trailingNode', name: 'trailingNode',
...@@ -41,7 +63,7 @@ export const TrailingNode = Extension.create({ ...@@ -41,7 +63,7 @@ export const TrailingNode = Extension.create({
const { doc, schema, tr } = newState const { doc, schema, tr } = newState
const last = doc.lastChild const last = doc.lastChild
if (!last || last.isTextblock || !schema.nodes.paragraph) { if (!last || местоДляКурсора(last) || !schema.nodes.paragraph) {
return null return null
} }
...@@ -51,7 +73,7 @@ export const TrailingNode = Extension.create({ ...@@ -51,7 +73,7 @@ export const TrailingNode = Extension.create({
props: { props: {
handleClick: (view, pos, event) => { handleClick: (view, pos, event) => {
const last = view.state.doc.lastChild const last = view.state.doc.lastChild
if (!last || last.isTextblock) { if (!last || местоДляКурсора(last)) {
return false return false
} }
......
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