/** * Copyright (C) 2001-2017 by RapidMiner and the contributors * * Complete list of developers available at our web site: * * http://rapidminer.com * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Affero General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see http://www.gnu.org/licenses/. */ package com.rapidminer.gui.flow.processrendering.annotations; import java.awt.Color; import java.awt.Cursor; import java.awt.Font; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.ComponentListener; 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.font.TextAttribute; import java.awt.geom.Rectangle2D; import java.io.IOException; import java.io.StringWriter; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.logging.Level; import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.ButtonGroup; import javax.swing.ButtonModel; import javax.swing.Icon; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JDialog; import javax.swing.JEditorPane; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JToggleButton; import javax.swing.SwingUtilities; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.text.AttributeSet; import javax.swing.text.BadLocationException; import javax.swing.text.DefaultEditorKit; import javax.swing.text.DocumentFilter; import javax.swing.text.html.HTMLDocument; import com.rapidminer.gui.ApplicationFrame; import com.rapidminer.gui.flow.processrendering.annotations.event.AnnotationEventHook; import com.rapidminer.gui.flow.processrendering.annotations.model.AnnotationsModel; import com.rapidminer.gui.flow.processrendering.annotations.model.OperatorAnnotation; import com.rapidminer.gui.flow.processrendering.annotations.model.ProcessAnnotation; import com.rapidminer.gui.flow.processrendering.annotations.model.WorkflowAnnotation; import com.rapidminer.gui.flow.processrendering.annotations.model.WorkflowAnnotations; import com.rapidminer.gui.flow.processrendering.annotations.style.AnnotationAlignment; import com.rapidminer.gui.flow.processrendering.annotations.style.AnnotationColor; import com.rapidminer.gui.flow.processrendering.draw.OperatorDrawDecorator; import com.rapidminer.gui.flow.processrendering.draw.ProcessDrawDecorator; import com.rapidminer.gui.flow.processrendering.draw.ProcessDrawUtils; import com.rapidminer.gui.flow.processrendering.model.ProcessRendererModel; import com.rapidminer.gui.flow.processrendering.view.ProcessRendererView; import com.rapidminer.gui.flow.processrendering.view.RenderPhase; import com.rapidminer.gui.tools.Ionicon; import com.rapidminer.gui.tools.SwingTools; import com.rapidminer.operator.ExecutionUnit; import com.rapidminer.operator.Operator; import com.rapidminer.tools.I18N; import com.rapidminer.tools.LogService; /** * This class handles event hooks and draw decorators registered to the {@link ProcessRendererView} * for workflow annotations. * * @author Marco Boeck * @since 6.4.0 * */ public final class AnnotationsDecorator { /** name of the paste action (context menu) */ private static final String PASTE_ACTION_NAME = "paste"; /** name of the paste from clipboard action (ctrl+v) */ private static final String PASTE_FROM_CLIPBOARD_ACTION_NAME = DefaultEditorKit.pasteAction; /** icon depicting annotations on an operator */ private static final ImageIcon IMAGE_ANNOTATION = SwingTools.createIcon("16/note_pinned.png"); /** the width of the edit panel above/below the annotation editor */ private static final int EDIT_PANEL_WIDTH = 190; /** the height of the edit panel above/below the annotation editor */ private static final int EDIT_PANEL_HEIGHT = 30; /** the gap betwen the annotation and the edit buttons */ private static final int BUTTON_PANEL_GAP = 10; /** the width of the color panel above/below the annotation editor */ private static final int EDIT_COLOR_PANEL_WIDTH = 215; /** the height of the color panel above/below the annotation editor */ private static final int EDIT_COLOR_PANEL_HEIGHT = EDIT_PANEL_HEIGHT; /** the pane which can be used to edit the text */ private JEditorPane editPane; /** the panel which can be used to edit color and alignment during editing */ private JPanel editPanel; /** the dialog panel which can be used to edit color while editing */ private JDialog colorOverlay; /** the button opening the color overlay */ private JToggleButton colorButton; /** the process renderer */ private final ProcessRendererView view; /** the process renderer model */ private final ProcessRendererModel rendererModel; /** the annotation visualizer instance */ private final AnnotationsVisualizer visualizer; /** the model backing this decorator */ private final AnnotationsModel model; /** the drawer for the annotations */ private final AnnotationDrawer drawer; /** the event handling for annotations */ private final AnnotationEventHook hook; /** draws process (free-flowing) annotations behind operators */ private ProcessDrawDecorator processAnnotationDrawer = new ProcessDrawDecorator() { @Override public void draw(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel) { draw(process, g2, rendererModel, false); } @Override public void print(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel) { draw(process, g2, rendererModel, true); } /** * Draws the background annoations. */ private void draw(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel, final boolean printing) { if (!visualizer.isActive()) { return; } // background annotations WorkflowAnnotations annotations = rendererModel.getProcessAnnotations(process); if (annotations != null) { for (WorkflowAnnotation anno : annotations.getAnnotationsDrawOrder()) { // selected is drawn by highlight decorator if (anno.equals(model.getSelected())) { continue; } // paint the annotation itself Graphics2D g2P = (Graphics2D) g2.create(); drawer.drawAnnotation(anno, g2P, printing); g2P.dispose(); } } } }; /** draws operator annotations */ private ProcessDrawDecorator operatorAnnotationDrawer = new ProcessDrawDecorator() { @Override public void draw(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel) { draw(process, g2, rendererModel, false); } @Override public void print(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel) { draw(process, g2, rendererModel, true); } /** * Draws the operator annoations. */ private void draw(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel, final boolean printing) { if (!visualizer.isActive()) { return; } // operator attached annotations List<Operator> selectedOperators = rendererModel.getSelectedOperators(); for (Operator operator : process.getOperators()) { if (selectedOperators.contains(operator)) { continue; } drawOpAnno(operator, g2, rendererModel, printing); } // selected operators annotations need to be drawn over non selected ones for (Operator selOp : selectedOperators) { if (process.equals(selOp.getExecutionUnit())) { drawOpAnno(selOp, g2, rendererModel, printing); } } } /** * Draws the annotation for the given operator (if he has one). */ private void drawOpAnno(final Operator operator, final Graphics2D g2, final ProcessRendererModel rendererModel, final boolean printing) { WorkflowAnnotations annotations = rendererModel.getOperatorAnnotations(operator); if (annotations == null) { return; } for (WorkflowAnnotation anno : annotations.getAnnotationsDrawOrder()) { // selected is drawn by highlight decorator if (anno.equals(model.getSelected())) { continue; } // paint the annotation itself Graphics2D g2P = (Graphics2D) g2.create(); drawer.drawAnnotation(anno, g2P, printing); g2P.dispose(); } } }; /** draws process (free-flowing) annotations which are selected. Drawn over operators */ private ProcessDrawDecorator workflowAnnotationDrawerHighlight = new ProcessDrawDecorator() { @Override public void draw(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel) { draw(process, g2, rendererModel, false); } @Override public void print(ExecutionUnit process, Graphics2D g2, ProcessRendererModel model) { draw(process, g2, rendererModel, true); } /** * Draws the selected annotation. */ private void draw(final ExecutionUnit process, final Graphics2D g2, final ProcessRendererModel rendererModel, final boolean printing) { if (!visualizer.isActive()) { return; } // paint the selected annotation WorkflowAnnotation selected = model.getSelected(); if (selected != null) { // only draw in correct execution unit if (selected.getProcess().equals(process)) { // only paint annotation if not editing if (editPane == null) { // paint the annotation itself Graphics2D g2P = (Graphics2D) g2.create(); drawer.drawAnnotation(selected, g2P, printing); g2P.dispose(); } else { // only paint border Rectangle2D loc = selected.getLocation(); g2.setColor(Color.BLACK); g2.draw(new Rectangle2D.Double(loc.getX() - 1, loc.getY() - 1, editPane.getBounds().getWidth() * (1 / rendererModel.getZoomFactor()) + 1, editPane.getBounds().getHeight() * (1 / rendererModel.getZoomFactor()) + 1)); } } } } }; /** draws annotation icons on operators */ private OperatorDrawDecorator opAnnotationIconDrawer = new OperatorDrawDecorator() { @Override public void draw(final Operator operator, final Graphics2D g2, final ProcessRendererModel rendererModel) { draw(operator, g2, rendererModel, true); } @Override public void print(Operator operator, Graphics2D g2, ProcessRendererModel model) { draw(operator, g2, rendererModel, true); } /** * Draws the annotation icon on operators. */ private void draw(final Operator operator, final Graphics2D g2, final ProcessRendererModel rendererModel, final boolean printing) { // Draw annotation icons only if hidden if (visualizer.isActive()) { return; } WorkflowAnnotations annotations = rendererModel.getOperatorAnnotations(operator); if (annotations == null || annotations.isEmpty()) { return; } Rectangle2D frame = rendererModel.getOperatorRect(operator); int xOffset = (IMAGE_ANNOTATION.getIconWidth() + 2) * 2; ProcessDrawUtils.getIcon(operator, IMAGE_ANNOTATION).paintIcon(null, g2, (int) (frame.getX() + frame.getWidth() - xOffset), (int) (frame.getY() + frame.getHeight() - IMAGE_ANNOTATION.getIconHeight() - 1)); } }; /** listener which triggers color panel moving if required */ private ComponentListener colorPanelMover = new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { updateColorPanelPosition(); } @Override public void componentMoved(ComponentEvent e) { updateColorPanelPosition(); } }; /** * Creates a new workflow annotation decorator * * @param view * the process renderer instance * @param visualizer * the annotation visualizer instance * @param model * the model backing this instance */ public AnnotationsDecorator(final ProcessRendererView view, final AnnotationsVisualizer visualizer, final AnnotationsModel model) { this.view = view; this.model = model; this.rendererModel = view.getModel(); this.drawer = new AnnotationDrawer(model, rendererModel); this.hook = new AnnotationEventHook(this, model, visualizer, drawer, view, rendererModel); this.visualizer = visualizer; } /** * Start inline editing of the selected annotation. If no annotation is selected, does nothing. */ public void editSelected() { if (model.getSelected() == null) { return; } // editor to actually edit comment string removeEditor(); createEditor(); // panel to edit alignment and color createEditPanel(); editPane.requestFocusInWindow(); view.repaint(); } /** * Stop all editing and remove editors. */ public void reset() { drawer.reset(); removeEditor(); } /** * Registers the event hooks and draw decorators to the process renderer. */ void registerEventHooks() { view.addDrawDecorator(processAnnotationDrawer, RenderPhase.ANNOTATIONS); view.addDrawDecorator(operatorAnnotationDrawer, RenderPhase.OPERATOR_ANNOTATIONS); view.addDrawDecorator(workflowAnnotationDrawerHighlight, RenderPhase.OVERLAY); view.addDrawDecorator(opAnnotationIconDrawer); view.getOverviewPanelDrawer().addDecorator(processAnnotationDrawer, RenderPhase.ANNOTATIONS); view.getOverviewPanelDrawer().addDecorator(operatorAnnotationDrawer, RenderPhase.OPERATOR_ANNOTATIONS); hook.registerDecorators(); // this listener makes the color edit panel move when required view.addComponentListener(colorPanelMover); ApplicationFrame.getApplicationFrame().addComponentListener(colorPanelMover); } /** * Removes the event hooks and draw decorators from the process renderer. */ void unregisterDecorators() { view.removeDrawDecorator(processAnnotationDrawer, RenderPhase.ANNOTATIONS); view.removeDrawDecorator(operatorAnnotationDrawer, RenderPhase.OPERATOR_ANNOTATIONS); view.removeDrawDecorator(workflowAnnotationDrawerHighlight, RenderPhase.OVERLAY); view.removeDrawDecorator(opAnnotationIconDrawer); view.getOverviewPanelDrawer().removeDecorator(processAnnotationDrawer, RenderPhase.ANNOTATIONS); view.getOverviewPanelDrawer().removeDecorator(operatorAnnotationDrawer, RenderPhase.OPERATOR_ANNOTATIONS); hook.unregisterEventHooks(); view.removeComponentListener(colorPanelMover); ApplicationFrame.getApplicationFrame().removeComponentListener(colorPanelMover); } /** * Creates and adds the JEditorPane for the currently selected annotation to the process * renderer. */ private void createEditor() { final WorkflowAnnotation selected = model.getSelected(); Rectangle2D loc = selected.getLocation(); // JEditorPane to edit the comment string editPane = new JEditorPane("text/html", ""); editPane.setBorder(null); int paneX = (int) (loc.getX() * rendererModel.getZoomFactor()); int paneY = (int) (loc.getY() * rendererModel.getZoomFactor()); int index = view.getModel().getProcessIndex(selected.getProcess()); Point absolute = ProcessDrawUtils.convertToAbsoluteProcessPoint(new Point(paneX, paneY), index, rendererModel); editPane.setBounds((int) absolute.getX(), (int) absolute.getY(), (int) (loc.getWidth() * rendererModel.getZoomFactor()), (int) (loc.getHeight() * rendererModel.getZoomFactor())); editPane.setText(AnnotationDrawUtils.createStyledCommentString(selected)); // use proxy for paste actions to trigger reload of editor after paste Action pasteFromClipboard = editPane.getActionMap().get(PASTE_FROM_CLIPBOARD_ACTION_NAME); Action paste = editPane.getActionMap().get(PASTE_ACTION_NAME); if (pasteFromClipboard != null) { editPane.getActionMap().put(PASTE_FROM_CLIPBOARD_ACTION_NAME, new PasteAnnotationProxyAction(pasteFromClipboard, this)); } if (paste != null) { editPane.getActionMap().put(PASTE_ACTION_NAME, new PasteAnnotationProxyAction(paste, this)); } // use proxy for transfer actions to convert e.g. HTML paste to plaintext paste editPane.setTransferHandler(new TransferHandlerAnnotationPlaintext(editPane)); // IMPORTANT: Linebreaks do not work without the following! // this filter inserts a \r every time the user enters a newline // this signal is later used to convert newline to <br/> ((HTMLDocument) editPane.getDocument()).setDocumentFilter(new DocumentFilter() { @Override public void insertString(DocumentFilter.FilterBypass fb, int offs, String str, AttributeSet a) throws BadLocationException { // this is never called.. super.insertString(fb, offs, str.replaceAll("\n", "\n" + AnnotationDrawUtils.ANNOTATION_HTML_NEWLINE_SIGNAL), a); } @Override public void replace(FilterBypass fb, int offs, int length, String str, AttributeSet a) throws BadLocationException { if (selected instanceof OperatorAnnotation) { // operator annotations have a character limit, enforce here try { int existingLength = AnnotationDrawUtils.getPlaintextFromEditor(editPane, false).length() - length; if (existingLength + str.length() > OperatorAnnotation.MAX_CHARACTERS) { // insert at beginning or end is fine, cut off excess characters if (existingLength <= 0 || offs >= existingLength) { int acceptableLength = OperatorAnnotation.MAX_CHARACTERS - existingLength; int newLength = Math.max(acceptableLength, 0); str = str.substring(0, newLength); } else { // inserting into middle, do NOT paste at all return; } } } catch (IOException e) { // should not happen, if it does this is our smallest problem -> ignore } } super.replace(fb, offs, length, str.replaceAll("\n", "\n" + AnnotationDrawUtils.ANNOTATION_HTML_NEWLINE_SIGNAL), a); } }); // set background color if (selected.getStyle().getAnnotationColor() == AnnotationColor.TRANSPARENT) { editPane.setBackground(Color.WHITE); } else { editPane.setBackground(selected.getStyle().getAnnotationColor().getColorHighlight()); } editPane.addFocusListener(new FocusAdapter() { @Override public void focusLost(final FocusEvent e) { // right-click menu if (e.isTemporary()) { return; } if (editPane != null && e.getOppositeComponent() != null) { // style edit menu, no real focus loss if (SwingUtilities.isDescendingFrom(e.getOppositeComponent(), editPanel)) { return; } if (SwingUtilities.isDescendingFrom(e.getOppositeComponent(), colorOverlay)) { return; } if (colorOverlay.getParent() == e.getOppositeComponent()) { return; } saveEdit(selected); removeEditor(); } } }); editPane.addKeyListener(new KeyAdapter() { /** keep track of control down so Ctrl+Enter works but Enter+Ctrl not */ private boolean controlDown; @Override public void keyPressed(final KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_CONTROL) { controlDown = true; } // consume so undo/redo etc are not passed to the process if (SwingTools.isControlOrMetaDown(e) && e.getKeyCode() == KeyEvent.VK_Z || e.getKeyCode() == KeyEvent.VK_Y) { e.consume(); } } @Override public void keyReleased(final KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_CONTROL: controlDown = false; break; case KeyEvent.VK_ENTER: if (!controlDown) { updateEditorHeight(selected); } else { // if control was down before Enter was pressed, save & exit saveEdit(selected); removeEditor(); model.setSelected(null); } break; case KeyEvent.VK_ESCAPE: // ignore changes on escape removeEditor(); model.setSelected(null); break; default: break; } } }); editPane.getDocument().addDocumentListener(new DocumentListener() { @Override public void removeUpdate(DocumentEvent e) { updateEditorHeight(selected); } @Override public void insertUpdate(DocumentEvent e) { updateEditorHeight(selected); } @Override public void changedUpdate(DocumentEvent e) { updateEditorHeight(selected); } }); view.add(editPane); editPane.selectAll(); } /** * Creates and adds the edit panel for the currently selected annotation to the process * renderer. * */ private void createEditPanel() { final WorkflowAnnotation selected = model.getSelected(); Rectangle2D loc = selected.getLocation(); // panel containing buttons editPanel = new JPanel(); editPanel.setCursor(Cursor.getDefaultCursor()); editPanel.setLayout(new BoxLayout(editPanel, BoxLayout.LINE_AXIS)); updateEditPanelPosition(loc, false); editPanel.setOpaque(true); editPanel.setBorder(BorderFactory.createLineBorder(Color.BLACK)); // consume mouse events so focus is not lost editPanel.addMouseListener(new MouseAdapter() { @Override public void mouseReleased(MouseEvent e) { e.consume(); } @Override public void mousePressed(MouseEvent e) { e.consume(); } @Override public void mouseClicked(MouseEvent e) { e.consume(); } }); // add alignment controls final List<JToggleButton> alignmentButtonList = new LinkedList<JToggleButton>(); ButtonGroup nonSelectionGroup = new ButtonGroup() { private static final long serialVersionUID = 1L; @Override public void setSelected(ButtonModel model, boolean selected) { if (selected) { super.setSelected(model, selected); } else { clearSelection(); } } }; for (AnnotationAlignment align : AnnotationAlignment.values()) { final Action action = align.makeAlignmentChangeAction(model, model.getSelected()); final JToggleButton alignButton = new JToggleButton(); nonSelectionGroup.add(alignButton); alignButton.setIcon((Icon) action.getValue(Action.SMALL_ICON)); alignButton.setBorderPainted(false); alignButton.setBorder(null); alignButton.setFocusable(false); if (align == selected.getStyle().getAnnotationAlignment()) { alignButton.setSelected(true); } alignButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { removeColorPanel(); colorButton.setSelected(false); int caretPos = editPane.getCaretPosition(); // remember if we were at last position because doc length can change after 1st // save boolean lastPos = caretPos == editPane.getDocument().getLength(); int selStart = editPane.getSelectionStart(); int selEnd = editPane.getSelectionEnd(); // change alignment and save current comment action.actionPerformed(e); saveEdit(selected); // reload edit pane with changes editPane.setText(AnnotationDrawUtils.createStyledCommentString(selected)); // special handling for documents of length 1 to avoid not being able to type if (editPane.getDocument().getLength() == 1) { caretPos = 1; } else if (lastPos) { caretPos = editPane.getDocument().getLength(); } else { caretPos = Math.min(editPane.getDocument().getLength(), caretPos); } editPane.setCaretPosition(caretPos); if (selEnd - selStart > 0) { editPane.setSelectionStart(selStart); editPane.setSelectionEnd(selEnd); } editPane.requestFocusInWindow(); } }); editPanel.add(alignButton); alignmentButtonList.add(alignButton); } // add small empty space editPanel.add(Box.createHorizontalStrut(2)); // add color controls colorOverlay = new JDialog(ApplicationFrame.getApplicationFrame()); colorOverlay.setCursor(Cursor.getDefaultCursor()); colorOverlay.getRootPane().setLayout(new BoxLayout(colorOverlay.getRootPane(), BoxLayout.LINE_AXIS)); colorOverlay.setUndecorated(true); colorOverlay.getRootPane().setBorder(BorderFactory.createLineBorder(Color.LIGHT_GRAY)); colorOverlay.setFocusable(false); colorOverlay.setAutoRequestFocus(false); // consume mouse events so focus is not lost colorOverlay.addMouseListener(new MouseAdapter() { @Override public void mouseReleased(MouseEvent e) { e.consume(); } @Override public void mousePressed(MouseEvent e) { e.consume(); } @Override public void mouseClicked(MouseEvent e) { e.consume(); } }); for (final AnnotationColor color : AnnotationColor.values()) { final Action action = color.makeColorChangeAction(model, selected); JButton colChangeButton = new JButton(); colChangeButton.setText(null); colChangeButton.setBorderPainted(false); colChangeButton.setBorder(null); colChangeButton.setFocusable(false); final Icon icon = SwingTools.createIconFromColor(color.getColor(), Color.BLACK, 16, 16, new Rectangle2D.Double(1, 1, 14, 14)); colChangeButton.setIcon(icon); colChangeButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { // change color and save current comment action.actionPerformed(e); saveEdit(selected); // set edit pane bg color editPane.requestFocusInWindow(); if (color == AnnotationColor.TRANSPARENT) { editPane.setBackground(Color.WHITE); } else { editPane.setBackground(color.getColorHighlight()); } // adapt color of main button, remove color panel colorButton.setIcon(icon); if (removeColorPanel()) { colorButton.setSelected(false); view.repaint(); } } }); colorOverlay.getRootPane().add(colChangeButton); } colorButton = new JToggleButton( "<html><span style=\"color: 4F4F4F;\">" + Ionicon.ARROW_DOWN_B.getHtml() + "</span></html>"); colorButton.setBorderPainted(false); colorButton.setFocusable(false); AnnotationColor color = selected.getStyle().getAnnotationColor(); colorButton.setIcon( SwingTools.createIconFromColor(color.getColor(), Color.BLACK, 16, 16, new Rectangle2D.Double(1, 1, 14, 14))); colorButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { if (removeColorPanel()) { colorButton.setSelected(false); view.repaint(); return; } updateColorPanelPosition(); colorOverlay.setVisible(true); editPane.requestFocusInWindow(); view.repaint(); } }); editPanel.add(colorButton); // add separator JLabel separator = new JLabel() { private static final long serialVersionUID = 1L; @Override public void paintComponent(Graphics g) { Graphics2D g2 = (Graphics2D) g; g2.setColor(Color.LIGHT_GRAY); g2.drawLine(2, 0, 2, 20); } }; separator.setText(" "); // dummy text to show label editPanel.add(separator); // add delete button final JButton deleteButton = new JButton( I18N.getMessage(I18N.getGUIBundle(), "gui.action.workflow.annotation.delete.label")); deleteButton.setForeground(Color.RED); deleteButton.setContentAreaFilled(false); deleteButton.setFocusable(false); deleteButton.setBorderPainted(false); deleteButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { model.deleteAnnotation(selected); removeEditor(); } }); deleteButton.addMouseListener(new MouseAdapter() { @Override @SuppressWarnings({ "unchecked", "rawtypes" }) public void mouseExited(MouseEvent e) { Font font = deleteButton.getFont(); Map attributes = font.getAttributes(); attributes.put(TextAttribute.UNDERLINE, -1); deleteButton.setFont(font.deriveFont(attributes)); } @SuppressWarnings({ "unchecked", "rawtypes" }) @Override public void mouseEntered(MouseEvent e) { Font font = deleteButton.getFont(); Map attributes = font.getAttributes(); attributes.put(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON); deleteButton.setFont(font.deriveFont(attributes)); } }); editPanel.add(deleteButton); // add panel to view view.add(editPanel); } /** * Saves the content of the comment editor as the new comment for the given * {@link WorkflowAnnotation}. * * @param selected * the annotation for which the content of the editor pane should be saved as new * comment */ private void saveEdit(final WorkflowAnnotation selected) { if (editPane == null) { return; } HTMLDocument document = (HTMLDocument) editPane.getDocument(); StringWriter writer = new StringWriter(); try { editPane.getEditorKit().write(writer, document, 0, document.getLength()); } catch (IndexOutOfBoundsException | IOException | BadLocationException e1) { // should not happen LogService.getRoot().log(Level.WARNING, "com.rapidminer.gui.flow.processrendering.annotations.AnnotationsDecorator.cannot_save"); } String comment = writer.toString(); comment = AnnotationDrawUtils.removeStyleFromComment(comment); Rectangle2D loc = selected.getLocation(); Rectangle2D newLoc = new Rectangle2D.Double(loc.getX(), loc.getY(), editPane.getBounds().getWidth() * (1 / rendererModel.getZoomFactor()), editPane.getBounds().getHeight() * (1 / rendererModel.getZoomFactor())); selected.setLocation(newLoc); boolean overflowing = false; int prefHeight = AnnotationDrawUtils.getContentHeight( AnnotationDrawUtils.createStyledCommentString(comment, selected.getStyle()), (int) newLoc.getWidth()); if (prefHeight > newLoc.getHeight()) { overflowing = true; } selected.setOverflowing(overflowing); model.setAnnotationComment(selected, comment); } /** * Reloads the editor pane content to match editor and annotation styling. After this call, the * editor pane displays the annotation in the same way as it is displayed in the process * renderer. */ void updateEditorContent() { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { if (editPane == null || model.getSelected() == null) { return; } HTMLDocument document = (HTMLDocument) editPane.getDocument(); StringWriter writer = new StringWriter(); try { editPane.getEditorKit().write(writer, document, 0, document.getLength()); } catch (IndexOutOfBoundsException | IOException | BadLocationException e1) { // should not happen LogService.getRoot().log(Level.WARNING, "com.rapidminer.gui.flow.processrendering.annotations.AnnotationsDecorator.cannot_save"); } String comment = writer.toString(); comment = AnnotationDrawUtils.removeStyleFromComment(comment); int caretPos = editPane.getCaretPosition(); boolean lastPos = caretPos == editPane.getDocument().getLength(); editPane.setText(AnnotationDrawUtils.createStyledCommentString(comment, model.getSelected().getStyle())); if (lastPos) { caretPos = editPane.getDocument().getLength(); } caretPos = Math.min(caretPos, editPane.getDocument().getLength()); editPane.setCaretPosition(caretPos); editPane.requestFocusInWindow(); } }); } /** * Removes the annotation editor from the process renderer. */ private void removeEditor() { if (editPane != null) { view.remove(editPane); editPane = null; } if (editPanel != null) { view.remove(editPanel); editPanel = null; } removeColorPanel(); // this makes sure that pressing F2 afterwards works // otherwise nothing is focused until the next click view.requestFocusInWindow(); view.repaint(); } /** * Tries to remove the color panel. If not found or not showing, does nothing. */ private boolean removeColorPanel() { if (colorOverlay != null && colorOverlay.isShowing()) { colorOverlay.dispose(); return true; } return false; } /** * Updates the position and size of the edit panel relative to the given location. * * @param loc * the location the edit panel should be relative to * @param absolute * if {@code true} the loc is treated as absolute position on the process renderer; * if {@code false} it is treated as relative to the current process */ private void updateEditPanelPosition(final Rectangle2D loc, final boolean absolute) { int panelX = (int) (loc.getCenterX() * rendererModel.getZoomFactor() - EDIT_PANEL_WIDTH / 2); int panelY = (int) (loc.getY() * rendererModel.getZoomFactor() - EDIT_PANEL_HEIGHT - BUTTON_PANEL_GAP); // if panel would be outside process renderer, fix it if (panelX < WorkflowAnnotation.MIN_X) { panelX = WorkflowAnnotation.MIN_X; } if (panelY < 0) { panelY = (int) (loc.getMaxY() * rendererModel.getZoomFactor()) + BUTTON_PANEL_GAP; } // last fallback is cramped to the bottom. If that does not fit either, don't care if (panelY + EDIT_PANEL_HEIGHT > view.getSize().getHeight() - BUTTON_PANEL_GAP * 2) { panelY = (int) (loc.getMaxY() * rendererModel.getZoomFactor()); } int index = view.getModel().getProcessIndex(model.getSelected().getProcess()); if (absolute) { editPanel.setBounds(panelX, panelY, EDIT_PANEL_WIDTH, EDIT_PANEL_HEIGHT); } else { Point absoluteP = ProcessDrawUtils.convertToAbsoluteProcessPoint(new Point(panelX, panelY), index, rendererModel); editPanel.setBounds((int) absoluteP.getX(), (int) absoluteP.getY(), EDIT_PANEL_WIDTH, EDIT_PANEL_HEIGHT); } } /** * Makes sure the current editor height matches its content if the annotation was never resized. * If the annotation has been manually resized before, does nothing. * * @param anno * the annotation currently in the editor */ private void updateEditorHeight(final WorkflowAnnotation anno) { if (anno.wasResized()) { return; } Rectangle bounds = editPane.getBounds(); // height is either the pref height or the current height, depending on what is bigger int prefHeight; if (anno instanceof ProcessAnnotation) { prefHeight = (int) Math.max(getContentHeightOfEditor((int) bounds.getWidth()), bounds.getHeight()); } else { prefHeight = Math.max(getContentHeightOfEditor((int) bounds.getWidth()), OperatorAnnotation.MIN_HEIGHT); } Rectangle newBounds = new Rectangle((int) bounds.getX(), (int) bounds.getY(), (int) bounds.getWidth(), prefHeight); if (!bounds.equals(newBounds)) { editPane.setBounds(newBounds); updateEditPanelPosition(newBounds, true); view.getModel().fireAnnotationMiscChanged(anno); } } /** * Updates the location of the color edit panel (if shown). */ private void updateColorPanelPosition() { if (editPanel != null && colorOverlay != null) { int colorPanelX = (int) editPanel.getLocationOnScreen().getX() + colorButton.getX(); int colorPanelY = (int) (editPanel.getLocationOnScreen().getY() + editPanel.getBounds().getHeight()); colorOverlay.setBounds(colorPanelX, colorPanelY, EDIT_COLOR_PANEL_WIDTH, EDIT_COLOR_PANEL_HEIGHT); } } /** * Calculates the preferred height of the editor pane with the given fixed width. * * @param width * the width of the pane * @return the preferred height given the current editor pane content or {@code -1} if there was * a problem. Value will never exceed {@link WorkflowAnnotation#MAX_HEIGHT} */ private int getContentHeightOfEditor(final int width) { HTMLDocument document = (HTMLDocument) editPane.getDocument(); StringWriter writer = new StringWriter(); try { editPane.getEditorKit().write(writer, document, 0, document.getLength()); } catch (IndexOutOfBoundsException | IOException | BadLocationException e1) { // should not happen return -1; } String comment = writer.toString(); comment = AnnotationDrawUtils.removeStyleFromComment(comment); int maxHeight = model.getSelected() instanceof ProcessAnnotation ? ProcessAnnotation.MAX_HEIGHT : OperatorAnnotation.MAX_HEIGHT; return Math.min( AnnotationDrawUtils.getContentHeight( AnnotationDrawUtils.createStyledCommentString(comment, model.getSelected().getStyle()), width), maxHeight); } }