Commit 1d0665b4 authored by Яков's avatar Яков
Browse files

Интерактивное изображение: точки в сущности платформы



Точки жили в атрибуте data-points внутри разметки урока. Библиотечный модуль
при установке в карту клонируется — точки уезжали копией, и правка схемы
в библиотеке до карт больше не доходила. Молча, как это уже было бы
с содержимым задания.

Теперь у узла есть imageId: он ссылается на сущность платформы, а точки
читаются и пишутся тремя колбэками (пакет о маршрутах не знает). Колбэков
нет — узел работает по-старому, точки лежат в документе: накопленные уроки
открываются без изменений и конвертировать их не будем.

Ошибка записи точки не глотается: состояние возвращается назад и автор видит
сообщение. Иначе точка осталась бы на экране, но не в базе.
Co-Authored-By: default avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 613c5e49
...@@ -201,6 +201,10 @@ const QEditor = ({ ...@@ -201,6 +201,10 @@ const QEditor = ({
// Открыть конструктор задания из карточки в редакторе // Открыть конструктор задания из карточки в редакторе
onOpenActivity = null, onOpenActivity = null,
activityLabels = null, activityLabels = null,
/** Интерактивное изображение как сущность платформы: три колбэка */
onCreateInteractiveImage = null,
onLoadInteractiveImage = null,
onSaveInteractiveImage = null,
// Документ tiptap наружу. Отдельным пропом, а НЕ вторым аргументом onChange: // Документ tiptap наружу. Отдельным пропом, а НЕ вторым аргументом onChange:
// редактор используется в 21 месте, и все ждут строку с HTML. // редактор используется в 21 месте, и все ждут строку с HTML.
// Не передали — ничего не меняется. // Не передали — ничего не меняется.
...@@ -870,7 +874,11 @@ const QEditor = ({ ...@@ -870,7 +874,11 @@ const QEditor = ({
Superscript, Superscript,
Subscript, Subscript,
ToggleBlock, ToggleBlock,
InteractiveImage, InteractiveImage.configure({
onLoadImage: onLoadInteractiveImage,
onSaveImage: onSaveInteractiveImage,
onCreateImage: onCreateInteractiveImage,
}),
WordNavigation, WordNavigation,
ImageWrapEnter, ImageWrapEnter,
DragAndDrop.configure({ DragAndDrop.configure({
...@@ -1774,12 +1782,31 @@ const QEditor = ({ ...@@ -1774,12 +1782,31 @@ const QEditor = ({
const img = new Image() const img = new Image()
img.src = file.path img.src = file.path
img.onload = () => { img.onload = async () => {
const domWidth = editor.view.dom.clientWidth const domWidth = editor.view.dom.clientWidth
const maxWidth = domWidth > 0 ? domWidth - 32 : img.naturalWidth const maxWidth = domWidth > 0 ? domWidth - 32 : img.naturalWidth
const realWidth = Math.min(img.naturalWidth, maxWidth) const realWidth = Math.min(img.naturalWidth, maxWidth)
const realHeight = img.naturalHeight * (realWidth / img.naturalWidth) const realHeight = img.naturalHeight * (realWidth / img.naturalWidth)
// Картинка заводится сущностью НА СЕРВЕРЕ, и в урок уходит
// ссылка на неё. Так одну схему можно поставить в несколько
// уроков, а правка точек доедет до всех.
//
// Хост колбэка не дал (встраивание без платформы) — работаем
// по-старому, точки лягут в документ.
let imageId = null
if (onCreateInteractiveImage) {
try {
imageId = await onCreateInteractiveImage({
src: file.path,
width: Math.round(realWidth),
height: Math.round(realHeight),
})
} catch (e) {
imageId = null
}
}
editor editor
.chain() .chain()
.focus() .focus()
...@@ -1790,6 +1817,7 @@ const QEditor = ({ ...@@ -1790,6 +1817,7 @@ const QEditor = ({
width: Math.round(realWidth), width: Math.round(realWidth),
height: Math.round(realHeight), height: Math.round(realHeight),
align: 'center', align: 'center',
imageId: imageId,
points: [] points: []
}, },
}) })
......
...@@ -9,9 +9,75 @@ const MIN_WIDTH = 60; ...@@ -9,9 +9,75 @@ const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd'; const BORDER_COLOR = '#0096fd';
const ALIGN_OPTIONS = ['left', 'center', 'right']; const ALIGN_OPTIONS = ['left', 'center', 'right'];
const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected }) => { const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected, extension }) => {
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
const [points, setPoints] = useState(node.attrs.points || []) const [points, setPoints] = useState(node.attrs.points || [])
// ── Где живут точки ──────────────────────────────────────────────────────
//
// Есть imageId — в базе, и правка уходит на сервер: та же схема может стоять
// в десятке уроков, и держать её копией в каждом значит обречь их разойтись.
// Нет imageId — прежний вид, точки в атрибуте документа; так открываются
// накопленные уроки, конвертировать их не будем.
const opts = (extension && extension.options) || {}
const imageId = node.attrs.imageId || null
const byRef = !!(imageId && opts.onLoadImage && opts.onSaveImage)
// Точки из базы: подтягиваются один раз при открытии узла
useEffect(() => {
if (!byRef) {
return
}
let alive = true
Promise.resolve(opts.onLoadImage(imageId))
.then((image) => {
if (alive && image && Array.isArray(image.points)) {
setPoints(image.points)
}
})
.catch(() => {})
return () => { alive = false }
}, [byRef, imageId])
/**
* Записать точки туда, где они живут.
*
* Ошибку записи ГЛОТАТЬ НЕЛЬЗЯ: точка осталась бы на экране, но не в базе,
* и автор узнал бы об этом, открыв урок завтра. Возвращаем состояние назад
* и показываем сообщение.
*/
const persistPoints = (next, previous) => {
setPoints(next)
if (!byRef) {
updateAttributes({ points: next })
return
}
Promise.resolve(opts.onSaveImage({
id: imageId,
src: node.attrs.src,
width: node.attrs.width,
height: node.attrs.height,
points: next,
}))
.then((image) => {
// Сервер возвращает точки с идентификаторами: у только что
// созданных их не было, а без них следующая правка создала бы
// дубли вместо обновления.
if (image && Array.isArray(image.points)) {
setPoints(image.points)
}
})
.catch(() => {
setPoints(previous)
Modal.error({
title: 'Точка не сохранилась',
content: 'Изменение не записалось на сервер. Проверьте связь и повторите.',
})
})
}
const [newPoint, setNewPoint] = useState(null) const [newPoint, setNewPoint] = useState(null)
const [newPointText, setNewPointText] = useState('') const [newPointText, setNewPointText] = useState('')
const [newPointTitle, setNewPointTitle] = useState('') const [newPointTitle, setNewPointTitle] = useState('')
...@@ -112,8 +178,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected ...@@ -112,8 +178,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const confirmAddPoint = () => { const confirmAddPoint = () => {
const newPoints = [...points, { ...newPoint, text: newPointText, title: newPointTitle }] const newPoints = [...points, { ...newPoint, text: newPointText, title: newPointTitle }]
setPoints(newPoints) persistPoints(newPoints, points)
updateAttributes({ points: newPoints })
setNewPoint(null) setNewPoint(null)
setNewPointText('') setNewPointText('')
setNewPointTitle('') setNewPointTitle('')
...@@ -126,8 +191,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected ...@@ -126,8 +191,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const removePoint = (index) => { const removePoint = (index) => {
const newPoints = points.filter((_, i) => i !== index) const newPoints = points.filter((_, i) => i !== index)
setPoints(newPoints) persistPoints(newPoints, points)
updateAttributes({ points: newPoints })
} }
// ─── Ресайз ─────────────────────────────────────────────────────────────── // ─── Ресайз ───────────────────────────────────────────────────────────────
...@@ -611,8 +675,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected ...@@ -611,8 +675,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const updated = [...points] const updated = [...points]
updated[idx].title = editingTitle updated[idx].title = editingTitle
updated[idx].text = editingText updated[idx].text = editingText
setPoints(updated) persistPoints(updated, points)
updateAttributes({ points: updated })
setEditingIdx(null) setEditingIdx(null)
}} }}
> >
...@@ -661,9 +724,40 @@ export const InteractiveImage = Node.create({ ...@@ -661,9 +724,40 @@ export const InteractiveImage = Node.create({
selectable: true, selectable: true,
atom: true, atom: true,
addOptions() {
return {
/**
* Хост даёт три колбэка. Пакет о маршрутах платформы не знает,
* поэтому чтение и запись точек уходят наружу.
*
* onLoadImage(id) → {id, title, src, width, height, points}
* onSaveImage({id,...,points}) → сохранённая картинка
* onCreateImage(src, w, h) → новая картинка, вернуть её id
*
* Не заданы — узел работает по-старому: точки хранятся в атрибуте
* документа. Так открываются накопленные уроки.
*/
onLoadImage: null,
onSaveImage: null,
onCreateImage: null,
}
},
addAttributes() { addAttributes() {
return { return {
src: { default: null }, src: { default: null },
/**
* Ссылка на сущность InteractiveImage.
*
* Есть — точки живут в базе, и одна схема переиспользуется
* в разных уроках. Нет — прежний вид: точки в атрибуте points.
* Оба вида читаются; конвертировать накопленное не будем.
*/
imageId: {
default: null,
parseHTML: el => parseInt(el.getAttribute('data-image-id'), 10) || null,
renderHTML: attrs => attrs.imageId ? { 'data-image-id': String(attrs.imageId) } : {},
},
width: { width: {
default: null, default: null,
parseHTML: el => parseInt(el.getAttribute('width'), 10) || null, parseHTML: el => parseInt(el.getAttribute('width'), 10) || null,
...@@ -703,9 +797,14 @@ export const InteractiveImage = Node.create({ ...@@ -703,9 +797,14 @@ export const InteractiveImage = Node.create({
const align = node.attrs.align || 'left'; const align = node.attrs.align || 'left';
const wrap = node.attrs.wrap || false; const wrap = node.attrs.wrap || false;
const points = node.attrs.points || []; const points = node.attrs.points || [];
const imageId = node.attrs.imageId || null;
// Инлайновых стилей нет: размеры едут атрибутами, раскладку задают // Инлайновых стилей нет: размеры едут атрибутами, раскладку задают
// data-align/data-wrap через CSS платформы. Подробности — в Image.jsx. // data-align/data-wrap через CSS платформы. Подробности — в Image.jsx.
//
// Точки уезжают в разметку ТОЛЬКО у старого вида. У нового там ссылка:
// держать в html снимок точек значит вернуть расхождение копии урока
// с оригиналом, ради устранения которого сущность и заводилась.
return [ return [
'interactive-image', 'interactive-image',
{ {
...@@ -714,7 +813,8 @@ export const InteractiveImage = Node.create({ ...@@ -714,7 +813,8 @@ export const InteractiveImage = Node.create({
height, height,
'data-align': align, 'data-align': align,
'data-wrap': wrap ? 'true' : undefined, 'data-wrap': wrap ? 'true' : undefined,
'data-points': JSON.stringify(points), 'data-image-id': imageId ? String(imageId) : undefined,
'data-points': imageId ? undefined : JSON.stringify(points),
} }
] ]
}, },
......
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