增加预览页介绍信息显示
This commit is contained in:
@@ -12,6 +12,7 @@ set(CORE_SOURCES
|
||||
)
|
||||
|
||||
set(CORE_HEADERS
|
||||
${CORE_ROOT}/domain/EntityIntro.h
|
||||
${CORE_ROOT}/domain/Project.h
|
||||
${CORE_ROOT}/workspace/ProjectWorkspace.h
|
||||
${CORE_ROOT}/persistence/PersistentBinaryObject.h
|
||||
|
||||
29
client/core/domain/EntityIntro.h
Normal file
29
client/core/domain/EntityIntro.h
Normal 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
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "domain/EntityIntro.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QPointF>
|
||||
#include <QVector>
|
||||
@@ -47,8 +49,11 @@ public:
|
||||
int depth = 0; // 0..255
|
||||
QString imagePath; // 相对路径,例如 "assets/entities/entity-1.png"
|
||||
QPointF imageTopLeftWorld; // 贴图左上角 world 坐标
|
||||
// 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale)
|
||||
// 人为整体缩放,与深度驱动的距离缩放相乘(画布中 visualScale = distanceScale * userScale;
|
||||
// distanceScale 在有 distanceScaleCalibMult 时为 (0.5+depth01)/calib,使抠图处为 1.0)
|
||||
double userScale = 1.0;
|
||||
// 抠图创建时该位置对应的原始距离乘子(0.5+depth01),用于校准:该处 distanceScale==1.0。0 表示未校准(兼容旧工程)
|
||||
double distanceScaleCalibMult = 0.0;
|
||||
|
||||
struct KeyframeVec2 {
|
||||
int frame = 0;
|
||||
@@ -76,6 +81,8 @@ public:
|
||||
QVector<KeyframeFloat01> depthScaleKeys;
|
||||
QVector<KeyframeDouble> userScaleKeys;
|
||||
QVector<ImageFrame> imageFrames;
|
||||
|
||||
EntityIntroContent intro;
|
||||
};
|
||||
|
||||
void setEntities(const QVector<Entity>& entities) { m_entities = entities; }
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
|
||||
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) {
|
||||
out.locationKeys.clear();
|
||||
out.depthScaleKeys.clear();
|
||||
@@ -220,7 +250,8 @@ public:
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -231,12 +262,17 @@ public:
|
||||
}
|
||||
QString dn;
|
||||
double us = 1.0;
|
||||
ds >> dn >> us;
|
||||
double cal = 0.0;
|
||||
ds >> dn >> us >> cal;
|
||||
if (ds.status() != QDataStream::Ok) {
|
||||
return false;
|
||||
}
|
||||
tmp.displayName = dn;
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
@@ -293,7 +329,7 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
|
||||
if (ds.status() != QDataStream::Ok || magic != kMagicPayload) {
|
||||
return false;
|
||||
}
|
||||
if (ver != 1 && ver != 2 && ver != 3) {
|
||||
if (ver != 1 && ver != 2 && ver != 3 && ver != 4 && ver != 5) {
|
||||
return false;
|
||||
}
|
||||
Project::Entity tmp;
|
||||
@@ -309,6 +345,19 @@ bool EntityPayloadBinary::load(const QString& absolutePath, Project::Entity& ent
|
||||
}
|
||||
tmp.displayName = dn;
|
||||
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 {
|
||||
tmp.displayName.clear();
|
||||
tmp.userScale = 1.0;
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace core {
|
||||
class EntityPayloadBinary {
|
||||
public:
|
||||
static constexpr quint32 kMagicPayload = 0x48464550; // 'HFEP'
|
||||
static constexpr quint32 kPayloadVersion = 3; // v3:追加 userScaleKeys(动画轨道)
|
||||
static constexpr quint32 kPayloadVersion = 5; // v5:追加 intro(预览介绍:标题/正文/配图/视频路径预留)
|
||||
|
||||
// 旧版独立动画文件(仍用于打开 v1 项目时合并)
|
||||
static constexpr quint32 kMagicLegacyAnim = 0x48465441; // 'HFTA'
|
||||
|
||||
@@ -1031,7 +1031,59 @@ bool ProjectWorkspace::setEntityDisplayName(const QString& id, const QString& di
|
||||
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()) {
|
||||
return false;
|
||||
}
|
||||
@@ -1043,10 +1095,13 @@ bool ProjectWorkspace::setEntityUserScale(const QString& id, double userScale) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
e.userScale = u;
|
||||
break;
|
||||
}
|
||||
if (!found) {
|
||||
@@ -1196,8 +1251,17 @@ bool ProjectWorkspace::moveEntityBy(const QString& id, const QPointF& delta, int
|
||||
e.originWorld,
|
||||
KeyInterpolation::Linear);
|
||||
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 {
|
||||
// 无自动关键帧时,直接修改“基准”位置
|
||||
e.originWorld += delta;
|
||||
e.imageTopLeftWorld += delta;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ public:
|
||||
bool addEntity(const Project::Entity& entity, const QImage& image);
|
||||
bool setEntityVisible(const QString& id, bool on);
|
||||
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 须与画布一致
|
||||
bool moveEntityCentroidTo(const QString& id, int frame, const QPointF& targetCentroidWorld, double sTotal,
|
||||
bool autoKeyLocation);
|
||||
@@ -95,6 +96,11 @@ public:
|
||||
bool removeEntityUserScaleKey(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:
|
||||
bool writeIndexJson();
|
||||
bool readIndexJson(const QString& indexPath);
|
||||
|
||||
@@ -9,6 +9,7 @@ set(GUI_SOURCES
|
||||
${GUI_ROOT}/dialogs/ImageCropDialog.cpp
|
||||
${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp
|
||||
${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp
|
||||
${GUI_ROOT}/dialogs/EntityIntroPopup.cpp
|
||||
${GUI_ROOT}/editor/EditorCanvas.cpp
|
||||
${GUI_ROOT}/params/ParamControls.cpp
|
||||
${GUI_ROOT}/props/BackgroundPropertySection.cpp
|
||||
@@ -23,6 +24,7 @@ set(GUI_HEADERS
|
||||
${GUI_ROOT}/dialogs/ImageCropDialog.h
|
||||
${GUI_ROOT}/dialogs/FrameAnimationDialog.h
|
||||
${GUI_ROOT}/dialogs/CancelableTaskDialog.h
|
||||
${GUI_ROOT}/dialogs/EntityIntroPopup.h
|
||||
${GUI_ROOT}/editor/EditorCanvas.h
|
||||
${GUI_ROOT}/params/ParamControls.h
|
||||
${GUI_ROOT}/props/BackgroundPropertySection.h
|
||||
|
||||
176
client/gui/dialogs/EntityIntroPopup.cpp
Normal file
176
client/gui/dialogs/EntityIntroPopup.cpp
Normal 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
|
||||
37
client/gui/dialogs/EntityIntroPopup.h
Normal file
37
client/gui/dialogs/EntityIntroPopup.h
Normal 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
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <QPaintEvent>
|
||||
#include <QPainterPath>
|
||||
#include <QWheelEvent>
|
||||
#include <QKeyEvent>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -163,6 +164,16 @@ double depthToScale01(int depthZ) {
|
||||
return static_cast<double>(d) / 255.0;
|
||||
}
|
||||
|
||||
// depth01 0..1 -> 原始距离乘子 0.5..1.5;calibMult>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) {
|
||||
if (bg.isNull() || polyWorld.size() < 3) {
|
||||
outTopLeftWorld = {};
|
||||
@@ -246,6 +257,15 @@ EditorCanvas::EditorCanvas(QWidget* parent)
|
||||
setMouseTracking(true);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -254,6 +274,19 @@ void EditorCanvas::setPresentationPreviewMode(bool on) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
m_tool = Tool::Move;
|
||||
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);
|
||||
v.animatedOriginWorld = originWorld;
|
||||
v.cutoutPolygonWorld = e.cutoutPolygonWorld;
|
||||
v.distanceScaleCalibMult = e.distanceScaleCalibMult;
|
||||
|
||||
// 逐帧自动算 z:使用实体多边形质心作为锚点采样深度(O(1)),避免卡顿
|
||||
QVector<QPointF> polyTmp;
|
||||
@@ -308,7 +342,7 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, c
|
||||
const double userScaleAnimated =
|
||||
core::sampleUserScale(e.userScaleKeys, m_currentFrame, e.userScale, core::KeyInterpolation::Linear);
|
||||
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;
|
||||
v.visualScale = scale;
|
||||
|
||||
@@ -412,8 +446,8 @@ double EditorCanvas::selectedDistanceScaleMultiplier() const {
|
||||
if (m_selectedEntity < 0 || m_selectedEntity >= m_entities.size()) {
|
||||
return 1.0;
|
||||
}
|
||||
const double ds01 = std::clamp(m_entities[m_selectedEntity].animatedDepthScale01, 0.0, 1.0);
|
||||
return 0.5 + ds01 * 1.0;
|
||||
const auto& ent = m_entities[m_selectedEntity];
|
||||
return distanceScaleFromDepth01(ent.animatedDepthScale01, ent.distanceScaleCalibMult);
|
||||
}
|
||||
|
||||
double EditorCanvas::selectedUserScale() const {
|
||||
@@ -427,6 +461,95 @@ double EditorCanvas::selectedCombinedScale() const {
|
||||
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() {
|
||||
if (m_selectedEntity < 0) {
|
||||
return;
|
||||
@@ -598,7 +721,11 @@ void EditorCanvas::ensurePixmapLoaded() const {
|
||||
|
||||
void EditorCanvas::updateCursor() {
|
||||
if (m_presentationPreviewMode) {
|
||||
setCursor(Qt::OpenHandCursor);
|
||||
if (m_presHoverEntityIndex >= 0) {
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
} else {
|
||||
setCursor(Qt::OpenHandCursor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (m_tool) {
|
||||
@@ -784,8 +911,18 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
|
||||
p.drawImage(target, ent.image);
|
||||
p.restore();
|
||||
} else {
|
||||
const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
|
||||
const QRectF target(ent.imageTopLeft, sz);
|
||||
const qreal pop =
|
||||
(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);
|
||||
}
|
||||
} else {
|
||||
@@ -853,6 +990,33 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
|
||||
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 坐标)
|
||||
@@ -986,8 +1150,27 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
|
||||
emit hoveredWorldPosDepthChanged(wp0, z0);
|
||||
|
||||
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_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();
|
||||
setCursor(Qt::ClosedHandCursor);
|
||||
}
|
||||
@@ -1117,6 +1300,23 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
|
||||
}
|
||||
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) {
|
||||
QWidget::mouseMoveEvent(e);
|
||||
return;
|
||||
@@ -1198,7 +1398,8 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
|
||||
|
||||
const double ds01 = depthToScale01(depthZ);
|
||||
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;
|
||||
if (m_dragPreviewActive) {
|
||||
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) ||
|
||||
(m_presentationPreviewMode && (e->buttons() & Qt::LeftButton))) {
|
||||
if (m_presentationPreviewMode && m_presBgPanSession) {
|
||||
m_presBgDragDist += std::abs(deltaView.x()) + std::abs(deltaView.y());
|
||||
}
|
||||
m_pan += deltaView;
|
||||
update();
|
||||
return;
|
||||
@@ -1226,6 +1430,13 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
|
||||
|
||||
void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
|
||||
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) {
|
||||
m_dragging = false;
|
||||
m_drawingEntity = false;
|
||||
@@ -1252,6 +1463,10 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
|
||||
}
|
||||
const QPointF c = polygonCentroid(ent.cutoutPolygonWorld);
|
||||
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;
|
||||
@@ -1325,3 +1540,12 @@ void EditorCanvas::wheelEvent(QWheelEvent* e) {
|
||||
e->accept();
|
||||
}
|
||||
|
||||
void EditorCanvas::keyPressEvent(QKeyEvent* e) {
|
||||
if (m_presentationPreviewMode && e->key() == Qt::Key_Escape) {
|
||||
clearPresentationEntityFocus();
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(e);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
#include <QElapsedTimer>
|
||||
#include <QTimer>
|
||||
|
||||
class QKeyEvent;
|
||||
|
||||
class EditorCanvas final : public QWidget {
|
||||
Q_OBJECT
|
||||
@@ -51,6 +54,8 @@ public:
|
||||
// 预览呈现:完整背景 + 全部实体(忽略显隐开关),隐藏编辑辅助元素,仅可平移/缩放查看
|
||||
void setPresentationPreviewMode(bool on);
|
||||
bool presentationPreviewMode() const { return m_presentationPreviewMode; }
|
||||
/// 退出「点击实体放大」状态并平滑回到进入前的视图(预览模式)
|
||||
void clearPresentationEntityFocus();
|
||||
|
||||
void setEntities(const QVector<core::Project::Entity>& entities, const QString& projectDirAbs);
|
||||
void setCurrentFrame(int frame);
|
||||
@@ -79,6 +84,10 @@ signals:
|
||||
void requestMoveEntity(const QString& id, const QPointF& delta);
|
||||
void entityDragActiveChanged(bool on);
|
||||
void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld);
|
||||
/// 预览模式下点击实体:anchorView 为实体质心在视图中的位置,用于摆放介绍浮层
|
||||
void presentationEntityIntroRequested(const QString& entityId, QPointF anchorViewPoint);
|
||||
/// 预览模式下应关闭介绍层(空白处轻点、Esc、开始还原缩放时由画布侧触发)
|
||||
void presentationInteractionDismissed();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* e) override;
|
||||
@@ -87,6 +96,7 @@ protected:
|
||||
void mouseMoveEvent(QMouseEvent* e) override;
|
||||
void mouseReleaseEvent(QMouseEvent* e) override;
|
||||
void wheelEvent(QWheelEvent* e) override;
|
||||
void keyPressEvent(QKeyEvent* e) override;
|
||||
|
||||
private:
|
||||
void ensurePixmapLoaded() const;
|
||||
@@ -97,6 +107,12 @@ private:
|
||||
QPointF worldToView(const QPointF& w) const;
|
||||
QRectF worldRectOfBackground() const;
|
||||
|
||||
void tickPresentationZoomAnimation();
|
||||
void tickPresentationHoverAnimation();
|
||||
void beginPresentationZoomTowardEntity(int entityIndex);
|
||||
void beginPresentationZoomRestore();
|
||||
void presentationComputeZoomTarget(int entityIndex, QPointF* outPan, qreal* outScale) const;
|
||||
|
||||
private:
|
||||
struct Entity {
|
||||
QString id;
|
||||
@@ -112,6 +128,7 @@ private:
|
||||
QPointF imageTopLeft; // image 对应的 world 左上角
|
||||
double visualScale = 1.0; // 实体在 world 坐标下的缩放(用于贴图绘制)
|
||||
double userScale = 1.0; // 与深度距离缩放相乘
|
||||
double distanceScaleCalibMult = 0.0; // 与 Project::Entity 一致;0=未校准
|
||||
QPointF animatedOriginWorld;
|
||||
double animatedDepthScale01 = 0.5;
|
||||
// 编辑模式下实体被设为隐藏时:不响应点选且不绘制,除非当前选中(便于树选隐藏实体)
|
||||
@@ -175,5 +192,22 @@ private:
|
||||
QVector<QPointF> m_strokeWorld;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
#include "dialogs/CancelableTaskDialog.h"
|
||||
#include "editor/EditorCanvas.h"
|
||||
#include "dialogs/ImageCropDialog.h"
|
||||
#include "core/domain/EntityIntro.h"
|
||||
#include "core/net/ModelServerClient.h"
|
||||
#include "params/ParamControls.h"
|
||||
#include "props/BackgroundPropertySection.h"
|
||||
#include "props/EntityPropertySection.h"
|
||||
#include "timeline/TimelineWidget.h"
|
||||
#include "dialogs/FrameAnimationDialog.h"
|
||||
#include "dialogs/EntityIntroPopup.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QAbstractSpinBox>
|
||||
#include <QApplication>
|
||||
#include <QAction>
|
||||
#include <QBoxLayout>
|
||||
#include <QButtonGroup>
|
||||
@@ -43,7 +47,10 @@
|
||||
#include <QFile>
|
||||
#include <QIcon>
|
||||
#include <QResizeEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QShowEvent>
|
||||
#include <QtGlobal>
|
||||
#include <QWheelEvent>
|
||||
#include <QSizePolicy>
|
||||
#include <QStyle>
|
||||
#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"(
|
||||
#TimelineDockBar QToolButton, #TimelineDockBar QPushButton {
|
||||
border: 1px solid palette(midlight);
|
||||
@@ -158,6 +190,7 @@ public:
|
||||
EditorCanvas* canvas = nullptr;
|
||||
QWidget* modeDock = nullptr;
|
||||
QWidget* toolDock = nullptr;
|
||||
QWidget* previewPlaybackBar = nullptr;
|
||||
|
||||
void relayoutFloaters() {
|
||||
if (canvas) {
|
||||
@@ -201,6 +234,18 @@ public:
|
||||
if (toolDock && toolDock->isVisible()) {
|
||||
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:
|
||||
@@ -229,6 +274,21 @@ MainWindow::MainWindow(QWidget* parent)
|
||||
createMenus();
|
||||
createProjectTreeDock();
|
||||
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();
|
||||
updateUiEnabledState();
|
||||
refreshEditorPage();
|
||||
@@ -604,6 +664,15 @@ void MainWindow::onTogglePlay(bool on) {
|
||||
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() {
|
||||
@@ -860,7 +929,14 @@ void MainWindow::createProjectTreeDock() {
|
||||
}
|
||||
});
|
||||
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->installEventFilter(this);
|
||||
@@ -875,7 +951,7 @@ void MainWindow::createProjectTreeDock() {
|
||||
|
||||
m_bgPropertySection = new gui::BackgroundPropertySection();
|
||||
m_entityPropertySection = new gui::EntityPropertySection();
|
||||
m_propertyStack = new QStackedWidget(m_dockProperties);
|
||||
m_propertyStack = new QStackedWidget();
|
||||
m_propertyStack->setContentsMargins(4, 4, 4, 4);
|
||||
m_propertyStack->addWidget(m_bgPropertySection);
|
||||
m_propertyStack->addWidget(m_entityPropertySection);
|
||||
@@ -927,11 +1003,48 @@ void MainWindow::createProjectTreeDock() {
|
||||
});
|
||||
connect(m_entityPropertySection, &gui::EntityPropertySection::userScaleEdited, this, [this](double v) {
|
||||
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();
|
||||
});
|
||||
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);
|
||||
splitDockWidget(m_dockProjectTree, m_dockProperties, Qt::Vertical);
|
||||
@@ -996,10 +1109,12 @@ void MainWindow::refreshPropertyPanel() {
|
||||
|
||||
QString displayName;
|
||||
double userScale = 1.0;
|
||||
core::EntityIntroContent intro;
|
||||
for (const auto& e : m_workspace.entities()) {
|
||||
if (e.id == m_selectedEntityId) {
|
||||
displayName = e.displayName;
|
||||
userScale = e.userScale;
|
||||
intro = e.intro;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +1128,7 @@ void MainWindow::refreshPropertyPanel() {
|
||||
st.pivot = m_editorCanvas->selectedAnimatedOriginWorld();
|
||||
st.centroid = m_editorCanvas->selectedEntityCentroidWorld();
|
||||
st.userScale = userScale;
|
||||
st.intro = intro;
|
||||
m_entityPropertySection->applyState(st);
|
||||
m_propertyStack->setCurrentWidget(m_entityPropertySection);
|
||||
m_dockProperties->setWindowTitle(QStringLiteral("属性 — 实体"));
|
||||
@@ -1323,6 +1439,9 @@ void MainWindow::applyUiMode(UiMode mode) {
|
||||
if (m_floatingToolDock) {
|
||||
m_floatingToolDock->setVisible(projectOpen && !preview);
|
||||
}
|
||||
if (m_previewPlaybackBar) {
|
||||
m_previewPlaybackBar->setVisible(projectOpen && preview);
|
||||
}
|
||||
if (m_canvasHost) {
|
||||
m_canvasHost->updateGeometry();
|
||||
static_cast<CanvasHost*>(m_canvasHost)->relayoutFloaters();
|
||||
@@ -1500,6 +1619,28 @@ void MainWindow::rebuildCentralPages() {
|
||||
m_editorCanvas = new EditorCanvas(canvasHost);
|
||||
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->setObjectName(QStringLiteral("FloatingModeDock"));
|
||||
m_floatingModeDock->setFrameShape(QFrame::NoFrame);
|
||||
@@ -1685,6 +1826,33 @@ void MainWindow::rebuildCentralPages() {
|
||||
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) {
|
||||
if (!m_editorCanvas) {
|
||||
return;
|
||||
@@ -1793,6 +1961,12 @@ void MainWindow::setPreviewRequested(bool preview) {
|
||||
return;
|
||||
}
|
||||
m_previewRequested = preview;
|
||||
if (!preview && m_editorCanvas) {
|
||||
m_editorCanvas->clearPresentationEntityFocus();
|
||||
}
|
||||
if (!preview && m_entityIntroPopup) {
|
||||
m_entityIntroPopup->clearAndHide();
|
||||
}
|
||||
updateUiEnabledState();
|
||||
if (preview) {
|
||||
refreshPreviewPage();
|
||||
|
||||
@@ -30,6 +30,7 @@ class TimelineWidget;
|
||||
namespace gui {
|
||||
class BackgroundPropertySection;
|
||||
class EntityPropertySection;
|
||||
class EntityIntroPopup;
|
||||
}
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
@@ -96,6 +97,7 @@ private:
|
||||
void applyTimelineFromProject();
|
||||
void refreshDopeSheet();
|
||||
void setPreviewRequested(bool preview);
|
||||
void syncPreviewPlaybackBar();
|
||||
|
||||
QStackedWidget* m_centerStack = nullptr;
|
||||
QWidget* m_pageWelcome = nullptr;
|
||||
@@ -174,4 +176,9 @@ private:
|
||||
// 旧版 DopeSheet 已移除,这里保留占位便于后续扩展区间 UI(如自定义小部件)
|
||||
QTreeWidget* m_dopeTree = nullptr;
|
||||
QPushButton* m_btnDopeDeleteKey = nullptr;
|
||||
|
||||
gui::EntityIntroPopup* m_entityIntroPopup = nullptr;
|
||||
QFrame* m_previewPlaybackBar = nullptr;
|
||||
QToolButton* m_previewBtnPlay = nullptr;
|
||||
QToolButton* m_previewBtnPause = nullptr;
|
||||
};
|
||||
|
||||
@@ -117,7 +117,11 @@ void Vec2ParamControl::emitIfChanged() {
|
||||
if (m_block || !m_x || !m_y) return;
|
||||
const double nx = m_x->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_lastY = ny;
|
||||
emit valueChanged(nx, ny);
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QPushButton>
|
||||
#include <QTextEdit>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace gui {
|
||||
@@ -50,8 +56,75 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
|
||||
form->addRow(QStringLiteral("整体缩放"), m_userScale);
|
||||
|
||||
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);
|
||||
|
||||
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]() {
|
||||
if (m_name) {
|
||||
emit displayNameCommitted(m_name->text());
|
||||
@@ -60,9 +133,41 @@ EntityPropertySection::EntityPropertySection(QWidget* parent)
|
||||
connect(m_pivot, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::pivotEdited);
|
||||
connect(m_centroid, &Vec2ParamControl::valueChanged, this, &EntityPropertySection::centroidEdited);
|
||||
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() {
|
||||
if (m_introSaveTimer) {
|
||||
m_introSaveTimer->stop();
|
||||
}
|
||||
m_introBulkUpdate = true;
|
||||
setEditingEnabled(false);
|
||||
if (m_name) {
|
||||
m_name->blockSignals(true);
|
||||
@@ -78,9 +183,32 @@ void EntityPropertySection::clearDisconnected() {
|
||||
m_userScale->setValue(1.0);
|
||||
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) {
|
||||
if (m_introSaveTimer) {
|
||||
m_introSaveTimer->stop();
|
||||
}
|
||||
m_introBulkUpdate = true;
|
||||
|
||||
setEditingEnabled(true);
|
||||
if (m_name) {
|
||||
m_name->blockSignals(true);
|
||||
@@ -96,6 +224,63 @@ void EntityPropertySection::applyState(const EntityPropertyUiState& s) {
|
||||
m_userScale->setValue(s.userScale);
|
||||
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) {
|
||||
@@ -103,6 +288,14 @@ void EntityPropertySection::setEditingEnabled(bool on) {
|
||||
if (m_pivot) m_pivot->setEnabled(on);
|
||||
if (m_centroid) m_centroid->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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/domain/EntityIntro.h"
|
||||
#include "props/PropertySectionWidget.h"
|
||||
|
||||
#include <QPointF>
|
||||
@@ -8,6 +9,12 @@
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QDoubleSpinBox;
|
||||
class QTextEdit;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QToolButton;
|
||||
class QTimer;
|
||||
class QWidget;
|
||||
|
||||
namespace gui {
|
||||
class Vec2ParamControl;
|
||||
@@ -22,6 +29,7 @@ struct EntityPropertyUiState {
|
||||
QPointF pivot;
|
||||
QPointF centroid;
|
||||
double userScale = 1.0;
|
||||
core::EntityIntroContent intro;
|
||||
};
|
||||
|
||||
// 实体相关属性(可嵌入 QStackedWidget 的一页)
|
||||
@@ -33,20 +41,41 @@ public:
|
||||
void clearDisconnected();
|
||||
void applyState(const EntityPropertyUiState& s);
|
||||
void setEditingEnabled(bool on);
|
||||
[[nodiscard]] core::EntityIntroContent introSnapshot() const;
|
||||
void appendIntroImagePath(const QString& relativePath);
|
||||
|
||||
signals:
|
||||
void displayNameCommitted(const QString& text);
|
||||
void pivotEdited(double x, double y);
|
||||
void centroidEdited(double x, double y);
|
||||
void userScaleEdited(double value);
|
||||
/// 介绍字段变更后防抖触发,由主窗口写入工程
|
||||
void introContentEdited();
|
||||
void introAddImageRequested();
|
||||
|
||||
private:
|
||||
void scheduleIntroPersist();
|
||||
void setIntroSectionExpanded(bool expanded);
|
||||
|
||||
QLineEdit* m_name = nullptr;
|
||||
QLabel* m_depth = nullptr;
|
||||
QLabel* m_distScale = nullptr;
|
||||
Vec2ParamControl* m_pivot = nullptr;
|
||||
Vec2ParamControl* m_centroid = 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
|
||||
|
||||
Reference in New Issue
Block a user