mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-28 04:54:13 +02:00
1894 lines
73 KiB
C++
1894 lines
73 KiB
C++
/*
|
|
Copyright 2006-2026 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "diagramimageitem.h"
|
|
|
|
#include "../PropertiesEditor/propertieseditordialog.h"
|
|
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
|
|
#include "../diagram.h"
|
|
#include "../diagramview.h"
|
|
#include "../qet.h"
|
|
#include "../qetapp.h"
|
|
#include "../qetdiagrameditor.h"
|
|
#include "../qeticons.h"
|
|
#include "../ui/imagepropertieswidget.h"
|
|
#include "../ui/imagecropdialog.h"
|
|
#include "../ui/imagetransparentcolordialog.h"
|
|
#include "../utils/qetutils.h"
|
|
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
|
|
|
|
#include <QAction>
|
|
#include <QBuffer>
|
|
#include <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QGraphicsSceneContextMenuEvent>
|
|
#include <QImageWriter>
|
|
#include <QMenu>
|
|
#include <QMessageBox>
|
|
#include <QTextStream>
|
|
|
|
/**
|
|
@brief DiagramImageItem::DiagramImageItem
|
|
Constructor without pixmap
|
|
@param parent_item the parent graphics item
|
|
*/
|
|
DiagramImageItem::DiagramImageItem(QetGraphicsItem *parent_item):
|
|
QetGraphicsItem(parent_item)
|
|
{
|
|
setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges);
|
|
setAcceptHoverEvents(true);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::DiagramImageItem
|
|
Constructor with pixmap
|
|
@param pixmap the pixmap to be draw
|
|
@param parent_item the parent graphic item
|
|
*/
|
|
DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *parent_item):
|
|
QetGraphicsItem(parent_item),
|
|
pixmap_(pixmap),
|
|
m_base_pixmap(pixmap),
|
|
m_crop_rect(pixmap.rect())
|
|
{
|
|
// m_transform.toMatrix(), not QGraphicsItem::setRotation()/setScale():
|
|
// those are a single uniform scale() float, which is exactly why an
|
|
// image could never break its own aspect ratio before this class
|
|
// gained proper independent scaleX/scaleY.
|
|
m_transform.pivot = boundingRect().center();
|
|
setTransform(m_transform.toMatrix());
|
|
setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges);
|
|
setAcceptHoverEvents(true);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::~DiagramImageItem
|
|
Destructor
|
|
*/
|
|
DiagramImageItem::~DiagramImageItem()
|
|
{
|
|
if (!m_handler_vector.isEmpty())
|
|
qDeleteAll(m_handler_vector);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::paint
|
|
Draw the pixmap.
|
|
@param painter the Qpainter to use for draw the pixmap
|
|
@param option the style option
|
|
@param widget the QWidget where we draw the pixmap
|
|
*/
|
|
void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
|
|
painter -> drawPixmap(pixmap_.rect(),pixmap_);
|
|
|
|
Q_UNUSED(option); Q_UNUSED(widget);
|
|
|
|
if (isSelected()) {
|
|
painter -> save();
|
|
// Annulation des renderhints
|
|
painter -> setRenderHint(QPainter::Antialiasing, false);
|
|
painter -> setRenderHint(QPainter::TextAntialiasing, false);
|
|
painter -> setRenderHint(QPainter::SmoothPixmapTransform, false);
|
|
// Dessin du cadre de selection en noir à partir du boundingrect
|
|
QPen t(Qt::black);
|
|
t.setStyle(Qt::DashLine);
|
|
painter -> setPen(t);
|
|
painter -> drawRect(boundingRect());
|
|
painter -> restore();
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::editProperty
|
|
Open the appropriate dialog to edit this image
|
|
*/
|
|
void DiagramImageItem::editProperty()
|
|
{
|
|
if (diagram() -> isReadOnly()) return;
|
|
PropertiesEditorDialog dialog(new ImagePropertiesWidget(this), QApplication::activeWindow());
|
|
dialog.exec();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::setPixmap
|
|
Set the new pixmap to be draw
|
|
@param pixmap the new pixmap
|
|
*/
|
|
void DiagramImageItem::setPixmap(const QPixmap &pixmap) {
|
|
// Only ever mattered once setPixmap() could be called on an
|
|
// already-placed item with a differently-sized replacement (see
|
|
// replaceImage()) -- previously this only ran from the constructor,
|
|
// before the item existed in any scene, where there was nothing yet
|
|
// to invalidate. Without this, a same-session replace to a
|
|
// different-sized image would leave the scene's bounding-rect cache
|
|
// stale, the exact class of bug already fixed multiple times this
|
|
// session for shapes.
|
|
//
|
|
// Deliberately does NOT touch m_transform.pivot/pos() here, even
|
|
// though a differently-sized pixmap generally means the old pivot
|
|
// (a bounding-rect-relative point) no longer means the same thing:
|
|
// crop() and replaceImage(), the only two callers that ever change
|
|
// the pixmap's size, each have their own specific idea of what
|
|
// should happen to position and pivot when they do (see their own
|
|
// comments) -- a single generic rule here would fight with
|
|
// whichever of them actually needs something more specific.
|
|
prepareGeometryChange();
|
|
pixmap_ = pixmap;
|
|
emit pixmapChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::setScaleFactorX / setScaleFactorY / setRotationAngle
|
|
Matching QetShapeItem's own setters for the identical fields --
|
|
same pattern, same reasoning: change the one field, rebuild the
|
|
matrix, notify. No pivot or position compensation needed here,
|
|
unlike setPivot() below, since neither scale nor rotation moves the
|
|
pivot itself.
|
|
*/
|
|
void DiagramImageItem::setScaleFactorX(qreal factor)
|
|
{
|
|
if (qFuzzyCompare(m_transform.scaleX, factor)) return;
|
|
prepareGeometryChange();
|
|
m_transform.scaleX = factor;
|
|
setTransform(m_transform.toMatrix());
|
|
emit transformChanged();
|
|
}
|
|
|
|
void DiagramImageItem::setScaleFactorY(qreal factor)
|
|
{
|
|
if (qFuzzyCompare(m_transform.scaleY, factor)) return;
|
|
prepareGeometryChange();
|
|
m_transform.scaleY = factor;
|
|
setTransform(m_transform.toMatrix());
|
|
emit transformChanged();
|
|
}
|
|
|
|
void DiagramImageItem::setRotationAngle(qreal angle)
|
|
{
|
|
if (qFuzzyCompare(m_transform.rotation, angle)) return;
|
|
prepareGeometryChange();
|
|
m_transform.rotation = angle;
|
|
setTransform(m_transform.toMatrix());
|
|
emit transformChanged();
|
|
}
|
|
|
|
void DiagramImageItem::setSkewX(qreal skew)
|
|
{
|
|
if (qFuzzyCompare(m_transform.skewX, skew)) return;
|
|
prepareGeometryChange();
|
|
m_transform.skewX = skew;
|
|
setTransform(m_transform.toMatrix());
|
|
emit transformChanged();
|
|
}
|
|
|
|
void DiagramImageItem::setSkewY(qreal skew)
|
|
{
|
|
if (qFuzzyCompare(m_transform.skewY, skew)) return;
|
|
prepareGeometryChange();
|
|
m_transform.skewY = skew;
|
|
setTransform(m_transform.toMatrix());
|
|
emit transformChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::setPivot
|
|
Unlike scale/rotation, moving the pivot alone WOULD visibly shift
|
|
the image on screen, since the pivot is baked directly into the
|
|
transform matrix -- compensatedPositionForNewPivot() (already
|
|
proven correct for QetShapeItem's identical need) solves for
|
|
whatever pos() keeps the image exactly where it already was, so
|
|
only the *handle* moves, not the picture underneath it.
|
|
*/
|
|
void DiagramImageItem::setPivot(const QPointF &newPivot)
|
|
{
|
|
if (m_transform.pivot == newPivot) return;
|
|
const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, newPivot, m_transform.linearPart());
|
|
prepareGeometryChange();
|
|
m_transform.pivot = newPivot;
|
|
// Verified numerically that the underlying math keeps the image
|
|
// exactly in place when the pivot moves -- this guard is about a
|
|
// DIFFERENT problem: setPos() and setTransform() are two separate
|
|
// QGraphicsItem operations, each independently triggering
|
|
// itemChange(), so without this, repositionHandles() (and,
|
|
// visibly, a render) could happen using the new pos() but the
|
|
// still-old transform (or vice versa) in the moment between the
|
|
// two calls -- a real, if brief, wobble, not a calculation error.
|
|
// QetShapeItem's own setPivot() already needed the identical fix
|
|
// for the identical reason.
|
|
m_deferHandleReposition = true;
|
|
// QGraphicsObject::setPos(), not the plain setPos() this class
|
|
// would otherwise inherit: QetGraphicsItem overrides setPos() to
|
|
// snap to the diagram's grid, which is exactly right for a user
|
|
// dragging the whole image around, but silently corrupts this
|
|
// specific, exactly-computed compensation -- rounding it to the
|
|
// nearest grid point defeats the entire point of computing an
|
|
// exact value in the first place, and was the actual cause of the
|
|
// "picture drifts slightly" symptom (invisible at scale 1 with no
|
|
// rotation, since the compensation is a no-op there regardless).
|
|
QGraphicsObject::setPos(newPos);
|
|
setTransform(m_transform.toMatrix());
|
|
m_deferHandleReposition = false;
|
|
repositionHandles();
|
|
emit transformChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::setPivotRaw
|
|
Sets m_transform.pivot directly, with NO position compensation at
|
|
all -- deliberately, unlike setPivot() above. Exists solely for
|
|
crop()'s own undo chain (see the Q_PROPERTY declaration's comment
|
|
for why): crop() already computes the fully correct pos() itself,
|
|
accounting for the crop, and pushes that as its own independent
|
|
undo step, so compensating pos() *again* here would silently
|
|
corrupt it back to a wrong value the moment this step replays.
|
|
Never call this expecting the image to stay visually in place on
|
|
its own -- it won't; the caller is responsible for that.
|
|
*/
|
|
void DiagramImageItem::setPivotRaw(const QPointF &newPivot)
|
|
{
|
|
if (m_transform.pivot == newPivot) return;
|
|
prepareGeometryChange();
|
|
m_transform.pivot = newPivot;
|
|
setTransform(m_transform.toMatrix());
|
|
repositionHandles();
|
|
emit transformChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::resetPivotToBoundingRectCenter
|
|
Used after a resize drag ends (see handlerMouseReleaseEvent()) --
|
|
NOT after crop() or replaceImage(), which each compute their own,
|
|
more specific position handling instead (see setPixmap()'s comment
|
|
for why a single generic rule doesn't fit both of those).
|
|
*/
|
|
void DiagramImageItem::resetPivotToBoundingRectCenter()
|
|
{
|
|
m_pivotIsCustom = false;
|
|
setPivot(boundingRect().center());
|
|
}
|
|
|
|
namespace {
|
|
// Local-space (natural, unscaled pixmap size) position of each of
|
|
// the 8 resize handles. Indices: 0=TL 1=TM 2=TR 3=ML 4=MR 5=BL
|
|
// 6=BM 7=BR -- arbitrary but fixed, matched by oppositeHandleIndex()
|
|
// and adjustsX()/adjustsY() below.
|
|
QPointF handleNaturalPosition(int index, qreal w, qreal h)
|
|
{
|
|
switch (index)
|
|
{
|
|
case 0: return QPointF(0, 0);
|
|
case 1: return QPointF(w / 2, 0);
|
|
case 2: return QPointF(w, 0);
|
|
case 3: return QPointF(0, h / 2);
|
|
case 4: return QPointF(w, h / 2);
|
|
case 5: return QPointF(0, h);
|
|
case 6: return QPointF(w / 2, h);
|
|
case 7: return QPointF(w, h);
|
|
}
|
|
return QPointF();
|
|
}
|
|
|
|
int oppositeHandleIndex(int index)
|
|
{
|
|
static const int opposite[8] = {7, 6, 5, 4, 3, 2, 1, 0};
|
|
return opposite[index];
|
|
}
|
|
|
|
// Corners adjust both axes; edge midpoints adjust only the axis
|
|
// perpendicular to their own edge.
|
|
bool adjustsX(int index) { return index != 1 && index != 6; }
|
|
bool adjustsY(int index) { return index != 3 && index != 4; }
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::cornerPosition
|
|
Matches QetShapeItem::cornerPoint()'s own slot convention exactly
|
|
(0=TL 1=TR 2=BR 3=BL), so the rotate math ported from there (see
|
|
dragRotateHandle()) lines up without needing its own re-derivation.
|
|
*/
|
|
QPointF DiagramImageItem::cornerPosition(int cornerIndex, qreal w, qreal h)
|
|
{
|
|
switch (cornerIndex & 3)
|
|
{
|
|
case 0: return QPointF(0, 0);
|
|
case 1: return QPointF(w, 0);
|
|
case 2: return QPointF(w, h);
|
|
default: return QPointF(0, h);
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::edgeMidpointPosition
|
|
Matches QetShapeItem::edgeMidpoint()'s own slot convention exactly
|
|
(0=N 1=E 2=S 3=W), for the same reason as cornerPosition() above.
|
|
*/
|
|
QPointF DiagramImageItem::edgeMidpointPosition(int edgeIndex, qreal w, qreal h)
|
|
{
|
|
switch (edgeIndex & 3)
|
|
{
|
|
case 0: return QPointF(w / 2, 0);
|
|
case 1: return QPointF(w, h / 2);
|
|
case 2: return QPointF(w / 2, h);
|
|
default: return QPointF(0, h / 2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::scaleOnlyOffset / scaleAndShearOffset
|
|
Verbatim ports of QetShapeItem's own identically-named helpers (see
|
|
their definitions there for the derivation) -- the offset of a local
|
|
point from the pivot, with scale (and, for the second, shear too)
|
|
applied but rotation deliberately left out. Used by
|
|
dragRotateHandle()/dragSkewHandle() to solve for rotation/skew
|
|
directly rather than through mapFromScene(), which would divide out
|
|
the very value being solved for.
|
|
*/
|
|
QPointF DiagramImageItem::scaleOnlyOffset(const QPointF &localPoint) const
|
|
{
|
|
const QPointF offset = localPoint - m_transform.pivot;
|
|
return QPointF(offset.x() * m_transform.scaleX, offset.y() * m_transform.scaleY);
|
|
}
|
|
|
|
QPointF DiagramImageItem::scaleAndShearOffset(const QPointF &localPoint) const
|
|
{
|
|
const QPointF scaled = scaleOnlyOffset(localPoint);
|
|
const qreal kx = qTan(qDegreesToRadians(m_transform.skewX));
|
|
const qreal ky = qTan(qDegreesToRadians(m_transform.skewY));
|
|
return QPointF(scaled.x() + kx * scaled.y(), scaled.y() + ky * scaled.x());
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::handlePosition
|
|
Local-space position of handle at vector index `index`, for
|
|
whichever role it currently holds -- the single source both
|
|
rebuildHandles() and repositionHandles() read from, so the two can
|
|
never disagree about where a handle belongs.
|
|
*/
|
|
QPointF DiagramImageItem::handlePosition(int index) const
|
|
{
|
|
const qreal w = pixmap_.width(), h = pixmap_.height();
|
|
const HandleRole role = m_handleRoles.at(index);
|
|
if (role == HandleRole::Resize)
|
|
return handleNaturalPosition(index, w, h);
|
|
if (role == HandleRole::Rotate)
|
|
return cornerPosition(index, w, h); // Rotate handles are always the first 4 in RotateSkew mode, so index IS the corner slot
|
|
if (role == HandleRole::SkewEdge)
|
|
return edgeMidpointPosition(index - 4, w, h); // ...and SkewEdge the next 4, offset by those same 4
|
|
return m_transform.pivot; // Pivot
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::mousePressEvent
|
|
Clicking an already-selected image cycles its handle mode, exactly
|
|
matching QetShapeItem's own established convention for the same
|
|
gesture -- deliberately kept consistent rather than inventing a
|
|
different interaction just because this is a different class.
|
|
*/
|
|
void DiagramImageItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
|
{
|
|
const bool wasAlreadySelected = isSelected();
|
|
event->ignore();
|
|
QetGraphicsItem::mousePressEvent(event);
|
|
|
|
if (event->button() == Qt::LeftButton)
|
|
{
|
|
if (wasAlreadySelected && isSelected())
|
|
toggleHandleMode();
|
|
event->accept();
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::toggleHandleMode
|
|
*/
|
|
void DiagramImageItem::toggleHandleMode()
|
|
{
|
|
prepareGeometryChange();
|
|
m_handleMode = (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size;
|
|
rebuildHandles();
|
|
refreshInteractionHints();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::nextHandleMode
|
|
@return whichever mode a click would switch to from here -- the
|
|
other one, since there are only two.
|
|
*/
|
|
DiagramImageItem::HandleMode DiagramImageItem::nextHandleMode() const
|
|
{
|
|
return (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size;
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::handleModeLabel
|
|
*/
|
|
QString DiagramImageItem::handleModeLabel(HandleMode mode)
|
|
{
|
|
return (mode == HandleMode::Size) ? tr("redimensionner") : tr("pivoter/incliner");
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::updateModeHint
|
|
Keeps the tooltip in sync with what the next click would do --
|
|
called on selection change (so it appears/disappears with the
|
|
handles themselves) and after every mode switch. Ported from
|
|
QetShapeItem's identical method: deliberately short, since this is
|
|
a tooltip, not documentation -- the fuller gesture/modifier
|
|
reference lives in the status bar instead (see
|
|
currentModeStatusHint(), hoverEnterEvent()).
|
|
*/
|
|
void DiagramImageItem::updateModeHint()
|
|
{
|
|
setToolTip(isSelected()
|
|
? tr("Cliquer : mode %1").arg(handleModeLabel(nextHandleMode()))
|
|
: QString());
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::refreshInteractionHints
|
|
Keeps the tooltip text current, and -- if already being hovered --
|
|
immediately re-shows both the tooltip and the status bar hint
|
|
rather than leaving them stuck on whatever was true before. Ported
|
|
from QetShapeItem's identical method, for the identical reason: Qt
|
|
only re-evaluates a tooltip, and this class only re-shows the
|
|
status bar, when the cursor *moves* -- selecting an image (often
|
|
clicked while the mouse was already sitting on it) or cycling
|
|
handle modes (definitely clicked while sitting on it) both change
|
|
what should be shown without the cursor moving at all.
|
|
*/
|
|
void DiagramImageItem::refreshInteractionHints()
|
|
{
|
|
updateModeHint();
|
|
|
|
if (!isHovered() || !isSelected())
|
|
return;
|
|
|
|
showStatusHint(currentModeStatusHint());
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::currentModeStatusHint
|
|
One-line reference for whatever handles are visible right now,
|
|
shown in the status bar while hovering a selected image's body --
|
|
the modifier keys in particular (Ctrl, Shift) have no other visible
|
|
indication that they do anything at all. Also carries the same
|
|
"next mode" information as the tooltip, since the status bar has
|
|
room for the full picture in one place.
|
|
*/
|
|
QString DiagramImageItem::currentModeStatusHint() const
|
|
{
|
|
QString hint = (m_handleMode == HandleMode::Size)
|
|
? tr("Glisser un coin/bord : redimensionner (Ctrl = depuis le centre, Maj = conserver les proportions)")
|
|
: tr("Glisser un coin : pivoter (Maj = par pas de 15°) ; glisser un bord : incliner (Maj = par pas de 15°) ; point rouge : déplacer le centre de rotation");
|
|
hint += tr(" -- %1 : mode %2").arg(tr("Cliquer"), handleModeLabel(nextHandleMode()));
|
|
return hint;
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::hoverEnterEvent
|
|
*/
|
|
void DiagramImageItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event)
|
|
{
|
|
QetGraphicsItem::hoverEnterEvent(event);
|
|
refreshInteractionHints();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::hoverLeaveEvent
|
|
*/
|
|
void DiagramImageItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event)
|
|
{
|
|
QetGraphicsItem::hoverLeaveEvent(event);
|
|
clearStatusHint();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::clearHandles
|
|
*/
|
|
void DiagramImageItem::clearHandles()
|
|
{
|
|
if (!m_handler_vector.isEmpty())
|
|
{
|
|
qDeleteAll(m_handler_vector);
|
|
m_handler_vector.clear();
|
|
}
|
|
m_handleRoles.clear();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::rebuildHandles
|
|
A deliberately small switch compared to QetShapeItem's own version:
|
|
images have exactly one "shape" (a plain rectangle matching the
|
|
pixmap's natural size), so there's no shape-type branching to do --
|
|
just the two handle modes themselves.
|
|
*/
|
|
/**
|
|
@brief DiagramImageItem::colorForHandleRole / hintForHandleRole
|
|
Single source of truth for both rebuildHandles() (which sets each
|
|
handle's native tooltip) and sceneEventFilter()'s hover dispatch
|
|
(which shows the identical text in the status bar) -- keeping these
|
|
as two independently-maintained copies is exactly how they'd
|
|
quietly drift apart over time, as already happened once between
|
|
writing this pair.
|
|
*/
|
|
QColor DiagramImageItem::colorForHandleRole(HandleRole role)
|
|
{
|
|
switch (role)
|
|
{
|
|
case HandleRole::Resize: return Qt::blue;
|
|
case HandleRole::Rotate: return Qt::darkGreen;
|
|
case HandleRole::SkewEdge: return QColor(255, 140, 0);
|
|
case HandleRole::Pivot: return Qt::red;
|
|
}
|
|
return Qt::blue;
|
|
}
|
|
|
|
QString DiagramImageItem::hintForHandleRole(HandleRole role)
|
|
{
|
|
switch (role)
|
|
{
|
|
case HandleRole::Resize: return tr("Glisser : redimensionner (Maj = conserver les proportions, Ctrl = depuis le centre)");
|
|
case HandleRole::Rotate: return tr("Glisser : pivoter (Maj = par pas de 15°)");
|
|
case HandleRole::SkewEdge: return tr("Glisser : incliner (Maj = par pas de 15°)");
|
|
case HandleRole::Pivot: return tr("Glisser : déplacer le centre de rotation");
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
void DiagramImageItem::rebuildHandles()
|
|
{
|
|
clearHandles();
|
|
|
|
if (m_handleMode == HandleMode::Size)
|
|
{
|
|
for (int i = 0; i < 8; ++i)
|
|
m_handleRoles << HandleRole::Resize;
|
|
}
|
|
else // RotateSkew: 4 corners (rotate), then 4 edges (skew), then 1 pivot -- handlePosition() relies on this exact order
|
|
{
|
|
for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::Rotate;
|
|
for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::SkewEdge;
|
|
m_handleRoles << HandleRole::Pivot;
|
|
}
|
|
|
|
if (m_handleRoles.isEmpty() || !scene())
|
|
return;
|
|
|
|
QVector<QPointF> positions;
|
|
for (int i = 0; i < m_handleRoles.size(); ++i)
|
|
positions << handlePosition(i);
|
|
|
|
m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(positions), QETUtils::graphicsHandlerSize(this));
|
|
|
|
for (int i = 0; i < m_handler_vector.size(); ++i)
|
|
{
|
|
QetGraphicsHandlerItem *h = m_handler_vector.at(i);
|
|
h->setZValue(zValue() + 1);
|
|
h->setColor(colorForHandleRole(m_handleRoles.at(i)));
|
|
h->setToolTip(hintForHandleRole(m_handleRoles.at(i)));
|
|
h->setAcceptHoverEvents(true);
|
|
scene()->addItem(h);
|
|
h->installSceneEventFilter(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::repositionHandles
|
|
Moves the *existing* handle items to match current geometry, without
|
|
touching their identity -- safe to call on every frame of a live
|
|
drag, matching QetShapeItem's own repositionHandles()/rebuildHandles()
|
|
split for the identical reason.
|
|
*/
|
|
void DiagramImageItem::repositionHandles()
|
|
{
|
|
if (m_handler_vector.isEmpty())
|
|
return;
|
|
if (m_handler_vector.size() != m_handleRoles.size())
|
|
{
|
|
rebuildHandles();
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < m_handleRoles.size(); ++i)
|
|
m_handler_vector.at(i)->setPos(mapToScene(handlePosition(i)));
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::sceneEventFilter
|
|
Dispatches handle interaction events -- structurally identical to
|
|
QetShapeItem::sceneEventFilter(), reused as a pattern rather than
|
|
shared as code, since the two classes' handle roles are different
|
|
enough (no path/polygon/arc concepts here at all) that sharing the
|
|
dispatcher itself would need an awkward, indirect abstraction for
|
|
very little actual code saved.
|
|
*/
|
|
bool DiagramImageItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event)
|
|
{
|
|
if (watched->type() != QetGraphicsHandlerItem::Type)
|
|
return false;
|
|
|
|
QetGraphicsHandlerItem *qghi = qgraphicsitem_cast<QetGraphicsHandlerItem *>(watched);
|
|
const int index = m_handler_vector.indexOf(qghi);
|
|
if (index == -1)
|
|
return false;
|
|
|
|
if (event->type() == QEvent::GraphicsSceneMousePress)
|
|
{
|
|
handlerMousePressEvent(index, static_cast<QGraphicsSceneMouseEvent *>(event)->modifiers());
|
|
return true;
|
|
}
|
|
if (event->type() == QEvent::GraphicsSceneMouseMove)
|
|
{
|
|
handlerMouseMoveEvent(index, static_cast<QGraphicsSceneMouseEvent *>(event));
|
|
return true;
|
|
}
|
|
if (event->type() == QEvent::GraphicsSceneMouseRelease)
|
|
{
|
|
handlerMouseReleaseEvent(index);
|
|
return true;
|
|
}
|
|
if (event->type() == QEvent::GraphicsSceneHoverEnter)
|
|
{
|
|
// On top of the tooltip Qt already shows natively from the
|
|
// handle's own setToolTip() (see rebuildHandles()) -- returning
|
|
// false leaves that native tooltip handling alone, matching
|
|
// QetShapeItem's identical dual approach for its own handles.
|
|
showStatusHint(hintForHandleRole(m_handleRoles.at(index)));
|
|
return false;
|
|
}
|
|
if (event->type() == QEvent::GraphicsSceneHoverLeave)
|
|
{
|
|
clearStatusHint();
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::showStatusHint
|
|
*/
|
|
void DiagramImageItem::showStatusHint(const QString &text) const
|
|
{
|
|
if (text.isEmpty() || !diagram() || diagram()->views().isEmpty())
|
|
return;
|
|
if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
|
|
editor->statusBar()->showMessage(text);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::clearStatusHint
|
|
*/
|
|
void DiagramImageItem::clearStatusHint() const
|
|
{
|
|
if (!diagram() || diagram()->views().isEmpty())
|
|
return;
|
|
if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
|
|
editor->statusBar()->clearMessage();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::handlerMousePressEvent
|
|
For a Resize handle, temporarily repositions the pivot for the
|
|
duration of this drag: to the OPPOSITE corner/edge by default (so
|
|
that one stays fixed, the usual convention), or to the CENTER if
|
|
Ctrl is held at the moment of the press (so the image grows
|
|
symmetrically from its middle instead). Decided once here, not
|
|
re-checked on every subsequent move -- mirroring the identical fix
|
|
already made for shape creation, and for the identical reason:
|
|
continuously re-checking made releasing the modifier mid-drag
|
|
revert a decision that felt already made, rather than a deliberate
|
|
choice made once at the start.
|
|
|
|
Verified numerically before relying on this: with the pivot fixed
|
|
at whichever reference point applies, solving for a new scale that
|
|
puts the dragged handle at the mouse automatically keeps that
|
|
reference point exactly where it was, with zero drift, across all 8
|
|
handles and a rotated, non-uniformly-scaled starting transform.
|
|
compensatedPositionForNewPivot() is what keeps this repositioning
|
|
itself invisible -- without it, temporarily moving the pivot would
|
|
visibly jump the image the instant the drag starts.
|
|
*/
|
|
void DiagramImageItem::handlerMousePressEvent(int index, Qt::KeyboardModifiers mods)
|
|
{
|
|
m_vector_index = index;
|
|
m_original_pos = pos();
|
|
m_original_transform = m_transform;
|
|
|
|
if (m_handleRoles.at(index) == HandleRole::Resize)
|
|
{
|
|
m_resizeCenterAnchored = mods & Qt::ControlModifier;
|
|
const QPointF referencePoint = m_resizeCenterAnchored
|
|
? QPointF(pixmap_.width() / 2.0, pixmap_.height() / 2.0)
|
|
: handleNaturalPosition(oppositeHandleIndex(index), pixmap_.width(), pixmap_.height());
|
|
const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, referencePoint, m_transform.linearPart());
|
|
m_deferHandleReposition = true;
|
|
m_transform.pivot = referencePoint;
|
|
// QGraphicsObject::setPos(), not the plain setPos() -- see
|
|
// setPivot()'s identical comment for why: QetGraphicsItem's
|
|
// override snaps to the grid, which would corrupt this exact
|
|
// compensation the same way it did there.
|
|
QGraphicsObject::setPos(newPos);
|
|
setTransform(m_transform.toMatrix());
|
|
m_deferHandleReposition = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::handlerMouseMoveEvent
|
|
*/
|
|
void DiagramImageItem::handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent *event)
|
|
{
|
|
const HandleRole role = m_handleRoles.at(index);
|
|
|
|
if (role == HandleRole::Resize)
|
|
dragResize(index, mapFromScene(event->scenePos()), event->modifiers());
|
|
else if (role == HandleRole::Rotate)
|
|
dragRotateHandle(index, event->scenePos(), event->modifiers()); // index IS the corner slot (0-3): Rotate handles are always first
|
|
else if (role == HandleRole::SkewEdge)
|
|
dragSkewHandle(index - 4, event->scenePos(), event->modifiers()); // offset by the 4 preceding Rotate handles
|
|
else if (role == HandleRole::Pivot)
|
|
dragPivot(mapFromScene(event->scenePos()));
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::handlerMouseReleaseEvent
|
|
Two responsibilities, in order: first, Resize's own pivot cleanup
|
|
(the pivot was temporarily relocated to the opposite corner or
|
|
center in handlerMousePressEvent() purely to make the drag math
|
|
simple, and has to move back now that the drag is over, via
|
|
resetPivotToBoundingRectCenter(), which itself handles the position
|
|
compensation that reset needs) -- but only when the pivot isn't
|
|
user-customized, matching dragPivot()'s own comment for why.
|
|
Second -- and previously entirely missing -- building the actual
|
|
undo command for whatever changed during this drag: every drag
|
|
method above mutates m_transform directly and calls setTransform()
|
|
immediately, live, with no undo tracking of its own at all, exactly
|
|
like QetShapeItem's identical handlerMouseMoveEvent()/dragResize()
|
|
pair. QetShapeItem's own handlerMouseReleaseEvent() is where that
|
|
gets reconciled into a single undo step per role, comparing
|
|
m_original_transform/m_original_pos (captured at press time) against
|
|
the now-already-applied live values -- this is a direct port of
|
|
that same pattern for this class's own, smaller role set. Pushing a
|
|
command whose redo() sets a property to what it's already live at
|
|
is intentional, not wasted: redo() runs once immediately, as a
|
|
harmless no-op, so the stack has a correct entry for Ctrl+Z without
|
|
the live drag needing any awareness of undo at all. For Pivot
|
|
specifically, chaining "pos" before "rawPivot" -- not the
|
|
compensating "pivot" -- means undoing simply restores both to their
|
|
exact recorded values, without a second, redundant compensation
|
|
needing to run and (correctly, but confusingly) arrive at the same
|
|
place a longer way around.
|
|
*/
|
|
void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
|
{
|
|
const HandleRole role = m_handleRoles.at(index);
|
|
|
|
if (role == HandleRole::Resize && !m_pivotIsCustom)
|
|
resetPivotToBoundingRectCenter();
|
|
|
|
if (diagram())
|
|
{
|
|
QUndoCommand *undo = nullptr;
|
|
|
|
switch (role)
|
|
{
|
|
case HandleRole::Resize:
|
|
if (!qFuzzyCompare(m_transform.scaleX, m_original_transform.scaleX))
|
|
undo = new QPropertyUndoCommand(this, "scaleFactorX", m_original_transform.scaleX, m_transform.scaleX);
|
|
if (!qFuzzyCompare(m_transform.scaleY, m_original_transform.scaleY))
|
|
{
|
|
if (undo)
|
|
new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY, undo);
|
|
else
|
|
undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY);
|
|
}
|
|
if (undo)
|
|
undo->setText(tr("Redimensionner une image"));
|
|
break;
|
|
|
|
case HandleRole::Rotate:
|
|
if (!qFuzzyCompare(m_transform.rotation, m_original_transform.rotation))
|
|
{
|
|
undo = new QPropertyUndoCommand(this, "rotationAngle", m_original_transform.rotation, m_transform.rotation);
|
|
undo->setText(tr("Faire pivoter une image"));
|
|
}
|
|
break;
|
|
|
|
case HandleRole::SkewEdge:
|
|
if (!qFuzzyCompare(m_transform.skewX, m_original_transform.skewX))
|
|
undo = new QPropertyUndoCommand(this, "skewX", m_original_transform.skewX, m_transform.skewX);
|
|
else if (!qFuzzyCompare(m_transform.skewY, m_original_transform.skewY))
|
|
undo = new QPropertyUndoCommand(this, "skewY", m_original_transform.skewY, m_transform.skewY);
|
|
if (undo)
|
|
undo->setText(tr("Incliner une image"));
|
|
break;
|
|
|
|
case HandleRole::Pivot:
|
|
if (m_transform.pivot != m_original_transform.pivot)
|
|
{
|
|
undo = new QUndoCommand(tr("Déplacer le centre de rotation d'une image"));
|
|
new QPropertyUndoCommand(this, "pos", m_original_pos, pos(), undo);
|
|
new QPropertyUndoCommand(this, "rawPivot", m_original_transform.pivot, m_transform.pivot, undo);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (undo)
|
|
{
|
|
// Defensive fallback only -- every role above now sets its
|
|
// own, distinct label directly (Resize/Rotate/SkewEdge used
|
|
// to all fall through to this same generic text, making the
|
|
// undo list unable to tell three completely different edits
|
|
// apart); this only still matters if some future role is
|
|
// ever added without setting one of its own.
|
|
if (undo->text().isEmpty())
|
|
undo->setText(tr("Modifier une image"));
|
|
|
|
// Every push here is one complete, finished gesture (press,
|
|
// drag, release) -- never a continuation of an earlier one.
|
|
// QPropertyUndoCommand::mergeWith() already treats any
|
|
// command with children as never mergeable; a dummy child
|
|
// guarantees that here regardless of which role produced
|
|
// undo. Without this, a single-property change (Rotate and
|
|
// SkewEdge always are; Resize sometimes is, when only one
|
|
// axis actually changed) would silently coalesce into
|
|
// whatever identically-labelled edit came right before it
|
|
// -- even after a deselect/reselect proved they were two
|
|
// separate actions, since the shared label alone is
|
|
// otherwise indistinguishable from a genuine continuation.
|
|
new QUndoCommand(undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
}
|
|
|
|
m_vector_index = -1;
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::dragResize
|
|
Verified numerically (all 8 handles, a rotated and non-uniformly
|
|
scaled starting transform) before writing this: undoing only
|
|
rotation from the target -- never scale, since that's the very
|
|
value being solved for -- is what avoids the feedback-loop mistake
|
|
a full mapFromScene()-based solve would fall into (mapFromScene()
|
|
divides out the CURRENT scale, not the new one, since it doesn't
|
|
know a new one is being computed at all).
|
|
*/
|
|
void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods)
|
|
{
|
|
// mapToScene(), not the pos() + m_transform.pivot shortcut: that
|
|
// shortcut only holds because the pivot is a fixed point of
|
|
// m_transform.toMatrix() -- true exactly as long as m_transform is
|
|
// the item's ONLY transform. It silently breaks the moment anything
|
|
// else is composed on top (QGraphicsItem's own scale()/rotation(),
|
|
// or a future parent-group transform), giving a scenePivot that
|
|
// doesn't match where the pivot handle is actually drawn (rebuildHandles()
|
|
// already uses mapToScene() for that, which is why the handle looks
|
|
// right even when a drag computed from this shortcut doesn't).
|
|
// mapToScene() is correct regardless of what else is composed in.
|
|
const QPointF pivotScene = mapToScene(m_transform.pivot);
|
|
QTransform undoRotation;
|
|
undoRotation.rotate(-m_transform.rotation);
|
|
const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene);
|
|
|
|
const QPointF handleNatural = handleNaturalPosition(index, pixmap_.width(), pixmap_.height());
|
|
const qreal handleDx = handleNatural.x() - m_transform.pivot.x();
|
|
const qreal handleDy = handleNatural.y() - m_transform.pivot.y();
|
|
|
|
qreal newScaleX = m_transform.scaleX;
|
|
qreal newScaleY = m_transform.scaleY;
|
|
if (adjustsX(index) && qAbs(handleDx) > 1e-6)
|
|
newScaleX = postScaleOffset.x() / handleDx;
|
|
if (adjustsY(index) && qAbs(handleDy) > 1e-6)
|
|
newScaleY = postScaleOffset.y() / handleDy;
|
|
|
|
if (mods & Qt::ShiftModifier)
|
|
{
|
|
// Preserve the ORIGINAL aspect ratio (captured at press time in
|
|
// m_original_transform), not force scaleX==scaleY -- an image
|
|
// that wasn't square to begin with should stay proportional to
|
|
// itself, not become literally square the moment Shift is held.
|
|
const qreal originalRatio = (qFuzzyIsNull(m_original_transform.scaleY))
|
|
? 1.0 : m_original_transform.scaleX / m_original_transform.scaleY;
|
|
if (adjustsX(index) && !adjustsY(index))
|
|
newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio);
|
|
else if (adjustsY(index) && !adjustsX(index))
|
|
newScaleX = newScaleY * originalRatio;
|
|
else
|
|
{
|
|
// Corner handle: whichever axis moved further from its own
|
|
// starting value, in relative terms, drives the other.
|
|
const qreal relX = qAbs(newScaleX / m_original_transform.scaleX);
|
|
const qreal relY = qAbs(newScaleY / m_original_transform.scaleY);
|
|
if (relX > relY)
|
|
newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio);
|
|
else
|
|
newScaleX = newScaleY * originalRatio;
|
|
}
|
|
}
|
|
|
|
// A pixmap can't meaningfully render at zero or negative scale --
|
|
// clamped well above zero rather than letting it vanish or silently
|
|
// flip (negative scale is a mirror, and mirroring is already its
|
|
// own deliberate, explicit context-menu action, not something a
|
|
// fast drag past the opposite edge should trigger by accident).
|
|
const qreal MIN_SCALE = 0.02;
|
|
if (newScaleX < MIN_SCALE) newScaleX = MIN_SCALE;
|
|
if (newScaleY < MIN_SCALE) newScaleY = MIN_SCALE;
|
|
|
|
m_transform.scaleX = newScaleX;
|
|
m_transform.scaleY = newScaleY;
|
|
setTransform(m_transform.toMatrix());
|
|
repositionHandles();
|
|
emit transformChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::dragRotateHandle
|
|
Verified numerically before writing this (all 4 corners, WITH
|
|
nonzero skew already present -- scaleAndShearOffset() is what
|
|
makes the reference direction correctly account for that): the
|
|
handle ends up pointing exactly at the mouse's angle from the
|
|
pivot, and the pivot itself never moves. Ported from
|
|
QetShapeItem::dragRotateHandle() -- see that copy's own comment for
|
|
why this is a plain assignment rather than a recurrence (computing
|
|
the angle via mapFromScene() would divide out the current rotation
|
|
and turn each frame's result into a feedback loop).
|
|
*/
|
|
void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
|
|
{
|
|
// mapToScene(), not pos() + m_transform.pivot: see dragResize()'s
|
|
// identical comment for why.
|
|
const QPointF scenePivot = mapToScene(m_transform.pivot);
|
|
const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x()));
|
|
|
|
const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height()));
|
|
const qreal angleReference = qRadiansToDegrees(qAtan2(reference.y(), reference.x()));
|
|
|
|
qreal angle = angleMouse - angleReference;
|
|
if (mods & Qt::ShiftModifier)
|
|
angle = qRound(angle / 15.0) * 15.0;
|
|
|
|
m_transform.rotation = angle;
|
|
setTransform(m_transform.toMatrix());
|
|
repositionHandles();
|
|
emit transformChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::dragSkewHandle
|
|
Verified numerically before writing this (all 4 edges, WITH nonzero
|
|
rotation already present): recovers the exact skew angle that would
|
|
put the edge handle at a given scene position, for both skewX
|
|
(N/S edges) and skewY (E/W edges). Ported from
|
|
QetShapeItem::dragSkewHandle() -- see that copy's own comment for
|
|
the closed-form derivation (solved for the one skew component being
|
|
dragged, holding everything else fixed, since going through
|
|
mapFromScene() would divide out the very value being solved for).
|
|
*/
|
|
void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
|
|
{
|
|
const QPointF pivot = m_transform.pivot;
|
|
const QPointF Q = scaleOnlyOffset(edgeMidpointPosition(edgeIndex, pixmap_.width(), pixmap_.height()));
|
|
|
|
const qreal rad = qDegreesToRadians(m_transform.rotation);
|
|
const qreal c = qCos(rad), s = qSin(rad);
|
|
// mapToScene(pivot), not pos() + pivot: see dragResize()'s identical
|
|
// comment for why.
|
|
const QPointF targetRel = scenePos - mapToScene(pivot);
|
|
const QPointF M(targetRel.x() * c + targetRel.y() * s,
|
|
-targetRel.x() * s + targetRel.y() * c);
|
|
|
|
qreal degrees;
|
|
if (edgeIndex == 0 || edgeIndex == 2) // N/S edge -> skewX
|
|
{
|
|
if (qFuzzyIsNull(Q.y())) return;
|
|
degrees = qRadiansToDegrees(qAtan((M.x() - Q.x()) / Q.y()));
|
|
if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0;
|
|
m_transform.skewX = degrees;
|
|
}
|
|
else // E/W edge -> skewY
|
|
{
|
|
if (qFuzzyIsNull(Q.x())) return;
|
|
degrees = qRadiansToDegrees(qAtan((M.y() - Q.y()) / Q.x()));
|
|
if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0;
|
|
m_transform.skewY = degrees;
|
|
}
|
|
|
|
setTransform(m_transform.toMatrix());
|
|
repositionHandles();
|
|
emit transformChanged();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::dragPivot
|
|
Marks the pivot as user-customized, the same way QetShapeItem's own
|
|
pivot handle does -- so a later resize (see handlerMouseReleaseEvent())
|
|
doesn't silently snap it back to the bounding-rect center the
|
|
instant the user deliberately moved it somewhere else.
|
|
*/
|
|
void DiagramImageItem::dragPivot(const QPointF &localPos)
|
|
{
|
|
m_pivotIsCustom = true;
|
|
setPivot(localPos);
|
|
repositionHandles();
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::restoreAspectRatio
|
|
Context-menu action, the direct answer to "restoration of aspect
|
|
ratio" from the original wishlist: makes scaleY match scaleX,
|
|
keeping the current width fixed. The natural aspect ratio is
|
|
already fully accounted for by the pixmap's own width/height (a
|
|
uniform scale, by definition, can never distort it) -- the
|
|
distortion is entirely scaleX and scaleY disagreeing with each
|
|
other, so undoing it is exactly "make them agree", not a
|
|
computation involving pixmap_'s own dimensions at all. An earlier
|
|
version of this multiplied by the natural height/width ratio a
|
|
second time, which double-counted it and left the image still
|
|
visibly distorted, just less obviously so.
|
|
*/
|
|
void DiagramImageItem::restoreAspectRatio()
|
|
{
|
|
if (!diagram() || diagram()->isReadOnly())
|
|
return;
|
|
|
|
if (qFuzzyCompare(m_transform.scaleY, m_transform.scaleX))
|
|
return;
|
|
|
|
auto *undo = new QPropertyUndoCommand(this, "scaleFactorY", m_transform.scaleY, m_transform.scaleX);
|
|
undo->setText(tr("Restaurer les proportions d'une image"));
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::saveImageAs
|
|
Saves the currently displayed pixmap (crop and colour-keyed
|
|
transparency already applied -- what the item actually looks like
|
|
on the diagram, not the pristine original) to an arbitrary file on
|
|
disk.
|
|
*/
|
|
void DiagramImageItem::saveImageAs()
|
|
{
|
|
saveImagePixmapAs(pixmap_, tr("Enregistrer l'image sous..."), !m_transparent_colors.isEmpty());
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::saveOriginalImageAs
|
|
Saves m_base_pixmap -- the true, pristine original, before any crop
|
|
or colour-keyed transparency -- rather than the item's current,
|
|
possibly-cropped-and-keyed display pixmap. The only way to recover
|
|
the un-cropped, un-keyed source once either of those has actually
|
|
been applied, short of undoing every edit back to the point it was
|
|
first inserted or replaced.
|
|
*/
|
|
void DiagramImageItem::saveOriginalImageAs()
|
|
{
|
|
saveImagePixmapAs(m_base_pixmap, tr("Enregistrer l'image d'origine sous..."), false);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::saveImagePixmapAs
|
|
Shared by saveImageAs() and saveOriginalImageAs(): prompts for a
|
|
destination, resolves whichever format was actually intended, warns
|
|
before silently dropping transparency, and writes the file. A
|
|
read-only export, not an edit: doesn't touch diagram()'s undo stack,
|
|
and works even on a read-only diagram, unlike every other action in
|
|
this item's context menu.
|
|
@param pixmap the pixmap to save -- pixmap_ or m_base_pixmap
|
|
@param dialogTitle distinguishes the two callers in the save dialog's own title bar
|
|
@param hasTransparency whether `pixmap` has colour-keyed transparency worth warning about losing (never true for the pristine original, which predates any such keying)
|
|
*/
|
|
void DiagramImageItem::saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency)
|
|
{
|
|
QWidget *parentWidget = (diagram() && !diagram()->views().isEmpty()) ? diagram()->views().first() : nullptr;
|
|
|
|
// Filter text -> extension. Built once and used both to construct
|
|
// the dialog's filter list and, below, to resolve whichever one the
|
|
// person actually had selected -- rather than only ever falling
|
|
// back to a single hardcoded format regardless of their choice,
|
|
// which is what this used to do (getSaveFileName() doesn't reliably
|
|
// auto-append the selected filter's extension on every platform,
|
|
// and the previous version's fallback ignored the selected filter
|
|
// entirely, defaulting to PNG even when JPEG or BMP had been
|
|
// explicitly chosen).
|
|
const QList<QPair<QString, QString>> filters = {
|
|
{tr("Image PNG (*.png)"), QStringLiteral("png")},
|
|
{tr("Image JPEG (*.jpg *.jpeg)"), QStringLiteral("jpg")},
|
|
{tr("Image BMP (*.bmp)"), QStringLiteral("bmp")},
|
|
// SVG here always means a raster image wrapped in an SVG
|
|
// container (an <image> element embedding this same pixmap as
|
|
// base64 PNG), never a true vector export -- this item only
|
|
// ever holds raster data, even when originally inserted from an
|
|
// SVG file, since that file was rasterized once at import time
|
|
// and its vector information is already gone by the time this
|
|
// runs.
|
|
{tr("Image SVG (*.svg)"), QStringLiteral("svg")},
|
|
};
|
|
QStringList filterStrings;
|
|
for (const auto &f : filters)
|
|
filterStrings << f.first;
|
|
filterStrings << tr("Tous les fichiers (*)");
|
|
|
|
QString selectedFilter;
|
|
QString path = QFileDialog::getSaveFileName(
|
|
parentWidget, dialogTitle, QString(), filterStrings.join(QLatin1String(";;")), &selectedFilter);
|
|
if (path.isEmpty())
|
|
return;
|
|
|
|
// Resolve the target format: prefer an extension already present
|
|
// and actually writable, otherwise fall back to whichever filter
|
|
// was selected in the dialog (not a fixed default), so the format
|
|
// picked in the dropdown is the one that's actually honoured even
|
|
// when the typed name carries no extension of its own.
|
|
QString suffix = QFileInfo(path).suffix().toLower();
|
|
const bool suffixIsSvg = (suffix == QLatin1String("svg"));
|
|
const bool suffixIsWritableRaster = QImageWriter::supportedImageFormats().contains(suffix.toUtf8());
|
|
if (!suffixIsSvg && !suffixIsWritableRaster)
|
|
{
|
|
QString fallbackExt = QStringLiteral("png");
|
|
for (const auto &f : filters)
|
|
if (f.first == selectedFilter) { fallbackExt = f.second; break; }
|
|
path += QLatin1Char('.') + fallbackExt;
|
|
suffix = fallbackExt;
|
|
}
|
|
|
|
// JPEG and BMP have no usable alpha channel here -- Qt's writers for
|
|
// both silently drop it. That alone wouldn't be too bad (the
|
|
// warning already tells the person transparency won't survive),
|
|
// except pixmap_ itself has already lost the true colour
|
|
// information wherever it's transparent by this point: QPixmap
|
|
// stores its data premultiplied internally, which zeroes out RGB
|
|
// at alpha=0 the moment applyColorKey()'s QImage result gets
|
|
// wrapped into a QPixmap -- confirmed directly, not assumed
|
|
// (QPixmap::fromImage() on a white, alpha=0 pixel reliably comes
|
|
// back black on toImage()). Saving pixmap_ as-is to a format with
|
|
// no alpha channel doesn't fall back to the original picture, it
|
|
// reveals that already-lost black. The fix below substitutes the
|
|
// cropped base pixmap for the save in this specific case -- it has
|
|
// the same, correct RGB values everywhere applyColorKey() left a
|
|
// pixel opaque (colour-keying only ever changes alpha, never RGB),
|
|
// so it's an exact reproduction of what the image looked like
|
|
// before any colour was ever keyed out, not an approximation.
|
|
const bool formatPreservesAlpha = (suffix == QLatin1String("png") || suffix == QLatin1String("svg"));
|
|
QPixmap toSave = pixmap;
|
|
if (hasTransparency && !formatPreservesAlpha)
|
|
{
|
|
if (QMessageBox::warning(parentWidget,
|
|
tr("Transparence non conservée"),
|
|
tr("Ce format ne prend pas en charge la transparence : l'image sera enregistrée "
|
|
"telle qu'elle était avant l'application de la couleur transparente. Continuer ?"),
|
|
QMessageBox::Yes | QMessageBox::Cancel) != QMessageBox::Yes)
|
|
return;
|
|
|
|
toSave = m_base_pixmap.copy(m_crop_rect);
|
|
}
|
|
|
|
bool ok;
|
|
if (suffix == QLatin1String("svg"))
|
|
ok = writeRasterAsSvg(toSave, path);
|
|
else
|
|
ok = toSave.save(path);
|
|
|
|
if (!ok)
|
|
{
|
|
QMessageBox::warning(parentWidget,
|
|
tr("Échec de l'enregistrement"),
|
|
tr("Impossible d'enregistrer l'image à cet emplacement."));
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::writeRasterAsSvg
|
|
Wraps `pixmap` as a base64-embedded PNG inside a minimal, valid SVG
|
|
document -- the closest this can honestly offer to "save as SVG"
|
|
given this item only ever holds raster data (see the note in
|
|
saveImagePixmapAs()). Opens in any SVG viewer at the pixmap's own
|
|
pixel size, but is not, and cannot be, a vector re-export.
|
|
@param pixmap the raster image to embed
|
|
@param path destination file path
|
|
@return whether the file was written successfully
|
|
*/
|
|
bool DiagramImageItem::writeRasterAsSvg(const QPixmap &pixmap, const QString &path)
|
|
{
|
|
QByteArray pngData;
|
|
QBuffer buffer(&pngData);
|
|
buffer.open(QIODevice::WriteOnly);
|
|
if (!pixmap.save(&buffer, "PNG"))
|
|
return false;
|
|
|
|
QFile file(path);
|
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
|
|
return false;
|
|
|
|
QTextStream out(&file);
|
|
out << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
<< "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
|
|
<< "width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
|
|
<< "viewBox=\"0 0 " << pixmap.width() << ' ' << pixmap.height() << "\">\n"
|
|
<< " <image width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
|
|
<< "xlink:href=\"data:image/png;base64," << QString::fromLatin1(pngData.toBase64()) << "\"/>\n"
|
|
<< "</svg>\n";
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::itemChange
|
|
*/
|
|
QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant &value)
|
|
{
|
|
if (change == ItemSelectedHasChanged)
|
|
{
|
|
if (value.toBool())
|
|
rebuildHandles();
|
|
else
|
|
{
|
|
prepareGeometryChange();
|
|
clearHandles();
|
|
m_handleMode = HandleMode::Size;
|
|
}
|
|
refreshInteractionHints();
|
|
}
|
|
else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged)
|
|
{
|
|
if (!m_deferHandleReposition)
|
|
repositionHandles();
|
|
}
|
|
else if (change == ItemSceneHasChanged)
|
|
{
|
|
if (!scene())
|
|
setSelected(false);
|
|
}
|
|
|
|
return QGraphicsItem::itemChange(change, value);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::computeDisplayPixmap
|
|
Re-derives what pixmap_ should be from first principles: crop the
|
|
true original down to the chosen region, then colour-key whichever
|
|
colours have been picked out of it, each at its own tolerance. Used
|
|
whenever crop() or setTransparentColor() changes one of those two
|
|
independently, so the other's effect is correctly re-applied on top
|
|
rather than lost or compounded -- cropping after colours were
|
|
already picked has to still show them keyed out; picking colours
|
|
after a crop has to only ever consider what's still actually part
|
|
of the image.
|
|
@param base the true, uncropped original
|
|
@param cropRect the region of base to keep, in base's own coordinates
|
|
@param colors colours (each with its own tolerance) to key transparent within the cropped region
|
|
*/
|
|
QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect,
|
|
const QList<ImageTransparentColorDialog::PickedColor> &colors)
|
|
{
|
|
const QPixmap cropped = cropRect == base.rect() ? base : base.copy(cropRect);
|
|
if (colors.isEmpty())
|
|
return cropped;
|
|
return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors));
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::boundingRect
|
|
the outer bounds of the item as a rectangle,
|
|
if no pixmap are set, return a default QRectF
|
|
@return a QRectF represent the bounding rectangle
|
|
*/
|
|
QRectF DiagramImageItem::boundingRect() const
|
|
{
|
|
if (!pixmap_.isNull()) {
|
|
return (QRectF(pixmap_.rect()));
|
|
} else {
|
|
QRectF bound;
|
|
return (bound);
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::name
|
|
@return the generic name of this item (picture)
|
|
*/
|
|
QString DiagramImageItem::name() const
|
|
{
|
|
return tr("une image");
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::fromXml
|
|
Load this image from xml element e
|
|
@param e
|
|
@return true if successfully loaded.
|
|
*/
|
|
bool DiagramImageItem::fromXml(const QDomElement &e)
|
|
{
|
|
if (e.tagName() != "image") {
|
|
return (false);
|
|
}
|
|
|
|
QDomNode image_node = e.firstChild();
|
|
if (!image_node.isText()) {
|
|
return (false);
|
|
}
|
|
|
|
//load xml image to QByteArray
|
|
QByteArray array;
|
|
array = QByteArray::fromBase64(e.text().toLatin1());
|
|
|
|
//Set QPixmap from the array
|
|
QPixmap pixmap;
|
|
pixmap.loadFromData(array);
|
|
setPixmap(pixmap);
|
|
|
|
// Falls back to treating the loaded result as its own base, with no
|
|
// remembered crop or colours -- correct both for a genuinely plain
|
|
// image and for a file saved before these features existed.
|
|
// Overwritten below if the file actually does carry this
|
|
// information.
|
|
m_base_pixmap = pixmap;
|
|
m_crop_rect = pixmap.rect();
|
|
m_transparent_colors.clear();
|
|
|
|
const QDomElement colorsElement = e.firstChildElement("transparent_colors");
|
|
bool hasColors = !colorsElement.isNull();
|
|
if (hasColors)
|
|
{
|
|
// Files saved before per-colour tolerance existed wrote a
|
|
// single value on the wrapper element itself, shared by every
|
|
// colour -- kept here purely as the fallback default for a
|
|
// <color> that doesn't carry its own attribute, which for
|
|
// those old files is every one of them, exactly reproducing
|
|
// what they used to do (one tolerance applied to all of them).
|
|
const int wrapperTolerance = colorsElement.attribute("tolerance", "10").toInt();
|
|
for (const QDomElement &colorElement : QET::findInDomElement(colorsElement, "color"))
|
|
{
|
|
m_transparent_colors.append({
|
|
QColor(colorElement.attribute("r").toInt(),
|
|
colorElement.attribute("g").toInt(),
|
|
colorElement.attribute("b").toInt()),
|
|
colorElement.attribute("tolerance", QString::number(wrapperTolerance)).toInt()});
|
|
}
|
|
}
|
|
|
|
const QDomElement cropElement = e.firstChildElement("crop");
|
|
bool hasCrop = !cropElement.isNull();
|
|
if (hasCrop)
|
|
{
|
|
m_crop_rect = QRect(
|
|
cropElement.attribute("x").toInt(),
|
|
cropElement.attribute("y").toInt(),
|
|
cropElement.attribute("w").toInt(),
|
|
cropElement.attribute("h").toInt());
|
|
}
|
|
|
|
// Present, and only meaningful, whenever either of the above is --
|
|
// the base pixmap on its own, with no crop or colours to apply to
|
|
// it, wouldn't mean anything.
|
|
if (hasColors || hasCrop)
|
|
{
|
|
const QDomElement baseElement = e.firstChildElement("image_base");
|
|
if (!baseElement.isNull())
|
|
{
|
|
const QByteArray baseArray = QByteArray::fromBase64(baseElement.text().toLatin1());
|
|
QPixmap basePixmap;
|
|
if (basePixmap.loadFromData(baseArray))
|
|
m_base_pixmap = basePixmap;
|
|
}
|
|
// m_crop_rect may still refer to a saved file's base image, not
|
|
// pixmap (used as a fallback above only when nothing better is
|
|
// available) -- clamp it to whatever base actually ended up
|
|
// loaded, so an inconsistent or hand-edited file can't produce
|
|
// an out-of-bounds crop rect later.
|
|
m_crop_rect = m_crop_rect.intersected(m_base_pixmap.rect());
|
|
if (m_crop_rect.isEmpty())
|
|
m_crop_rect = m_base_pixmap.rect();
|
|
}
|
|
|
|
// Baseline: the plain "rotation"/"size" attributes, understood by
|
|
// every version of this code, past and future -- size applied
|
|
// uniformly to both axes, since a single float can't say otherwise.
|
|
// Overwritten below if the file actually carries the richer
|
|
// <transform> element (independent scaleX/scaleY, and/or a custom
|
|
// pivot), the same two-tier fallback QetShapeItem's own fromXml()
|
|
// already uses for its identical <transform> element.
|
|
m_transform.rotation = e.attribute("rotation").toDouble();
|
|
m_transform.scaleX = e.attribute("size").toDouble();
|
|
m_transform.scaleY = m_transform.scaleX;
|
|
m_transform.pivot = boundingRect().center();
|
|
m_pivotIsCustom = false;
|
|
|
|
const QDomElement transformElement = e.firstChildElement("transform");
|
|
if (!transformElement.isNull())
|
|
{
|
|
m_transform.rotation = transformElement.attribute("rotation", "0").toDouble();
|
|
m_transform.skewX = transformElement.attribute("skewX", "0").toDouble();
|
|
m_transform.skewY = transformElement.attribute("skewY", "0").toDouble();
|
|
m_transform.scaleX = transformElement.attribute("scaleX", "1").toDouble();
|
|
m_transform.scaleY = transformElement.attribute("scaleY", "1").toDouble();
|
|
m_transform.pivot = QPointF(transformElement.attribute("pivotX", "0").toDouble(),
|
|
transformElement.attribute("pivotY", "0").toDouble());
|
|
m_pivotIsCustom = true;
|
|
}
|
|
setTransform(m_transform.toMatrix());
|
|
|
|
//We directly call setPos from QGraphicsObject, because QetGraphicsItem will snap to grid
|
|
QGraphicsObject::setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble());
|
|
setZValue(e.attribute("z", QString::number(this->zValue())).toDouble());
|
|
is_movable_ = (e.attribute("is_movable").toInt());
|
|
|
|
return (true);
|
|
}
|
|
|
|
/**
|
|
@param document Le document XML a utiliser
|
|
@return L'element XML representant l'image
|
|
*/
|
|
QDomElement DiagramImageItem::toXml(QDomDocument &document) const
|
|
{
|
|
QDomElement result = document.createElement("image");
|
|
//write some attribute
|
|
result.setAttribute("x", QString::number(pos().x()));
|
|
result.setAttribute("y", QString::number(pos().y()));
|
|
result.setAttribute("z", QString::number(this->zValue()));
|
|
// m_transform.rotation/scaleX, not rotation()/scale(): those are
|
|
// QGraphicsItem's own convenience properties, and this class no
|
|
// longer ever calls their setters at all -- it uses setTransform()
|
|
// directly now, the only way to represent independent scaleX/scaleY
|
|
// at all, so rotation()/scale() would always read back their
|
|
// defaults (0 and 1) regardless of the image's actual, visible
|
|
// state. "size" keeps scaleX specifically (not some average of
|
|
// scaleX/scaleY) as its best-effort value for older code that only
|
|
// ever understood a single uniform scale -- the <transform> element
|
|
// below is what any code that understands both axes independently
|
|
// should prefer instead, exactly mirroring how QetShapeItem's own
|
|
// otherwise-identical <transform> element already works.
|
|
result.setAttribute("rotation", QString::number(QET::correctAngle(m_transform.rotation)));
|
|
result.setAttribute("size", QString::number(m_transform.scaleX));
|
|
result.setAttribute("is_movable", bool(is_movable_));
|
|
|
|
//write the pixmap in the xml element after he was been transformed to base64
|
|
QByteArray array;
|
|
QBuffer buffer(&array);
|
|
buffer.open(QIODevice::ReadWrite);
|
|
pixmap_.save(&buffer, "PNG");
|
|
QDomText base64 = document.createTextNode(array.toBase64());
|
|
result.appendChild(base64);
|
|
|
|
// Full-fidelity transform, only written when it actually carries
|
|
// something the rotation/size attributes above can't already
|
|
// express -- exactly the same convention QetShapeItem's own
|
|
// identical <transform> element already uses, for the same reason:
|
|
// this keeps a plain, never-resized-independently image's saved
|
|
// file byte-for-byte unchanged.
|
|
const bool hasRichTransform = !qFuzzyCompare(m_transform.scaleX, m_transform.scaleY)
|
|
|| !qFuzzyIsNull(m_transform.skewX) || !qFuzzyIsNull(m_transform.skewY) || m_pivotIsCustom;
|
|
if (hasRichTransform)
|
|
{
|
|
QDomElement transformElement = document.createElement("transform");
|
|
transformElement.setAttribute("rotation", QString::number(m_transform.rotation));
|
|
transformElement.setAttribute("skewX", QString::number(m_transform.skewX));
|
|
transformElement.setAttribute("skewY", QString::number(m_transform.skewY));
|
|
transformElement.setAttribute("scaleX", QString::number(m_transform.scaleX));
|
|
transformElement.setAttribute("scaleY", QString::number(m_transform.scaleY));
|
|
transformElement.setAttribute("pivotX", QString::number(m_transform.pivot.x()));
|
|
transformElement.setAttribute("pivotY", QString::number(m_transform.pivot.y()));
|
|
result.appendChild(transformElement);
|
|
}
|
|
|
|
// Only written when there's actually something to remember -- a
|
|
// plain image that's never been cropped or had transparency applied
|
|
// shouldn't carry this extra weight at all. pixmap_ above already
|
|
// reflects the current result on its own, so older code (or a file
|
|
// that never used either feature) reads back exactly what it
|
|
// always did; this is purely additional context for the dialogs'
|
|
// own memory, letting them be reopened non-destructively.
|
|
const bool hasCrop = (m_crop_rect != m_base_pixmap.rect());
|
|
const bool hasColors = !m_transparent_colors.isEmpty();
|
|
|
|
if (hasColors)
|
|
{
|
|
QDomElement colorsElement = document.createElement("transparent_colors");
|
|
// Best-effort fallback for an OLDER version of this same code
|
|
// (from before per-colour tolerance existed) reading a file
|
|
// saved by this one: uses the first colour's own tolerance as
|
|
// a single, plausible value rather than some fixed default,
|
|
// in case it ever needs to open a file like this. Newer code
|
|
// (including this version) always prefers each <color>'s own
|
|
// attribute below over this one.
|
|
colorsElement.setAttribute("tolerance", m_transparent_colors.first().tolerance);
|
|
for (const auto &pc : m_transparent_colors)
|
|
{
|
|
QDomElement colorElement = document.createElement("color");
|
|
colorElement.setAttribute("r", pc.color.red());
|
|
colorElement.setAttribute("g", pc.color.green());
|
|
colorElement.setAttribute("b", pc.color.blue());
|
|
colorElement.setAttribute("tolerance", pc.tolerance);
|
|
colorsElement.appendChild(colorElement);
|
|
}
|
|
result.appendChild(colorsElement);
|
|
}
|
|
|
|
if (hasCrop)
|
|
{
|
|
QDomElement cropElement = document.createElement("crop");
|
|
cropElement.setAttribute("x", m_crop_rect.x());
|
|
cropElement.setAttribute("y", m_crop_rect.y());
|
|
cropElement.setAttribute("w", m_crop_rect.width());
|
|
cropElement.setAttribute("h", m_crop_rect.height());
|
|
result.appendChild(cropElement);
|
|
}
|
|
|
|
if (hasCrop || hasColors)
|
|
{
|
|
QByteArray baseArray;
|
|
QBuffer baseBuffer(&baseArray);
|
|
baseBuffer.open(QIODevice::ReadWrite);
|
|
m_base_pixmap.save(&baseBuffer, "PNG");
|
|
QDomElement baseElement = document.createElement("image_base");
|
|
baseElement.appendChild(document.createTextNode(baseArray.toBase64()));
|
|
result.appendChild(baseElement);
|
|
}
|
|
|
|
return(result);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::contextMenuEvent
|
|
@param event
|
|
*/
|
|
void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
|
|
{
|
|
if (!diagram())
|
|
{
|
|
QetGraphicsItem::contextMenuEvent(event);
|
|
return;
|
|
}
|
|
|
|
if (diagram()->selectedItems().isEmpty())
|
|
this->setSelected(true);
|
|
|
|
if (isSelected() && scene()->selectedItems().size() == 1)
|
|
{
|
|
DiagramView *d_view = nullptr;
|
|
for (QGraphicsView *view : diagram()->views())
|
|
{
|
|
if (view->isActiveWindow())
|
|
{
|
|
d_view = dynamic_cast<DiagramView *>(view);
|
|
if (d_view)
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (d_view)
|
|
{
|
|
QScopedPointer<QMenu> menu(new QMenu());
|
|
|
|
QAction *replace = menu.data()->addAction(tr("Remplacer l'image..."));
|
|
connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage);
|
|
|
|
QAction *saveAs = menu.data()->addAction(tr("Enregistrer l'image sous..."));
|
|
connect(saveAs, &QAction::triggered, this, &DiagramImageItem::saveImageAs);
|
|
|
|
QAction *saveOriginalAs = menu.data()->addAction(tr("Enregistrer l'image d'origine sous..."));
|
|
connect(saveOriginalAs, &QAction::triggered, this, &DiagramImageItem::saveOriginalImageAs);
|
|
|
|
QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente..."));
|
|
transparentColor->setIcon(QET::Icons::EditOpacity);
|
|
connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor);
|
|
|
|
QAction *cropAction = menu.data()->addAction(tr("Rogner..."));
|
|
cropAction->setIcon(QET::Icons::TransformCrop);
|
|
connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop);
|
|
|
|
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
|
|
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
|
|
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
|
|
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
|
|
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
|
|
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
|
|
|
|
QAction *restoreRatio = menu.data()->addAction(tr("Restaurer les proportions"));
|
|
connect(restoreRatio, &QAction::triggered, this, &DiagramImageItem::restoreAspectRatio);
|
|
|
|
// menu.data()->addSeparator();
|
|
// QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
|
// connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
|
|
|
|
menu.data()->addSeparator();
|
|
menu.data()->addActions(d_view->contextMenuActions());
|
|
menu.data()->exec(event->screenPos());
|
|
event->accept();
|
|
return;
|
|
}
|
|
}
|
|
|
|
QetGraphicsItem::contextMenuEvent(event);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::replaceImage
|
|
Context-menu action: swaps the underlying pixmap for one loaded from
|
|
a new file, keeping position, rotation and scale untouched -- only
|
|
pixmap_ changes, everything else about how this item sits on the
|
|
diagram is left exactly as it was. Reuses the same file dialog
|
|
filter and error handling as DiagramEventAddImage::openDialog(), so
|
|
picking a replacement looks and behaves like picking a new image did
|
|
when this item was first inserted.
|
|
*/
|
|
void DiagramImageItem::replaceImage()
|
|
{
|
|
if (!diagram() || diagram()->isReadOnly())
|
|
return;
|
|
|
|
QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
|
|
const QString fileName = QFileDialog::getOpenFileName(
|
|
parentWidget, tr("Selectionner une image..."),
|
|
QETApp::pictureDir(), tr("Image Files (*.png *.jpg *.jpeg *.bmp *.svg)"));
|
|
if (fileName.isEmpty())
|
|
return;
|
|
|
|
QImage image(fileName);
|
|
if (image.isNull())
|
|
{
|
|
QMessageBox::critical(parentWidget, tr("Erreur"), tr("Impossible de charger l'image."));
|
|
return;
|
|
}
|
|
|
|
const QPixmap oldPixmap = pixmap_;
|
|
const QPixmap newPixmap = QPixmap::fromImage(image);
|
|
|
|
// A wholesale replacement, not an edit of the existing image -- the
|
|
// new picture has nothing to do with whatever colours were picked
|
|
// or region was cropped for the old one, so this starts that
|
|
// memory fresh rather than carrying over choices that would no
|
|
// longer make sense.
|
|
m_base_pixmap = newPixmap;
|
|
m_crop_rect = newPixmap.rect();
|
|
m_transparent_colors.clear();
|
|
|
|
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
|
undo->setText(tr("Remplacer une image"));
|
|
// Every call here is a separate, deliberate menu action with no
|
|
// compound child of its own (unlike crop(), which always chains a
|
|
// pos/rawPivot change and is naturally immune) -- two of them in a
|
|
// row would carry the exact same object, property, and text, which
|
|
// is indistinguishable from a legitimate merge to
|
|
// QPropertyUndoCommand::mergeWith(). A dummy child (already treated
|
|
// as "never merge" by that check) keeps each one its own, separate
|
|
// undo step regardless.
|
|
new QUndoCommand(undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::mirror
|
|
Context-menu action: flips the pixmap itself, not a transform --
|
|
unlike QetShapeItem::mirror(), which has rotation/skew to contend
|
|
with, there's no equivalent linear-transform decomposition needed
|
|
here: the bitmap is flipped once, directly, and stays flipped
|
|
regardless of whatever rotation is applied on top afterward.
|
|
*/
|
|
void DiagramImageItem::mirror(bool horizontal)
|
|
{
|
|
if (!diagram() || diagram()->isReadOnly())
|
|
return;
|
|
|
|
const QPixmap oldPixmap = pixmap_;
|
|
const QTransform flip = horizontal ? QTransform(-1, 0, 0, 1, 0, 0) : QTransform(1, 0, 0, -1, 0, 0);
|
|
const QPixmap newPixmap = pixmap_.transformed(flip);
|
|
|
|
// The base is flipped the same way, to stay in sync with pixmap_ --
|
|
// but the picked-colours list itself is left untouched: the actual
|
|
// colour values don't change when the image is mirrored, only their
|
|
// positions, so whatever was already keyed transparent should stay
|
|
// remembered and still apply correctly to the flipped version.
|
|
m_base_pixmap = m_base_pixmap.transformed(flip);
|
|
|
|
// m_crop_rect, unlike the colour list, DOES need to change: it's
|
|
// defined in terms of positions within the base, and those
|
|
// positions just moved. Width/height and the other axis are
|
|
// untouched -- only the axis being flipped needs its edge mirrored
|
|
// (dimensions are unaffected by the flip, so it doesn't matter
|
|
// whether this reads m_base_pixmap's size from before or after the
|
|
// assignment above).
|
|
if (horizontal)
|
|
m_crop_rect = QRect(m_base_pixmap.width() - m_crop_rect.left() - m_crop_rect.width(),
|
|
m_crop_rect.top(), m_crop_rect.width(), m_crop_rect.height());
|
|
else
|
|
m_crop_rect = QRect(m_crop_rect.left(), m_base_pixmap.height() - m_crop_rect.top() - m_crop_rect.height(),
|
|
m_crop_rect.width(), m_crop_rect.height());
|
|
|
|
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
|
undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image"));
|
|
// See replaceImage()'s identical comment: a separate, deliberate
|
|
// action with no compound child of its own, so a dummy one is
|
|
// needed to stop two consecutive same-direction mirrors (identical
|
|
// object, property, and text) from silently merging into one.
|
|
new QUndoCommand(undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::setTransparentColor
|
|
Context-menu action: opens ImageTransparentColorDialog against
|
|
m_base_pixmap (the pristine source), pre-populated with whatever
|
|
colours and tolerance were remembered from a previous session --
|
|
both problems fixed together, since they had the same root cause:
|
|
passing pixmap_ (the already colour-keyed result) as if it were the
|
|
source, with nowhere to remember which colours produced it. Applies
|
|
the result the same way replaceImage() and mirror() do, through the
|
|
"pixmap" property, so undo/redo stays consistent across all three;
|
|
m_base_pixmap itself is deliberately left untouched here, since this
|
|
action only ever changes which colours are keyed out of it, not the
|
|
source those colours are keyed out of.
|
|
*/
|
|
/**
|
|
@brief DiagramImageItem::setTransparentColor
|
|
Context-menu action: opens ImageTransparentColorDialog against the
|
|
CROPPED base (m_base_pixmap.copy(m_crop_rect)), not the full,
|
|
uncropped original -- picking a colour from a region that's already
|
|
been permanently cropped away would be picking a colour that isn't
|
|
even part of the image anymore. Pre-populated with whatever colours
|
|
and tolerance were remembered from a previous session. Applies the
|
|
result the same way replaceImage() and mirror() do, through the
|
|
"pixmap" property, so undo/redo stays consistent across all three;
|
|
m_base_pixmap and m_crop_rect are deliberately left untouched here,
|
|
since this action only ever changes which colours are keyed out,
|
|
never the source region they're keyed out of.
|
|
*/
|
|
void DiagramImageItem::setTransparentColor()
|
|
{
|
|
if (!diagram() || diagram()->isReadOnly())
|
|
return;
|
|
|
|
QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
|
|
const QPixmap croppedBase = m_base_pixmap.copy(m_crop_rect);
|
|
ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, parentWidget);
|
|
if (dialog.exec() != QDialog::Accepted)
|
|
return;
|
|
|
|
m_transparent_colors = dialog.pickedColors();
|
|
|
|
const QPixmap oldPixmap = pixmap_;
|
|
const QPixmap newPixmap = dialog.resultPixmap();
|
|
|
|
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
|
undo->setText(tr("Définir une couleur transparente"));
|
|
// See replaceImage()'s identical comment: a separate, deliberate
|
|
// action with no compound child of its own, so a dummy one is
|
|
// needed to stop two consecutive transparency edits (identical
|
|
// object, property, and text) from silently merging into one.
|
|
new QUndoCommand(undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
|
|
/**
|
|
@brief DiagramImageItem::crop
|
|
Context-menu action: opens ImageCropDialog against pixmap_ (the
|
|
current, already colour-keyed display, so cropping is WYSIWYG
|
|
against whatever is actually visible), then applies the chosen
|
|
rectangle to both pixmap_ and m_base_pixmap together -- kept in
|
|
sync the same way mirror() keeps them in sync, since cropping is a
|
|
permanent, geometric change to the image's own content, unlike
|
|
setTransparentColor()'s non-destructive colour keying.
|
|
|
|
pos() also needs adjusting, not just pixmap_: setPixmap() (called
|
|
via the "pixmap" undo command below) recomputes
|
|
transformOriginPoint() from the new, smaller boundingRect(), but
|
|
pos() itself is untouched by that -- without fixing it up here too,
|
|
the surviving content would visually jump to wherever local (0,0)
|
|
happens to land after shrinking, rather than staying exactly where
|
|
it already was. Chained into one undo step together with the pixmap
|
|
change, since undoing a crop has to restore both, or the restored
|
|
(larger) image ends up in the wrong place.
|
|
*/
|
|
/**
|
|
@brief DiagramImageItem::crop
|
|
Context-menu action: opens ImageCropDialog against m_base_pixmap
|
|
(the true, uncropped original), pre-populated with whatever crop
|
|
rectangle was chosen in a previous session -- re-editable, not
|
|
destructive: nothing about the original content is ever discarded,
|
|
only which region of it is currently being shown, exactly the same
|
|
principle setTransparentColor() already follows for its own choices.
|
|
Recomputes pixmap_ via computeDisplayPixmap() so any already-picked
|
|
transparent colours are correctly re-applied to the newly-cropped
|
|
region, rather than lost (the crop dialog itself knows nothing
|
|
about them).
|
|
|
|
pos() also needs adjusting, not just pixmap_: setPixmap() (called
|
|
via the "pixmap" undo command below) recomputes
|
|
transformOriginPoint() from the new boundingRect(), but pos() itself
|
|
is untouched by that -- without fixing it up here too, the
|
|
surviving content would visually jump to wherever local (0,0) ends
|
|
up after the crop rect changes, rather than staying exactly where
|
|
it already was. This has to work whether this is the first crop
|
|
ever applied or an adjustment of an existing one, so the position
|
|
math is always done relative to the CURRENT crop rect (m_crop_rect,
|
|
before it's updated below) -- when there's no previous crop, that's
|
|
simply the whole base, which is what the very first version of this
|
|
method assumed unconditionally.
|
|
|
|
Chained into one undo step together with the pixmap change, since
|
|
undoing a crop has to restore both, or the restored (larger) image
|
|
ends up in the wrong place.
|
|
*/
|
|
void DiagramImageItem::crop()
|
|
{
|
|
if (!diagram() || diagram()->isReadOnly())
|
|
return;
|
|
|
|
QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
|
|
ImageCropDialog dialog(m_base_pixmap, m_crop_rect, parentWidget);
|
|
if (dialog.exec() != QDialog::Accepted)
|
|
return;
|
|
|
|
const QRect newCropRect = dialog.cropRect();
|
|
if (newCropRect.isEmpty() || newCropRect == m_crop_rect)
|
|
return; // nothing actually changed
|
|
|
|
// newCropRect is in m_base_pixmap's own coordinates; converting its
|
|
// center into the CURRENT local space (pixmap_'s own coordinates,
|
|
// i.e. relative to the OLD m_crop_rect's own top-left) before
|
|
// mapping to the scene through the transform that's still active
|
|
// right now, prior to anything below changing it.
|
|
const QPointF newCropCenterInCurrentLocal = QRectF(newCropRect).center() - QPointF(m_crop_rect.topLeft());
|
|
const QPointF cropCenterScene = mapToScene(newCropCenterInCurrentLocal);
|
|
const QPointF oldPos = pos();
|
|
|
|
const QPixmap oldPixmap = pixmap_;
|
|
const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors);
|
|
m_crop_rect = newCropRect;
|
|
|
|
// boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by
|
|
// reading the actual implementation, not assumed) -- so the new
|
|
// origin point can be derived directly from newPixmap here. Unlike
|
|
// before this class gained a proper transform, setPixmap() no
|
|
// longer manages the pivot automatically at all (see its own
|
|
// comment for why) -- crop() now has to set it explicitly itself,
|
|
// chained into the same undo step as the pixmap and position
|
|
// changes, since all three genuinely change together here.
|
|
const QPointF oldPivot = m_transform.pivot;
|
|
const QPointF newOriginPoint = QRectF(newPixmap.rect()).center();
|
|
const QPointF newPos = cropCenterScene - newOriginPoint;
|
|
|
|
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
|
undo->setText(tr("Rogner une image"));
|
|
new QPropertyUndoCommand(this, "pos", oldPos, newPos, undo);
|
|
new QPropertyUndoCommand(this, "rawPivot", oldPivot, newOriginPoint, undo);
|
|
m_pivotIsCustom = false;
|
|
diagram()->undoStack().push(undo);
|
|
}
|