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 = ({
// Открыть конструктор задания из карточки в редакторе
onOpenActivity = null,
activityLabels = null,
/** Интерактивное изображение как сущность платформы: три колбэка */
onCreateInteractiveImage = null,
onLoadInteractiveImage = null,
onSaveInteractiveImage = null,
// Документ tiptap наружу. Отдельным пропом, а НЕ вторым аргументом onChange:
// редактор используется в 21 месте, и все ждут строку с HTML.
// Не передали — ничего не меняется.
......@@ -870,7 +874,11 @@ const QEditor = ({
Superscript,
Subscript,
ToggleBlock,
InteractiveImage,
InteractiveImage.configure({
onLoadImage: onLoadInteractiveImage,
onSaveImage: onSaveInteractiveImage,
onCreateImage: onCreateInteractiveImage,
}),
WordNavigation,
ImageWrapEnter,
DragAndDrop.configure({
......@@ -1774,12 +1782,31 @@ const QEditor = ({
const img = new Image()
img.src = file.path
img.onload = () => {
img.onload = async () => {
const domWidth = editor.view.dom.clientWidth
const maxWidth = domWidth > 0 ? domWidth - 32 : img.naturalWidth
const realWidth = Math.min(img.naturalWidth, maxWidth)
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
.chain()
.focus()
......@@ -1790,6 +1817,7 @@ const QEditor = ({
width: Math.round(realWidth),
height: Math.round(realHeight),
align: 'center',
imageId: imageId,
points: []
},
})
......
......@@ -9,9 +9,75 @@ const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
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 [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 [newPointText, setNewPointText] = useState('')
const [newPointTitle, setNewPointTitle] = useState('')
......@@ -112,8 +178,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const confirmAddPoint = () => {
const newPoints = [...points, { ...newPoint, text: newPointText, title: newPointTitle }]
setPoints(newPoints)
updateAttributes({ points: newPoints })
persistPoints(newPoints, points)
setNewPoint(null)
setNewPointText('')
setNewPointTitle('')
......@@ -126,8 +191,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const removePoint = (index) => {
const newPoints = points.filter((_, i) => i !== index)
setPoints(newPoints)
updateAttributes({ points: newPoints })
persistPoints(newPoints, points)
}
// ─── Ресайз ───────────────────────────────────────────────────────────────
......@@ -611,8 +675,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const updated = [...points]
updated[idx].title = editingTitle
updated[idx].text = editingText
setPoints(updated)
updateAttributes({ points: updated })
persistPoints(updated, points)
setEditingIdx(null)
}}
>
......@@ -661,9 +724,40 @@ export const InteractiveImage = Node.create({
selectable: 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() {
return {
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: {
default: null,
parseHTML: el => parseInt(el.getAttribute('width'), 10) || null,
......@@ -703,9 +797,14 @@ export const InteractiveImage = Node.create({
const align = node.attrs.align || 'left';
const wrap = node.attrs.wrap || false;
const points = node.attrs.points || [];
const imageId = node.attrs.imageId || null;
// Инлайновых стилей нет: размеры едут атрибутами, раскладку задают
// data-align/data-wrap через CSS платформы. Подробности — в Image.jsx.
//
// Точки уезжают в разметку ТОЛЬКО у старого вида. У нового там ссылка:
// держать в html снимок точек значит вернуть расхождение копии урока
// с оригиналом, ради устранения которого сущность и заводилась.
return [
'interactive-image',
{
......@@ -714,7 +813,8 @@ export const InteractiveImage = Node.create({
height,
'data-align': align,
'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