/* * (c) Copyright 2010-2011 AgileBirds * * This file is part of OpenFlexo. * * OpenFlexo is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * OpenFlexo is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with OpenFlexo. If not, see <http://www.gnu.org/licenses/>. * */ package org.openflexo.wysiwyg; import java.awt.Color; import java.awt.Component; import java.awt.Graphics; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.HashSet; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JFileChooser; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JToolBar; import javax.swing.SwingUtilities; import javax.swing.border.Border; import javax.swing.border.LineBorder; import javax.swing.text.Document; import javax.swing.text.html.HTML.Tag; import javax.swing.text.html.HTMLDocument; import org.openflexo.toolbox.FileUtils; import org.openflexo.toolbox.HTMLUtils; import org.openflexo.toolbox.ToolBox; import sferyx.administration.editors.EditorHTMLDocument; import sferyx.administration.editors.HTMLEditor; public abstract class FlexoWysiwyg extends HTMLEditor { protected static final Logger logger = Logger.getLogger(FlexoWysiwyg.class.getPackage().getName()); private boolean isViewSourceAvailable = false; private boolean isFocusListenerActivated = true; private FocusListener focusListener; private boolean focusListenerAddedToSourceComponent = false; private Set<String> removedPopupMenuItems = new HashSet<String>(); private boolean isInPaste = false; /** * Creates the default wysiwyg lightweight component (JRootPane), with the JMenuBar and without any CSS file. This class must implement * <code>textChanged(String htmlText)</code> to be concrete. */ public FlexoWysiwyg(boolean isViewSourceAvailable) { this(null, isViewSourceAvailable); } /** * Creates the default wysiwyg lightweight component (JRootPane) initialized with <code>htmlContent</code> with the JMenuBar and without * any CSS file. This class must implement <code>textChanged(String htmlText)</code> to be concrete. * * @param htmlContent * the HTML content to initialize the wysiwyg with. */ public FlexoWysiwyg(String htmlContent, boolean isViewSourceAvailable) { this(htmlContent, null, isViewSourceAvailable); } /* * Main constructor for all the wysiwyg. Should only be called from this package */ /** * Creates the default wysiwyg lightweight component (JRootPane). This class must implement <code>textChanged(String htmlText)</code> to * be concrete. * * @param htmlContent * the HTML content to initialize the wysiwyg with. * @param cssFile * the CSS file to apply on the document. */ protected FlexoWysiwyg(String htmlContent, File cssFile, boolean isViewSourceAvailable) { super(); this.isViewSourceAvailable = isViewSourceAvailable; applyDefaultTextOptions(); applyDefaultDisplayOptions(); setContent(htmlContent); // let handleCss AFTER setContent otherwise a deadlock occurs !! handleCss(cssFile); getSelectedEditorComponent().addFocusListener(focusListener = new FlexoWysiwygFocusListener()); setPreferredPasteOperation(PASTE_FORMATTED__TEXT); if (!getIsViewSourceAvailable()) { setSourceEditorVisible(false); } addToConstructor(); } protected Component getSourceEditorComponent() { if (getMainTabbedPane().getTabCount() > 1) { Component c = getMainTabbedPane().getComponentAt(1); if (c instanceof JScrollPane) { c = ((JScrollPane) c).getViewport().getView(); } return c; } return null; } @Override public void setSourceEditorVisible(boolean visible) { if (!visible && getSourceEditorComponent() != null) { getSourceEditorComponent().removeFocusListener(focusListener); focusListenerAddedToSourceComponent = false; } super.setSourceEditorVisible(visible); if (visible && !focusListenerAddedToSourceComponent && getSourceEditorComponent() != null) { getSourceEditorComponent().addFocusListener(focusListener); focusListenerAddedToSourceComponent = true; } } @Override public boolean hasFocus() { return getSelectedEditorComponent().hasFocus(); } /* * CALLS FROM CONSTRUCTOR */ /** * Override this method if you want to add statements at the end of the constructor */ public void addToConstructor() { } @Override public void pasteFormattedTextFromClipboard() { /** * Actually the super.pasteFormattedTextFromClipboard performs a lot of cleaning on the pasted content, and then call the * insertContent method. So the isInPaste boolean is set to true to perform the html cleaning in insertContent method only if this * occurs because of a paste. This is ugly but I cannot find a better way to do it: - doing the clean after the whole paste process * will clean the entire content and breaks the 'undo' action - doing it before means getting the content from the clipboard, doing * the same cleaning than the one performed by super.pasteFormattedTextFromClipboard and setting back this content into the * clipboard. */ isInPaste = true; super.pasteFormattedTextFromClipboard(); isInPaste = false; } @Override public void insertContent(String content) { if (isInPaste) { try { content = FlexoWysiwygHtmlCleaner.cleanHtml(content, getStyleClasses()); } catch (IOException e) { logger.log(Level.SEVERE, "Cannot clean pasted content !", e); } } super.insertContent(content); } private void applyDefaultTextOptions() { setDefaultCharset("utf-8"); // setDefaultInitialFont("Dialog"); setDefaultInitialFontSize("8"); setShowBodyContentOnlyInSource(Boolean.TRUE.toString()); } /** * Some useless menus are removed by default (File, Tools, Window and Help), as well as the 'new file', 'open file' and 'save file' * icons on the toolbar. */ private void applyDefaultDisplayOptions() { getFormattingToolBar().setFloatable(false); getEditingToolBar().setFloatable(false); // to remove the default Border is bad looking on MacOsX L&F if (ToolBox.isMacOSLaf()) { for (int i = 0; i < getFormattingToolBar().getComponentCount(); i++) { if (getFormattingToolBar().getComponent(i) instanceof JComboBox) { JComboBox c = (JComboBox) getFormattingToolBar().getComponent(i); c.setBorder(null); } } } setRemovedMenus("menuFile, menuTools, menuWindow, menuForm, menuHelp"); setRemovedMenuItems("pagePropertiesMainMenuItem"); setRemovedPopupMenuItems("pagePropertiesMenuItem, createTablePopupMenu"); setRemovedToolbarItems("newFileButton, openFileButton, saveFileButton, tableBtn"); // items must be separated by a comma } @Override public void setRemovedToolbarItems(String toolbarItemNames) { super.setRemovedToolbarItems(toolbarItemNames); cleanUpToolBars(); } @Override public void setRemovedPopupMenuItems(String removedItems) { // Fix bug in implementation, the method setRemovedPopupMenuItems from parent overrides the previously entered items // So we keep those items in set removedPopupMenuItems to avoid losing previously removed items for (String removedItem : removedItems.split(",")) { removedPopupMenuItems.add(removedItem.trim()); } StringBuilder sb = new StringBuilder(); for (String removedItem : removedPopupMenuItems) { if (sb.length() > 0) { sb.append(", "); } sb.append(removedItem); } super.setRemovedPopupMenuItems(sb.toString()); } @Override protected void adjustPopupForElement() { super.adjustPopupForElement(); cleanUpPopupMenu(); } private void cleanUpPopupMenu() { removeDuplicateAndUnusedSeparator(getVisualEditorPopupMenu(), JPopupMenu.Separator.class); } private void cleanUpToolBars() { removeDuplicateAndUnusedSeparator(getEditingToolBar(), JToolBar.Separator.class); removeDuplicateAndUnusedSeparator(getFormattingToolBar(), JToolBar.Separator.class); } private void removeDuplicateAndUnusedSeparator(JComponent container, Class separatorClass) { boolean isFirstShowComponent = true; Component previousComponent = null; Component[] c = container.getComponents(); // Set all separators to visible for (int i = 0; i < c.length; i++) { Component component = c[i]; if (separatorClass.isInstance(component)) { component.setVisible(true); } } for (int i = 0; i < c.length; i++) { Component component = c[i]; if (!component.isVisible()) { continue; } if (separatorClass.isInstance(component)) { // If it is the first shown component or the previous shown component was separator, we hide this one if (isFirstShowComponent || separatorClass.isInstance(previousComponent)) { component.setVisible(false); continue; } } previousComponent = component; isFirstShowComponent = false; } // Hides trailing separator if (separatorClass.isInstance(previousComponent)) { previousComponent.setVisible(false); } } /** * applies a css to the html document. The css also defines styles to add to the styles JComboBox on the formatting toolbar * * @param cssFile * the CSS file to apply. All the CSS properties will be applied on the page rendering but only classes defined as * <code>.aClass{property:value;}</code>will be added to the CSS styles JComboBox. <br> * Example:<br> * <code>.green{color:green;}</code> will be applied in the rendering and added as 'green' in the styles JComboBox<br> * <code>p.green{color:green;}</code> will only be applied in the rendering * @Deprecated foire à mort...on va s'en passer pour le moment; */ private void handleCss(File cssFile) { if (cssFile != null) { try { loadExternalStyleSheet(cssFile.toURL().toString()); } catch (Exception e) { logger.log(Level.WARNING, "Could not load the external style sheet '" + cssFile.getPath() + "'. Use level fine for stacktrace"); if (logger.isLoggable(Level.FINE)) { logger.log(Level.FINE, "Stacktrace : ", e); } } } else { setRemovedToolbarItems("styleClasses"); } } /** * add a gray line border around the toolbars to give a better look, in particular in inspectors. */ public void addBorderAroundToolbar() { try { // the toolbar panel is actually a panel which contains two panels, each containing a JToolbar JPanel toolbarPanel = (JPanel) getFormattingToolBar().getParent().getParent(); Border grayBorder = new LineBorder(Color.GRAY) { @Override public void paintBorder(Component c, Graphics g, int x, int y, int width, int height) { super.paintBorder(c, g, x, y, width, height + 1); // height+1 = astuce pour ne pas dessiner la ligne du bas du border... } }; toolbarPanel.setBorder(grayBorder); } catch (Exception e) { logger.log(Level.WARNING, "Error while drawing the gray border around the toolbars", e); } } /** * Little hack so that the file chooser does not take hours to load up. Ideally, we should provide the FlexoFileChooser. * * @see sferyx.administration.editors.HTMLEditor#getFileDialog() */ @Override public JFileChooser getFileDialog() { if (isLocalFileBrowsingDisabled()) { setLocalFileBrowsingDisabled(false); } if (ToolBox.fileChooserRequiresFix()) { ToolBox.fixFileChooser(); } JFileChooser dialog = super.getFileDialog(); if (ToolBox.fileChooserRequiresFix()) { ToolBox.undoFixFileChooser(); } return dialog; } private File documentBaseFolder; private String insertedObjectsFolderName; /** * Please use this method if you need this wysiwyg to handle with images imported into a specific directory. Each time the user will * upload an image, it will be copied into <code>insertedObjectsFolderPath</code> and the <code>src</code> attribute of the image will * be made relative.<br> * <p> * Example: * </p> * <p> * <code>addSupportForInsertedObjects(aFolder, "images")</code> will set <code>aFolder</code> as <code>base</code> of the * <code>Document</code>.<br> * The images will be copied into <code>.../aFolder/images/</code><br> * The <code>src<code> attribute of an image file such as "example.jpg" will be <code>src="images/example.jpg"</code><br> * </p> * * @param insertedObjectsFolder * the name of the folder in which all the images will be copied. This parameter cannot be null or empty. The HTML file will * be saved in the insertedObjectsFolder parent folder (actually the <code>base</code> of the <code>Document</code>). * * @see Document */ public void addSupportForInsertedObjects(File insertedObjectsFolder) { try { if (insertedObjectsFolder == null) { throw new IllegalArgumentException("insertedObjectsFolder cannot be null"); } if (!insertedObjectsFolder.exists()) { insertedObjectsFolder.mkdir(); } documentBaseFolder = insertedObjectsFolder.getParentFile(); if (documentBaseFolder == null) { throw new IllegalArgumentException("insertedObjectsFolder must have a parent, cannot be a root folder"); } // pretty stupid but we need to get the file dialog to set the document base folder. Otherwise you would get a // NullpointerException at saving. getFileDialog().setSelectedFile(documentBaseFolder); ((HTMLDocument) getInternalJEditorPane().getDocument()).setBase(documentBaseFolder.toURL()); insertedObjectsFolderName = insertedObjectsFolder.getName(); setLinkedObjectsFolderName(insertedObjectsFolderName); setGenerateUniqueImageFilenames(true); setSaveEntireDocumentTree(true); getInternalJEditorPane().repaint(); // Reload wysiwyg to display images if any (otherwise images are not displayed in the Toc editor) EditorHTMLDocument document = (EditorHTMLDocument) getInternalJEditorPane().getDocument(); if (document.getIterator(Tag.IMG).isValid()) { createNewDocument(getBodyContent(), ((EditorHTMLDocument) getInternalJEditorPane().getDocument()).getBase()); } } catch (Exception e) { logger.log(Level.WARNING, "Could not add support for inserted objects : " + e.getMessage() + ". Use level fine for stacktrace"); if (logger.isLoggable(Level.FINE)) { logger.log(Level.FINE, "Stacktrace : ", e); } } } /** * inserts the image specified at this URL into the document and copies the image into the <code>insertedObjectsFolderPath</code> * specified in the method <code>addSupportForInsertedObjects</code>. If the support for inserted objects has not been added, the image * will not be copied. */ @Override public void insertImage(String imageURL) { String fileName = imageURL; int index = fileName.lastIndexOf('/'); if (index > -1) { fileName = fileName.substring(index + 1); } File test = new File(imageURL); if (test.exists()) { try { imageURL = test.toURI().toURL().toString(); } catch (MalformedURLException e1) { e1.printStackTrace(); } } fileName = FileUtils.lowerCaseExtension(FileUtils.removeNonASCIIAndPonctuationAndBadFileNameChars(fileName)); File tempFile = new File(System.getProperty("java.io.tmpdir"), fileName); tempFile.deleteOnExit(); try { FileUtils.createNewFile(tempFile); FileUtils.saveToFile(tempFile, new URL(imageURL).openStream()); super.insertImage(tempFile.toURI().toURL().toString()); } catch (MalformedURLException e) { e.printStackTrace(); return; } catch (IOException e) { e.printStackTrace(); super.insertImage(imageURL); } saveRelatedObjects(); } @Override public String getRelativePath(String imageURL) { if (imageURL == null || imageURL.startsWith("file:/")) { return imageURL; } return super.getRelativePath(imageURL); } private void saveRelatedObjects() { final File documentBaseFolder = getDocumentBaseFolder(); if (documentBaseFolder != null && getLinkedObjectsFolderName() != null) { // means there is a support for the inserted Objects getFileDialog().setSelectedFile(documentBaseFolder); boolean crappyFixRequired = getFileDialog().getSelectedFile() == null || !getFileDialog().getSelectedFile().equals(documentBaseFolder); JFileChooser fileChooser = null; if (crappyFixRequired) { fileChooser = getFileDialog(); setFileDialog(new JFileChooser() { @Override public File getSelectedFile() { return documentBaseFolder; } }); } try { // we create a fake html file in order to let the function 'saveEntireDocumentTree(htmlFile)' save the whole page and copy // the related objects into 'getLinkedObjectsFolderName()' File htmlFile = new File(documentBaseFolder, "destroyMeMaster.html"); saveEntireDocumentTree(htmlFile); // we delete the fake html page once everything has been saved if (!htmlFile.delete()) { htmlFile.deleteOnExit(); } } finally { if (crappyFixRequired) { setFileDialog(fileChooser); } } } } private File getDocumentBaseFolder() { try { URL baseURL = ((HTMLDocument) getInternalJEditorPane().getDocument()).getBase(); return new File(baseURL.getPath()); } catch (Exception e) { logger.log(Level.WARNING, "Could not get document base folder : " + e.getMessage() + ". Use level fine for stacktrace"); if (logger.isLoggable(Level.FINE)) { logger.log(Level.FINE, "Stacktrace : ", e); } setDocumentBaseFolder(); return documentBaseFolder; } } @Override public String getBodyContent() { String s = super.getBodyContent(); if (s != null) { s = s.trim(); if (s.length() == 0 || HTMLUtils.isEmtpyParagraph(s)) { return ""; } } return s; } /** * @deprecated please use getBodyContent to prevent from importing <code><html></code>, <code><head></code> and * <code><body></code> tags; */ @Override @Deprecated public String getContent() { return super.getContent(); } @Override public void setContent(String htmlContent) { if (htmlContent == null) { htmlContent = ""; } super.setContent(htmlContent); setDocumentBaseFolder(); } private void setDocumentBaseFolder() { if (documentBaseFolder != null) { try { ((HTMLDocument) getInternalJEditorPane().getDocument()).setBase(documentBaseFolder.toURL()); } catch (MalformedURLException e) { e.printStackTrace(); } } if (insertedObjectsFolderName != null) { setLinkedObjectsFolderName(insertedObjectsFolderName); } } /** * Implements this method to handle the changes in the HTML code of the wysiwyg. These changes are only notified if * <code>isDocumentListenerActivated<code> is true. * Observers should then call getBodyContent to retrieve the HTML of the wysiwyg */ public abstract void notifyTextChanged(); /** * Returns if the focus listener is currently activated */ public boolean isActivated() { return isFocusListenerActivated; } /** * Activates or deactivates the focus listener on the wysiwyg and the focusable property If set to false, the wysiwyg will not call * <code>textChanged</code> when an focus occurs on the HTML document. */ public void setActivated(boolean activated) { this.isFocusListenerActivated = activated; getSelectedEditorComponent().setFocusable(activated); // remove focus because JEditorPane requests the focus on // JEditorPane.setText() -> Avoid to lose focus when both doc editors are // opened. } /** * Default DocumentListener that redirects all the document events to <code>textChanged</code> if * <code>isDocumentListenerActivated</code> is true. */ public class FlexoWysiwygFocusListener implements FocusListener { @Override public void focusGained(FocusEvent e) { Component focussedComp = e.getOppositeComponent(); if (focussedComp == null || !SwingUtilities.isDescendingFrom(focussedComp, FlexoWysiwyg.this)) { if (logger.isLoggable(Level.FINE)) { logger.fine("focus gained on the wysiwyg, init key strokes"); } initKeyStrokes(); } else { if (logger.isLoggable(Level.FINE)) { logger.fine("Focus is still on the wysiwyg, do not init key strokes"); } } } @Override public void focusLost(FocusEvent e) { if (isActivated()) { Component focussedComp = e.getOppositeComponent(); if (focussedComp == null || !SwingUtilities.isDescendingFrom(focussedComp, FlexoWysiwyg.this)) { if (logger.isLoggable(Level.INFO)) { logger.info("Focus lost on the wysiwyg, applying changes"); } notifyTextChanged(); } else { if (logger.isLoggable(Level.FINE)) { logger.fine("Focus is still on the wysiwyg, do not apply changes"); } } } } } public boolean getIsViewSourceAvailable() { return isViewSourceAvailable; } public void setViewSourceAvailable(boolean isViewSourceAvailable) { this.isViewSourceAvailable = isViewSourceAvailable; } /* * @Override public void addNotify() { if (logger.isLoggable(Level.FINE)) logger.fine("add notify on " + getClass().getName() + " " + * hashCode()); super.addNotify(); if (logger.isLoggable(Level.FINE) && * getSelectedEditorComponent().getKeymap().getBoundActions().length == 0) { logger.fine("!!!!!!!! Key map is empty on " + * getClass().getName() + " " + hashCode()); } } * * @Override public void initKeyStrokes() { if (logger.isLoggable(Level.FINE)) logger.fine("Init key strokes on " + getClass().getName() * + " " + hashCode()); super.initKeyStrokes(); } * * @Override public void removeKeyStrokes() { if (logger.isLoggable(Level.FINE)) logger.fine("Remove key strokes on " + * getClass().getName() + " " + hashCode()); super.removeKeyStrokes(); } * * @Override public void removeNotify() { if (logger.isLoggable(Level.FINE)) logger.fine("remove notify on " + getClass().getName() + * " " + hashCode()); super.removeNotify(); } */ }