All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.igormaznitsa.mindmap.swing.panel.MindMapPanel Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (C) 2015-2022 Igor A. Maznitsa
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.igormaznitsa.mindmap.swing.panel;

import static com.igormaznitsa.mindmap.swing.panel.utils.Utils.assertSwingDispatchThread;
import static com.igormaznitsa.mindmap.swing.panel.utils.Utils.isPopupEvent;
import static java.awt.event.InputEvent.ALT_MASK;
import static java.awt.event.InputEvent.CTRL_MASK;
import static java.awt.event.InputEvent.META_MASK;
import static java.awt.event.InputEvent.SHIFT_MASK;
import static java.util.Objects.requireNonNull;

import com.igormaznitsa.mindmap.model.Extra;
import com.igormaznitsa.mindmap.model.ExtraFile;
import com.igormaznitsa.mindmap.model.ExtraNote;
import com.igormaznitsa.mindmap.model.ExtraTopic;
import com.igormaznitsa.mindmap.model.MindMap;
import com.igormaznitsa.mindmap.model.ModelUtils;
import com.igormaznitsa.mindmap.model.StandardMmdAttributes;
import com.igormaznitsa.mindmap.model.Topic;
import com.igormaznitsa.mindmap.model.logger.Logger;
import com.igormaznitsa.mindmap.model.logger.LoggerFactory;
import com.igormaznitsa.mindmap.plugins.MindMapPluginRegistry;
import com.igormaznitsa.mindmap.plugins.api.ModelAwarePlugin;
import com.igormaznitsa.mindmap.plugins.api.PanelAwarePlugin;
import com.igormaznitsa.mindmap.plugins.api.PluginContext;
import com.igormaznitsa.mindmap.plugins.api.VisualAttributePlugin;
import com.igormaznitsa.mindmap.swing.i18n.MmdI18n;
import com.igormaznitsa.mindmap.swing.ide.IDEBridgeFactory;
import com.igormaznitsa.mindmap.swing.panel.ui.AbstractCollapsableElement;
import com.igormaznitsa.mindmap.swing.panel.ui.AbstractElement;
import com.igormaznitsa.mindmap.swing.panel.ui.ElementLevelFirst;
import com.igormaznitsa.mindmap.swing.panel.ui.ElementLevelOther;
import com.igormaznitsa.mindmap.swing.panel.ui.ElementPart;
import com.igormaznitsa.mindmap.swing.panel.ui.ElementRoot;
import com.igormaznitsa.mindmap.swing.panel.ui.MouseSelectedArea;
import com.igormaznitsa.mindmap.swing.panel.ui.gfx.MMGraphics;
import com.igormaznitsa.mindmap.swing.panel.ui.gfx.MMGraphics2DWrapper;
import com.igormaznitsa.mindmap.swing.panel.ui.gfx.StrokeType;
import com.igormaznitsa.mindmap.swing.panel.utils.KeyEventType;
import com.igormaznitsa.mindmap.swing.panel.utils.MindMapUtils;
import com.igormaznitsa.mindmap.swing.panel.utils.Pair;
import com.igormaznitsa.mindmap.swing.panel.utils.RenderQuality;
import com.igormaznitsa.mindmap.swing.panel.utils.Utils;
import com.igormaznitsa.mindmap.swing.services.UIComponentFactory;
import com.igormaznitsa.mindmap.swing.services.UIComponentFactoryProvider;
import java.awt.AWTEvent;
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Container;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.ClipboardOwner;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.geom.Dimension2D;
import java.awt.geom.GeneralPath;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JTextArea;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import org.apache.commons.text.StringEscapeUtils;

/**
 * Swing component allows to visualize and edit MMD Mind Map model.
 * It is not thread safe one and as a swing component, it should be used from swing thread!
 *
 * @see MindMap
 */
public class MindMapPanel extends JComponent implements ClipboardOwner {

  private static final long serialVersionUID = 2783412123454232L;
  private static final int MIN_DISTANCE_FOR_TOPIC_DRAGGING_START = 8;
  private static final Logger LOGGER = LoggerFactory.getLogger(MindMapPanel.class);
  private static final UIComponentFactory UI_COMPO_FACTORY =
      UIComponentFactoryProvider.findInstance();
  private static final int ALL_SUPPORTED_MODIFIERS =
      SHIFT_MASK | ALT_MASK | META_MASK | CTRL_MASK;
  private static final double SCALE_STEP = 0.1d;
  private static final double SCALE_MINIMUM = 0.3d;
  private static final double SCALE_MAXIMUM = 8.0d;
  private static final Color COLOR_MOUSE_DRAG_SELECTION = new Color(0x80000000, true);
  private static final int DRAG_POSITION_UNKNOWN = -1;
  private static final int DRAG_POSITION_LEFT = 1;
  private static final int DRAG_POSITION_TOP = 2;
  private static final int DRAG_POSITION_BOTTOM = 3;
  private static final int DRAG_POSITION_RIGHT = 4;
  private final MindMapPanelController controller;
  private final AtomicBoolean disposed = new AtomicBoolean();
  private final Map sessionObjects = new ConcurrentHashMap<>();
  private final List mindMapListeners = new CopyOnWriteArrayList<>();
  private final JTextArea textEditor = UI_COMPO_FACTORY.makeTextArea();
  private final JPanel textEditorPanel = UI_COMPO_FACTORY.makePanel();
  private boolean birdsEyeMode;
  private final List selectedTopics = new ArrayList<>();
  private final MindMapPanelConfig config;
  private final AtomicBoolean popupMenuActive = new AtomicBoolean();
  private final AtomicBoolean removeEditedTopicForRollback = new AtomicBoolean();
  private final UUID uuid = UUID.randomUUID();
  private final transient ResourceBundle bundle = MmdI18n.getInstance().findBundle();
  private Dimension mindMapImageSize = new Dimension();
  private volatile MindMap model;
  private volatile String errorText;
  private transient AbstractElement elementUnderEdit = null;
  private transient int[] pathToPrevTopicBeforeEdit = null;
  private transient MouseSelectedArea mouseDragSelection = null;
  private transient DraggedElement draggedElement = null;
  private transient AbstractElement destinationElement = null;
  private Point lastMousePressed = null;

  /**
   * COnstructor.
   *
   * @param controller object providing information for panel which operations allowed and take part in some functions, must not be null
   * @throws NullPointerException if controller is null
   */
  public MindMapPanel(final MindMapPanelController controller) {
    super();

    final MindMapPanelConfig panelConfig = controller.provideConfigForMindMapPanel(this);

    this.textEditorPanel.setLayout(new BorderLayout(0, 0));
    this.controller = controller;

    this.config = new MindMapPanelConfig(panelConfig, false);

    this.textEditor.setMargin(new Insets(5, 5, 5, 5));
    this.textEditor.setBorder(BorderFactory.createEtchedBorder());
    this.textEditor.setTabSize(4);
    this.textEditor.addKeyListener(new KeyAdapter() {

      @Override
      public void keyPressed(final KeyEvent e) {
        if (isDisposed()) {
          return;
        }
        if (birdsEyeMode) {
          e.consume();
        } else if (!e.isConsumed()) {
          switch (e.getKeyCode()) {
            case KeyEvent.VK_ENTER: {
              e.consume();
            }
            break;
            case KeyEvent.VK_TAB: {
              if ((e.getModifiers() & ALL_SUPPORTED_MODIFIERS) == 0) {
                e.consume();
                final Topic edited = elementUnderEdit.getModel();
                final int[] topicPosition = edited.getPositionPath();
                endEdit(true);
                final Topic theTopic = model.findAtPosition(topicPosition);
                if (theTopic != null) {
                  makeNewChildAndFocus(theTopic, null,
                      controller.isStartEditNewTopicCreatedDuringEdit(MindMapPanel.this));
                }
              }
            }
            break;
            default:
              break;
          }
        }
      }

      @Override
      public void keyTyped(final KeyEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          if (config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_TEXT_NEXT_LINE, e)) {
            e.consume();
            textEditor.insert("\n", textEditor.getCaretPosition());
          } else if (e.getKeyChar() == KeyEvent.VK_ENTER &&
              (e.getModifiers() & ALL_SUPPORTED_MODIFIERS) == 0) {
            e.consume();
            endEdit(true);
          }
        }
      }

      @Override
      public void keyReleased(final KeyEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          if (config.isKeyEvent(MindMapPanelConfig.KEY_CANCEL_EDIT, e)) {
            e.consume();
            final Topic edited = elementUnderEdit == null ? null : elementUnderEdit.getModel();
            endEdit(false);
            if (edited != null && controller.canTopicBeDeleted(MindMapPanel.this, edited)) {
              deleteTopics(false, edited);
              if (pathToPrevTopicBeforeEdit != null) {
                final int[] path = pathToPrevTopicBeforeEdit;
                pathToPrevTopicBeforeEdit = null;
                final Topic topic = model.findAtPosition(path);
                if (topic != null) {
                  select(topic, false);
                }
              }
            }
          }
        }
      }
    });

    this.textEditor.getDocument().addDocumentListener(new DocumentListener() {

      private void updateEditorPanelSize(final Dimension newSize) {
        final Dimension editorPanelMinSize = textEditorPanel.getMinimumSize();
        final Dimension newDimension =
            new Dimension(Math.max(editorPanelMinSize.width, newSize.width),
                Math.max(editorPanelMinSize.height, newSize.height));

        final Rectangle editorBounds = textEditorPanel.getBounds();
        editorBounds.setSize(newDimension);

        final Rectangle mainPanelBounds = MindMapPanel.this.getBounds();

        if (!mainPanelBounds.contains(editorBounds)) {
          double ex = editorBounds.getX();
          double ey = editorBounds.getY();
          double ew = editorBounds.getWidth();
          double eh = editorBounds.getHeight();

          if (ex < 0.0d) {
            ew -= ex;
            ex = 0.0d;
          }
          if (ey < 0.0d) {
            eh -= ey;
            ey = 0.0d;
          }

          if (ex + ew > mainPanelBounds.getWidth()) {
            ex = mainPanelBounds.getWidth() - ew;
          }
          if (ey + eh > mainPanelBounds.getHeight()) {
            ey = mainPanelBounds.getHeight() - eh;
          }

          editorBounds.setRect(ex, ey, ew, eh);
        }

        textEditorPanel.setBounds(editorBounds);
        textEditorPanel.repaint();
      }

      private void callUpdateEditorPanelSize() {
        SwingUtilities.invokeLater(() -> updateEditorPanelSize(textEditor.getPreferredSize()));
      }

      @Override
      public void insertUpdate(final DocumentEvent e) {
        callUpdateEditorPanelSize();
      }

      @Override
      public void removeUpdate(final DocumentEvent e) {
        callUpdateEditorPanelSize();
      }

      @Override
      public void changedUpdate(final DocumentEvent e) {
        callUpdateEditorPanelSize();
      }
    });

    super.setOpaque(true);

    final KeyAdapter keyAdapter = new KeyAdapter() {
      @Override
      public void keyPressed(final KeyEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          fireNotificationNonConsumedKeyEvent(e, KeyEventType.PRESSED);
        }
      }

      private void processTypedKeyInternal(final KeyEvent e) {
        if (config.isKeyEvent(MindMapPanelConfig.KEY_ADD_CHILD_AND_START_EDIT, e)) {
          e.consume();
          if (!selectedTopics.isEmpty()) {
            makeNewChildAndFocus(selectedTopics.get(0), null, true);
          }
        } else if (config.isKeyEvent(MindMapPanelConfig.KEY_ADD_SIBLING_AND_START_EDIT, e)) {
          e.consume();
          if (!hasActiveEditor() && hasOnlyTopicSelected()) {
            final Topic baseTopic = selectedTopics.get(0);
            makeNewChildAndFocus(
                baseTopic.getParent() == null ? baseTopic : baseTopic.getParent(), baseTopic, true);
          }
        } else if (config.isKeyEvent(MindMapPanelConfig.KEY_FOCUS_ROOT_OR_START_EDIT, e)) {
          e.consume();
          if (!hasSelectedTopics()) {
            select(getModel().getRoot(), false);
          } else if (hasOnlyTopicSelected()) {
            startEdit((AbstractElement) selectedTopics.get(0).getPayload());
          }
        }
      }

      @Override
      public void keyTyped(final KeyEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          this.processTypedKeyInternal(e);
          if (!e.isConsumed()) {
            fireNotificationNonConsumedKeyEvent(e, KeyEventType.TYPED);
          }
        }
      }

      @Override
      public void keyReleased(final KeyEvent e) {
        if (isDisposed()) {
          return;
        }

        if (!e.isConsumed()) {
          if ((e.getKeyCode() >= KeyEvent.VK_F1 && e.getKeyCode() <= KeyEvent.VK_F12)
              || e.getKeyCode() == KeyEvent.VK_INSERT) {
            this.processTypedKeyInternal(e);
          }

          if (!e.isConsumed()) {
            if (e.getKeyCode() == KeyEvent.VK_CONTEXT_MENU) {
              e.consume();
              processContextMenuKey();
            } else if (config.isKeyEvent(MindMapPanelConfig.KEY_SHOW_POPUP, e)) {
              e.consume();
              processPopUpForShortcut();
            } else if (config.isKeyEvent(MindMapPanelConfig.KEY_DELETE_TOPIC, e)) {
              e.consume();
              focusTo(deleteSelectedTopics(false));
            } else if (config.isKeyEventDetected(e,
                MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT,
                MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT,
                MindMapPanelConfig.KEY_FOCUS_MOVE_UP,
                MindMapPanelConfig.KEY_FOCUS_MOVE_DOWN,
                MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT_ADD_FOCUSED,
                MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT_ADD_FOCUSED,
                MindMapPanelConfig.KEY_FOCUS_MOVE_UP_ADD_FOCUSED,
                MindMapPanelConfig.KEY_FOCUS_MOVE_DOWN_ADD_FOCUSED)) {
              e.consume();
              processMoveFocusByKey(e);
            } else if (config.isKeyEvent(MindMapPanelConfig.KEY_ZOOM_IN, e)) {
              e.consume();
              setScale(Math.max(SCALE_MINIMUM, Math.min(getScale() + SCALE_STEP, SCALE_MAXIMUM)),
                  false);
              doLayout();
              revalidate();
              repaint();
            } else if (config.isKeyEvent(MindMapPanelConfig.KEY_ZOOM_OUT, e)) {
              e.consume();
              setScale(Math.max(SCALE_MINIMUM, Math.min(getScale() - SCALE_STEP, SCALE_MAXIMUM)),
                  false);
              doLayout();
              revalidate();
              repaint();
            } else if (config.isKeyEvent(MindMapPanelConfig.KEY_ZOOM_RESET, e)) {
              e.consume();
              setScale(1.0, false);
              doLayout();
              revalidate();
              repaint();
            } else if (config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_FOLD, e)
                || config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_UNFOLD, e)
                || config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_FOLD_ALL, e)
                || config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_UNFOLD_ALL, e)) {
              e.consume();
              final List elements = new ArrayList<>();
              for (final Topic t : getSelectedTopics()) {
                final AbstractElement element = (AbstractElement) t.getPayload();
                if (element != null) {
                  elements.add(element);
                }
              }
              if (!elements.isEmpty()) {
                endEdit(false);
                doFoldOrUnfoldTopic(elements,
                    config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_FOLD, e) ||
                        config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_FOLD_ALL, e),
                    config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_FOLD, e) ||
                        config.isKeyEvent(MindMapPanelConfig.KEY_TOPIC_UNFOLD, e)
                );
              }
            }

            if (!e.isConsumed()) {
              fireNotificationNonConsumedKeyEvent(e, KeyEventType.RELEASED);
            }
          }
        }
      }
    };

    this.setFocusTraversalKeysEnabled(false);

    final MouseAdapter adapter = new MouseAdapter() {

      @Override
      public void mouseEntered(final MouseEvent e) {
        setCursor(Cursor.getDefaultCursor());
      }

      @Override
      public void mouseMoved(final MouseEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          if (!controller.isMouseMoveProcessingAllowed(MindMapPanel.this)) {
            return;
          }
          final AbstractElement element = findTopicUnderPoint(e.getPoint());
          if (element == null) {
            setCursor(Cursor.getDefaultCursor());
            setToolTipText(null);
          } else {
            final ElementPart part = element.findPartForPoint(e.getPoint());
            switch (part) {
              case ICONS: {
                final Extra extra = element.getIconBlock()
                    .findExtraForPoint(e.getPoint().getX() - element.getBounds().getX(),
                        e.getPoint().getY() - element.getBounds().getY());
                if (extra != null) {
                  setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
                  setToolTipText(makeHtmlTooltipForExtra(extra));
                } else {
                  setCursor(null);
                  setToolTipText(null);
                }
              }
              break;
              case VISUAL_ATTRIBUTES: {
                final VisualAttributePlugin plugin = element.getVisualAttributeImageBlock()
                    .findPluginForPoint(e.getPoint().getX() - element.getBounds().getX(),
                        e.getPoint().getY() - element.getBounds().getY());
                final PluginContext context = controller.makePluginContext(MindMapPanel.this);
                if (plugin != null) {
                  final Topic theTopic = element.getModel();
                  if (plugin.isClickable(context, theTopic)) {
                    setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
                  } else {
                    setCursor(null);
                  }
                  setToolTipText(plugin.getToolTip(context, theTopic));
                } else {
                  setCursor(null);
                  setToolTipText(null);
                }
              }
              break;
              case COLLAPSATOR: {
                setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
                setToolTipText(null);
              }
              break;
              default: {
                setCursor(Cursor.getDefaultCursor());
                setToolTipText(null);
              }
              break;
            }
          }
        }
      }

      @Override
      public void mousePressed(final MouseEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          lastMousePressed = e.getPoint();
          if (!controller.isMouseClickProcessingAllowed(MindMapPanel.this)) {
            return;
          }
          if (!birdsEyeMode) {
            try {
              if (isBirdsEyeActivationEvent(e) && elementUnderEdit == null) {
                birdsEyeMode = true;
                e.consume();
                repaint();
              } else if (isPopupEvent(e)) {
                mouseDragSelection = null;
                MindMap theMap = model;
                AbstractElement element = null;
                if (theMap != null) {
                  element = findTopicUnderPoint(e.getPoint());
                }
                processPopUp(e.getPoint(), element);
                e.consume();
              } else {
                if (elementUnderEdit != null) {
                  endEdit(!(textEditor.getText().isEmpty() && removeEditedTopicForRollback.get()));
                }
                mouseDragSelection = null;
              }
            } catch (Exception ex) {
              LOGGER.error("Error during mousePressed()", ex);
            }
          }
        }
      }

      @Override
      public void mouseReleased(final MouseEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          if (!controller.isMouseClickProcessingAllowed(MindMapPanel.this)) {
            return;
          }
          if (birdsEyeMode) {
            birdsEyeMode = !isBirdsEyeModeMouseButton(e);
            if (!birdsEyeMode) {
              repaint();
            }
          } else {
            try {
              if (draggedElement != null) {
                draggedElement.updatePosition(e.getPoint());
                if (endDragOfElement(draggedElement, destinationElement)) {
                  doLayout();
                  revalidate();
                  repaint();
                  fireNotificationMindMapChanged(true);
                }
              } else if (mouseDragSelection != null) {
                final List covered = mouseDragSelection.getAllSelectedElements(model);
                if (e.isShiftDown()) {
                  for (final Topic m : covered) {
                    select(m, false);
                  }
                } else if (e.isControlDown()) {
                  for (final Topic m : covered) {
                    select(m, true);
                  }
                } else {
                  removeAllSelection();
                  for (final Topic m : covered) {
                    select(m, false);
                  }
                }
              } else if (isPopupEvent(e)) {
                mouseDragSelection = null;
                MindMap theMap = model;
                AbstractElement element = null;
                if (theMap != null) {
                  element = findTopicUnderPoint(e.getPoint());
                }
                processPopUp(e.getPoint(), element);
                e.consume();
              }
            } catch (Exception ex) {
              LOGGER.error("Error during mouseReleased()", ex);
            } finally {
              mouseDragSelection = null;
              draggedElement = null;
              destinationElement = null;
              repaint();
            }
          }
        }
      }

      private boolean isNonOverCollapsator(final MouseEvent e, final AbstractElement element) {
        final ElementPart part = element.findPartForPoint(e.getPoint());
        return part != ElementPart.COLLAPSATOR;
      }

      private boolean isDraggedDistanceReached(final MouseEvent dragEvent) {
        boolean result = false;
        if (lastMousePressed != null) {
          final double dx = (double) lastMousePressed.x - dragEvent.getX();
          final double dy = (double) lastMousePressed.y - dragEvent.getY();
          final double distance = Math.sqrt(dx * dx + dy * dy);
          result = distance >= MIN_DISTANCE_FOR_TOPIC_DRAGGING_START;
        }
        return result;
      }

      @Override
      public void mouseDragged(final MouseEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          if (!controller.isMouseMoveProcessingAllowed(MindMapPanel.this)) {
            return;
          }
          scrollRectToVisible(new Rectangle(e.getX(), e.getY(), 1, 1));

          if (birdsEyeMode) {
            processMouseEventInBirdsEyeMode(e);
            e.consume();
          } else if (popupMenuActive.get()) {
            mouseDragSelection = null;
          } else {
            if (draggedElement == null && mouseDragSelection == null) {
              final AbstractElement elementUnderMouse = findTopicUnderPoint(e.getPoint());
              if (elementUnderMouse == null) {
                MindMap theMap = model;
                if (theMap != null) {
                  final AbstractElement element = findTopicUnderPoint(e.getPoint());
                  if (controller.isSelectionAllowed(MindMapPanel.this) && element == null) {
                    mouseDragSelection = new MouseSelectedArea(e.getPoint());
                  }
                }
              } else if (controller.isElementDragAllowed(MindMapPanel.this)) {
                if (elementUnderMouse.isMoveable() &&
                    isNonOverCollapsator(e, elementUnderMouse) && isDraggedDistanceReached(e)) {
                  selectedTopics.clear();

                  final Point mouseOffset = new Point((int) Math.round(
                      e.getPoint().getX() - elementUnderMouse.getBounds().getX()),
                      (int) Math.round(
                          e.getPoint().getY() - elementUnderMouse.getBounds().getY()));
                  draggedElement = new DraggedElement(elementUnderMouse, config, mouseOffset,
                      e.isControlDown() || e.isMetaDown() ? DraggedElement.Modifier.MAKE_JUMP :
                          DraggedElement.Modifier.NONE, getConfiguration().getRenderQuality());
                  draggedElement.updatePosition(e.getPoint());
                  findDestinationElementForDragged();
                } else {
                  draggedElement = null;
                }
                repaint();
              }
            } else if (mouseDragSelection != null) {
              if (controller.isSelectionAllowed(MindMapPanel.this)) {
                mouseDragSelection.update(e);
              } else {
                mouseDragSelection = null;
              }
              repaint();
            } else {
              if (controller.isElementDragAllowed(MindMapPanel.this)) {
                draggedElement.updatePosition(e.getPoint());
                findDestinationElementForDragged();
              } else {
                draggedElement = null;
              }
              repaint();
            }
          }
        }
      }

      @Override
      public void mouseWheelMoved(final MouseWheelEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!e.isConsumed()) {
          if (controller.isMouseWheelProcessingAllowed(MindMapPanel.this)) {
            mouseDragSelection = null;
            draggedElement = null;

            final MindMapPanelConfig finalConfig = config;

            if (!e.isConsumed() && (e.getModifiers() & finalConfig.getScaleModifiers()) ==
                finalConfig.getScaleModifiers()) {
              e.consume();

              endEdit(elementUnderEdit != null);

              final Dimension oldSize = MindMapPanel.this.mindMapImageSize;

              final double oldScale = getScale();
              final double curScale = ((long) (10.0d *
                  (oldScale + (SCALE_STEP * -e.getWheelRotation()) + (SCALE_STEP / 2.0d)))) /
                  10.0d;
              final double newScale = Math.max(SCALE_MINIMUM, Math.min(curScale, SCALE_MAXIMUM));

              setScale(newScale, false);
              MindMapPanel.this.doLayout();
              MindMapPanel.this.revalidate();
              MindMapPanel.this.repaint();

              final Dimension newSize = MindMapPanel.this.mindMapImageSize;

              fireNotificationScaledByMouse(e.getPoint(), oldScale, newScale, oldSize, newSize);
            } else {
              if (!e.isConsumed()) {
                sendToParent(e);
              }
            }
          }
        }
      }

      @Override
      public void mouseClicked(final MouseEvent e) {
        if (isDisposed()) {
          return;
        }
        if (!birdsEyeMode) {
          if (!e.isConsumed() && !popupMenuActive.get()) {
            requestFocus();
            if (!controller.isMouseClickProcessingAllowed(MindMapPanel.this)
                || (controller.isBirdsEyeAllowed(MindMapPanel.this) &&
                MindMapPanel.this.isBirdsEyeActivationEvent(e))) {
              return;
            }
            mouseDragSelection = null;
            draggedElement = null;

            MindMap theMap = model;
            AbstractElement element = null;
            if (theMap != null) {
              element = findTopicUnderPoint(e.getPoint());
            }

            final boolean isCtrlDown = e.isControlDown();
            final boolean isShiftDown = e.isShiftDown();

            if (element != null) {
              final ElementPart part = element.findPartForPoint(e.getPoint());
              if (part == ElementPart.COLLAPSATOR) {
                fireNotificationTopicCollapsatorClick(element.getModel(), true);
                doFoldOrUnfoldTopic(Collections.singletonList(element), !element.isCollapsed(),
                    isCtrlDown);
                if (selectedTopics.isEmpty() || selectedTopics.size() == 1) {
                  removeAllSelection();
                  select(element.getModel(), false);
                }
                fireNotificationTopicCollapsatorClick(element.getModel(), false);
              } else if (!isCtrlDown) {
                switch (part) {
                  case VISUAL_ATTRIBUTES:
                    final VisualAttributePlugin plugin = element.getVisualAttributeImageBlock()
                        .findPluginForPoint(e.getPoint().getX() - element.getBounds().getX(),
                            e.getPoint().getY() - element.getBounds().getY());
                    boolean processedByPlugin = false;
                    if (plugin != null) {
                      final PluginContext context = controller.makePluginContext(MindMapPanel.this);
                      if (plugin.isClickable(context, element.getModel())) {
                        processedByPlugin = true;
                        try {
                          if (plugin.onClick(context, element.getModel(),
                              e.isShiftDown() || e.isControlDown(), e.getClickCount())) {
                            doLayout();
                            revalidate();
                            repaint();
                            fireNotificationMindMapChanged(true);
                          }
                        } catch (Exception ex) {
                          LOGGER.error("Error during visual attribute processing", ex);
                          controller.getDialogProvider(MindMapPanel.this)
                              .msgError(IDEBridgeFactory.findInstance().findApplicationComponent(),
                                  "Detectd unexpected critical error! See log!\n" +
                                      ex.getMessage());
                        }
                      }
                    }
                    if (!processedByPlugin) {
                      removeAllSelection();
                      select(element.getModel(), false);
                    }
                    break;
                  case ICONS:
                    final Extra extra = element.getIconBlock()
                        .findExtraForPoint(e.getPoint().getX() - element.getBounds().getX(),
                            e.getPoint().getY() - element.getBounds().getY());
                    if (extra != null) {
                      fireNotificationClickOnExtra(element.getModel(), e.getModifiers(),
                          e.getClickCount(), extra);
                    }
                    break;
                  default:
                    if (isShiftDown) {
                      // diapason
                      selectSiblingDiapason(element);
                    } else {
                      // only
                      removeAllSelection();
                      select(element.getModel(), false);
                      if (e.getClickCount() > 1) {
                        startEdit(element);
                      }
                    }
                    break;
                }
              } else {
                // group
                select(element.getModel(), !selectedTopics.isEmpty());
              }
            }
          }
        }
      }
    };

    SwingUtilities.invokeLater(() -> {
      addComponentListener(new ComponentAdapter() {
        @Override
        public void componentResized(final ComponentEvent e) {
          doLayout();
          updateEditorAfterResizing();
        }
      });

      addMouseWheelListener(adapter);
      addMouseListener(adapter);
      addMouseMotionListener(adapter);
      addKeyListener(keyAdapter);

      textEditorPanel.add(textEditor, BorderLayout.CENTER);
      textEditorPanel.setVisible(false);
      add(textEditorPanel);

      for (final PanelAwarePlugin p : MindMapPluginRegistry.getInstance()
          .findFor(PanelAwarePlugin.class)) {
        p.onPanelCreate(MindMapPanel.this);
      }
    });

    this.addFocusListener(new FocusAdapter() {
      @Override
      public void focusLost(final FocusEvent e) {
        if (MindMapPanel.this.birdsEyeMode) {
          MindMapPanel.this.birdsEyeMode = false;
          MindMapPanel.this.repaint();
        }
      }
    });
  }

  private static void drawBackground(final MMGraphics g, final MindMapPanelConfig cfg) {
    final Rectangle clipBounds = g.getClipBounds();

    if (cfg.isDrawBackground()) {
      if (clipBounds == null) {
        LOGGER.warn("Can't draw background because clip bounds is not provided!");
      } else {
        g.drawRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height, null,
            cfg.getPaperColor());

        if (cfg.isShowGrid()) {
          final double scaledGridStep = cfg.getGridStep() * cfg.getScale();

          final float minX = clipBounds.x;
          final float minY = clipBounds.y;
          final float maxX = (float) clipBounds.x + (float) clipBounds.width;
          final float maxY = (float) clipBounds.y + (float) clipBounds.height;

          final Color gridColor = cfg.getGridColor();

          for (float x = 0.0f; x < maxX; x += scaledGridStep) {
            if (x < minX) {
              continue;
            }
            final int roundedX = Math.round(x);
            g.drawLine(roundedX, (int) minY, roundedX, (int) maxY, gridColor);
          }

          for (float y = 0.0f; y < maxY; y += scaledGridStep) {
            if (y < minY) {
              continue;
            }
            final int roundedY = Math.round(y);
            g.drawLine((int) minX, roundedY, (int) maxX, roundedY, gridColor);
          }
        }
      }
    }
  }

  public static void drawOnGraphicsForConfiguration(final MMGraphics g,
                                                    final MindMapPanelConfig config,
                                                    final MindMap map, final boolean drawSelection,
                                                    final List selectedTopics) {
    drawBackground(g, config);
    drawTopics(g, config, map);
    if (drawSelection && selectedTopics != null && !selectedTopics.isEmpty()) {
      drawSelection(g, config, selectedTopics);
    }
  }

  private static void drawSelection(final MMGraphics g, final MindMapPanelConfig cfg,
                                    final List selectedTopics) {
    if (selectedTopics != null && !selectedTopics.isEmpty()) {
      final Color selectLineColor = cfg.getSelectLineColor();
      g.setStroke(cfg.safeScaleFloatValue(cfg.getSelectLineWidth(), 0.1f), StrokeType.DASHES);
      final double selectLineGap = cfg.safeScaleFloatValue(cfg.getSelectLineGap(), 0.05f);
      final double selectLineGapX2 = selectLineGap + selectLineGap;

      for (final Topic s : selectedTopics) {
        final AbstractElement e = (AbstractElement) s.getPayload();
        if (e != null) {
          final int x = (int) Math.round(e.getBounds().getX() - selectLineGap);
          final int y = (int) Math.round(e.getBounds().getY() - selectLineGap);
          final int w = (int) Math.round(e.getBounds().getWidth() + selectLineGapX2);
          final int h = (int) Math.round(e.getBounds().getHeight() + selectLineGapX2);
          g.drawRect(x, y, w, h, selectLineColor, null);
        }
      }
    }
  }

  private static void drawTopics(final MMGraphics g, final MindMapPanelConfig cfg,
                                 final MindMap map) {
    if (map != null) {
      if (Boolean.parseBoolean(map.findAttribute(StandardMmdAttributes.MMD_ATTRIBUTE_SHOW_JUMPS))) {
        drawJumps(g, map, cfg);
      }

      final Topic root = map.getRoot();
      if (root != null) {
        drawTopicTree(g, root, cfg);
      }
    }
  }

  private static double findLineAngle(final double sx, final double sy, final double ex,
                                      final double ey) {
    final double deltax = ex - sx;
    if (deltax == 0.0d) {
      return Math.PI / 2;
    }
    return Math.atan((ey - sy) / deltax) + (ex < sx ? Math.PI : 0);
  }

  private static void drawJumps(final MMGraphics gfx, final MindMap map,
                                final MindMapPanelConfig cfg) {
    final List allTopicsWithJumps = map.findAllTopicsForExtraType(Extra.ExtraType.TOPIC);

    final float lineWidth = cfg.safeScaleFloatValue(cfg.getJumpLinkWidth(), 0.1f);
    final float connectorLineWidth = cfg.safeScaleFloatValue(1.0f, 0.1f);

    final Color jumpLinkColor = cfg.getJumpLinkColor();

    final float arrowSize = cfg.safeScaleFloatValue(10.0f * cfg.getConnectorWidth(), 0.2f);

    for (Topic src : allTopicsWithJumps) {
      final ExtraTopic extra =
          (ExtraTopic) requireNonNull(requireNonNull(src).getExtras()).get(Extra.ExtraType.TOPIC);

      src = MindMapUtils.isHidden(src) ? MindMapUtils.findFirstVisibleAncestor(src) : src;

      if (extra != null) {
        Topic targetTopic = map.findTopicForLink(extra);
        if (targetTopic != null) {
          if (MindMapUtils.isHidden(targetTopic)) {
            targetTopic = MindMapUtils.findFirstVisibleAncestor(targetTopic);
            if (targetTopic == src) {
              targetTopic = null;
            }
          }

          if (targetTopic != null) {
            final AbstractElement dstElement = (AbstractElement) targetTopic.getPayload();
            if (!MindMapUtils.isHidden(targetTopic) && dstElement != null) {
              final AbstractElement srcElement =
                  requireNonNull((AbstractElement) requireNonNull(src).getPayload());
              final Rectangle2D srcRect = srcElement.getBounds();
              final Rectangle2D dstRect = dstElement.getBounds();
              drawArrowToDestination(
                  gfx,
                  srcRect,
                  dstRect,
                  lineWidth,
                  connectorLineWidth,
                  arrowSize,
                  jumpLinkColor
              );
            }
          }
        }
      }
    }
  }

  private static void drawArrowToDestination(
      final MMGraphics gfx,
      final Rectangle2D start,
      final Rectangle2D destination,
      final float lineWidth,
      final float connectorLineWidth,
      final float arrowSize,
      final Color color
  ) {

    final double startX = start.getCenterX();
    final double startY = start.getCenterY();

    final Point2D arrowPoint = Utils.findRectEdgeIntersection(destination, startX, startY);

    if (arrowPoint != null) {
      gfx.setStroke(connectorLineWidth, StrokeType.SOLID);

      double angle = findLineAngle(arrowPoint.getX(), arrowPoint.getY(), startX, startY);

      final double arrowAngle = Math.PI / 12.0d;

      final double x1 = arrowSize * Math.cos(angle - arrowAngle);
      final double y1 = arrowSize * Math.sin(angle - arrowAngle);
      final double x2 = arrowSize * Math.cos(angle + arrowAngle);
      final double y2 = arrowSize * Math.sin(angle + arrowAngle);

      final double cx = (arrowSize / 2.0f) * Math.cos(angle);
      final double cy = (arrowSize / 2.0f) * Math.sin(angle);

      final GeneralPath polygon = new GeneralPath();
      polygon.moveTo(arrowPoint.getX(), arrowPoint.getY());
      polygon.lineTo(arrowPoint.getX() + x1, arrowPoint.getY() + y1);
      polygon.lineTo(arrowPoint.getX() + x2, arrowPoint.getY() + y2);
      polygon.closePath();
      gfx.draw(polygon, null, color);

      gfx.setStroke(lineWidth, StrokeType.DOTS);
      gfx.drawLine((int) startX, (int) startY, (int) (arrowPoint.getX() + cx),
          (int) (arrowPoint.getY() + cy), color);
    }
  }

  private static void drawTopicTree(final MMGraphics gfx, final Topic topic,
                                    final MindMapPanelConfig cfg) {
    paintTopic(gfx, topic, cfg);
    final AbstractElement w = (AbstractElement) topic.getPayload();
    if (w != null) {
      if (w.isCollapsed()) {
        return;
      }
      for (final Topic t : topic.getChildren()) {
        drawTopicTree(gfx, t, cfg);
      }
    }
  }

  private static void paintTopic(final MMGraphics gfx, final Topic topic,
                                 final MindMapPanelConfig cfg) {
    final AbstractElement element = (AbstractElement) topic.getPayload();
    if (element != null) {
      element.doPaint(gfx, cfg, true);
    }
  }

  private static void setElementSizesForElementAndChildren(final MMGraphics gfx,
                                                           final MindMapPanelConfig cfg,
                                                           final Topic topic, final int level) {
    AbstractElement widget = (AbstractElement) topic.getPayload();
    if (widget == null) {
      switch (level) {
        case 0:
          widget = new ElementRoot(topic);
          break;
        case 1:
          widget = new ElementLevelFirst(topic);
          break;
        default:
          widget = new ElementLevelOther(topic);
          break;
      }
      topic.setPayload(widget);
    }

    widget.updateElementBounds(gfx, cfg);
    for (final Topic t : topic.getChildren()) {
      setElementSizesForElementAndChildren(gfx, cfg, t, level + 1);
    }
    widget.updateBlockSize(cfg);
  }

  public static boolean calculateElementSizes(final MMGraphics gfx, final MindMap model,
                                              final MindMapPanelConfig cfg) {
    boolean result = false;

    final Topic root = model == null ? null : model.getRoot();
    if (root != null) {
      model.clearAllPayloads();
      setElementSizesForElementAndChildren(gfx, cfg, root, 0);
      result = true;
    }
    return result;
  }

  public static Dimension2D layoutModelElements(final MindMap model, final MindMapPanelConfig cfg) {
    Dimension2D result = null;
    if (model != null) {
      final Topic rootTopic = model.getRoot();
      if (rootTopic != null) {
        final AbstractElement root = (AbstractElement) rootTopic.getPayload();
        if (root != null) {
          root.alignElementAndChildren(cfg, true, 0, 0);
          result = root.getBlockSize();
        }
      }
    }
    return result;
  }

  protected static void moveDiagram(final MindMap model, final double deltaX, final double deltaY) {
    if (model != null) {
      final Topic root = model.getRoot();
      if (root != null) {
        final AbstractElement element = (AbstractElement) root.getPayload();
        if (element != null) {
          element.moveWholeTreeBranchCoordinates(deltaX, deltaY);
        }
      }
    }
  }

  public static Dimension layoutFullDiagramWithCenteringToPaper(final MMGraphics gfx,
                                                                final MindMap map,
                                                                final MindMapPanelConfig cfg,
                                                                final Dimension2D paperSize) {
    Dimension resultSize = null;
    if (calculateElementSizes(gfx, map, cfg)) {
      Dimension2D rootBlockSize = layoutModelElements(map, cfg);
      final double paperMargin = cfg.getPaperMargins() * cfg.getScale();

      if (rootBlockSize != null) {
        final ElementRoot rootElement =
            requireNonNull((ElementRoot) requireNonNull(map.getRoot()).getPayload());

        double rootOffsetXInBlock = rootElement.getLeftBlockSize().getWidth();
        double rootOffsetYInBlock =
            (rootBlockSize.getHeight() - rootElement.getBounds().getHeight()) / 2;

        rootOffsetXInBlock +=
            (paperSize.getWidth() - rootBlockSize.getWidth()) <= paperMargin ? paperMargin :
                (paperSize.getWidth() - rootBlockSize.getWidth()) / 2;
        rootOffsetYInBlock +=
            (paperSize.getHeight() - rootBlockSize.getHeight()) <= paperMargin ? paperMargin :
                (paperSize.getHeight() - rootBlockSize.getHeight()) / 2;

        moveDiagram(map, rootOffsetXInBlock, rootOffsetYInBlock);
        resultSize = new Dimension((int) Math.round(rootBlockSize.getWidth() + paperMargin * 2),
            (int) Math.round(rootBlockSize.getHeight() + paperMargin * 2));
      }
    }

    return resultSize;
  }

  private static void drawErrorText(final Graphics2D gfx, final Dimension fullSize,
                                    final String error) {
    final Font font = new Font(Font.DIALOG, Font.BOLD, 24);
    final FontMetrics metrics = gfx.getFontMetrics(font);
    final Rectangle2D textBounds = metrics.getStringBounds(error, gfx);
    gfx.setFont(font);
    gfx.setColor(Color.DARK_GRAY);
    gfx.fillRect(0, 0, fullSize.width, fullSize.height);
    final int x = (int) (fullSize.width - textBounds.getWidth()) / 2;
    final int y = (int) (fullSize.height - textBounds.getHeight()) / 2;
    gfx.setColor(Color.BLACK);
    gfx.drawString(error, x + 5, y + 5);
    gfx.setColor(Color.RED.brighter());
    gfx.drawString(error, x, y);
  }

  public static Dimension2D calculateSizeOfMapInPixels(final MindMap model,
                                                       final Graphics2D graphicsContext,
                                                       final MindMapPanelConfig cfg,
                                                       final boolean expandAll,
                                                       final RenderQuality quality) {
    final MindMap workMap = model.makeCopy();
    workMap.clearAllPayloads();

    Graphics2D g = graphicsContext;

    if (g == null) {
      BufferedImage img = new BufferedImage(32, 32,
          cfg.isDrawBackground() ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB);
      g = img.createGraphics();
    }
    final MMGraphics gfx = new MMGraphics2DWrapper(g);

    quality.prepare(g);

    Dimension2D blockSize = null;
    try {

      if (calculateElementSizes(gfx, workMap, cfg)) {
        if (expandAll) {
          final AbstractElement root =
              requireNonNull((AbstractElement) requireNonNull(workMap.getRoot()).getPayload());
          root.collapseOrExpandAllChildren(false);
          calculateElementSizes(gfx, workMap, cfg);
        }
        blockSize = requireNonNull(layoutModelElements(workMap, cfg));
        final double paperMargin = cfg.getPaperMargins() * cfg.getScale();
        blockSize.setSize(blockSize.getWidth() + paperMargin * 2,
            blockSize.getHeight() + paperMargin * 2);
      }
    } finally {
      gfx.dispose();
    }
    return blockSize;
  }

  public static BufferedImage renderMindMapAsImage(final MindMap model,
                                                   final MindMapPanelConfig cfg,
                                                   final boolean expandAll,
                                                   final RenderQuality quality) {
    final MindMap workMap = model.makeCopy();
    workMap.clearAllPayloads();

    if (expandAll) {
      MindMapUtils.removeCollapseAttr(workMap);
    }

    final Dimension2D blockSize =
        calculateSizeOfMapInPixels(workMap, null, cfg, expandAll, quality);
    if (blockSize == null) {
      return null;
    }

    final BufferedImage img =
        new BufferedImage((int) blockSize.getWidth(), (int) blockSize.getHeight(),
            BufferedImage.TYPE_INT_ARGB);
    final Graphics2D g = img.createGraphics();
    final MMGraphics gfx = new MMGraphics2DWrapper(g);
    try {
      quality.prepare(g);
      gfx.setClip(0, 0, img.getWidth(), img.getHeight());
      layoutFullDiagramWithCenteringToPaper(gfx, workMap, cfg, blockSize);
      drawOnGraphicsForConfiguration(gfx, cfg, workMap, false, null);
    } finally {
      gfx.dispose();
    }
    return img;
  }

  private static Topic[] ensureNoRootInArray(final Topic... topics) {
    final List buffer = new ArrayList<>(topics.length);
    for (final Topic t : topics) {
      if (!t.isRoot()) {
        buffer.add(t);
      }
    }
    return buffer.toArray(new Topic[0]);
  }

  public UUID getUuid() {
    return this.uuid;
  }

  private void processMouseEventInBirdsEyeMode(final MouseEvent mouseEvent) {
    this.findBirdEyeVisualizer()
        .onPanelMouseDragging(this, mouseEvent, r -> this.scrollRectToVisible(r.getBounds()));
  }

  private void selectSiblingDiapason(final AbstractElement element) {
    final AbstractElement parent = element.getParent();
    Topic selectedSibling = null;

    if (parent != null) {
      for (final Topic e : this.selectedTopics) {
        if (e != element.getModel() && parent.getModel() == e.getParent() &&
            element.isLeftDirection() == AbstractCollapsableElement.isLeftSidedTopic(e)) {
          selectedSibling = e;
          break;
        }
      }
    }

    if (selectedSibling != null) {
      boolean select = false;
      for (final Topic t : parent.getModel().getChildren()) {
        if (select && element.isLeftDirection() == AbstractCollapsableElement.isLeftSidedTopic(t)) {
          select(t, false);
        }

        if (t == element.getModel() || t == selectedSibling) {
          select = !select;
          if (!select) {
            break;
          }
        }
      }
    }
    select(element.getModel(), false);
  }

  private void doFoldOrUnfoldTopic(final List elements, final boolean fold,
                                   final boolean onlyFirstLevel) {
    boolean changed = false;

    for (final AbstractElement e : elements) {
      if (fold) {
        changed |= MindMapUtils.foldOrUnfoldChildren(e.getModel(), true,
            onlyFirstLevel ? 1 : Integer.MAX_VALUE);
        for (final Topic t : getSelectedTopics()) {
          if (!MindMapUtils.isTopicVisible(t)) {
            this.selectedTopics.remove(t);
            changed = true;
          }
        }
      } else {
        changed |= MindMapUtils.foldOrUnfoldChildren(e.getModel(), false,
            onlyFirstLevel ? 1 : Integer.MAX_VALUE);
      }
    }

    if (changed) {
      this.doLayout();
      this.revalidate();
      this.repaint();
      this.fireNotificationMindMapChanged(true);
    }
  }

  /**
   * Get saved session object. Object is presented and saved only for the
   * current panel and only in memory.
   *
   * @param    type of object
   * @param key   key of object, must not be null
   * @param klazz object type, must not be null
   * @param def   default value will be returned as result if object not
   *              presented, can be null
   * @return null if object is not found, the found object otherwise
   * @throws ClassCastException if object type is wrong for saved object
   * @since 1.4.2
   */
  public  T getSessionObject(final String key, final Class klazz, final T def) {
    assertNotDisposed();
    T result = klazz.cast(this.sessionObjects.get(key));
    return result == null ? def : result;
  }

  /**
   * Put session object for key.
   *
   * @param key key of the object, must not be null
   * @param obj object to be placed, if null then object will be removed
   * @since 1.4.2
   */
  public void putSessionObject(final String key, final Object obj) {
    assertNotDisposed();
    if (obj == null) {
      this.sessionObjects.remove(key);
    } else {
      this.sessionObjects.put(key, obj);
    }
  }

  public Optional> findBestPointForContextMenu(
      final boolean ensureComponentVisibility) {
    final AbstractElement element = findTopicForContextMenu();
    if (element == null) {
      return Optional.empty();
    } else {
      if (ensureComponentVisibility) {
        this.ensureVisibility(element);
      }
      return Optional.of(new Pair<>(element, element.getCenter()));
    }
  }

  private void processContextMenuKey() {
    final Pair elementPoint =
        this.findBestPointForContextMenu(true).orElse(null);
    if (elementPoint == null) {
      final Rectangle rect = this.getBounds();
      this.processPopUp(new Point((int) rect.getCenterX(), (int) rect.getCenterY()), null);
    } else {
      this.processPopUp(elementPoint.getRight(), elementPoint.getLeft());
    }
  }

  private String makeHtmlTooltipForExtra(final Extra extra) {
    final StringBuilder builder = new StringBuilder();

    builder.append("");

    switch (extra.getType()) {
      case FILE: {
        final ExtraFile efile = (ExtraFile) extra;
        final String line = efile.getAsURI().getParameters().getProperty("line", null);
        if (line != null && !line.equals("0")) {
          builder.append(String.format(
              this.bundle.getString("MindMapPanel.tooltipOpenFileWithLine"),
              StringEscapeUtils.escapeHtml3(efile.getAsString()),
              StringEscapeUtils.escapeHtml3(line)));
        } else {
          builder.append(this.bundle.getString("MindMapPanel.tooltipOpenFile"))
              .append(StringEscapeUtils.escapeHtml3(efile.getAsString()));
        }
      }
      break;
      case TOPIC: {
        final Topic topic = this.getModel().findTopicForLink((ExtraTopic) extra);
        builder.append(this.bundle.getString("MindMapPanel.tooltipJumpToTopic"))
            .append(StringEscapeUtils.escapeHtml3(
                ModelUtils.makeEllipsis(topic == null ? "----" : topic.getText(), 32)));
      }
      break;
      case LINK: {
        builder.append(this.bundle.getString("MindMapPanel.tooltipOpenLink")).append(
            StringEscapeUtils.escapeHtml3(ModelUtils.makeEllipsis(extra.getAsString(), 48)));
      }
      break;
      case NOTE: {
        final ExtraNote extraNote = (ExtraNote) extra;
        if (extraNote.isEncrypted()) {
          builder.append(this.bundle.getString("MindMapPanel.tooltipOpenText"))
              .append("#######");
        } else {
          builder.append(this.bundle.getString("MindMapPanel.tooltipOpenText"))
              .append(StringEscapeUtils
                  .escapeHtml3(ModelUtils.makeEllipsis(extra.getAsString(), 64)));
        }
      }
      break;
      default: {
        builder.append("Unknown");
      }
      break;
    }

    builder.append("");

    return builder.toString();
  }

  public void refreshConfiguration() {
    assertNotDisposed();
    final MindMapPanel theInstance = this;
    final double scale = this.config.getScale();
    this.config.makeAtomicChange(() -> {
      config.makeFullCopyOf(controller.provideConfigForMindMapPanel(theInstance), false,
          false);
      config.setScale(scale);
    });
    invalidate();
    repaint();
  }

  private int calcDropPosition(final AbstractElement destination, final Point dropPoint) {
    final int result;
    if (destination.getClass() == ElementRoot.class) {
      result = dropPoint.getX() < destination.getBounds().getCenterX() ? DRAG_POSITION_LEFT :
          DRAG_POSITION_RIGHT;
    } else {
      final boolean destinationIsLeft = destination.isLeftDirection();
      final Rectangle2D bounds = destination.getBounds();

      final double edgeOffset = bounds.getWidth() * 0.2d;
      if (dropPoint.getX() >= (bounds.getX() + edgeOffset) &&
          dropPoint.getX() <= (bounds.getMaxX() - edgeOffset)) {
        result = dropPoint.getY() < bounds.getCenterY() ? DRAG_POSITION_TOP : DRAG_POSITION_BOTTOM;
      } else if (destinationIsLeft) {
        result =
            dropPoint.getX() < bounds.getCenterX() ? DRAG_POSITION_LEFT : DRAG_POSITION_UNKNOWN;
      } else {
        result =
            dropPoint.getX() > bounds.getCenterX() ? DRAG_POSITION_RIGHT : DRAG_POSITION_UNKNOWN;
      }
    }
    return result;
  }

  private boolean endDragOfElement(final DraggedElement draggedElement,
                                   final AbstractElement destination) {
    final AbstractElement dragged = draggedElement.getElement();
    final Point dropPoint = draggedElement.getPosition();

    final boolean ignore =
        dragged.getModel() == destination.getModel() || dragged.getBounds().contains(dropPoint) ||
            destination.getModel().hasAncestor(dragged.getModel());
    if (ignore) {
      return false;
    }

    boolean changed = true;

    if (draggedElement.getModifier() == DraggedElement.Modifier.MAKE_JUMP) {
      // make link
      return this.controller.processDropTopicToAnotherTopic(this, dropPoint, dragged.getModel(),
          destination.getModel());
    }

    final int pos = calcDropPosition(destination, dropPoint);
    switch (pos) {
      case DRAG_POSITION_TOP:
      case DRAG_POSITION_BOTTOM: {
        dragged.getModel().moveToNewParent(requireNonNull(destination.getParent()).getModel());
        if (pos == DRAG_POSITION_TOP) {
          dragged.getModel().moveBefore(destination.getModel());
        } else {
          dragged.getModel().moveAfter(destination.getModel());
        }

        if (destination.getClass() == ElementLevelFirst.class) {
          AbstractCollapsableElement.makeTopicLeftSided(dragged.getModel(),
              destination.isLeftDirection());
        } else {
          AbstractCollapsableElement.makeTopicLeftSided(dragged.getModel(), false);
        }
      }
      break;
      case DRAG_POSITION_RIGHT:
      case DRAG_POSITION_LEFT: {
        if (dragged.getParent() == destination) {
          // the same parent
          if (destination.getClass() == ElementRoot.class) {
            // process only for the root, just update direction
            if (dragged instanceof AbstractCollapsableElement) {
              ((AbstractCollapsableElement) dragged).setLeftDirection(pos == DRAG_POSITION_LEFT);
            }
          }
        } else {
          dragged.getModel().moveToNewParent(destination.getModel());
          if (destination instanceof AbstractCollapsableElement && destination.isCollapsed() &&
              (controller == null || controller.isUnfoldCollapsedTopicDropTarget(this))) {
            ((AbstractCollapsableElement) destination).setCollapse(false);
          }
          if (dropPoint.getY() < destination.getBounds().getY()) {
            dragged.getModel().makeFirst();
          } else {
            dragged.getModel().makeLast();
          }
          if (destination.getClass() == ElementRoot.class) {
            AbstractCollapsableElement.makeTopicLeftSided(dragged.getModel(),
                pos == DRAG_POSITION_LEFT);
          } else {
            AbstractCollapsableElement.makeTopicLeftSided(dragged.getModel(), false);
          }
        }
      }
      break;
      default:
        break;
    }
    dragged.getModel().setPayload(null);

    return changed;
  }

  private void sendToParent(final AWTEvent evt) {
    final Container parent = this.getParent();
    if (parent != null) {
      parent.dispatchEvent(evt);
    }
  }

  private void processMoveFocusByKey(final KeyEvent key) {
    final AbstractElement lastSelectedTopic = this.selectedTopics.isEmpty() ? null :
        (AbstractElement) this.selectedTopics.get(this.selectedTopics.size() - 1).getPayload();
    if (lastSelectedTopic == null) {
      return;
    }

    AbstractElement nextFocused = null;

    boolean modelChanged = false;

    if (lastSelectedTopic.isMoveable()) {
      boolean processFirstChild = false;
      if (config.isKeyEventDetected(key, MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT,
          MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT_ADD_FOCUSED)) {
        if (lastSelectedTopic.isLeftDirection()) {
          processFirstChild = true;
        } else {
          nextFocused = (AbstractElement) requireNonNull(
              lastSelectedTopic.getModel().getParent()).getPayload();
        }
      } else if (config.isKeyEventDetected(key, MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT,
          MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT_ADD_FOCUSED)) {
        if (lastSelectedTopic.isLeftDirection()) {
          nextFocused = (AbstractElement) requireNonNull(
              lastSelectedTopic.getModel().getParent()).getPayload();
        } else {
          processFirstChild = true;
        }
      } else {
        final boolean pressedButtonMoveUp =
            config.isKeyEventDetected(key, MindMapPanelConfig.KEY_FOCUS_MOVE_UP,
                MindMapPanelConfig.KEY_FOCUS_MOVE_UP_ADD_FOCUSED);
        final boolean firstLevel = lastSelectedTopic.getClass() == ElementLevelFirst.class;
        final boolean currentLeft =
            AbstractCollapsableElement.isLeftSidedTopic(lastSelectedTopic.getModel());

        final Predicate checker = topic -> {
          if (!firstLevel) {
            return true;
          } else if (currentLeft) {
            return AbstractCollapsableElement.isLeftSidedTopic(topic);
          } else {
            return !AbstractCollapsableElement.isLeftSidedTopic(topic);
          }
        };
        final Topic topic = pressedButtonMoveUp ? lastSelectedTopic.getModel().findPrev(checker) :
            lastSelectedTopic.getModel().findNext(checker);
        nextFocused = topic == null ? null : (AbstractElement) topic.getPayload();
      }

      if (processFirstChild) {
        if (lastSelectedTopic.hasChildren()) {
          if (lastSelectedTopic.isCollapsed()) {
            ((AbstractCollapsableElement) lastSelectedTopic).setCollapse(false);
            modelChanged = true;
          }

          nextFocused =
              (AbstractElement) (lastSelectedTopic.getModel().getChildren().get(0)).getPayload();
        }
      }
    } else if (config.isKeyEventDetected(key, MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT,
        MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT_ADD_FOCUSED)) {
      for (final Topic t : lastSelectedTopic.getModel().getChildren()) {
        final AbstractElement e = (AbstractElement) t.getPayload();
        if (e != null && e.isLeftDirection()) {
          nextFocused = e;
          break;
        }
      }
    } else if (config.isKeyEventDetected(key, MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT,
        MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT_ADD_FOCUSED)) {
      for (final Topic t : lastSelectedTopic.getModel().getChildren()) {
        final AbstractElement e = (AbstractElement) t.getPayload();
        if (e != null && !e.isLeftDirection()) {
          nextFocused = e;
          break;
        }
      }
    }

    if (nextFocused != null) {
      final boolean addFocused = config.isKeyEventDetected(key,
          MindMapPanelConfig.KEY_FOCUS_MOVE_UP_ADD_FOCUSED,
          MindMapPanelConfig.KEY_FOCUS_MOVE_DOWN_ADD_FOCUSED,
          MindMapPanelConfig.KEY_FOCUS_MOVE_LEFT_ADD_FOCUSED,
          MindMapPanelConfig.KEY_FOCUS_MOVE_RIGHT_ADD_FOCUSED);
      if (!addFocused || this.selectedTopics.contains(nextFocused.getModel())) {
        removeAllSelection();
      }
      select(nextFocused.getModel(), false);
    }

    if (modelChanged) {
      fireNotificationMindMapChanged(true);
    }
  }

  /**
   * Safe Swing thread execution sequence of some jobs over model with model
   * changed notification in the end
   *
   * @param jobs sequence of jobs to be executed
   * @since 1.3.1
   */
  public void executeModelJobs(final ModelJob... jobs) {
    Utils.safeSwingCall(() -> {
      for (final ModelJob j : jobs) {
        try {
          if (!j.doChangeModel(model)) {
            break;
          }
        } catch (Exception ex) {
          LOGGER.error("Errot during job execution", ex);
        }
      }
      fireNotificationMindMapChanged(true);
    });
  }

  private void ensureVisibility(final AbstractElement e) {
    fireNotificationEnsureTopicVisibility(e.getModel());
  }

  private boolean hasActiveEditor() {
    assertNotDisposed();
    return this.elementUnderEdit != null;
  }

  public boolean isShowJumps() {
    return Boolean.parseBoolean(
        this.model.findAttribute(StandardMmdAttributes.MMD_ATTRIBUTE_SHOW_JUMPS));
  }

  public void setShowJumps(final boolean flag) {
    assertNotDisposed();
    this.model.putAttribute(StandardMmdAttributes.MMD_ATTRIBUTE_SHOW_JUMPS,
        flag ? "true" : null);
    repaint();
    fireNotificationMindMapChanged(true);
  }

  private Topic makeNewTopic(final Topic parent, final Topic afterTopic, final String text) {
    final Topic result = parent.makeChild(text, afterTopic);
    for (final ModelAwarePlugin p : MindMapPluginRegistry.getInstance()
        .findFor(ModelAwarePlugin.class)) {
      p.onCreateTopic(this, parent, result);
    }
    invalidate();
    return result;
  }

  /**
   * Create new child topic and start its edit.
   * @param parent parent topic, must not be null
   * @param baseTopic topic to be used as base if we want to add after it, can be null
   */
  public void makeNewChildAndStartEdit(final Topic parent, final Topic baseTopic) {
    this.makeNewChildAndFocus(parent, baseTopic, true);
  }

  /**
   * Create new child topic, move focus to it and optionally start its edit.
   *
   * @param parent parent topic, must not be null
   * @param baseTopic topic to be used as base if we want to add after it, can be null
   * @param startEdit if true then new topic should be moved into edit mode, false otherwise
   * @since 1.6.5
   */
  public void makeNewChildAndFocus(final Topic parent, final Topic baseTopic, final boolean startEdit) {
    assertNotDisposed();
    if (parent != null) {
      final Topic currentSelected = getFirstSelected();
      this.pathToPrevTopicBeforeEdit =
          currentSelected == null ? null : currentSelected.getPositionPath();

      removeAllSelection();

      final Topic newTopic = makeNewTopic(parent, baseTopic, "");

      if (this.controller.isCopyColorInfoFromParentToNewChildAllowed(this) &&
          !parent.isRoot()) {
        MindMapUtils.copyColorAttributes(parent, newTopic);
      }

      AbstractElement parentElement = (AbstractElement) parent.getPayload();
      if (parentElement == null) {
        doLayout();
        parentElement = (AbstractElement) parent.getPayload();
      }

      if (parent.getChildren().size() != 1 && parent.getParent() == null && baseTopic == null) {
        int numLeft = 0;
        int numRight = 0;
        for (final Topic t : parent.getChildren()) {
          if (AbstractCollapsableElement.isLeftSidedTopic(t)) {
            numLeft++;
          } else {
            numRight++;
          }
        }

        AbstractCollapsableElement.makeTopicLeftSided(newTopic,
            Utils.LTR_LANGUAGE ? numLeft < numRight : numLeft > numRight);
      } else if (baseTopic != null && baseTopic.getPayload() != null) {
        final AbstractElement element =
            requireNonNull((AbstractElement) baseTopic.getPayload());
        AbstractCollapsableElement.makeTopicLeftSided(newTopic, element.isLeftDirection());
      }

      if (parentElement instanceof AbstractCollapsableElement && parentElement.isCollapsed()) {
        ((AbstractCollapsableElement) parentElement).setCollapse(false);
      }

      doLayout();
      fireNotificationMindMapChanged(false);
      removeEditedTopicForRollback.set(true);

      fireNotificationEnsureTopicVisibility(newTopic);
      select(newTopic, false);
      if (startEdit) {
        this.startEdit((AbstractElement) newTopic.getPayload());
      }
      repaint();
    }
  }

  protected void fireNotificationSelectionChanged() {
    final Topic[] selected = this.selectedTopics.toArray(new Topic[0]);
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onChangedSelection(MindMapPanel.this, selected);
    }
    this.repaint();
  }

  protected void fireNotificationMindMapChanged(final boolean saveToHistory) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onMindMapModelChanged(MindMapPanel.this, saveToHistory);
    }
    this.repaint();
  }

  protected void fireNotificationComponentElementsLayouted(final Graphics2D graphics) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onComponentElementsLayout(MindMapPanel.this, graphics);
    }
    this.repaint();
  }

  protected void fireNotificationClickOnExtra(final Topic topic, final int modifiers,
                                              final int clicks, final Extra extra) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onClickOnExtra(MindMapPanel.this, modifiers, clicks, topic, extra);
    }
  }

  protected void fireNotificationEnsureTopicVisibility(final Topic topic) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onEnsureVisibilityOfTopic(MindMapPanel.this, topic);
    }
  }

  protected void fireNotificationTopicCollapsatorClick(final Topic topic,
                                                       final boolean beforeAction) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onTopicCollapsatorClick(MindMapPanel.this, topic, beforeAction);
    }
    this.repaint();
  }

  protected void fireNotificationScaledByMouse(
      final Point mousePoint,
      final double oldScale,
      final double newScale,
      final Dimension oldSize,
      final Dimension newSize
  ) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      l.onScaledByMouse(MindMapPanel.this, mousePoint, oldScale, newScale, oldSize, newSize);
    }
    this.repaint();
  }

  protected void fireNotificationNonConsumedKeyEvent(final KeyEvent keyEvent,
                                                     final KeyEventType type) {
    for (final MindMapListener l : MindMapPanel.this.mindMapListeners) {
      if (keyEvent.isConsumed()) {
        break;
      }
      l.onNonConsumedKeyEvent(MindMapPanel.this, keyEvent, type);
    }
  }

  public void deleteTopics(final boolean force, final Topic... topics) {
    assertNotDisposed();
    endEdit(false);

    final List plugins =
        MindMapPluginRegistry.getInstance().findFor(ModelAwarePlugin.class);

    boolean allowed = true;
    if (!force) {
      for (final MindMapListener l : this.mindMapListeners) {
        allowed &= l.allowedRemovingOfTopics(this, topics);
      }
    }

    if (allowed) {
      removeAllSelection();
      for (final Topic t : topics) {
        for (final ModelAwarePlugin p : plugins) {
          p.onDeleteTopic(this, t);
        }
        this.model.removeTopic(t);
      }
      doLayout();
      revalidate();
      repaint();
      fireNotificationMindMapChanged(true);
    }
  }

  public void collapseOrExpandAll(final boolean collapse) {
    assertNotDisposed();
    endEdit(false);
    removeAllSelection();

    final Topic topic = this.model.getRoot();

    if (topic != null &&
        MindMapUtils.foldOrUnfoldChildren(topic, collapse, Integer.MAX_VALUE)) {
      doLayout();
      revalidate();
      repaint();
      fireNotificationMindMapChanged(true);
    }
  }

  public Topic deleteSelectedTopics(final boolean force) {
    assertNotDisposed();
    Topic nextToFocus = null;
    if (!this.selectedTopics.isEmpty()) {
      if (this.selectedTopics.size() == 1) {
        nextToFocus = this.selectedTopics.get(0).getParent();
      }

      deleteTopics(force, this.selectedTopics.toArray(new Topic[0]));
    }
    return nextToFocus;
  }

  public boolean hasSelectedTopics() {
    assertNotDisposed();
    return !this.selectedTopics.isEmpty();
  }

  public boolean hasOnlyTopicSelected() {
    assertNotDisposed();
    return this.selectedTopics.size() == 1;
  }

  public void removeFromSelection(final Topic t) {
    assertNotDisposed();
    if (this.selectedTopics.contains(t)) {
      if (this.selectedTopics.remove(t)) {
        fireNotificationSelectionChanged();
      }
      repaint();
    }
  }

  public void select(final Topic topic, final boolean removeIfPresented) {
    assertNotDisposed();
    if (this.controller.isSelectionAllowed(this) && topic != null) {
      if (!this.selectedTopics.contains(topic)) {
        this.selectedTopics.add(topic);
        fireNotificationSelectionChanged();
        fireNotificationEnsureTopicVisibility(topic);
        repaint();
      } else if (removeIfPresented) {
        removeFromSelection(topic);
      }
    }
  }

  public Topic[] getSelectedTopics() {
    assertNotDisposed();
    return this.selectedTopics.toArray(new Topic[0]);
  }

  public void setSelectedTopics(final List topics) {
    assertNotDisposed();
    if (this.controller.isSelectionAllowed(this)) {
      this.selectedTopics.clear();
      this.selectedTopics.addAll(topics);
      fireNotificationSelectionChanged();
      repaint();
    }
  }

  public void updateEditorAfterResizing() {
    assertNotDisposed();
    if (this.elementUnderEdit != null) {
      final AbstractElement element = this.elementUnderEdit;
      final Dimension textBlockSize = new Dimension((int) element.getBounds().getWidth(),
          (int) element.getBounds().getHeight());
      this.textEditorPanel.setBounds((int) element.getBounds().getX(),
          (int) element.getBounds().getY(), textBlockSize.width, textBlockSize.height);
      this.textEditor.setMinimumSize(textBlockSize);
      this.textEditorPanel.setVisible(true);
      this.textEditor.requestFocus();
    }
  }

  public void hideEditor() {
    assertNotDisposed();
    this.textEditorPanel.setVisible(false);
    this.elementUnderEdit = null;
  }

  public boolean endEdit(final boolean commit) {
    assertNotDisposed();
    boolean result = this.elementUnderEdit != null;
    try {
      if (this.elementUnderEdit != null) {
        final AbstractElement editedElement = this.elementUnderEdit;
        Topic editedTopic = this.elementUnderEdit.getModel();

        final int[] pathToEditedTopic = editedTopic.getPositionPath();

        if (commit) {
          this.pathToPrevTopicBeforeEdit = null;

          final String oldText = editedElement.getText();
          String newText = this.textEditor.getText();

          if (this.controller.isTrimTopicTextBeforeSet(this)) {
            newText = newText.trim();
          }

          boolean contentChanged = false;
          if (!oldText.equals(newText)) {
            editedElement.setText(newText);
            contentChanged = true;
          }
          this.textEditorPanel.setVisible(false);

          doLayout();
          revalidate();
          repaint();
          fireNotificationEnsureTopicVisibility(editedTopic);

          if (contentChanged) {
            fireNotificationMindMapChanged(true);
          }

          editedTopic = this.model.findAtPosition(pathToEditedTopic);
          this.focusTo(editedTopic);
        } else {
          if (this.removeEditedTopicForRollback.get()) {
            this.selectedTopics.remove(editedTopic);
            this.model.removeTopic(editedTopic);
            doLayout();
            revalidate();
            repaint();
          }
        }
      }
    } finally {
      this.removeEditedTopicForRollback.set(false);
      this.elementUnderEdit = null;
      this.textEditorPanel.setVisible(false);
      this.requestFocus();
    }
    return result;
  }

  public void startEdit(final AbstractElement element) {
    assertNotDisposed();
    if (element == null) {
      this.elementUnderEdit = null;
      this.textEditorPanel.setVisible(false);
    } else {
      this.elementUnderEdit = element;
      element.fillByTextAndFont(this.textEditor);
      ensureVisibility(this.elementUnderEdit);

      final Dimension textBlockSize = new Dimension((int) element.getBounds().getWidth(),
          (int) element.getBounds().getHeight());
      textEditorPanel.setBounds((int) element.getBounds().getX(),
          (int) element.getBounds().getY(), textBlockSize.width, textBlockSize.height);
      textEditor.setMinimumSize(textBlockSize);

      textEditorPanel.setVisible(true);
      textEditor.requestFocus();
    }
  }

  private void findDestinationElementForDragged() {
    final Topic theroot = this.model.getRoot();
    if (this.draggedElement != null && theroot != null) {
      final AbstractElement root = (AbstractElement) requireNonNull(theroot.getPayload());
      this.destinationElement = root.findNearestOpenedTopicToPoint(this.draggedElement.getElement(),
          this.draggedElement.getPosition());
    } else {
      this.destinationElement = null;
    }
  }

  protected void processPopUpForShortcut() {
    assertNotDisposed();
    final Topic topic = this.selectedTopics.isEmpty() ? null : this.selectedTopics.get(0);

    if (topic != null) {
      fireNotificationEnsureTopicVisibility(topic);
    }

    if (topic == null) {
      select(getModel().getRoot(), false);
    } else {
      final AbstractElement element = (AbstractElement) topic.getPayload();
      if (element != null) {
        final Rectangle2D bounds = element.getBounds();
        processPopUp(new Point((int) Math.round(bounds.getCenterX()),
            (int) Math.round(bounds.getCenterY())), element);
      }
    }
  }

  protected void processPopUp(final Point point, final AbstractElement elementUnderMouse) {
    assertNotDisposed();
    if (this.controller != null) {
      final ElementPart partUnderMouse =
          elementUnderMouse == null ? null : elementUnderMouse.findPartForPoint(point);

      if (elementUnderMouse != null &&
          !this.selectedTopics.contains(elementUnderMouse.getModel())) {
        this.selectedTopics.clear();
        this.select(elementUnderMouse.getModel(), false);
      }

      final JPopupMenu menu =
          this.controller.makePopUpForMindMapPanel(this, point, elementUnderMouse,
              partUnderMouse);
      if (menu != null) {

        final MindMapPanel theInstance = this;

        menu.addPopupMenuListener(new PopupMenuListener() {
          @Override
          public void popupMenuWillBecomeVisible(final PopupMenuEvent e) {
            theInstance.mouseDragSelection = null;
            theInstance.popupMenuActive.set(true);
          }

          @Override
          public void popupMenuWillBecomeInvisible(final PopupMenuEvent e) {
            theInstance.mouseDragSelection = null;
            theInstance.popupMenuActive.set(false);
          }

          @Override
          public void popupMenuCanceled(final PopupMenuEvent e) {
            theInstance.mouseDragSelection = null;
            theInstance.popupMenuActive.set(false);
          }
        });

        menu.show(this, point.x, point.y);
      }
    }
  }

  public void addMindMapListener(final MindMapListener l) {
    assertNotDisposed();
    this.mindMapListeners.add(requireNonNull(l));
  }

  public void removeMindMapListener(final MindMapListener l) {
    assertNotDisposed();
    this.mindMapListeners.remove(requireNonNull(l));
  }

  /**
   * Set model for the panel, allows to notify listeners optionally.
   *
   * @param model                      model to be set
   * @param notifyModelChangeListeners true if to notify model change listeners,
   *                                   false otherwise
   * @since 1.3.0
   */
  public void setModel(final MindMap model, final boolean notifyModelChangeListeners) {
    assertNotDisposed();
    try {
      if (this.elementUnderEdit != null) {
        Utils.safeSwingBlockingCall(() -> endEdit(false));
      }

      final List selectedPaths = new ArrayList<>();
      for (final Topic t : this.selectedTopics) {
        selectedPaths.add(t.getPositionPath());
      }

      this.selectedTopics.clear();

      final MindMap oldModel = this.model;
      this.model = requireNonNull(model, "Model must not be null");

      for (final PanelAwarePlugin p : MindMapPluginRegistry.getInstance()
          .findFor(PanelAwarePlugin.class)) {
        p.onPanelModelChange(this, oldModel, this.model);
      }

      doLayout();
      revalidate();

      boolean selectionChanged = false;
      for (final int[] posPath : selectedPaths) {
        final Topic topic = this.model.findAtPosition(posPath);
        if (topic == null) {
          selectionChanged = true;
        } else if (!MindMapUtils.isHidden(topic)) {
          this.selectedTopics.add(topic);
        }
      }
      if (selectionChanged) {
        fireNotificationSelectionChanged();
      }
      repaint();
    } finally {
      if (notifyModelChangeListeners) {
        fireNotificationMindMapChanged(true);
      }
    }
  }

  @Override
  public boolean isFocusable() {
    return true;
  }

  public MindMap getModel() {
    assertNotDisposed();
    return requireNonNull(this.model, "Model is not provided, it must not be null!");
  }

  public void setModel(final MindMap model) {
    this.setModel(model, false);
  }

  public double getScale() {
    assertNotDisposed();
    return this.config.getScale();
  }

  public void setScale(final double zoom, final boolean notifyListeners) {
    assertNotDisposed();
    if (notifyListeners) {
      this.config.setScale(zoom);
    } else {
      this.config.setScaleWithoutListenerNotification(zoom);
    }
  }

  private void drawDestinationElement(final Graphics2D g, final MindMapPanelConfig cfg) {
    if (this.destinationElement != null && this.draggedElement != null) {
      g.setColor(new Color((cfg.getSelectLineColor().getRGB() & 0xFFFFFF) | 0x80000000, true));
      g.setStroke(new BasicStroke(this.config.safeScaleFloatValue(3.0f, 0.1f)));

      final Rectangle2D rectToDraw = new Rectangle2D.Double();
      rectToDraw.setRect(this.destinationElement.getBounds());
      final double selectLineGap = cfg.getSelectLineGap() * 3.0d * cfg.getScale();
      rectToDraw.setRect(rectToDraw.getX() - selectLineGap, rectToDraw.getY() - selectLineGap,
          rectToDraw.getWidth() + selectLineGap * 2, rectToDraw.getHeight() + selectLineGap * 2);

      final int position =
          calcDropPosition(this.destinationElement, this.draggedElement.getPosition());

      boolean draw = !this.draggedElement.isPositionInside() && !this.destinationElement.getModel()
          .hasAncestor(this.draggedElement.getElement().getModel());

      switch (this.draggedElement.getModifier()) {
        case NONE: {
          switch (position) {
            case DRAG_POSITION_TOP: {
              rectToDraw.setRect(rectToDraw.getX(), rectToDraw.getY(), rectToDraw.getWidth(),
                  rectToDraw.getHeight() / 2);
            }
            break;
            case DRAG_POSITION_BOTTOM: {
              rectToDraw.setRect(rectToDraw.getX(), rectToDraw.getY() + rectToDraw.getHeight() / 2,
                  rectToDraw.getWidth(), rectToDraw.getHeight() / 2);
            }
            break;
            case DRAG_POSITION_LEFT: {
              rectToDraw.setRect(rectToDraw.getX(), rectToDraw.getY(), rectToDraw.getWidth() / 2,
                  rectToDraw.getHeight());
            }
            break;
            case DRAG_POSITION_RIGHT: {
              rectToDraw.setRect(rectToDraw.getX() + rectToDraw.getWidth() / 2, rectToDraw.getY(),
                  rectToDraw.getWidth() / 2, rectToDraw.getHeight());
            }
            break;
            default:
              draw = false;
              break;
          }
        }
        break;
        case MAKE_JUMP: {
        }
        break;
        default:
          throw new Error("Unexpected state " + this.draggedElement.getModifier());
      }

      if (draw) {
        g.fill(rectToDraw);
      }
    }
  }

  @Override
  public Dimension getPreferredSize() {
    return this.mindMapImageSize;
  }

  @Override
  public Dimension getMinimumSize() {
    return this.getPreferredSize();
  }

  private void changeSizeOfComponent(final Dimension size,
                                     final boolean doNotificationThatRealigned) {
    if (size != null) {
      final Dimension oldSize = this.mindMapImageSize;
      this.mindMapImageSize = size;

      if (doNotificationThatRealigned) {
        this.firePropertyChange("preferredSize", oldSize, size);
        this.firePropertyChange("minimumSize", oldSize, size);

        for (final MindMapListener l : this.mindMapListeners) {
          l.onMindMapModelRealigned(this, size);
        }
      }
    }
  }

  @Override
  public void doLayout() {
    assertNotDisposed();

    final Runnable run = () -> {
      invalidate();
      updateElementsAndSizeForCurrentGraphics(true, false);
      repaint();
      MindMapPanel.super.doLayout();
    };

    if (SwingUtilities.isEventDispatchThread()) {
      run.run();
    } else {
      try {
        SwingUtilities.invokeAndWait(run);
      } catch (InvocationTargetException ex) {
        throw new RuntimeException(ex);
      } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
      }
    }
  }

  public boolean updateElementsAndSizeForGraphics(final Graphics2D graph, final boolean forceLayout,
                                                  final boolean doListenerNotification) {
    assertNotDisposed();
    boolean result = false;
    if (forceLayout || !isValid()) {
      if (graph != null) {
        final MMGraphics gfx = new MMGraphics2DWrapper(graph);
        if (calculateElementSizes(gfx, this.model, this.config)) {

          Dimension pageSize = getSize();

          final Container parent = this.getParent();
          if (parent != null) {
            if (parent instanceof JViewport) {
              pageSize = ((JViewport) parent).getExtentSize();
            }
          }

          changeSizeOfComponent(
              layoutFullDiagramWithCenteringToPaper(gfx, this.model, this.config, pageSize),
              doListenerNotification);
          result = true;

          if (doListenerNotification) {
            fireNotificationComponentElementsLayouted(graph);
          }
        }
      }
    }
    return result;
  }

  public boolean updateElementsAndSizeForCurrentGraphics(final boolean enforce,
                                                         final boolean doListenerNotification) {
    assertSwingDispatchThread();
    Graphics2D gfx = (Graphics2D) this.getGraphics();
    try {
      if (gfx == null) {
        gfx = new BufferedImage(32, 32, BufferedImage.TYPE_INT_RGB).createGraphics();
      }
      return updateElementsAndSizeForGraphics((Graphics2D) getGraphics(), enforce,
          doListenerNotification);
    } finally {
      gfx.dispose();
    }
  }

  public String getErrorText() {
    return this.errorText;
  }

  public void setErrorText(final String text) {
    assertNotDisposed();
    this.errorText = text;
    repaint();
  }

  @Override
  public boolean isValid() {
    if (this.disposed == null) {
      // called in parent Object constructor before field init!
      return false;
    }

    if (this.isDisposed()) {
      return false;
    } else if (this.model != null) {
      final Topic root = this.model.getRoot();
      AbstractElement rootElement = null;
      if (root != null) {
        rootElement = (AbstractElement) root.getPayload();
      }
      return rootElement != null;
    }
    return true;
  }

  @Override
  public boolean isValidateRoot() {
    return true;
  }

  @Override
  public void invalidate() {
    super.invalidate();
    if (!this.isDisposed() && this.model != null && this.model.getRoot() != null) {
      this.model.clearAllPayloads();
    }
  }

  protected BirdsEyeVisualizer findBirdEyeVisualizer() {
    return new InMapBirdsEye(this);
  }

  @Override
  public void paintComponent(final Graphics g) {
    assertNotDisposed();
    final Graphics2D gfx = (Graphics2D) g.create();
    try {
      final String error = this.errorText;

      this.getConfiguration().getRenderQuality().prepare(gfx);

      if (error != null) {
        drawErrorText(gfx, this.getSize(), error);
      } else {
        if (this.model.getRoot().getPayload() == null) {
          updateElementsAndSizeForGraphics(gfx, true, false);
        }
        drawOnGraphicsForConfiguration(new MMGraphics2DWrapper(gfx), this.config, this.model,
            true, this.selectedTopics);
        drawDestinationElement(gfx, this.config);
      }

      paintChildren(g);

      if (this.draggedElement != null) {
        this.draggedElement.draw(gfx);
      } else if (this.mouseDragSelection != null) {
        gfx.setColor(COLOR_MOUSE_DRAG_SELECTION);
        gfx.fill(this.mouseDragSelection.asRectangle());
      }

      if (this.birdsEyeMode) {
        this.findBirdEyeVisualizer().draw(this, gfx);
      }
    } finally {
      gfx.dispose();
    }
  }

  public AbstractElement findTopicForContextMenu() {
    assertNotDisposed();
    AbstractElement result = null;
    if (this.selectedTopics.isEmpty()) {
      if (this.model != null) {
        final Topic root = this.model.getRoot();
        if (root != null) {
          result = (AbstractElement) root.getPayload();
        }
      }
    } else {
      result = (AbstractElement) this.selectedTopics.get(0).getPayload();
    }
    return result;
  }

  public AbstractElement findTopicUnderPoint(final Point point) {
    assertNotDisposed();
    AbstractElement result = null;
    if (this.model != null) {
      final Topic root = this.model.getRoot();
      if (root != null) {
        final AbstractElement rootWidget = (AbstractElement) root.getPayload();
        if (rootWidget != null) {
          result = rootWidget.findForPoint(point);
        }
      }
    }

    return result;
  }

  public void removeAllSelection() {
    assertNotDisposed();
    if (!this.selectedTopics.isEmpty()) {
      try {
        this.selectedTopics.clear();
        fireNotificationSelectionChanged();
      } finally {
        repaint();
      }
    }
  }

  public void focusTo(final Topic theTopic) {
    assertNotDisposed();
    if (theTopic != null) {
      final AbstractElement element = (AbstractElement) theTopic.getPayload();
      if (element instanceof AbstractCollapsableElement) {
        final AbstractCollapsableElement cel = (AbstractCollapsableElement) element;
        if (MindMapUtils.ensureVisibility(cel.getModel())) {
          doLayout();
          revalidate();
          repaint();
          fireNotificationMindMapChanged(false);
        }
      }

      removeAllSelection();

      final int[] path = theTopic.getPositionPath();
      this.select(this.model.findAtPosition(path), false);
    }
  }

  public boolean cloneTopic(final Topic topic) {
    return this.cloneTopic(topic, true);
  }

  public boolean cloneTopic(final Topic topic, final boolean cloneSubtree) {
    assertNotDisposed();
    if (topic == null || topic.getTopicLevel() == 0) {
      return false;
    }

    final Topic cloned = this.model.cloneTopicInMap(topic, cloneSubtree);

    if (cloned != null) {
      cloned.moveAfter(topic);
      doLayout();
      revalidate();
      repaint();
      fireNotificationMindMapChanged(true);
    }

    return true;
  }

  public MindMapPanelConfig getConfiguration() {
    assertNotDisposed();
    return this.config;
  }

  public MindMapPanelController getController() {
    assertNotDisposed();
    return this.controller;
  }

  public void assertNotDisposed() {
    if (this.isDisposed()) {
      throw new IllegalStateException("Already disposed");
    }
  }

  public Topic getFirstSelected() {
    assertNotDisposed();
    return this.selectedTopics.isEmpty() ? null : this.selectedTopics.get(0);
  }

  @Override
  public void lostOwnership(final Clipboard clipboard, final Transferable contents) {

  }

  /**
   * Create transferable topic list in system clipboard.
   *
   * @param cut    true shows that remove topics after placing into clipboard
   * @param topics topics to be placed into clipboard, if there are successors
   *               and ancestors then successors will be removed
   * @return true if topic array is not empty and operation completed
   * successfully, false otherwise
   * @since 1.3.1
   */
  public boolean copyTopicsToClipboard(final boolean cut, final Topic... topics) {
    assertNotDisposed();

    boolean result = false;
    this.endEdit(true);
    if (topics.length > 0) {
      final Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
      clipboard.setContents(new MMDTopicsTransferable(topics), this);

      if (cut) {
        deleteTopics(true, ensureNoRootInArray(topics));
      }

      result = true;
    }

    return result;
  }

  /**
   * Paste topics from clipboard to currently selected ones.
   *
   * @return true if there detected topic list in clipboard and these topics
   * added to selected ones, false otherwise
   * @since 1.3.1
   */
  public boolean pasteTopicsFromClipboard() {
    assertNotDisposed();
    boolean result = false;

    final Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
    if (Utils.isDataFlavorAvailable(clipboard, MMDTopicsTransferable.MMD_DATA_FLAVOR)) {
      try {
        final NBMindMapTopicsContainer container =
            (NBMindMapTopicsContainer) clipboard.getData(MMDTopicsTransferable.MMD_DATA_FLAVOR);
        if (container != null && !container.isEmpty()) {
          this.endEdit(true);

          final Topic[] selected = this.getSelectedTopics();

          for (final Topic s : selected) {
            for (final Topic t : container.getTopics()) {
              final Topic newTopic = new Topic(this.model, t, true);
              newTopic.removeExtra(Extra.ExtraType.TOPIC);
              newTopic.moveToNewParent(s);
              MindMapUtils.ensureVisibility(newTopic);
            }
          }

          doLayout();
          revalidate();
          repaint();

          fireNotificationMindMapChanged(true);
          result = true;
        }
      } catch (final Exception ex) {
        LOGGER.error("Can't get clipboard data", ex);
      }
    } else if (Utils.isDataFlavorAvailable(clipboard, DataFlavor.stringFlavor)) {
      try {
        final int MAX_TEXT_LEN = 96;
        String clipboardText =
            (String) clipboard.getContents(null).getTransferData(DataFlavor.stringFlavor);

        if (clipboardText != null) {
          if (this.textEditor != null) {
            this.textEditor.insert(clipboardText, this.textEditor.getCaretPosition());
          } else {
            if (this.getConfiguration().isSmartTextPaste()) {
              for (final Topic t : this.getSelectedTopics()) {
                MindMapUtils.makeSubTreeFromText(t, clipboardText);
              }
            } else {
              clipboardText = clipboardText.trim();

              final String topicText;
              final String extraNoteText;

              if (clipboardText.length() > MAX_TEXT_LEN) {
                topicText = clipboardText.substring(0, MAX_TEXT_LEN) + "...";
                extraNoteText = clipboardText;
              } else {
                topicText = clipboardText;
                extraNoteText = null;
              }

              final Topic[] selectedTopics = this.getSelectedTopics();

              for (final Topic s : selectedTopics) {
                final Topic newTopic;
                if (extraNoteText == null) {
                  newTopic = new Topic(this.model, s, topicText);
                } else {
                  newTopic =
                      new Topic(this.model, s, topicText, new ExtraNote(extraNoteText));
                }
                MindMapUtils.ensureVisibility(newTopic);
              }
            }
          }
          doLayout();
          revalidate();
          repaint();

          fireNotificationMindMapChanged(true);
          result = true;
        }
      } catch (Exception ex) {
        LOGGER.error("Can't get clipboard text", ex);
      }
    }
    return result;
  }

  public boolean isDisposed() {
    return this.disposed.get();
  }

  public void dispose() {
    if (this.disposed.compareAndSet(false, true)) {
      this.selectedTopics.clear();
      this.mindMapListeners.clear();

      for (final PanelAwarePlugin p : MindMapPluginRegistry.getInstance()
          .findFor(PanelAwarePlugin.class)) {
        p.onPanelDispose(this);
      }
    }
  }

  public void doNotifyModelChanged(final boolean addToHistory) {
    this.revalidate();
    this.fireNotificationMindMapChanged(addToHistory);
  }

  /**
   * Some Job over mind map model.
   *
   * @see MindMapPanel#executeModelJobs(com.igormaznitsa.mindmap.swing.panel.MindMapPanel.ModelJob...)
   * @since 1.3.1
   */
  public interface ModelJob {

    /**
     * Execute the job.
     *
     * @param model model to be processed
     * @return true if to continue job sequence, false if to interrupt
     */
    boolean doChangeModel(MindMap model);
  }

  public static class DraggedElement {

    private final AbstractElement element;
    private final Image prerenderedImage;
    private final Point mousePointerOffset;
    private final Point currentPosition;
    private final DraggedElement.Modifier modifier;

    public DraggedElement(final AbstractElement element, final MindMapPanelConfig cfg,
                          final Point mousePointerOffset, final DraggedElement.Modifier modifier,
                          final RenderQuality quality) {
      this.element = element;
      this.prerenderedImage = Utils.renderWithTransparency(0.55f, element, cfg, quality);
      this.mousePointerOffset = mousePointerOffset;
      this.currentPosition = new Point();
      this.modifier = modifier;
    }

    public DraggedElement.Modifier getModifier() {
      return this.modifier;
    }

    public boolean isPositionInside() {
      return this.element.getBounds().contains(this.currentPosition);
    }

    public AbstractElement getElement() {
      return this.element;
    }

    public void updatePosition(final Point point) {
      this.currentPosition.setLocation(point);
    }

    public Point getPosition() {
      return this.currentPosition;
    }

    public Point getMousePointerOffset() {
      return this.mousePointerOffset;
    }

    public int getDrawPositionX() {
      return this.currentPosition.x - this.mousePointerOffset.x;
    }

    public int getDrawPositionY() {
      return this.currentPosition.y - this.mousePointerOffset.y;
    }

    public Image getImage() {
      return this.prerenderedImage;
    }

    public void draw(final Graphics2D gfx) {
      final int x = getDrawPositionX();
      final int y = getDrawPositionY();
      gfx.drawImage(this.prerenderedImage, x, y, null);
    }

    public enum Modifier {

      NONE,
      MAKE_JUMP
    }
  }

  private boolean isBirdsEyeActivationEvent(final MouseEvent mouseEvent) {
    return this.controller.isBirdsEyeAllowed(this)
        && this.model != null
        && mouseEvent != null
        && this.config.isModifiers(MindMapPanelConfig.KEY_BIRDSEYE_MODIFIERS, mouseEvent)
        && this.isBirdsEyeModeMouseButton(mouseEvent);
  }

  private boolean isBirdsEyeModeMouseButton(final MouseEvent mouseEvent) {
    return this.config.getBirdseyeMouseButton().match(mouseEvent);
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy