增加预览页介绍信息显示

This commit is contained in:
2026-04-08 09:56:25 +08:00
parent f53fee8e5a
commit 028ed1b18d
17 changed files with 1059 additions and 23 deletions

View File

@@ -12,6 +12,7 @@ set(CORE_SOURCES
) )
set(CORE_HEADERS set(CORE_HEADERS
${CORE_ROOT}/domain/EntityIntro.h
${CORE_ROOT}/domain/Project.h ${CORE_ROOT}/domain/Project.h
${CORE_ROOT}/workspace/ProjectWorkspace.h ${CORE_ROOT}/workspace/ProjectWorkspace.h
${CORE_ROOT}/persistence/PersistentBinaryObject.h ${CORE_ROOT}/persistence/PersistentBinaryObject.h

View File

@@ -0,0 +1,29 @@
#pragma once
#include <QString>
#include <QVector>
namespace core {
/// 实体在「预览展示」模式下点击后显示的介绍内容(可持久化到 .hfe
struct EntityIntroContent {
QString title;
QString bodyText;
QVector<QString> imagePathsRelative;
/// 预留:相对项目根的视频路径,将来可用 QMediaPlayer 播放
QString videoPathRelative;
void clear() {
title.clear();
bodyText.clear();
imagePathsRelative.clear();
videoPathRelative.clear();
}
bool hasDisplayableContent() const {
return !title.trimmed().isEmpty() || !bodyText.trimmed().isEmpty() ||
!imagePathsRelative.isEmpty() || !videoPathRelative.trimmed().isEmpty();
}
};
} // namespace core

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "domain/EntityIntro.h"
#include <QString> #include <QString>
#include <QPointF> #include <QPointF>
#include <QVector> #include <QVector>
@@ -47,8 +49,11 @@ public:
int depth = 0; // 0..255 int depth = 0; // 0..255
QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png" QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png"
QPointF imageTopLeftWorld; // 贴图左上角 world 坐标 QPointF imageTopLeftWorld; // 贴图左上角 world 坐标
// 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale // 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale
// distanceScale 在有 distanceScaleCalibMult 时为 (0.5+depth01)/calib使抠图处为 1.0
double userScale = 1.0; double userScale = 1.0;
// 抠图创建时该位置对应的原始距离乘子0.5+depth01用于校准该处 distanceScale==1.0。0 表示未校准(兼容旧工程)
double distanceScaleCalibMult = 0.0;
struct KeyframeVec2 { struct KeyframeVec2 {
int frame = 0; int frame = 0;
@@ -76,6 +81,8 @@ public:
QVector<KeyframeFloat01> depthScaleKeys; QVector<KeyframeFloat01> depthScaleKeys;
QVector<KeyframeDouble> userScaleKeys; QVector<KeyframeDouble> userScaleKeys;
QVector<ImageFrame> imageFrames; QVector<ImageFrame> imageFrames;
EntityIntroContent intro;
}; };
void setEntities(const QVector<Entity>& entities) { m_entities = entities; } void setEntities(const QVector<Entity>& entities) { m_entities = entities; }

View File

@@ -30,6 +30,36 @@ void sortByFrame(QVector<Project::Entity::ImageFrame>& v) {
std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; }); std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.frame < b.frame; });
} }
void writeIntroBlock(QDataStream& ds, const Project::Entity& entity) {
ds << entity.intro.title << entity.intro.bodyText;
ds << qint32(entity.intro.imagePathsRelative.size());
for (const auto& p : entity.intro.imagePathsRelative) {
ds << p;
}
ds << entity.intro.videoPathRelative;
}
bool readIntroBlock(QDataStream& ds, EntityIntroContent& intro) {
ds >> intro.title >> intro.bodyText;
qint32 n = 0;
ds >> n;
if (ds.status() != QDataStream::Ok || n < 0 || n > 2048) {
return false;
}
intro.imagePathsRelative.clear();
intro.imagePathsRelative.reserve(n);
for (qint32 i = 0; i < n; ++i) {
QString p;
ds >> p;
if (ds.status() != QDataStream::Ok) {
return false;
}
intro.imagePathsRelative.push_back(std::move(p));
}
ds >> intro.videoPathRelative;
return ds.status() == QDataStream::Ok;
}
bool readAnimationBlock(QDataStream& ds, Project::Entity& out, bool hasUserScaleKeys) { bool readAnimationBlock(QDataStream& ds, Project::Entity& out, bool hasUserScaleKeys) {
out.locationKeys.clear(); out.locationKeys.clear();
out.depthScaleKeys.clear(); out.depthScaleKeys.clear();
@@ -220,7 +250,8 @@ public:
} }
writeAnimationBlock(ds, entity, true); writeAnimationBlock(ds, entity, true);
ds << entity.displayName << double(entity.userScale); ds << entity.displayName << double(entity.userScale) << double(entity.distanceScaleCalibMult);
writeIntroBlock(ds, entity);
} }
bool readBody(QDataStream& ds) override { bool readBody(QDataStream& ds) override {
@@ -231,12 +262,17 @@ public:
} }
QString dn; QString dn;
double us = 1.0; double us = 1.0;
ds >> dn >> us; double cal = 0.0;
ds >> dn >> us >> cal;
if (ds.status() != QDataStream::Ok) { if (ds.status() != QDataStream::Ok) {
return false; return false;
} }
tmp.displayName = dn; tmp.displayName = dn;
tmp.userScale = std::clamp(us, 1e-3, 1e3); tmp.userScale = std::clamp(us, 1e-3, 1e3);
tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0;
if (!readIntroBlock(ds, tmp.intro)) {
return false;
}
*m_dst = std::move(tmp); *m_dst = std::move(tmp);
return true; return true;
} }
@@ -293,7 +329,7 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) { if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
return false; return false;
} }
if (ver != 1 && ver != 2 && ver != 3) { if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5) {
return false; return false;
} }
Project::Entity tmp; Project::Entity tmp;
@@ -309,6 +345,19 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
} }
tmp.displayName = dn; tmp.displayName = dn;
tmp.userScale = std::clamp(us, 1e-3, 1e3); tmp.userScale = std::clamp(us, 1e-3, 1e3);
if (ver >= 4) {
double cal = 0.0;
ds >> cal;
if (ds.status() != QDataStream::Ok) {
return false;
}
tmp.distanceScaleCalibMult = (cal > 0.0) ? std::clamp(cal, 1e-6, 10.0) : 0.0;
}
if (ver >= 5) {
if (!readIntroBlock(ds, tmp.intro)) {
return false;
}
}
} else { } else {
tmp.displayName.clear(); tmp.displayName.clear();
tmp.userScale = 1.0; tmp.userScale = 1.0;

View File

@@ -12,7 +12,7 @@ namespace core {
class EntityPayloadBinary { class EntityPayloadBinary {
public: public:
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP' static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
static constexpr quint32 kPayloadVersion = 3; // v3:追加 userScaleKeys动画轨道 static constexpr quint32 kPayloadVersion = 5; // v5:追加 intro预览介绍标题/正文/配图/视频路径预留
// 旧版独立动画文件(仍用于打开 v1 项目时合并) // 旧版独立动画文件(仍用于打开 v1 项目时合并)
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA' static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'

View File

@@ -1031,7 +1031,59 @@ bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& di
return applyEntities(ents, true, QStringLiteral("重命名实体")); return applyEntities(ents, true, QStringLiteral("重命名实体"));
} }
bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) { bool ProjectWorkspace::setEntityIntroContent(const QString& id, const EntityIntroContent& intro) {
if (m_projectDir.isEmpty() || id.isEmpty()) {
return false;
}
auto ents = m_project.entities();
bool found = false;
for (auto& e : ents) {
if (e.id != id) {
continue;
}
found = true;
e.intro = intro;
break;
}
if (!found) {
return false;
}
return applyEntities(ents, true, QStringLiteral("实体介绍"));
}
bool ProjectWorkspace::importEntityIntroImageFromFile(const QString& id, const QString& absoluteImagePath,
QString* outRelativePath) {
if (m_projectDir.isEmpty() || id.isEmpty() || absoluteImagePath.isEmpty()) {
return false;
}
const QFileInfo srcFi(absoluteImagePath);
if (!srcFi.exists() || !srcFi.isFile()) {
return false;
}
const QString entsDir = ensureEntitiesDir();
if (entsDir.isEmpty()) {
return false;
}
const QString suf = fileSuffixWithDot(absoluteImagePath);
for (int n = 0; n < 100000; ++n) {
const QString base = id + QStringLiteral("-intro-%1").arg(n) + suf;
const QString destAbs = QDir(entsDir).filePath(base);
if (!QFileInfo::exists(destAbs)) {
if (!QFile::copy(absoluteImagePath, destAbs)) {
return false;
}
const QString rel =
QString::fromUtf8(kAssetsDirName) + QStringLiteral("/entities/") + base;
if (outRelativePath) {
*outRelativePath = rel;
}
return true;
}
}
return false;
}
bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame) {
if (m_projectDir.isEmpty() || id.isEmpty()) { if (m_projectDir.isEmpty() || id.isEmpty()) {
return false; return false;
} }
@@ -1043,10 +1095,13 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) {
continue; continue;
} }
found = true; found = true;
if (qFuzzyCompare(e.userScale + 1.0, u + 1.0)) { const bool baseSame = qFuzzyCompare(e.userScale + 1.0, u + 1.0);
e.userScale = u;
if (keyframeAtFrame >= 0) {
upsertKey(e.userScaleKeys, keyframeAtFrame, std::clamp(u, 1e-6, 1e3));
} else if (baseSame) {
return true; return true;
} }
e.userScale = u;
break; break;
} }
if (!found) { if (!found) {
@@ -1196,8 +1251,17 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int
e.originWorld, e.originWorld,
KeyInterpolation::Linear); KeyInterpolation::Linear);
upsertKey(e.locationKeys, currentFrame, sampled + delta); upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else if (e.locationKeys.isEmpty()) {
e.originWorld += delta;
e.imageTopLeftWorld += delta;
} else if (currentFrame >= 0) {
// 已有位置曲线但未勾选「自动关键帧」时,仍应移动当前帧上的位置,否则画布仍按关键帧插值,看起来不生效
const QPointF sampled = sampleLocation(e.locationKeys,
currentFrame,
e.originWorld,
KeyInterpolation::Linear);
upsertKey(e.locationKeys, currentFrame, sampled + delta);
} else { } else {
// 无自动关键帧时,直接修改“基准”位置
e.originWorld += delta; e.originWorld += delta;
e.imageTopLeftWorld += delta; e.imageTopLeftWorld += delta;
} }

View File

@@ -77,7 +77,8 @@ public:
bool addEntity(const Project::Entity& entity, const QImage& image); bool addEntity(const Project::Entity& entity, const QImage& image);
bool setEntityVisible(const QString& id, bool on); bool setEntityVisible(const QString& id, bool on);
bool setEntityDisplayName(const QString& id, const QString& displayName); bool setEntityDisplayName(const QString& id, const QString& displayName);
bool setEntityUserScale(const QString& id, double userScale); /// keyframeAtFrame >= 0 时同时写入该帧的 userScale 关键帧(与画布 sampleUserScale 一致)
bool setEntityUserScale(const QString& id, double userScale, int keyframeAtFrame = -1);
// 将多边形质心平移到 targetCentroidWorld整体平移sTotal 须与画布一致 // 将多边形质心平移到 targetCentroidWorld整体平移sTotal 须与画布一致
bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal, bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal,
bool autoKeyLocation); bool autoKeyLocation);
@@ -95,6 +96,11 @@ public:
bool removeEntityUserScaleKey(const QString& id, int frame); bool removeEntityUserScaleKey(const QString& id, int frame);
bool removeEntityImageFrame(const QString& id, int frame); bool removeEntityImageFrame(const QString& id, int frame);
bool setEntityIntroContent(const QString& id, const EntityIntroContent& intro);
/// 将外部图片拷贝到 assets/entities/ 并返回相对项目根的路径
bool importEntityIntroImageFromFile(const QString& id, const QString& absoluteImagePath,
QString* outRelativePath = nullptr);
private: private:
bool writeIndexJson(); bool writeIndexJson();
bool readIndexJson(const QString& indexPath); bool readIndexJson(const QString& indexPath);

View File

@@ -9,6 +9,7 @@ set(GUI_SOURCES
${GUI_ROOT}/dialogs/ImageCropDialog.cpp ${GUI_ROOT}/dialogs/ImageCropDialog.cpp
${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp
${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp
${GUI_ROOT}/dialogs/EntityIntroPopup.cpp
${GUI_ROOT}/editor/EditorCanvas.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp
${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/params/ParamControls.cpp
${GUI_ROOT}/props/BackgroundPropertySection.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp
@@ -23,6 +24,7 @@ set(GUI_HEADERS
${GUI_ROOT}/dialogs/ImageCropDialog.h ${GUI_ROOT}/dialogs/ImageCropDialog.h
${GUI_ROOT}/dialogs/FrameAnimationDialog.h ${GUI_ROOT}/dialogs/FrameAnimationDialog.h
${GUI_ROOT}/dialogs/CancelableTaskDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h
${GUI_ROOT}/dialogs/EntityIntroPopup.h
${GUI_ROOT}/editor/EditorCanvas.h ${GUI_ROOT}/editor/EditorCanvas.h
${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/params/ParamControls.h
${GUI_ROOT}/props/BackgroundPropertySection.h ${GUI_ROOT}/props/BackgroundPropertySection.h

View File

@@ -0,0 +1,176 @@
#include "dialogs/EntityIntroPopup.h"
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
#include <QLabel>
#include <QScreen>
#include <QPainter>
#include <QPaintEvent>
#include <QPixmap>
#include <QStyleOption>
#include <QTextEdit>
#include <QVBoxLayout>
namespace gui {
namespace {
constexpr int kMaxThumb = 200;
QString elideLine(const QString& s, int maxChars) {
if (s.size() <= maxChars) {
return s;
}
return s.left(maxChars - 1) + QChar(0x2026);
}
} // namespace
EntityIntroPopup::EntityIntroPopup(QWidget* parent)
: QWidget(parent) {
setAttribute(Qt::WA_TranslucentBackground, false);
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint);
auto* root = new QVBoxLayout(this);
root->setContentsMargins(14, 12, 14, 12);
root->setSpacing(8);
m_title = new QLabel(this);
QFont tf = m_title->font();
tf.setBold(true);
tf.setPointSizeF(tf.pointSizeF() + 1.5);
m_title->setFont(tf);
m_title->setWordWrap(true);
root->addWidget(m_title);
m_body = new QTextEdit(this);
m_body->setReadOnly(true);
m_body->setFrameShape(QFrame::NoFrame);
m_body->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_body->setMaximumHeight(180);
m_body->setPlaceholderText(QStringLiteral("(无)"));
root->addWidget(m_body);
m_imagesHost = new QWidget(this);
m_imagesLayout = new QVBoxLayout(m_imagesHost);
m_imagesLayout->setContentsMargins(0, 0, 0, 0);
m_imagesLayout->setSpacing(6);
root->addWidget(m_imagesHost);
m_videoHint = new QLabel(this);
m_videoHint->setWordWrap(true);
m_videoHint->setStyleSheet(QStringLiteral("QLabel { color: palette(mid); font-size: 11px; }"));
root->addWidget(m_videoHint);
setMinimumWidth(280);
setMaximumWidth(420);
hide();
}
void EntityIntroPopup::setProjectDir(const QString& absoluteProjectDir) {
m_projectDirAbs = absoluteProjectDir;
}
void EntityIntroPopup::setContent(const core::EntityIntroContent& content) {
const QString t = content.title.trimmed().isEmpty()
? QStringLiteral("介绍")
: content.title.trimmed();
m_title->setText(elideLine(t, 200));
m_body->setPlainText(content.bodyText);
while (QLayoutItem* it = m_imagesLayout->takeAt(0)) {
if (QWidget* w = it->widget()) {
w->deleteLater();
}
delete it;
}
for (const QString& rel : content.imagePathsRelative) {
if (rel.isEmpty()) {
continue;
}
const QString abs =
m_projectDirAbs.isEmpty() ? QString() : QDir(m_projectDirAbs).filePath(rel);
auto* lab = new QLabel(m_imagesHost);
lab->setAlignment(Qt::AlignCenter);
if (!abs.isEmpty() && QFileInfo::exists(abs)) {
QPixmap pm(abs);
if (!pm.isNull()) {
if (pm.width() > kMaxThumb || pm.height() > kMaxThumb) {
pm = pm.scaled(kMaxThumb, kMaxThumb, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
lab->setPixmap(pm);
} else {
lab->setText(QStringLiteral("(无法加载图片)"));
}
} else {
lab->setText(QStringLiteral("(缺少文件:%1").arg(rel));
}
lab->setWordWrap(true);
m_imagesLayout->addWidget(lab);
}
if (content.videoPathRelative.trimmed().isEmpty()) {
m_videoHint->setText(QStringLiteral("视频介绍"));
} else {
m_videoHint->setText(QStringLiteral("播放功能预留").arg(content.videoPathRelative));
}
adjustSize();
}
void EntityIntroPopup::showNearCanvasPoint(const QPoint& viewPos, QWidget* canvasWidget) {
if (!canvasWidget) {
return;
}
adjustSize();
const QPoint globalAnchor = canvasWidget->mapToGlobal(viewPos);
QScreen* scr = QGuiApplication::screenAt(globalAnchor);
if (!scr) {
scr = canvasWidget->screen();
}
const QRect screen = scr ? scr->availableGeometry() : QRect(0, 0, 1920, 1080);
int x = globalAnchor.x() + 18;
int y = globalAnchor.y() - height() / 3;
if (x + width() > screen.right()) {
x = globalAnchor.x() - width() - 18;
}
if (x < screen.left()) {
x = screen.left() + 8;
}
if (y + height() > screen.bottom()) {
y = screen.bottom() - height() - 8;
}
if (y < screen.top()) {
y = screen.top() + 8;
}
move(x, y);
show();
raise();
}
void EntityIntroPopup::clearAndHide() {
hide();
m_title->clear();
m_body->clear();
while (QLayoutItem* it = m_imagesLayout->takeAt(0)) {
if (QWidget* w = it->widget()) {
w->deleteLater();
}
delete it;
}
m_videoHint->clear();
}
void EntityIntroPopup::paintEvent(QPaintEvent* e) {
Q_UNUSED(e);
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
const QRect r = rect().adjusted(1, 1, -1, -1);
p.fillRect(r, palette().base());
p.setPen(QPen(palette().mid().color(), 1));
p.drawRoundedRect(r, 8, 8);
}
} // namespace gui

View File

@@ -0,0 +1,37 @@
#pragma once
#include "core/domain/EntityIntro.h"
#include <QWidget>
class QLabel;
class QTextEdit;
class QVBoxLayout;
namespace gui {
/// 预览模式下在实体附近显示的介绍浮层(非模态,可随内容伸缩)
class EntityIntroPopup final : public QWidget {
Q_OBJECT
public:
explicit EntityIntroPopup(QWidget* parent = nullptr);
void setProjectDir(const QString& absoluteProjectDir);
void setContent(const core::EntityIntroContent& content);
void showNearCanvasPoint(const QPoint& viewPos, QWidget* canvasWidget);
void clearAndHide();
protected:
void paintEvent(QPaintEvent* e) override;
private:
QString m_projectDirAbs;
QLabel* m_title = nullptr;
QTextEdit* m_body = nullptr;
QWidget* m_imagesHost = nullptr;
QVBoxLayout* m_imagesLayout = nullptr;
QLabel* m_videoHint = nullptr;
};
} // namespace gui

View File

@@ -13,6 +13,7 @@
#include <QPaintEvent> #include <QPaintEvent>
#include <QPainterPath> #include <QPainterPath>
#include <QWheelEvent> #include <QWheelEvent>
#include <QKeyEvent>
namespace { namespace {
@@ -163,6 +164,16 @@ double depthToScale01(int depthZ) {
return static_cast<double>(d) / 255.0; return static_cast<double>(d) / 255.0;
} }
// depth01 0..1 -> 原始距离乘子 0.5..1.5calibMult>0 时除以创建时记录的基准,使「原位置」为 1.0
double distanceScaleFromDepth01(double depth01, double calibMult) {
const double d = std::clamp(depth01, 0.0, 1.0);
const double raw = 0.5 + d * 1.0;
if (calibMult > 0.0) {
return raw / std::max(calibMult, 1e-6);
}
return raw;
}
QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& polyWorld, QPointF& outTopLeftWorld) { QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& polyWorld, QPointF& outTopLeftWorld) {
if (bg.isNull() || polyWorld.size() < 3) { if (bg.isNull() || polyWorld.size() < 3) {
outTopLeftWorld = {}; outTopLeftWorld = {};
@@ -246,6 +257,15 @@ EditorCanvas::EditorCanvas(QWidget* parent)
setMouseTracking(true); setMouseTracking(true);
m_previewEmitTimer.start(); m_previewEmitTimer.start();
m_presZoomTimer = new QTimer(this);
m_presZoomTimer->setInterval(16);
connect(m_presZoomTimer, &QTimer::timeout, this, &EditorCanvas::tickPresentationZoomAnimation);
m_presHoverTimer = new QTimer(this);
m_presHoverTimer->setInterval(40);
connect(m_presHoverTimer, &QTimer::timeout, this, &EditorCanvas::tickPresentationHoverAnimation);
updateCursor(); updateCursor();
} }
@@ -254,6 +274,19 @@ void EditorCanvas::setPresentationPreviewMode(bool on) {
return; return;
} }
m_presentationPreviewMode = on; m_presentationPreviewMode = on;
if (m_presZoomTimer) {
m_presZoomTimer->stop();
}
if (m_presHoverTimer) {
m_presHoverTimer->stop();
}
m_presHoverEntityIndex = -1;
m_presFocusedEntityIndex = -1;
m_presHoverPhase = 0.0;
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = false;
m_presBgPanSession = false;
m_presBgDragDist = 0.0;
if (on) { if (on) {
m_tool = Tool::Move; m_tool = Tool::Move;
m_selectedEntity = -1; m_selectedEntity = -1;
@@ -292,6 +325,7 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, c
core::sampleLocation(e.locationKeys, m_currentFrame, e.originWorld, KeyInterpolation::Linear); core::sampleLocation(e.locationKeys, m_currentFrame, e.originWorld, KeyInterpolation::Linear);
v.animatedOriginWorld = originWorld; v.animatedOriginWorld = originWorld;
v.cutoutPolygonWorld = e.cutoutPolygonWorld; v.cutoutPolygonWorld = e.cutoutPolygonWorld;
v.distanceScaleCalibMult = e.distanceScaleCalibMult;
// 逐帧自动算 z使用实体多边形质心作为锚点采样深度O(1)),避免卡顿 // 逐帧自动算 z使用实体多边形质心作为锚点采样深度O(1)),避免卡顿
QVector<QPointF> polyTmp; QVector<QPointF> polyTmp;
@@ -308,7 +342,7 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, c
const double userScaleAnimated = const double userScaleAnimated =
core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear); core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear);
v.userScale = std::max(1e-6, userScaleAnimated); v.userScale = std::max(1e-6, userScaleAnimated);
const double distScale = 0.5 + ds01 * 1.0; // 0..1 -> 0.5..1.5(深度距离) const double distScale = distanceScaleFromDepth01(ds01, e.distanceScaleCalibMult);
const double scale = distScale * v.userScale; const double scale = distScale * v.userScale;
v.visualScale = scale; v.visualScale = scale;
@@ -412,8 +446,8 @@ double EditorCanvas::selectedDistanceScaleMultiplier() const {
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) { if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
return 1.0; return 1.0;
} }
const double ds01 = std::clamp(m_entities[m_selectedEntity].animatedDepthScale01, 0.0, 1.0); const auto& ent = m_entities[m_selectedEntity];
return 0.5 + ds01 * 1.0; return distanceScaleFromDepth01(ent.animatedDepthScale01, ent.distanceScaleCalibMult);
} }
double EditorCanvas::selectedUserScale() const { double EditorCanvas::selectedUserScale() const {
@@ -427,6 +461,95 @@ double EditorCanvas::selectedCombinedScale() const {
return selectedDistanceScaleMultiplier() * selectedUserScale(); return selectedDistanceScaleMultiplier() * selectedUserScale();
} }
void EditorCanvas::tickPresentationHoverAnimation() {
if (!m_presentationPreviewMode) {
return;
}
m_presHoverPhase += 0.35;
if (m_presHoverPhase > 6.28318530718) {
m_presHoverPhase -= 6.28318530718;
}
update();
}
void EditorCanvas::tickPresentationZoomAnimation() {
m_presZoomAnimT += 0.16;
qreal u = std::min(1.0, static_cast<qreal>(m_presZoomAnimT));
u = 1.0 - std::pow(1.0 - u, 3.0);
m_pan = m_presZoomFromPan + (m_presZoomToPan - m_presZoomFromPan) * u;
m_scale = m_presZoomFromScale + (m_presZoomToScale - m_presZoomFromScale) * u;
if (m_presZoomAnimT >= 1.0) {
m_presZoomTimer->stop();
m_pan = m_presZoomToPan;
m_scale = m_presZoomToScale;
if (m_presZoomFinishingRestore) {
m_presFocusedEntityIndex = -1;
m_presZoomFinishingRestore = false;
}
}
update();
}
void EditorCanvas::presentationComputeZoomTarget(int entityIndex, QPointF* outPan, qreal* outScale) const {
if (!outPan || !outScale || entityIndex < 0 || entityIndex >= m_entities.size()) {
return;
}
const Entity& ent = m_entities[entityIndex];
QRectF bb;
if (!ent.image.isNull()) {
const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
bb = QRectF(ent.imageTopLeft, sz);
} else if (!ent.polygonWorld.isEmpty()) {
bb = pathFromWorldPolygon(ent.polygonWorld).boundingRect();
} else {
bb = ent.rect;
}
const QPointF c = bb.center();
const qreal rw = std::max(1.0, bb.width());
const qreal rh = std::max(1.0, bb.height());
qreal s = std::min(static_cast<qreal>(width()) / (rw * 1.28), static_cast<qreal>(height()) / (rh * 1.28));
s = std::clamp(s, 0.12, 14.0);
*outScale = s;
*outPan = QPointF(width() / 2.0, height() / 2.0) - c * s;
}
void EditorCanvas::beginPresentationZoomTowardEntity(int entityIndex) {
if (entityIndex < 0 || entityIndex >= m_entities.size()) {
return;
}
if (m_presFocusedEntityIndex < 0) {
m_presRestorePan = m_pan;
m_presRestoreScale = m_scale;
}
m_presFocusedEntityIndex = entityIndex;
m_presZoomFromPan = m_pan;
m_presZoomFromScale = m_scale;
presentationComputeZoomTarget(entityIndex, &m_presZoomToPan, &m_presZoomToScale);
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = false;
m_presZoomTimer->start();
}
void EditorCanvas::beginPresentationZoomRestore() {
m_presZoomFromPan = m_pan;
m_presZoomFromScale = m_scale;
m_presZoomToPan = m_presRestorePan;
m_presZoomToScale = m_presRestoreScale;
m_presZoomAnimT = 0.0;
m_presZoomFinishingRestore = true;
m_presZoomTimer->start();
}
void EditorCanvas::clearPresentationEntityFocus() {
emit presentationInteractionDismissed();
if (m_presZoomFinishingRestore) {
return;
}
if (m_presFocusedEntityIndex >= 0) {
beginPresentationZoomRestore();
}
}
void EditorCanvas::clearEntitySelection() { void EditorCanvas::clearEntitySelection() {
if (m_selectedEntity < 0) { if (m_selectedEntity < 0) {
return; return;
@@ -598,7 +721,11 @@ void EditorCanvas::ensurePixmapLoaded() const {
void EditorCanvas::updateCursor() { void EditorCanvas::updateCursor() {
if (m_presentationPreviewMode) { if (m_presentationPreviewMode) {
if (m_presHoverEntityIndex >= 0) {
setCursor(Qt::PointingHandCursor);
} else {
setCursor(Qt::OpenHandCursor); setCursor(Qt::OpenHandCursor);
}
return; return;
} }
switch (m_tool) { switch (m_tool) {
@@ -784,8 +911,18 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
p.drawImage(target, ent.image); p.drawImage(target, ent.image);
p.restore(); p.restore();
} else { } else {
const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); const qreal pop =
const QRectF target(ent.imageTopLeft, sz); (m_presentationPreviewMode && i == m_presFocusedEntityIndex) ? 1.1 : 1.0;
const QSizeF sz0(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
QRectF target;
if (pop > 1.001) {
const QRectF orig(ent.imageTopLeft, sz0);
const QPointF cen = orig.center();
const QSizeF sz = orig.size() * pop;
target = QRectF(QPointF(cen.x() - sz.width() * 0.5, cen.y() - sz.height() * 0.5), sz);
} else {
target = QRectF(ent.imageTopLeft, sz0);
}
p.drawImage(target, ent.image); p.drawImage(target, ent.image);
} }
} else { } else {
@@ -853,6 +990,33 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
p.drawRect(ent.rect.adjusted(-2, -2, 2, 2)); p.drawRect(ent.rect.adjusted(-2, -2, 2, 2));
} }
} }
if (m_presentationPreviewMode && !ent.hiddenInEditMode) {
const bool showHover = (i == m_presHoverEntityIndex);
const bool showFocus = (i == m_presFocusedEntityIndex);
if (showHover || showFocus) {
p.setBrush(Qt::NoBrush);
if (showHover) {
const qreal pulse = 0.45 + 0.55 * std::sin(static_cast<double>(m_presHoverPhase));
const qreal lw =
(2.0 + 2.8 * pulse) / std::max(static_cast<qreal>(m_scale), static_cast<qreal>(0.001));
p.setPen(QPen(QColor(255, 210, 80, static_cast<int>(65 + 110 * pulse)), lw));
if (!ent.pathWorld.isEmpty()) {
p.drawPath(ent.pathWorld);
} else {
p.drawRect(ent.rect);
}
}
if (showFocus) {
const qreal lw = 2.8 / std::max(static_cast<qreal>(m_scale), static_cast<qreal>(0.001));
p.setPen(QPen(QColor(255, 120, 40, 230), lw));
if (!ent.pathWorld.isEmpty()) {
p.drawPath(ent.pathWorld);
} else {
p.drawRect(ent.rect);
}
}
}
}
} }
// 创建实体手绘轨迹预览world 坐标) // 创建实体手绘轨迹预览world 坐标)
@@ -986,8 +1150,27 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
emit hoveredWorldPosDepthChanged(wp0, z0); emit hoveredWorldPosDepthChanged(wp0, z0);
if (m_presentationPreviewMode) { if (m_presentationPreviewMode) {
if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { if (e->button() == Qt::LeftButton) {
const int hit = hitTestEntity(wp0);
if (hit >= 0) {
const auto& ent = m_entities[hit];
const QPointF cWorld =
ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld);
const QPointF anchorView = worldToView(cWorld);
beginPresentationZoomTowardEntity(hit);
emit presentationEntityIntroRequested(ent.id, anchorView);
return;
}
m_dragging = true; m_dragging = true;
m_presBgPanSession = true;
m_presBgDragDist = 0.0;
m_lastMouseView = e->position();
setCursor(Qt::ClosedHandCursor);
return;
}
if (e->button() == Qt::MiddleButton) {
m_dragging = true;
m_presBgPanSession = false;
m_lastMouseView = e->position(); m_lastMouseView = e->position();
setCursor(Qt::ClosedHandCursor); setCursor(Qt::ClosedHandCursor);
} }
@@ -1117,6 +1300,23 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
} }
emit hoveredWorldPosDepthChanged(wp, z); emit hoveredWorldPosDepthChanged(wp, z);
if (m_presentationPreviewMode) {
const int h = hitTestEntity(wp);
if (h != m_presHoverEntityIndex) {
m_presHoverEntityIndex = h;
updateCursor();
update();
}
if (h >= 0) {
if (m_presHoverTimer && !m_presHoverTimer->isActive()) {
m_presHoverTimer->start();
}
} else if (m_presHoverTimer) {
m_presHoverTimer->stop();
m_presHoverPhase = 0.0;
}
}
if (!m_dragging) { if (!m_dragging) {
QWidget::mouseMoveEvent(e); QWidget::mouseMoveEvent(e);
return; return;
@@ -1198,7 +1398,8 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
const double ds01 = depthToScale01(depthZ); const double ds01 = depthToScale01(depthZ);
ent.animatedDepthScale01 = ds01; ent.animatedDepthScale01 = ds01;
const double newScale = (0.5 + ds01 * 1.0) * ent.userScale; const double newScale =
distanceScaleFromDepth01(ds01, ent.distanceScaleCalibMult) * ent.userScale;
ent.visualScale = newScale; ent.visualScale = newScale;
if (m_dragPreviewActive) { if (m_dragPreviewActive) {
m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0); m_dragScaleRatio = std::clamp(newScale / std::max(1e-6, m_dragScaleBase), 0.02, 50.0);
@@ -1218,6 +1419,9 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
// 平移画布 // 平移画布
if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) || if (m_tool == Tool::Move || (e->buttons() & Qt::MiddleButton) ||
(m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) { (m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) {
if (m_presentationPreviewMode && m_presBgPanSession) {
m_presBgDragDist += std::abs(deltaView.x()) + std::abs(deltaView.y());
}
m_pan += deltaView; m_pan += deltaView;
update(); update();
return; return;
@@ -1226,6 +1430,13 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) {
if (m_presentationPreviewMode && e->button() == Qt::LeftButton) {
if (m_presBgPanSession && m_presBgDragDist < 8.0) {
clearPresentationEntityFocus();
}
m_presBgPanSession = false;
m_presBgDragDist = 0.0;
}
if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton && m_drawingEntity) { if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton && m_drawingEntity) {
m_dragging = false; m_dragging = false;
m_drawingEntity = false; m_drawingEntity = false;
@@ -1252,6 +1463,10 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
} }
const QPointF c = polygonCentroid(ent.cutoutPolygonWorld); const QPointF c = polygonCentroid(ent.cutoutPolygonWorld);
ent.depth = m_depthImage8.isNull() ? 0 : sampleDepthAtPoint(m_depthImage8, c); ent.depth = m_depthImage8.isNull() ? 0 : sampleDepthAtPoint(m_depthImage8, c);
{
const double ds01 = depthToScale01(ent.depth);
ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0;
}
// 抠实体:从原背景抠出,背景对应区域填黑 // 抠实体:从原背景抠出,背景对应区域填黑
QImage cutout; QImage cutout;
@@ -1325,3 +1540,12 @@ void EditorCanvas::wheelEvent(QWheelEvent* e) {
e->accept(); e->accept();
} }
void EditorCanvas::keyPressEvent(QKeyEvent* e) {
if (m_presentationPreviewMode && e->key() == Qt::Key_Escape) {
clearPresentationEntityFocus();
e->accept();
return;
}
QWidget::keyPressEvent(e);
}

View File

@@ -9,6 +9,9 @@
#include <QVector> #include <QVector>
#include <QWidget> #include <QWidget>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QTimer>
class QKeyEvent;
class EditorCanvas final : public QWidget { class EditorCanvas final : public QWidget {
Q_OBJECT Q_OBJECT
@@ -51,6 +54,8 @@ public:
// 预览呈现:完整背景 + 全部实体(忽略显隐开关),隐藏编辑辅助元素,仅可平移/缩放查看 // 预览呈现:完整背景 + 全部实体(忽略显隐开关),隐藏编辑辅助元素,仅可平移/缩放查看
void setPresentationPreviewMode(bool on); void setPresentationPreviewMode(bool on);
bool presentationPreviewMode() const { return m_presentationPreviewMode; } bool presentationPreviewMode() const { return m_presentationPreviewMode; }
/// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式)
void clearPresentationEntityFocus();
void setEntities(const QVector<core::Project::Entity>& entities, const QString& projectDirAbs); void setEntities(const QVector<core::Project::Entity>& entities, const QString& projectDirAbs);
void setCurrentFrame(int frame); void setCurrentFrame(int frame);
@@ -79,6 +84,10 @@ signals:
void requestMoveEntity(const QString& id, const QPointF& delta); void requestMoveEntity(const QString& id, const QPointF& delta);
void entityDragActiveChanged(bool on); void entityDragActiveChanged(bool on);
void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld);
/// 预览模式下点击实体anchorView 为实体质心在视图中的位置,用于摆放介绍浮层
void presentationEntityIntroRequested(const QString& entityId, QPointF anchorViewPoint);
/// 预览模式下应关闭介绍层空白处轻点、Esc、开始还原缩放时由画布侧触发
void presentationInteractionDismissed();
protected: protected:
void paintEvent(QPaintEvent* e) override; void paintEvent(QPaintEvent* e) override;
@@ -87,6 +96,7 @@ protected:
void mouseMoveEvent(QMouseEvent* e) override; void mouseMoveEvent(QMouseEvent* e) override;
void mouseReleaseEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override;
void wheelEvent(QWheelEvent* e) override; void wheelEvent(QWheelEvent* e) override;
void keyPressEvent(QKeyEvent* e) override;
private: private:
void ensurePixmapLoaded() const; void ensurePixmapLoaded() const;
@@ -97,6 +107,12 @@ private:
QPointF worldToView(const QPointF& w) const; QPointF worldToView(const QPointF& w) const;
QRectF worldRectOfBackground() const; QRectF worldRectOfBackground() const;
void tickPresentationZoomAnimation();
void tickPresentationHoverAnimation();
void beginPresentationZoomTowardEntity(int entityIndex);
void beginPresentationZoomRestore();
void presentationComputeZoomTarget(int entityIndex, QPointF* outPan, qreal* outScale) const;
private: private:
struct Entity { struct Entity {
QString id; QString id;
@@ -112,6 +128,7 @@ private:
QPointF imageTopLeft; // image 对应的 world 左上角 QPointF imageTopLeft; // image 对应的 world 左上角
double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制) double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制)
double userScale = 1.0; // 与深度距离缩放相乘 double userScale = 1.0; // 与深度距离缩放相乘
double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致0=未校准
QPointF animatedOriginWorld; QPointF animatedOriginWorld;
double animatedDepthScale01 = 0.5; double animatedDepthScale01 = 0.5;
// 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体) // 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体)
@@ -175,5 +192,22 @@ private:
QVector<QPointF> m_strokeWorld; QVector<QPointF> m_strokeWorld;
int m_currentFrame = 0; int m_currentFrame = 0;
// —— 预览展示:实体悬停动效、点击聚焦缩放 ——
QTimer* m_presZoomTimer = nullptr;
QTimer* m_presHoverTimer = nullptr;
int m_presHoverEntityIndex = -1;
int m_presFocusedEntityIndex = -1;
qreal m_presHoverPhase = 0.0;
QPointF m_presRestorePan;
qreal m_presRestoreScale = 1.0;
QPointF m_presZoomFromPan;
QPointF m_presZoomToPan;
qreal m_presZoomFromScale = 1.0;
qreal m_presZoomToScale = 1.0;
qreal m_presZoomAnimT = 0.0;
bool m_presZoomFinishingRestore = false;
bool m_presBgPanSession = false;
qreal m_presBgDragDist = 0.0;
}; };

View File

@@ -3,14 +3,18 @@
#include "dialogs/CancelableTaskDialog.h" #include "dialogs/CancelableTaskDialog.h"
#include "editor/EditorCanvas.h" #include "editor/EditorCanvas.h"
#include "dialogs/ImageCropDialog.h" #include "dialogs/ImageCropDialog.h"
#include "core/domain/EntityIntro.h"
#include "core/net/ModelServerClient.h" #include "core/net/ModelServerClient.h"
#include "params/ParamControls.h" #include "params/ParamControls.h"
#include "props/BackgroundPropertySection.h" #include "props/BackgroundPropertySection.h"
#include "props/EntityPropertySection.h" #include "props/EntityPropertySection.h"
#include "timeline/TimelineWidget.h" #include "timeline/TimelineWidget.h"
#include "dialogs/FrameAnimationDialog.h" #include "dialogs/FrameAnimationDialog.h"
#include "dialogs/EntityIntroPopup.h"
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QAbstractSpinBox>
#include <QApplication>
#include <QAction> #include <QAction>
#include <QBoxLayout> #include <QBoxLayout>
#include <QButtonGroup> #include <QButtonGroup>
@@ -43,7 +47,10 @@
#include <QFile> #include <QFile>
#include <QIcon> #include <QIcon>
#include <QResizeEvent> #include <QResizeEvent>
#include <QScrollArea>
#include <QShowEvent> #include <QShowEvent>
#include <QtGlobal>
#include <QWheelEvent>
#include <QSizePolicy> #include <QSizePolicy>
#include <QStyle> #include <QStyle>
#include <QUrl> #include <QUrl>
@@ -130,6 +137,31 @@ const char* kFloatingModeDockQss = R"(
} }
)"; )";
/// 避免滚轮先被 QScrollArea 吃掉,导致内嵌 QDoubleSpinBox 无法用滚轮调节
class SpinFriendlyScrollArea final : public QScrollArea {
public:
explicit SpinFriendlyScrollArea(QWidget* parent = nullptr)
: QScrollArea(parent) {}
protected:
void wheelEvent(QWheelEvent* e) override {
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
const QPoint inVp = viewport()->mapFrom(this, e->position().toPoint());
#else
const QPoint inVp = viewport()->mapFrom(this, e->pos());
#endif
if (QWidget* w = viewport()->childAt(inVp)) {
for (QWidget* cur = w; cur; cur = cur->parentWidget()) {
if (qobject_cast<QAbstractSpinBox*>(cur)) {
QApplication::sendEvent(cur, e);
return;
}
}
}
QScrollArea::wheelEvent(e);
}
};
const char* kTimelineBarQss = R"( const char* kTimelineBarQss = R"(
#TimelineDockBar QToolButton, #TimelineDockBar QPushButton { #TimelineDockBar QToolButton, #TimelineDockBar QPushButton {
border: 1px solid palette(midlight); border: 1px solid palette(midlight);
@@ -158,6 +190,7 @@ public:
EditorCanvas* canvas = nullptr; EditorCanvas* canvas = nullptr;
QWidget* modeDock = nullptr; QWidget* modeDock = nullptr;
QWidget* toolDock = nullptr; QWidget* toolDock = nullptr;
QWidget* previewPlaybackBar = nullptr;
void relayoutFloaters() { void relayoutFloaters() {
if (canvas) { if (canvas) {
@@ -201,6 +234,18 @@ public:
if (toolDock && toolDock->isVisible()) { if (toolDock && toolDock->isVisible()) {
toolDock->raise(); toolDock->raise();
} }
if (previewPlaybackBar && previewPlaybackBar->isVisible()) {
if (QLayout* lay = previewPlaybackBar->layout()) {
lay->activate();
}
previewPlaybackBar->updateGeometry();
previewPlaybackBar->adjustSize();
const int x = std::max(kMargin, (width() - previewPlaybackBar->width()) / 2);
const int y = std::max(kMargin, height() - previewPlaybackBar->height() - kMargin);
previewPlaybackBar->move(x, y);
previewPlaybackBar->raise();
}
} }
protected: protected:
@@ -229,6 +274,21 @@ MainWindow::MainWindow(QWidget* parent)
createMenus(); createMenus();
createProjectTreeDock(); createProjectTreeDock();
createTimelineDock(); createTimelineDock();
if (m_previewBtnPlay && m_previewBtnPause && m_btnPlay) {
connect(m_previewBtnPlay, &QToolButton::clicked, this, [this]() {
if (m_btnPlay && !m_btnPlay->isChecked()) {
m_btnPlay->setChecked(true);
}
});
connect(m_previewBtnPause, &QToolButton::clicked, this, [this]() {
if (m_btnPlay && m_btnPlay->isChecked()) {
m_btnPlay->setChecked(false);
}
});
}
syncPreviewPlaybackBar();
refreshProjectTree(); refreshProjectTree();
updateUiEnabledState(); updateUiEnabledState();
refreshEditorPage(); refreshEditorPage();
@@ -604,6 +664,15 @@ void MainWindow::onTogglePlay(bool on) {
m_playTimer->stop(); m_playTimer->stop();
} }
} }
syncPreviewPlaybackBar();
}
void MainWindow::syncPreviewPlaybackBar() {
if (!m_previewBtnPlay || !m_previewBtnPause) {
return;
}
m_previewBtnPlay->setEnabled(!m_playing);
m_previewBtnPause->setEnabled(m_playing);
} }
void MainWindow::onInsertCombinedKey() { void MainWindow::onInsertCombinedKey() {
@@ -860,7 +929,14 @@ void MainWindow::createProjectTreeDock() {
} }
}); });
connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked); connect(m_projectTree, &QTreeWidget::itemClicked, this, &MainWindow::onProjectTreeItemClicked);
dockLayout->addWidget(m_projectTree, 1);
auto* treeScroll = new SpinFriendlyScrollArea(dockContent);
treeScroll->setWidgetResizable(true);
treeScroll->setFrameShape(QFrame::NoFrame);
treeScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
treeScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
treeScroll->setWidget(m_projectTree);
dockLayout->addWidget(treeScroll, 1);
m_dockProjectTree->setWidget(dockContent); m_dockProjectTree->setWidget(dockContent);
m_dockProjectTree->installEventFilter(this); m_dockProjectTree->installEventFilter(this);
@@ -875,7 +951,7 @@ void MainWindow::createProjectTreeDock() {
m_bgPropertySection = new gui::BackgroundPropertySection(); m_bgPropertySection = new gui::BackgroundPropertySection();
m_entityPropertySection = new gui::EntityPropertySection(); m_entityPropertySection = new gui::EntityPropertySection();
m_propertyStack = new QStackedWidget(m_dockProperties); m_propertyStack = new QStackedWidget();
m_propertyStack->setContentsMargins(4, 4, 4, 4); m_propertyStack->setContentsMargins(4, 4, 4, 4);
m_propertyStack->addWidget(m_bgPropertySection); m_propertyStack->addWidget(m_bgPropertySection);
m_propertyStack->addWidget(m_entityPropertySection); m_propertyStack->addWidget(m_entityPropertySection);
@@ -927,11 +1003,48 @@ void MainWindow::createProjectTreeDock() {
}); });
connect(m_entityPropertySection, &gui::EntityPropertySection::userScaleEdited, this, [this](double v) { connect(m_entityPropertySection, &gui::EntityPropertySection::userScaleEdited, this, [this](double v) {
if (m_selectedEntityId.isEmpty()) return; if (m_selectedEntityId.isEmpty()) return;
if (!m_workspace.setEntityUserScale(m_selectedEntityId, v)) return; if (!m_workspace.setEntityUserScale(m_selectedEntityId, v, m_currentFrame)) return;
refreshEditorPage();
refreshDopeSheet();
});
connect(m_entityPropertySection, &gui::EntityPropertySection::introContentEdited, this, [this]() {
if (m_selectedEntityId.isEmpty() || !m_entityPropertySection) {
return;
}
const core::EntityIntroContent intro = m_entityPropertySection->introSnapshot();
if (!m_workspace.setEntityIntroContent(m_selectedEntityId, intro)) {
QMessageBox::warning(this, QStringLiteral("介绍"), QStringLiteral("自动保存失败。"));
return;
}
refreshEditorPage(); refreshEditorPage();
}); });
connect(m_entityPropertySection, &gui::EntityPropertySection::introAddImageRequested, this, [this]() {
if (m_selectedEntityId.isEmpty() || !m_entityPropertySection) {
return;
}
const QString path = QFileDialog::getOpenFileName(
this,
QStringLiteral("选择配图"),
{},
QStringLiteral("图片 (*.png *.jpg *.jpeg *.webp *.bmp);;所有文件 (*)"));
if (path.isEmpty()) {
return;
}
QString rel;
if (!m_workspace.importEntityIntroImageFromFile(m_selectedEntityId, path, &rel)) {
QMessageBox::warning(this, QStringLiteral("介绍"), QStringLiteral("导入配图失败。"));
return;
}
m_entityPropertySection->appendIntroImagePath(rel);
});
m_dockProperties->setWidget(m_propertyStack); auto* propScroll = new SpinFriendlyScrollArea(m_dockProperties);
propScroll->setWidgetResizable(true);
propScroll->setFrameShape(QFrame::NoFrame);
propScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
propScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
propScroll->setWidget(m_propertyStack);
m_dockProperties->setWidget(propScroll);
addDockWidget(Qt::RightDockWidgetArea, m_dockProjectTree); addDockWidget(Qt::RightDockWidgetArea, m_dockProjectTree);
splitDockWidget(m_dockProjectTree, m_dockProperties, Qt::Vertical); splitDockWidget(m_dockProjectTree, m_dockProperties, Qt::Vertical);
@@ -996,10 +1109,12 @@ void MainWindow::refreshPropertyPanel() {
QString displayName; QString displayName;
double userScale = 1.0; double userScale = 1.0;
core::EntityIntroContent intro;
for (const auto& e : m_workspace.entities()) { for (const auto& e : m_workspace.entities()) {
if (e.id == m_selectedEntityId) { if (e.id == m_selectedEntityId) {
displayName = e.displayName; displayName = e.displayName;
userScale = e.userScale; userScale = e.userScale;
intro = e.intro;
break; break;
} }
} }
@@ -1013,6 +1128,7 @@ void MainWindow::refreshPropertyPanel() {
st.pivot = m_editorCanvas->selectedAnimatedOriginWorld(); st.pivot = m_editorCanvas->selectedAnimatedOriginWorld();
st.centroid = m_editorCanvas->selectedEntityCentroidWorld(); st.centroid = m_editorCanvas->selectedEntityCentroidWorld();
st.userScale = userScale; st.userScale = userScale;
st.intro = intro;
m_entityPropertySection->applyState(st); m_entityPropertySection->applyState(st);
m_propertyStack->setCurrentWidget(m_entityPropertySection); m_propertyStack->setCurrentWidget(m_entityPropertySection);
m_dockProperties->setWindowTitle(QStringLiteral("属性 — 实体")); m_dockProperties->setWindowTitle(QStringLiteral("属性 — 实体"));
@@ -1323,6 +1439,9 @@ void MainWindow::applyUiMode(UiMode mode) {
if (m_floatingToolDock) { if (m_floatingToolDock) {
m_floatingToolDock->setVisible(projectOpen && !preview); m_floatingToolDock->setVisible(projectOpen && !preview);
} }
if (m_previewPlaybackBar) {
m_previewPlaybackBar->setVisible(projectOpen && preview);
}
if (m_canvasHost) { if (m_canvasHost) {
m_canvasHost->updateGeometry(); m_canvasHost->updateGeometry();
static_cast<CanvasHost*>(m_canvasHost)->relayoutFloaters(); static_cast<CanvasHost*>(m_canvasHost)->relayoutFloaters();
@@ -1500,6 +1619,28 @@ void MainWindow::rebuildCentralPages() {
m_editorCanvas = new EditorCanvas(canvasHost); m_editorCanvas = new EditorCanvas(canvasHost);
canvasHost->canvas = m_editorCanvas; canvasHost->canvas = m_editorCanvas;
m_entityIntroPopup = new gui::EntityIntroPopup(this);
m_previewPlaybackBar = new QFrame(canvasHost);
m_previewPlaybackBar->setObjectName(QStringLiteral("PreviewPlaybackBar"));
m_previewPlaybackBar->setStyleSheet(QString::fromUtf8(kTimelineBarQss));
auto* pbl = new QHBoxLayout(m_previewPlaybackBar);
pbl->setContentsMargins(10, 6, 10, 6);
pbl->setSpacing(8);
m_previewBtnPlay = new QToolButton(m_previewPlaybackBar);
m_previewBtnPlay->setText(QStringLiteral("播放"));
m_previewBtnPlay->setToolTip(QStringLiteral("播放时间轴"));
m_previewBtnPause = new QToolButton(m_previewPlaybackBar);
m_previewBtnPause->setText(QStringLiteral("暂停"));
m_previewBtnPause->setToolTip(QStringLiteral("暂停时间轴"));
polishCompactToolButton(m_previewBtnPlay, 36);
polishCompactToolButton(m_previewBtnPause, 36);
pbl->addWidget(m_previewBtnPlay);
pbl->addWidget(m_previewBtnPause);
m_previewPlaybackBar->setParent(canvasHost);
m_previewPlaybackBar->hide();
canvasHost->previewPlaybackBar = m_previewPlaybackBar;
m_floatingModeDock = new QFrame(canvasHost); m_floatingModeDock = new QFrame(canvasHost);
m_floatingModeDock->setObjectName(QStringLiteral("FloatingModeDock")); m_floatingModeDock->setObjectName(QStringLiteral("FloatingModeDock"));
m_floatingModeDock->setFrameShape(QFrame::NoFrame); m_floatingModeDock->setFrameShape(QFrame::NoFrame);
@@ -1685,6 +1826,33 @@ void MainWindow::rebuildCentralPages() {
updateUiEnabledState(); updateUiEnabledState();
}); });
connect(m_editorCanvas, &EditorCanvas::presentationEntityIntroRequested, this,
[this](const QString& id, QPointF anchorView) {
if (!m_entityIntroPopup || !m_workspace.isOpen()) {
return;
}
const core::Project::Entity* hit = nullptr;
for (const auto& e : m_workspace.entities()) {
if (e.id == id) {
hit = &e;
break;
}
}
m_entityIntroPopup->setProjectDir(m_workspace.projectDir());
if (hit) {
m_entityIntroPopup->setContent(hit->intro);
} else {
core::EntityIntroContent empty;
m_entityIntroPopup->setContent(empty);
}
m_entityIntroPopup->showNearCanvasPoint(anchorView.toPoint(), m_editorCanvas);
});
connect(m_editorCanvas, &EditorCanvas::presentationInteractionDismissed, this, [this]() {
if (m_entityIntroPopup) {
m_entityIntroPopup->clearAndHide();
}
});
connect(group, &QButtonGroup::idClicked, this, [this](int id) { connect(group, &QButtonGroup::idClicked, this, [this](int id) {
if (!m_editorCanvas) { if (!m_editorCanvas) {
return; return;
@@ -1793,6 +1961,12 @@ void MainWindow::setPreviewRequested(bool preview) {
return; return;
} }
m_previewRequested = preview; m_previewRequested = preview;
if (!preview && m_editorCanvas) {
m_editorCanvas->clearPresentationEntityFocus();
}
if (!preview && m_entityIntroPopup) {
m_entityIntroPopup->clearAndHide();
}
updateUiEnabledState(); updateUiEnabledState();
if (preview) { if (preview) {
refreshPreviewPage(); refreshPreviewPage();

View File

@@ -30,6 +30,7 @@ class TimelineWidget;
namespace gui { namespace gui {
class BackgroundPropertySection; class BackgroundPropertySection;
class EntityPropertySection; class EntityPropertySection;
class EntityIntroPopup;
} }
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
@@ -96,6 +97,7 @@ private:
void applyTimelineFromProject(); void applyTimelineFromProject();
void refreshDopeSheet(); void refreshDopeSheet();
void setPreviewRequested(bool preview); void setPreviewRequested(bool preview);
void syncPreviewPlaybackBar();
QStackedWidget* m_centerStack = nullptr; QStackedWidget* m_centerStack = nullptr;
QWidget* m_pageWelcome = nullptr; QWidget* m_pageWelcome = nullptr;
@@ -174,4 +176,9 @@ private:
// 旧版 DopeSheet 已移除,这里保留占位便于后续扩展区间 UI如自定义小部件 // 旧版 DopeSheet 已移除,这里保留占位便于后续扩展区间 UI如自定义小部件
QTreeWidget* m_dopeTree = nullptr; QTreeWidget* m_dopeTree = nullptr;
QPushButton* m_btnDopeDeleteKey = nullptr; QPushButton* m_btnDopeDeleteKey = nullptr;
gui::EntityIntroPopup* m_entityIntroPopup = nullptr;
QFrame* m_previewPlaybackBar = nullptr;
QToolButton* m_previewBtnPlay = nullptr;
QToolButton* m_previewBtnPause = nullptr;
}; };

View File

@@ -117,7 +117,11 @@ void Vec2ParamControl::emitIfChanged() {
if (m_block || !m_x || !m_y) return; if (m_block || !m_x || !m_y) return;
const double nx = m_x->value(); const double nx = m_x->value();
const double ny = m_y->value(); const double ny = m_y->value();
if (nx == m_lastX && ny == m_lastY) return; auto nearEq = [](double a, double b) {
const double scale = std::max({1.0, std::abs(a), std::abs(b)});
return std::abs(a - b) <= 1e-4 * scale;
};
if (nearEq(nx, m_lastX) && nearEq(ny, m_lastY)) return;
m_lastX = nx; m_lastX = nx;
m_lastY = ny; m_lastY = ny;
emit valueChanged(nx, ny); emit valueChanged(nx, ny);

View File

@@ -4,8 +4,14 @@
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QListWidget>
#include <QPushButton>
#include <QTextEdit>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
namespace gui { namespace gui {
@@ -50,8 +56,75 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
form->addRow(QStringLiteral("整体缩放"), m_userScale); form->addRow(QStringLiteral("整体缩放"), m_userScale);
lay->addLayout(form); lay->addLayout(form);
m_introHeader = new QWidget(this);
auto* headLay = new QHBoxLayout(m_introHeader);
headLay->setContentsMargins(0, 2, 0, 2);
headLay->setSpacing(6);
m_introToggle = new QToolButton(m_introHeader);
m_introToggle->setAutoRaise(true);
m_introToggle->setToolButtonStyle(Qt::ToolButtonTextOnly);
m_introToggle->setCursor(Qt::PointingHandCursor);
m_introToggle->setToolTip(QStringLiteral("展开或折叠介绍设置"));
m_introToggle->setText(QStringLiteral(""));
m_introToggle->setFixedWidth(22);
auto* introTitleLab = new QLabel(QStringLiteral("介绍"), m_introHeader);
introTitleLab->setStyleSheet(QStringLiteral("QLabel { font-weight: 600; }"));
headLay->addWidget(m_introToggle, 0, Qt::AlignVCenter);
headLay->addWidget(introTitleLab, 1, Qt::AlignVCenter);
m_introContent = new QWidget(this);
auto* introLay = new QVBoxLayout(m_introContent);
introLay->setContentsMargins(8, 4, 0, 0);
introLay->setSpacing(6);
m_introTitle = new QLineEdit(m_introContent);
m_introTitle->setPlaceholderText(QStringLiteral("标题"));
introLay->addWidget(new QLabel(QStringLiteral("标题"), m_introContent));
introLay->addWidget(m_introTitle);
m_introBody = new QTextEdit(m_introContent);
m_introBody->setPlaceholderText(QStringLiteral("正文(纯文本)"));
m_introBody->setMaximumHeight(100);
introLay->addWidget(new QLabel(QStringLiteral("正文"), m_introContent));
introLay->addWidget(m_introBody);
m_introImages = new QListWidget(m_introContent);
m_introImages->setMinimumHeight(72);
m_introImages->setToolTip(QStringLiteral("配图相对路径列表;使用下方按钮从文件添加"));
introLay->addWidget(new QLabel(QStringLiteral("配图"), m_introContent));
introLay->addWidget(m_introImages);
auto* imgRow = new QHBoxLayout();
m_btnIntroAddImage = new QPushButton(QStringLiteral("添加配图…"), m_introContent);
m_btnIntroRemoveImage = new QPushButton(QStringLiteral("移除选中"), m_introContent);
imgRow->addWidget(m_btnIntroAddImage);
imgRow->addWidget(m_btnIntroRemoveImage);
introLay->addLayout(imgRow);
m_introVideo = new QLineEdit(m_introContent);
m_introVideo->setPlaceholderText(QStringLiteral("可选:视频相对路径(如 assets/entities/xxx.mp4"));
introLay->addWidget(new QLabel(QStringLiteral("视频路径(预留)"), m_introContent));
introLay->addWidget(m_introVideo);
lay->addWidget(m_introHeader);
lay->addWidget(m_introContent);
lay->addStretch(1); lay->addStretch(1);
m_introContent->setVisible(false);
m_introSaveTimer = new QTimer(this);
m_introSaveTimer->setSingleShot(true);
connect(m_introSaveTimer, &QTimer::timeout, this, [this]() {
if (!m_introBulkUpdate) {
emit introContentEdited();
}
});
connect(m_introToggle, &QToolButton::clicked, this, [this]() {
setIntroSectionExpanded(!m_introContent->isVisible());
});
connect(m_name, &QLineEdit::editingFinished, this, [this]() { connect(m_name, &QLineEdit::editingFinished, this, [this]() {
if (m_name) { if (m_name) {
emit displayNameCommitted(m_name->text()); emit displayNameCommitted(m_name->text());
@@ -60,9 +133,41 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited); connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited);
connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited); connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited);
connect(m_userScale, qOverload<double>(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited); connect(m_userScale, qOverload<double>(&QDoubleSpinBox::valueChanged), this, &EntityPropertySection::userScaleEdited);
connect(m_introTitle, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); });
connect(m_introBody, &QTextEdit::textChanged, this, [this]() { scheduleIntroPersist(); });
connect(m_introVideo, &QLineEdit::textChanged, this, [this](const QString&) { scheduleIntroPersist(); });
connect(m_btnIntroAddImage, &QPushButton::clicked, this, &EntityPropertySection::introAddImageRequested);
connect(m_btnIntroRemoveImage, &QPushButton::clicked, this, [this]() {
if (!m_introImages || !m_introImages->currentItem()) {
return;
}
delete m_introImages->takeItem(m_introImages->currentRow());
scheduleIntroPersist();
});
}
void EntityPropertySection::setIntroSectionExpanded(bool expanded) {
if (!m_introContent || !m_introToggle) {
return;
}
m_introContent->setVisible(expanded);
m_introToggle->setText(expanded ? QStringLiteral("") : QStringLiteral(""));
}
void EntityPropertySection::scheduleIntroPersist() {
if (m_introBulkUpdate || !m_introSaveTimer) {
return;
}
m_introSaveTimer->start(400);
} }
void EntityPropertySection::clearDisconnected() { void EntityPropertySection::clearDisconnected() {
if (m_introSaveTimer) {
m_introSaveTimer->stop();
}
m_introBulkUpdate = true;
setEditingEnabled(false); setEditingEnabled(false);
if (m_name) { if (m_name) {
m_name->blockSignals(true); m_name->blockSignals(true);
@@ -78,9 +183,32 @@ void EntityPropertySection::clearDisconnected() {
m_userScale->setValue(1.0); m_userScale->setValue(1.0);
m_userScale->blockSignals(false); m_userScale->blockSignals(false);
} }
if (m_introTitle) {
m_introTitle->blockSignals(true);
m_introTitle->clear();
m_introTitle->blockSignals(false);
}
if (m_introBody) {
m_introBody->blockSignals(true);
m_introBody->clear();
m_introBody->blockSignals(false);
}
if (m_introVideo) {
m_introVideo->blockSignals(true);
m_introVideo->clear();
m_introVideo->blockSignals(false);
}
if (m_introImages) m_introImages->clear();
setIntroSectionExpanded(false);
m_introBulkUpdate = false;
} }
void EntityPropertySection::applyState(const EntityPropertyUiState& s) { void EntityPropertySection::applyState(const EntityPropertyUiState& s) {
if (m_introSaveTimer) {
m_introSaveTimer->stop();
}
m_introBulkUpdate = true;
setEditingEnabled(true); setEditingEnabled(true);
if (m_name) { if (m_name) {
m_name->blockSignals(true); m_name->blockSignals(true);
@@ -96,6 +224,63 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) {
m_userScale->setValue(s.userScale); m_userScale->setValue(s.userScale);
m_userScale->blockSignals(false); m_userScale->blockSignals(false);
} }
if (m_introTitle) {
m_introTitle->blockSignals(true);
m_introTitle->setText(s.intro.title);
m_introTitle->blockSignals(false);
}
if (m_introBody) {
m_introBody->blockSignals(true);
m_introBody->setPlainText(s.intro.bodyText);
m_introBody->blockSignals(false);
}
if (m_introVideo) {
m_introVideo->blockSignals(true);
m_introVideo->setText(s.intro.videoPathRelative);
m_introVideo->blockSignals(false);
}
if (m_introImages) {
m_introImages->clear();
for (const QString& p : s.intro.imagePathsRelative) {
if (!p.isEmpty()) {
auto* it = new QListWidgetItem(p, m_introImages);
it->setData(Qt::UserRole, p);
}
}
}
setIntroSectionExpanded(false);
m_introBulkUpdate = false;
}
void EntityPropertySection::appendIntroImagePath(const QString& relativePath) {
if (!m_introImages || relativePath.isEmpty()) {
return;
}
auto* it = new QListWidgetItem(relativePath, m_introImages);
it->setData(Qt::UserRole, relativePath);
scheduleIntroPersist();
}
core::EntityIntroContent EntityPropertySection::introSnapshot() const {
core::EntityIntroContent out;
if (m_introTitle) {
out.title = m_introTitle->text();
}
if (m_introBody) {
out.bodyText = m_introBody->toPlainText();
}
if (m_introVideo) {
out.videoPathRelative = m_introVideo->text().trimmed();
}
if (m_introImages) {
for (int i = 0; i < m_introImages->count(); ++i) {
const QString p = m_introImages->item(i)->data(Qt::UserRole).toString();
if (!p.isEmpty()) {
out.imagePathsRelative.push_back(p);
}
}
}
return out;
} }
void EntityPropertySection::setEditingEnabled(bool on) { void EntityPropertySection::setEditingEnabled(bool on) {
@@ -103,6 +288,14 @@ void EntityPropertySection::setEditingEnabled(bool on) {
if (m_pivot) m_pivot->setEnabled(on); if (m_pivot) m_pivot->setEnabled(on);
if (m_centroid) m_centroid->setEnabled(on); if (m_centroid) m_centroid->setEnabled(on);
if (m_userScale) m_userScale->setEnabled(on); if (m_userScale) m_userScale->setEnabled(on);
if (m_introHeader) m_introHeader->setEnabled(on);
if (m_introToggle) m_introToggle->setEnabled(on);
if (m_introTitle) m_introTitle->setEnabled(on);
if (m_introBody) m_introBody->setEnabled(on);
if (m_introVideo) m_introVideo->setEnabled(on);
if (m_introImages) m_introImages->setEnabled(on);
if (m_btnIntroAddImage) m_btnIntroAddImage->setEnabled(on);
if (m_btnIntroRemoveImage) m_btnIntroRemoveImage->setEnabled(on);
} }
} // namespace gui } // namespace gui

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "core/domain/EntityIntro.h"
#include "props/PropertySectionWidget.h" #include "props/PropertySectionWidget.h"
#include <QPointF> #include <QPointF>
@@ -8,6 +9,12 @@
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QDoubleSpinBox; class QDoubleSpinBox;
class QTextEdit;
class QListWidget;
class QPushButton;
class QToolButton;
class QTimer;
class QWidget;
namespace gui { namespace gui {
class Vec2ParamControl; class Vec2ParamControl;
@@ -22,6 +29,7 @@ struct EntityPropertyUiState {
QPointF pivot; QPointF pivot;
QPointF centroid; QPointF centroid;
double userScale = 1.0; double userScale = 1.0;
core::EntityIntroContent intro;
}; };
// 实体相关属性(可嵌入 QStackedWidget 的一页) // 实体相关属性(可嵌入 QStackedWidget 的一页)
@@ -33,20 +41,41 @@ public:
void clearDisconnected(); void clearDisconnected();
void applyState(const EntityPropertyUiState& s); void applyState(const EntityPropertyUiState& s);
void setEditingEnabled(bool on); void setEditingEnabled(bool on);
[[nodiscard]] core::EntityIntroContent introSnapshot() const;
void appendIntroImagePath(const QString& relativePath);
signals: signals:
void displayNameCommitted(const QString& text); void displayNameCommitted(const QString& text);
void pivotEdited(double x, double y); void pivotEdited(double x, double y);
void centroidEdited(double x, double y); void centroidEdited(double x, double y);
void userScaleEdited(double value); void userScaleEdited(double value);
/// 介绍字段变更后防抖触发,由主窗口写入工程
void introContentEdited();
void introAddImageRequested();
private: private:
void scheduleIntroPersist();
void setIntroSectionExpanded(bool expanded);
QLineEdit* m_name = nullptr; QLineEdit* m_name = nullptr;
QLabel* m_depth = nullptr; QLabel* m_depth = nullptr;
QLabel* m_distScale = nullptr; QLabel* m_distScale = nullptr;
Vec2ParamControl* m_pivot = nullptr; Vec2ParamControl* m_pivot = nullptr;
Vec2ParamControl* m_centroid = nullptr; Vec2ParamControl* m_centroid = nullptr;
QDoubleSpinBox* m_userScale = nullptr; QDoubleSpinBox* m_userScale = nullptr;
QLineEdit* m_introTitle = nullptr;
QTextEdit* m_introBody = nullptr;
QLineEdit* m_introVideo = nullptr;
QListWidget* m_introImages = nullptr;
QPushButton* m_btnIntroAddImage = nullptr;
QPushButton* m_btnIntroRemoveImage = nullptr;
QWidget* m_introHeader = nullptr;
QToolButton* m_introToggle = nullptr;
QWidget* m_introContent = nullptr;
QTimer* m_introSaveTimer = nullptr;
bool m_introBulkUpdate = false;
}; };
} // namespace gui } // namespace gui