Add editable resize/rotate/skew handles for shapes and images

Shapes and images can now be resized, rotated, and skewed directly
on the canvas, not just moved. Both share one small transform
struct (rotation, then skew, then scale, anchored on a movable
pivot) and one handle widget, so a corner drag, an edge skew, or
grabbing the rotate handle behaves the same way and runs through
the same matrix math everywhere, instead of every item type
reinventing its own.

Shapes also gained a proper pen tool (bezier paths, corner/smooth/
symmetric nodes), arc support, and mirroring. Images gained
non-destructive cropping and colour-keyed transparency, both
remember their own settings, so reopening the dialog picks up
where you left off instead of starting over.

Properties dialogs for both were extended to match (position,
size, angle, skew), with undo/redo wired through for every handle
drag.

Old XML files can read easily as the transformation is only added
if needed and the old syntax is still used and understood if it is
not needed.
This commit is contained in:
Andre Rummler
2026-09-05 15:48:28 +02:00
parent 823468826d
commit 6b577ee757
29 changed files with 6784 additions and 433 deletions
+224 -27
View File
@@ -20,6 +20,7 @@
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
#include "../diagram.h"
#include "../qetgraphicsitem/diagramimageitem.h"
#include "../qeticons.h"
#include "../ui_imagepropertieswidget.h"
/**
@@ -34,6 +35,11 @@ ImagePropertiesWidget::ImagePropertiesWidget(DiagramImageItem *image, QWidget *p
m_image(nullptr)
{
ui->setupUi(this);
// Matches the .ui file's own initial checked="true" -- toggled()
// only fires on a subsequent CHANGE, not for the state a widget
// already starts in, so the icon needs setting explicitly here or
// the button would show blank until first clicked.
ui->m_lock_ratio_tb->setIcon(QET::Icons::ObjectLocked);
this->setDisabled(true);
setImageItem(image);
}
@@ -58,12 +64,16 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image)
this->setEnabled(true);
if (m_image == image) return;
if (m_image)
disconnect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
m_image = image;
connect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
m_movable = image->isMovable();
m_scale = m_image->scale();
m_scaleX = m_image->scaleFactorX();
m_scaleY = m_image->scaleFactorY();
m_rotation = m_image->rotationAngle();
m_skewX = m_image->skewX();
m_skewY = m_image->skewY();
updateUi();
}
@@ -77,16 +87,20 @@ void ImagePropertiesWidget::apply()
if (m_image->diagram())
{
if (m_live_edit) disconnect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
if (m_live_edit) disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
QUndoCommand *undo = associatedUndo();
if (undo)
m_image->diagram()->undoStack().push(undo);
if (m_live_edit) connect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
if (m_live_edit) connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
}
m_scale = m_image->scale();
m_scaleX = m_image->scaleFactorX();
m_scaleY = m_image->scaleFactorY();
m_rotation = m_image->rotationAngle();
m_skewX = m_image->skewX();
m_skewY = m_image->skewY();
}
/**
@@ -97,7 +111,11 @@ void ImagePropertiesWidget::reset()
{
if(!m_image) return;
m_image->setScale(m_scale);
m_image->setScaleFactorX(m_scaleX);
m_image->setScaleFactorY(m_scaleY);
m_image->setRotationAngle(m_rotation);
m_image->setSkewX(m_skewX);
m_image->setSkewY(m_skewY);
m_image->setMovable(m_movable);
updateUi();
}
@@ -115,13 +133,19 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit)
if (m_live_edit)
{
connect (ui->m_scale_slider, &QSlider::sliderReleased, this, &ImagePropertiesWidget::apply);
connect (ui->m_scale_sb, &QSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
connect (ui->m_width_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
connect (ui->m_height_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
connect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
}
else
{
disconnect (ui->m_scale_slider, &QSlider::sliderReleased, this, &ImagePropertiesWidget::apply);
disconnect (ui->m_scale_sb, &QSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
disconnect (ui->m_width_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
disconnect (ui->m_height_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
disconnect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
}
return true;
@@ -129,18 +153,44 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit)
/**
@brief ImagePropertiesWidget::associatedUndo
@return the change in an undo command (ItemResizerCommand).
If there is no change return nullptr
@return the change in an undo command. If there is no change return
nullptr. Chains scaleFactorX, scaleFactorY, rotationAngle, skewX and
skewY as separate QPropertyUndoCommands, matching the established
"check each field, chain if changed" pattern used elsewhere -- each
of these is genuinely independent (the resize/rotate/skew handles
can set any of them without touching the others), so a single
combined command wouldn't correctly capture "only two of five
fields actually changed".
*/
QUndoCommand* ImagePropertiesWidget::associatedUndo() const
{
const qreal newScaleX = ui->m_width_sb->value() / 100.0;
const qreal newScaleY = ui->m_height_sb->value() / 100.0;
const qreal newRotation = ui->m_angle_sb->value();
const qreal newSkewX = ui->m_skew_x_sb->value();
const qreal newSkewY = ui->m_skew_y_sb->value();
QPropertyUndoCommand *undo = nullptr;
auto chain = [&](const char *property, qreal oldValue, qreal newValue, const QString &text)
{
if (qFuzzyCompare(oldValue, newValue))
return;
if (undo)
new QPropertyUndoCommand(m_image, property, oldValue, newValue, undo);
else
{
undo = new QPropertyUndoCommand(m_image, property, oldValue, newValue);
undo->enableAnimation();
undo->setText(text);
}
};
chain("scaleFactorX", m_scaleX, newScaleX, tr("Modifier la largeur d'une image"));
chain("scaleFactorY", m_scaleY, newScaleY, tr("Modifier la hauteur d'une image"));
chain("rotationAngle", m_rotation, newRotation, tr("Modifier l'angle d'une image"));
chain("skewX", m_skewX, newSkewX, tr("Modifier l'inclinaison d'une image"));
chain("skewY", m_skewY, newSkewY, tr("Modifier l'inclinaison d'une image"));
qreal value = ui->m_scale_slider->value();
value /= 100;
if (m_scale == value) return nullptr;
QPropertyUndoCommand *undo = new QPropertyUndoCommand(m_image, "scale", m_scale, value);
undo->enableAnimation();
undo->setText(tr("Modifier la taille d'une image"));
return undo;
}
@@ -150,20 +200,167 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const
*/
void ImagePropertiesWidget::updateUi()
{
if (!m_image) return;
ui->m_scale_slider->setValue(m_image->scale() * 100);
// Two different uses of the same flag: an entry guard here (skip
// entirely if a locked width/height operation is already in
// progress elsewhere -- its own explicit updateUi() call once
// finished is what actually refreshes things), and, immediately
// below, a re-entrancy guard around this function's OWN setValue()
// calls, so they can't recurse back into
// on_m_width_sb_valueChanged()/on_m_height_sb_valueChanged() when
// this runs normally (e.g. from setImageItem(), or a transformChanged
// signal from a resize handle used directly on the canvas while
// this dialog is open).
if (!m_image || m_updating_ratio) return;
m_updating_ratio = true;
ui->m_width_sb->setValue(m_image->scaleFactorX() * 100.0);
ui->m_height_sb->setValue(m_image->scaleFactorY() * 100.0);
m_updating_ratio = false;
ui->m_angle_sb->setValue(m_image->rotationAngle());
ui->m_skew_x_sb->setValue(m_image->skewX());
ui->m_skew_y_sb->setValue(m_image->skewY());
ui->m_lock_pos_cb->setChecked(!m_image->isMovable());
}
/**
@brief ImagePropertiesWidget::on_m_scale_slider_valueChanged
Update the size of image when move slider.
@param value
@brief ImagePropertiesWidget::on_m_lock_ratio_tb_toggled
Keeps the button's own icon and tooltip in sync with its checked
state -- Qt shows the checked/unchecked state visually (a sunken
look) regardless, but the lock/unlock glyph makes the meaning
obvious at a glance rather than needing that convention noticed.
@param checked
*/
void ImagePropertiesWidget::on_m_scale_slider_valueChanged(int value)
void ImagePropertiesWidget::on_m_lock_ratio_tb_toggled(bool checked)
{
qreal scale = value;
m_image->setScale(scale / 100);
ui->m_lock_ratio_tb->setIcon(checked ? QET::Icons::ObjectLocked : QET::Icons::ObjectUnlocked);
ui->m_lock_ratio_tb->setToolTip(checked
? tr("Verrouillé : modifier la largeur ou la hauteur ajuste l'autre pour conserver les proportions. Cliquer pour déverrouiller.")
: tr("Déverrouillé : largeur et hauteur peuvent être modifiées indépendamment. Cliquer pour verrouiller."));
}
/**
@brief ImagePropertiesWidget::on_m_restore_ratio_pb_clicked
The same action as the item's own "Restaurer les proportions"
context-menu entry, offered here too since this dialog is exactly
where someone would notice the proportions are off in the first
place. Deliberately bypasses on_m_height_sb_valueChanged()'s own
lock-following logic (guarded by m_updating_ratio, then applied
directly rather than through the spinbox's own signal) rather than
going through it normally: if the lock happened to be engaged,
that logic would treat this as an ordinary edit and scale width by
the same ratio the height change just applied -- compounding into
some other, unrelated value instead of the plain "make height
match width" this button promises regardless of lock state.
*/
void ImagePropertiesWidget::on_m_restore_ratio_pb_clicked()
{
if (!m_image) return;
const qreal newScaleY = m_image->scaleFactorX();
m_updating_ratio = true;
ui->m_height_sb->setValue(newScaleY * 100.0);
m_updating_ratio = false;
m_image->setScaleFactorY(newScaleY);
apply();
}
/**
@brief ImagePropertiesWidget::on_m_width_sb_valueChanged
Applies the new width live, and -- if "Conserver les proportions" is
checked -- scales height by the same ratio this change just applied
to width, so the two stay locked together. Guarded by
m_updating_ratio: the setValue() call below would otherwise
re-trigger on_m_height_sb_valueChanged(), which would then try to
scale width again in response, recursing indefinitely.
@param value the new width, as a percentage
*/
void ImagePropertiesWidget::on_m_width_sb_valueChanged(double value)
{
if (!m_image || m_updating_ratio) return;
const qreal oldScaleX = m_image->scaleFactorX();
const qreal newScaleX = value / 100.0;
const bool wasLocked = ui->m_lock_ratio_tb->isChecked();
// Guards updateUi() itself here, not just the mutual spinbox
// updates below: setScaleFactorX() emits transformChanged()
// immediately, synchronously calling updateUi() before this
// function gets any further -- at that exact moment scaleX has
// already changed but scaleY hasn't caught up yet, so updateUi()'s
// "auto-unlock if currently mismatched" check would fire and
// uncheck the lock before the compensating height change below
// ever runs, defeating the lock on every single use. Released
// before the explicit updateUi() call at the end, once both axes
// genuinely agree again (if locked) and that check is meaningful.
m_updating_ratio = true;
m_image->setScaleFactorX(newScaleX);
if (wasLocked && !qFuzzyIsNull(oldScaleX))
{
const qreal ratio = newScaleX / oldScaleX;
const qreal newScaleY = m_image->scaleFactorY() * ratio;
ui->m_height_sb->setValue(newScaleY * 100.0);
m_image->setScaleFactorY(newScaleY);
}
m_updating_ratio = false;
updateUi();
}
/**
@brief ImagePropertiesWidget::on_m_height_sb_valueChanged
Mirror of on_m_width_sb_valueChanged() -- see its comment.
@param value the new height, as a percentage
*/
void ImagePropertiesWidget::on_m_height_sb_valueChanged(double value)
{
if (!m_image || m_updating_ratio) return;
const qreal oldScaleY = m_image->scaleFactorY();
const qreal newScaleY = value / 100.0;
const bool wasLocked = ui->m_lock_ratio_tb->isChecked();
m_updating_ratio = true; // see on_m_width_sb_valueChanged()'s identical comment
m_image->setScaleFactorY(newScaleY);
if (wasLocked && !qFuzzyIsNull(oldScaleY))
{
const qreal ratio = newScaleY / oldScaleY;
const qreal newScaleX = m_image->scaleFactorX() * ratio;
ui->m_width_sb->setValue(newScaleX * 100.0);
m_image->setScaleFactorX(newScaleX);
}
m_updating_ratio = false;
updateUi();
}
/**
@brief ImagePropertiesWidget::on_m_angle_sb_valueChanged
@param value the new rotation, in degrees
*/
void ImagePropertiesWidget::on_m_angle_sb_valueChanged(double value)
{
if (!m_image) return;
m_image->setRotationAngle(value);
}
/**
@brief ImagePropertiesWidget::on_m_skew_x_sb_valueChanged
@param value the new X skew, in degrees
*/
void ImagePropertiesWidget::on_m_skew_x_sb_valueChanged(double value)
{
if (!m_image) return;
m_image->setSkewX(value);
}
/**
@brief ImagePropertiesWidget::on_m_skew_y_sb_valueChanged
@param value the new Y skew, in degrees
*/
void ImagePropertiesWidget::on_m_skew_y_sb_valueChanged(double value)
{
if (!m_image) return;
m_image->setSkewY(value);
}
/**