From bce66a7d7dc9cfb1465c02ca06c483cb4d2dbb94 Mon Sep 17 00:00:00 2001 From: pacer Date: Mon, 16 Mar 2026 18:49:53 +0800 Subject: [PATCH 1/3] bugfix: fix long length text node show --- src/scene/NodeItem.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/scene/NodeItem.cpp b/src/scene/NodeItem.cpp index db09796..993dd47 100644 --- a/src/scene/NodeItem.cpp +++ b/src/scene/NodeItem.cpp @@ -84,12 +84,11 @@ void NodeItem::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, painter->setBrush(bg); painter->drawRoundedRect(m_rect, kRadius, kRadius); - // Text + // Text (word-wrapped within the padded area) painter->setPen(textColor); painter->setFont(m_font); - QFontMetricsF fm(m_font); - QString displayText = fm.elidedText(m_text, Qt::ElideRight, m_rect.width() - kPadding * 2); - painter->drawText(m_rect, Qt::AlignCenter, displayText); + QRectF textArea = m_rect.adjusted(kPadding, kPadding, -kPadding, -kPadding); + painter->drawText(textArea, Qt::AlignCenter | Qt::TextWrapAnywhere, m_text); } QString NodeItem::text() const { @@ -232,8 +231,18 @@ void NodeItem::updateGeometry() { prepareGeometryChange(); QFontMetricsF fm(m_font); qreal textW = fm.horizontalAdvance(m_text); - qreal textH = fm.height(); qreal w = qMax(kMinWidth, qMin(kMaxWidth, textW + kPadding * 2)); - qreal h = textH + kPadding * 2; + + // When text exceeds available width, wrap to multiple lines + qreal availableTextW = w - kPadding * 2; + QRectF textRect = + fm.boundingRect(QRectF(0, 0, availableTextW, 0), Qt::TextWrapAnywhere, m_text); + qreal h = textRect.height() + kPadding * 2; + m_rect = QRectF(-w / 2, -h / 2, w, h); + + // Update connected edges since node geometry changed + for (auto* edge : m_edges) { + edge->updatePath(); + } } From 8acb939b713bc1c1e7047b8328fb83eebd81125a Mon Sep 17 00:00:00 2001 From: pacer Date: Tue, 17 Mar 2026 09:41:21 +0800 Subject: [PATCH 2/3] bugfix: fix node layout after editing --- src/core/Commands.cpp | 12 +++++++++--- src/core/Commands.h | 5 +++-- src/scene/MindMapScene.cpp | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/core/Commands.cpp b/src/core/Commands.cpp index 4ba5012..492b35d 100644 --- a/src/core/Commands.cpp +++ b/src/core/Commands.cpp @@ -177,16 +177,22 @@ void RemoveNodeCommand::undo() { // EditTextCommand // =========================================================================== -EditTextCommand::EditTextCommand(NodeItem* node, const QString& oldText, const QString& newText, - QUndoCommand* parentCmd) - : QUndoCommand("Edit Text", parentCmd), m_node(node), m_oldText(oldText), m_newText(newText) {} +EditTextCommand::EditTextCommand(MindMapScene* scene, NodeItem* node, const QString& oldText, + const QString& newText, QUndoCommand* parentCmd) + : QUndoCommand("Edit Text", parentCmd), + m_scene(scene), + m_node(node), + m_oldText(oldText), + m_newText(newText) {} void EditTextCommand::undo() { m_node->setText(m_oldText); + m_scene->autoLayout(); } void EditTextCommand::redo() { m_node->setText(m_newText); + m_scene->autoLayout(); } // =========================================================================== diff --git a/src/core/Commands.h b/src/core/Commands.h index 71ccdcf..f2135c9 100644 --- a/src/core/Commands.h +++ b/src/core/Commands.h @@ -67,13 +67,14 @@ class RemoveNodeCommand : public QUndoCommand { // --------------------------------------------------------------------------- class EditTextCommand : public QUndoCommand { public: - EditTextCommand(NodeItem* node, const QString& oldText, const QString& newText, - QUndoCommand* parentCmd = nullptr); + EditTextCommand(MindMapScene* scene, NodeItem* node, const QString& oldText, + const QString& newText, QUndoCommand* parentCmd = nullptr); void undo() override; void redo() override; private: + MindMapScene* m_scene; NodeItem* m_node; QString m_oldText; QString m_newText; diff --git a/src/scene/MindMapScene.cpp b/src/scene/MindMapScene.cpp index 5639a10..7126240 100644 --- a/src/scene/MindMapScene.cpp +++ b/src/scene/MindMapScene.cpp @@ -285,7 +285,7 @@ void MindMapScene::finishEditing() { m_editProxy = nullptr; if (!newText.isEmpty() && newText != oldText) { - m_undoStack->push(new EditTextCommand(node, oldText, newText)); + m_undoStack->push(new EditTextCommand(this, node, oldText, newText)); } clearSelection(); node->setSelected(true); From 9db85aff2d2534019e3d5e60a6a9a82926ce4a05 Mon Sep 17 00:00:00 2001 From: pacer Date: Tue, 17 Mar 2026 17:19:45 +0800 Subject: [PATCH 3/3] feat: add hover + button for quick child node creation Extract the add-child button into a separate AddButtonOverlay child QGraphicsItem so it never inflates NodeItem::boundingRect(), preventing unwanted scene-rect expansion and auto-scroll when creating nodes near the canvas edge. --- src/scene/NodeItem.cpp | 284 +++++++++++++++++++++++++++++++++++++++++ src/scene/NodeItem.h | 21 +++ 2 files changed, 305 insertions(+) diff --git a/src/scene/NodeItem.cpp b/src/scene/NodeItem.cpp index 993dd47..efe25d8 100644 --- a/src/scene/NodeItem.cpp +++ b/src/scene/NodeItem.cpp @@ -2,18 +2,183 @@ #include "core/AppSettings.h" #include "core/Commands.h" #include "core/TemplateDescriptor.h" +#include "layout/LayoutStyle.h" #include "scene/EdgeItem.h" #include "scene/MindMapScene.h" #include "ui/ThemeManager.h" #include +#include #include +#include #include #include +#include +#include + +// =========================================================================== +// AddButtonOverlay — separate child item so it never inflates NodeItem's +// boundingRect and therefore cannot disturb the scene rect. +// =========================================================================== + +class AddButtonOverlay : public QGraphicsItem { +public: + explicit AddButtonOverlay(NodeItem* parentNode) + : QGraphicsItem(parentNode), m_node(parentNode) { + setAcceptHoverEvents(true); + setVisible(false); + } + + void setButtonOpacity(qreal opacity) { + m_opacity = opacity; + setVisible(opacity > 0.0); + update(); + } + + qreal buttonOpacity() const { return m_opacity; } + bool isButtonHovered() const { return m_hovered; } + + QRectF boundingRect() const override { + QRectF btn = m_node->addButtonRect(); + constexpr qreal m = NodeItem::kHoverZoneMargin; + QRectF area = btn.adjusted(-m, -m, m, m); + return area.united(bridgeRect()); + } + + QPainterPath shape() const override { + QPainterPath path; + QRectF btn = m_node->addButtonRect(); + constexpr qreal m = NodeItem::kHoverZoneMargin; + path.addEllipse(btn.adjusted(-m, -m, m, m)); + path.addRect(bridgeRect()); + return path; + } + + void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override { + if (m_opacity < 0.01) + return; + + auto* mindMapScene = dynamic_cast(m_node->scene()); + if (mindMapScene && mindMapScene->isEditing()) + return; + + painter->setRenderHint(QPainter::Antialiasing); + painter->save(); + painter->setOpacity(m_opacity); + + QRectF btnRect = m_node->addButtonRect(); + + // Resolve selection border color + const ThemeColors& globalTC = ThemeManager::colors(); + QColor selectionBorder = globalTC.nodeSelectionBorder; + if (mindMapScene) { + const auto* td = mindMapScene->templateDescriptor(); + if (td) + selectionBorder = td->activeColors().nodeSelectionBorder; + } + + // Button background + QColor btnBg; + if (m_hovered) { + btnBg = selectionBorder; + } else { + btnBg = ThemeManager::isDark() ? QColor(255, 255, 255, 60) : QColor(0, 0, 0, 60); + } + painter->setPen(Qt::NoPen); + painter->setBrush(btnBg); + painter->drawEllipse(btnRect); + + // "+" icon + QColor plusColor = m_hovered ? Qt::white + : ThemeManager::isDark() ? QColor(255, 255, 255, 200) + : QColor(0, 0, 0, 180); + QPen plusPen(plusColor, 2, Qt::SolidLine, Qt::RoundCap); + painter->setPen(plusPen); + QPointF center = btnRect.center(); + constexpr qreal arm = NodeItem::kAddButtonRadius * 0.45; + painter->drawLine(QPointF(center.x() - arm, center.y()), + QPointF(center.x() + arm, center.y())); + painter->drawLine(QPointF(center.x(), center.y() - arm), + QPointF(center.x(), center.y() + arm)); + + painter->restore(); + } + +protected: + void hoverEnterEvent(QGraphicsSceneHoverEvent*) override { + m_hovered = true; + setCursor(Qt::PointingHandCursor); + update(); + // Cancel the parent node's pending leave timer + if (m_node->m_hoverLeaveTimer) { + m_node->m_hoverLeaveTimer->stop(); + delete m_node->m_hoverLeaveTimer; + m_node->m_hoverLeaveTimer = nullptr; + } + } + + void hoverLeaveEvent(QGraphicsSceneHoverEvent*) override { + m_hovered = false; + unsetCursor(); + update(); + // Trigger fade-out on the parent node + m_node->m_hovered = false; + m_node->startAddButtonAnimation(false); + } + + void mousePressEvent(QGraphicsSceneMouseEvent* event) override { + if (event->button() == Qt::LeftButton && m_opacity > 0.5) { + event->accept(); + auto* mindMapScene = dynamic_cast(m_node->scene()); + if (mindMapScene) { + mindMapScene->clearSelection(); + m_node->setSelected(true); + QMetaObject::invokeMethod( + mindMapScene, [mindMapScene]() { mindMapScene->addChildToSelected(); }, + Qt::QueuedConnection); + } + return; + } + QGraphicsItem::mousePressEvent(event); + } + + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent* event) override { + event->accept(); // Eat double-clicks so they don't trigger text editing + } + +private: + QRectF bridgeRect() const { + QRectF btn = m_node->addButtonRect(); + QRectF nodeRect = m_node->m_rect; + constexpr qreal m = NodeItem::kHoverZoneMargin; + + switch (m_node->m_addButtonDir) { + case NodeItem::ButtonDirection::Right: + return QRectF(nodeRect.right() - 1, btn.top() - m, + btn.left() - nodeRect.right() + 2, btn.height() + m * 2); + case NodeItem::ButtonDirection::Left: + return QRectF(btn.right() - 1, btn.top() - m, nodeRect.left() - btn.right() + 2, + btn.height() + m * 2); + case NodeItem::ButtonDirection::Bottom: + return QRectF(btn.left() - m, nodeRect.bottom() - 1, btn.width() + m * 2, + btn.top() - nodeRect.bottom() + 2); + } + return {}; + } + + NodeItem* m_node; + qreal m_opacity = 0.0; + bool m_hovered = false; +}; + +// =========================================================================== +// NodeItem +// =========================================================================== NodeItem::NodeItem(const QString& text, QGraphicsItem* parent) : QGraphicsObject(parent), m_text(text) { setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); + setAcceptHoverEvents(true); setCacheMode(DeviceCoordinateCache); m_font.setPointSize(AppSettings::instance().defaultFontSize()); m_font.setFamily(AppSettings::instance().defaultFontFamily()); @@ -246,3 +411,122 @@ void NodeItem::updateGeometry() { edge->updatePath(); } } + +NodeItem::ButtonDirection NodeItem::addButtonDirection() const { + auto* mindMapScene = dynamic_cast(scene()); + if (!mindMapScene) + return ButtonDirection::Right; + + // Determine effective layout style (template overrides scene default) + LayoutStyle style = mindMapScene->layoutStyle(); + const auto* td = mindMapScene->templateDescriptor(); + if (td) + style = algorithmNameToLayoutStyle(td->layout.algorithm); + + switch (style) { + case LayoutStyle::TopDown: + return ButtonDirection::Bottom; + case LayoutStyle::RightTree: + return ButtonDirection::Right; + case LayoutStyle::Bilateral: + default: + if (!m_parentNode) { + // Root node: next child index determines direction + // Bilateral alternates even=right, odd=left + return (m_children.size() % 2 == 0) ? ButtonDirection::Right : ButtonDirection::Left; + } + // Non-root: inherit side from position relative to root (at origin) + return (pos().x() >= 0) ? ButtonDirection::Right : ButtonDirection::Left; + } +} + +QRectF NodeItem::addButtonRect() const { + qreal diameter = kAddButtonRadius * 2; + switch (m_addButtonDir) { + case ButtonDirection::Left: + return QRectF(m_rect.left() - kAddButtonOffset - diameter, -kAddButtonRadius, diameter, + diameter); + case ButtonDirection::Bottom: + return QRectF(-kAddButtonRadius, m_rect.bottom() + kAddButtonOffset, diameter, diameter); + case ButtonDirection::Right: + default: + return QRectF(m_rect.right() + kAddButtonOffset, -kAddButtonRadius, diameter, diameter); + } +} + +void NodeItem::startAddButtonAnimation(bool fadeIn) { + if (m_addButtonAnimation) { + m_addButtonAnimation->stop(); + m_addButtonAnimation->deleteLater(); + m_addButtonAnimation = nullptr; + } + + if (!m_addButtonOverlay) + return; + + auto* anim = new QVariantAnimation(this); + anim->setDuration(200); + anim->setEasingCurve(QEasingCurve::InOutQuad); + anim->setStartValue(m_addButtonOverlay->buttonOpacity()); + anim->setEndValue(fadeIn ? 1.0 : 0.0); + + connect(anim, &QVariantAnimation::valueChanged, this, [this](const QVariant& value) { + if (m_addButtonOverlay) + m_addButtonOverlay->setButtonOpacity(value.toReal()); + }); + + connect(anim, &QVariantAnimation::finished, this, [this, fadeIn, anim]() { + if (!fadeIn) { + setZValue(m_savedZValue); + if (m_addButtonOverlay) + m_addButtonOverlay->setVisible(false); + } + anim->deleteLater(); + m_addButtonAnimation = nullptr; + }); + + m_addButtonAnimation = anim; + anim->start(); +} + +void NodeItem::hoverEnterEvent(QGraphicsSceneHoverEvent* event) { + Q_UNUSED(event); + + // Cancel any pending fade-out from a previous brief leave + if (m_hoverLeaveTimer) { + m_hoverLeaveTimer->stop(); + delete m_hoverLeaveTimer; + m_hoverLeaveTimer = nullptr; + } + + if (!m_hovered) { + m_hovered = true; + m_addButtonDir = addButtonDirection(); + + // Raise above sibling nodes so the button is not occluded + m_savedZValue = zValue(); + setZValue(50); + + if (!m_addButtonOverlay) + m_addButtonOverlay = new AddButtonOverlay(this); + + startAddButtonAnimation(true); + } +} + +void NodeItem::hoverLeaveEvent(QGraphicsSceneHoverEvent* event) { + Q_UNUSED(event); + + // Delay the fade-out so the button doesn't vanish during imprecise mouse movements + if (!m_hoverLeaveTimer) { + m_hoverLeaveTimer = new QTimer(this); + m_hoverLeaveTimer->setSingleShot(true); + connect(m_hoverLeaveTimer, &QTimer::timeout, this, [this]() { + m_hovered = false; + startAddButtonAnimation(false); + m_hoverLeaveTimer->deleteLater(); + m_hoverLeaveTimer = nullptr; + }); + } + m_hoverLeaveTimer->start(150); +} diff --git a/src/scene/NodeItem.h b/src/scene/NodeItem.h index 320b5d7..03fa56f 100644 --- a/src/scene/NodeItem.h +++ b/src/scene/NodeItem.h @@ -5,8 +5,11 @@ #include #include +class AddButtonOverlay; class EdgeItem; class MindMapScene; +class QTimer; +class QVariantAnimation; class NodeItem : public QGraphicsObject { Q_OBJECT @@ -49,9 +52,18 @@ class NodeItem : public QGraphicsObject { void mousePressEvent(QGraphicsSceneMouseEvent* event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override; + void hoverEnterEvent(QGraphicsSceneHoverEvent* event) override; + void hoverLeaveEvent(QGraphicsSceneHoverEvent* event) override; private: + friend class AddButtonOverlay; + + enum class ButtonDirection { Right, Left, Bottom }; + void updateGeometry(); + ButtonDirection addButtonDirection() const; + QRectF addButtonRect() const; + void startAddButtonAnimation(bool fadeIn); QString m_text; QFont m_font; @@ -62,9 +74,18 @@ class NodeItem : public QGraphicsObject { QPointF m_dragStartPos; QPointF m_dragOrigPos; bool m_dragging = false; + bool m_hovered = false; + qreal m_savedZValue = 0.0; + ButtonDirection m_addButtonDir = ButtonDirection::Right; + QVariantAnimation* m_addButtonAnimation = nullptr; + QTimer* m_hoverLeaveTimer = nullptr; + AddButtonOverlay* m_addButtonOverlay = nullptr; static constexpr qreal kMinWidth = 120.0; static constexpr qreal kMaxWidth = 300.0; static constexpr qreal kPadding = 16.0; static constexpr qreal kRadius = 10.0; + static constexpr qreal kAddButtonRadius = 12.0; + static constexpr qreal kAddButtonOffset = 6.0; + static constexpr qreal kHoverZoneMargin = 10.0; };