/* * Copyright (C) 2015-2017 Emanuel Moecklin * * 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.onegravity.rteditor; import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.text.Editable; import android.text.Layout; import android.text.Layout.Alignment; import android.text.Spannable; import android.text.Spanned; import android.view.View; import android.view.ViewGroup; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.widget.Toast; import com.onegravity.rteditor.LinkFragment.Link; import com.onegravity.rteditor.LinkFragment.LinkEvent; import com.onegravity.rteditor.RTOperationManager.TextChangeOperation; import com.onegravity.rteditor.api.RTApi; import com.onegravity.rteditor.api.media.RTImage; import com.onegravity.rteditor.api.media.RTMedia; import com.onegravity.rteditor.effects.AbsoluteSizeEffect; import com.onegravity.rteditor.effects.AlignmentEffect; import com.onegravity.rteditor.effects.BackgroundColorEffect; import com.onegravity.rteditor.effects.BoldEffect; import com.onegravity.rteditor.effects.BulletEffect; import com.onegravity.rteditor.effects.Effect; import com.onegravity.rteditor.effects.Effects; import com.onegravity.rteditor.effects.ForegroundColorEffect; import com.onegravity.rteditor.effects.ItalicEffect; import com.onegravity.rteditor.effects.NumberEffect; import com.onegravity.rteditor.effects.SpanCollectMode; import com.onegravity.rteditor.effects.StrikethroughEffect; import com.onegravity.rteditor.effects.SubscriptEffect; import com.onegravity.rteditor.effects.SuperscriptEffect; import com.onegravity.rteditor.effects.TypefaceEffect; import com.onegravity.rteditor.effects.UnderlineEffect; import com.onegravity.rteditor.fonts.RTTypeface; import com.onegravity.rteditor.media.choose.MediaChooserActivity; import com.onegravity.rteditor.media.choose.MediaEvent; import com.onegravity.rteditor.spans.ImageSpan; import com.onegravity.rteditor.spans.LinkSpan; import com.onegravity.rteditor.spans.RTSpan; import com.onegravity.rteditor.utils.Constants.MediaAction; import com.onegravity.rteditor.utils.Helper; import com.onegravity.rteditor.utils.Selection; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.net.MalformedURLException; import java.net.URL; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * The RTManager manages the different components: * the toolbar(s), the editor(s) and the Activity/Fragment(s) via the RTProxy. * <p> * Note: the transient modifier is "misused" here to mark variables that * are not saved and restored in onSaveInstanceState / onCreate. */ public class RTManager implements RTToolbarListener, RTEditTextListener { /* * Identifies the link dialog / fragment */ private static final String ID_01_LINK_FRAGMENT = "ID_01_LINK_FRAGMENT"; /* * The toolbar(s) may automatically be shown or hidden when a rich text * editor gains or loses focus depending on the ToolbarVisibility setting. */ public enum ToolbarVisibility { /* * Toolbar(s) are shown/hidden automatically depending on whether an * editor that uses rich text gains/loses focus. * * This is the default. */ AUTOMATIC, /* * Toolbar(s) are always shown. */ SHOW, /* * Toolbar(s) are never shown. */ HIDE; } private ToolbarVisibility mToolbarVisibility = ToolbarVisibility.AUTOMATIC; /* * To set the visibility of the toolbar(s) we call setToolbarVisibility(boolean). * To change the visibility we start an animation. Before the animation ends * setToolbarVisibility() could have been called multiple times with different * visibility parameters. We need to make sure once (one of) the animation * ends we use the newest visibility status (that's what this variable stands for). * * We do clear the animation with each call to setToolbarVisibility but if the * animation has already started the onAnimationEnd is still called. */ private boolean mToolbarIsVisible; /* * When an Activity is started (e.g. to pick an image), * we need to know which editor gets the result */ private int mActiveEditor = Integer.MAX_VALUE; /* * This defines what Selection link operations are applied to * (inserting, editing, removing links). */ private Selection mLinkSelection; /* * We need these to delay hiding the toolbar after a focus loss of an editor */ transient private Handler mHandler; transient private boolean mIsPendingFocusLoss; transient private boolean mCancelPendingFocusLoss; /* * Map the registered editors by editor id (RTEditText.getId()) */ transient private Map<Integer, RTEditText> mEditors; /* * Map the registered toolbars by toolbar id (RTToolbar.getId()) */ transient private Map<Integer, RTToolbar> mToolbars; /* * That's our link to "the outside world" to perform operations that need * access to a Context or an Activity */ transient private RTApi mRTApi; /* * The RTOperationManager is used to undo/redo operations */ transient private RTOperationManager mOPManager; // ****************************************** Lifecycle Methods ******************************************* /** * @param rtApi The proxy to "the outside world" * @param savedInstanceState If the component is being re-initialized after previously * being shut down then this Bundle contains the data it most * recently supplied in onSaveInstanceState(Bundle). */ public RTManager(RTApi rtApi, Bundle savedInstanceState) { mRTApi = rtApi; mHandler = new Handler(); mEditors = new ConcurrentHashMap<Integer, RTEditText>(); mToolbars = new ConcurrentHashMap<Integer, RTToolbar>(); mOPManager = new RTOperationManager(); if (savedInstanceState != null) { String tmp = savedInstanceState.getString("mToolbarVisibility"); if (tmp != null) { mToolbarVisibility = ToolbarVisibility.valueOf(tmp); } mToolbarIsVisible = savedInstanceState.getBoolean("mToolbarIsVisible"); mActiveEditor = savedInstanceState.getInt("mActiveEditor"); mLinkSelection = (Selection) savedInstanceState.getSerializable("mLinkSelection"); } EventBus.getDefault().register(this); } /** * Called to retrieve per-instance state before being killed so that the * state can be restored in the constructor. * * @param outState Bundle in which to place your saved state. */ public void onSaveInstanceState(Bundle outState) { outState.putString("mToolbarVisibility", mToolbarVisibility.name()); outState.putBoolean("mToolbarIsVisible", mToolbarIsVisible); outState.putInt("mActiveEditor", mActiveEditor); if (mLinkSelection != null) { outState.putSerializable("mLinkSelection", mLinkSelection); } } /** * Perform any final cleanup before the component is destroyed. * * @param isSaved True if the text is saved, False if it's dismissed. This is * needed to decide whether media (images etc.) are to be * deleted. */ public void onDestroy(boolean isSaved) { EventBus.getDefault().unregister(this); for (RTEditText editor : mEditors.values()) { editor.unregister(); editor.onDestroy(isSaved); } mEditors.clear(); for (RTToolbar toolbar : mToolbars.values()) { toolbar.removeToolbarListener(); } mToolbars.clear(); mRTApi = null; } // ****************************************** Public Methods ******************************************* /** * Register a rich text editor. * <p> * Before using the editor it needs to be registered to an RTManager. * Using means any calls to the editor (setText will fail if the editor isn't registered)! * MUST be called from the ui thread. * * @param editor The rich text editor to register. */ public void registerEditor(RTEditText editor, boolean useRichTextEditing) { mEditors.put(editor.getId(), editor); editor.register(this, mRTApi); editor.setRichTextEditing(useRichTextEditing, false); updateToolbarVisibility(); } /** * Unregister a rich text editor. * <p> * This method may be called before the component is destroyed to stop any * interaction with the editor. Not doing so may result in (asynchronous) * calls coming through when the Activity/Fragment is already stopping its * operation. * <p> * Must be called from the ui thread. * <p> * Important: calling this method is obsolete once the onDestroy(boolean) is * called * * @param editor The rich text editor to unregister. */ public void unregisterEditor(RTEditText editor) { mEditors.remove(editor.getId()); editor.unregister(); updateToolbarVisibility(); } /** * Register a toolbar. * <p> * Only after doing that can it be used in conjunction with a rich text editor. * Must be called from the ui thread. * * @param toolbarContainer The ViewGroup containing the toolbar. * This container is used to show/hide the toolbar if needed (e.g. if the RTEditText field loses/gains focus). * We can't use the toolbar itself because there could be multiple and they could be embedded in a complex layout hierarchy. * @param toolbar The toolbar to register. */ public void registerToolbar(ViewGroup toolbarContainer, RTToolbar toolbar) { mToolbars.put(toolbar.getId(), toolbar); toolbar.setToolbarListener(this); toolbar.setToolbarContainer(toolbarContainer); updateToolbarVisibility(); } /** * Unregister a toolbar. * <p> * This method may be called before the component is destroyed to * stop any interaction with the toolbar. Not doing so may result * in (asynchronous) calls coming through when the Activity/Fragment * is already stopping its operation. * <p> * Must be called from the ui thread. * <p> * Important: calling this method is obsolete once the * onDestroy(boolean) is called * * @param toolbar The toolbar to unregister. */ public void unregisterToolbar(RTToolbar toolbar) { mToolbars.remove(toolbar.getId()); toolbar.removeToolbarListener(); updateToolbarVisibility(); } /** * Set the auto show/hide toolbar mode. */ public void setToolbarVisibility(ToolbarVisibility toolbarVisibility) { if (mToolbarVisibility != toolbarVisibility) { mToolbarVisibility = toolbarVisibility; updateToolbarVisibility(); } } private void updateToolbarVisibility() { boolean showToolbars = mToolbarVisibility == ToolbarVisibility.SHOW; if (mToolbarVisibility == ToolbarVisibility.AUTOMATIC) { RTEditText editor = getActiveEditor(); showToolbars = editor != null && editor.usesRTFormatting(); } for (RTToolbar toolbar : mToolbars.values()) { setToolbarVisibility(toolbar, showToolbars); } } private void setToolbarVisibility(final RTToolbar toolbar, final boolean visible) { mToolbarIsVisible = visible; final ViewGroup toolbarContainer = toolbar.getToolbarContainer(); int visibility = View.VISIBLE; synchronized (toolbarContainer) { visibility = toolbarContainer.getVisibility(); } // only change visibility if we actually have to if ((visibility == View.GONE && visible) || (visibility == View.VISIBLE && !visible)) { AlphaAnimation fadeAnimation = visible ? new AlphaAnimation(0.0f, 1.0f) : new AlphaAnimation(1.0f, 0.0f); fadeAnimation.setDuration(400); fadeAnimation.setAnimationListener(new AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationRepeat(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { synchronized (toolbarContainer) { toolbarContainer.setVisibility(mToolbarIsVisible ? View.VISIBLE : View.GONE); } } }); toolbarContainer.startAnimation(fadeAnimation); } else { toolbarContainer.clearAnimation(); } } // ****************************************** RTToolbarListener ******************************************* @Override /* @inheritDoc */ public <V, C extends RTSpan<V>> void onEffectSelected(Effect<V, C> effect, V value) { RTEditText editor = getActiveEditor(); if (editor != null) { editor.applyEffect(effect, value); } } @Override /* @inheritDoc */ public void onClearFormatting() { RTEditText editor = getActiveEditor(); if (editor != null) { int selStartBefore = editor.getSelectionStart(); int selEndBefore = editor.getSelectionEnd(); Spannable oldSpannable = editor.cloneSpannable(); for (Effect effect : Effects.FORMATTING_EFFECTS) { effect.clearFormattingInSelection(editor); } int selStartAfter = editor.getSelectionStart(); int selEndAfter = editor.getSelectionEnd(); Spannable newSpannable = editor.cloneSpannable(); mOPManager.executed(editor, new TextChangeOperation(oldSpannable, newSpannable, selStartBefore, selEndBefore, selStartAfter, selEndAfter)); } } @Override /* @inheritDoc */ public void onUndo() { RTEditText editor = getActiveEditor(); if (editor != null) { mOPManager.undo(editor); } } @Override /* @inheritDoc */ public void onRedo() { RTEditText editor = getActiveEditor(); if (editor != null) { mOPManager.redo(editor); } } @Override /* @inheritDoc */ public void onCreateLink() { RTEditText editor = getActiveEditor(); if (editor != null) { String url = null; String linkText = null; List<RTSpan<String>> links = Effects.LINK.getSpans(editor.getText(), new Selection(editor), SpanCollectMode.EXACT); if (links.isEmpty()) { // default values if no link is found at selection linkText = editor.getSelectedText(); try { // if this succeeds we have a valid URL and will use it for the link new URL(linkText); url = linkText; } catch (MalformedURLException ignore) { } mLinkSelection = editor.getSelection(); } else { // values if a link already exists RTSpan<String> linkSpan = links.get(0); url = linkSpan.getValue(); linkText = getLinkText(editor, linkSpan); } mRTApi.openDialogFragment(ID_01_LINK_FRAGMENT, LinkFragment.newInstance(linkText, url)); } } @Override /* @inheritDoc */ public void onPickImage() { onPickCaptureImage(MediaAction.PICK_PICTURE); } @Override /* @inheritDoc */ public void onCaptureImage() { onPickCaptureImage(MediaAction.CAPTURE_PICTURE); } private void onPickCaptureImage(MediaAction mediaAction) { RTEditText editor = getActiveEditor(); if (editor != null && mRTApi != null) { mActiveEditor = editor.getId(); Intent intent = new Intent(RTApi.getApplicationContext(), MediaChooserActivity.class) .putExtra(MediaChooserActivity.EXTRA_MEDIA_ACTION, mediaAction.name()) .putExtra(MediaChooserActivity.EXTRA_MEDIA_FACTORY, mRTApi); mRTApi.startActivityForResult(intent, mediaAction.requestCode()); } } /* called from onEventMainThread(MediaEvent) */ private void insertImage(final RTEditText editor, final RTImage image) { if (image != null && editor != null) { Selection selection = new Selection(editor); Editable str = editor.getText(); // Unicode Character 'OBJECT REPLACEMENT CHARACTER' (U+FFFC) // see http://www.fileformat.info/info/unicode/char/fffc/index.htm str.insert(selection.start(), "\uFFFC"); try { // now add the actual image and inform the RTOperationManager about the operation Spannable oldSpannable = editor.cloneSpannable(); ImageSpan imageSpan = new ImageSpan(image, false); str.setSpan(imageSpan, selection.start(), selection.end() + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); int selStartAfter = editor.getSelectionStart(); int selEndAfter = editor.getSelectionEnd(); editor.onAddMedia(image); Spannable newSpannable = editor.cloneSpannable(); mOPManager.executed(editor, new RTOperationManager.TextChangeOperation(oldSpannable, newSpannable, selection.start(), selection.end(), selStartAfter, selEndAfter)); } catch (OutOfMemoryError e) { str.delete(selection.start(), selection.end() + 1); mRTApi.makeText(R.string.rte_add_image_error, Toast.LENGTH_LONG).show(); } } } private RTEditText getActiveEditor() { for (RTEditText editor : mEditors.values()) { if (editor.hasFocus()) { return editor; } } return null; } // ****************************************** RTEditTextListener ******************************************* @Override public void onRestoredInstanceState(RTEditText editor) { /* * We need to process pending sticky MediaEvents once the editors are registered with the * RTManager and are fully restored. */ MediaEvent event = EventBus.getDefault().getStickyEvent(MediaEvent.class); if (event != null) { onEventMainThread(event); } } @Override /* @inheritDoc */ public void onFocusChanged(RTEditText editor, boolean focused) { if (editor.usesRTFormatting()) { synchronized (this) { // if a focus loss is pending then we cancel it if (mIsPendingFocusLoss) { mCancelPendingFocusLoss = true; } } if (focused) { changeFocus(); } else { mIsPendingFocusLoss = true; mHandler.postDelayed(new Runnable() { @Override public void run() { changeFocus(); } }, 10); } } } private void changeFocus() { synchronized (this) { if (!mCancelPendingFocusLoss) { updateToolbarVisibility(); } mCancelPendingFocusLoss = false; mIsPendingFocusLoss = false; } } @Override /* @inheritDoc */ public void onSelectionChanged(RTEditText editor, int start, int end) { if (editor == null) return; // default values boolean isBold = false; boolean isItalic = false; boolean isUnderLine = false; boolean isStrikethrough = false; boolean isSuperscript = false; boolean isSubscript = false; boolean isBullet = false; boolean isNumber = false; List<Alignment> alignments = null; List<RTTypeface> typefaces = null; List<Integer> sizes = null; List<Integer> fontColors = null; List<Integer> bgColors = null; // check if effect exists in selection for (Effect effect : Effects.ALL_EFFECTS) { if (effect.existsInSelection(editor)) { if (effect instanceof BoldEffect) { isBold = true; } else if (effect instanceof ItalicEffect) { isItalic = true; } else if (effect instanceof UnderlineEffect) { isUnderLine = true; } else if (effect instanceof StrikethroughEffect) { isStrikethrough = true; } else if (effect instanceof SuperscriptEffect) { isSuperscript = true; } else if (effect instanceof SubscriptEffect) { isSubscript = true; } else if (effect instanceof BulletEffect) { isBullet = true; } else if (effect instanceof NumberEffect) { isNumber = true; } else if (effect instanceof AlignmentEffect) { alignments = Effects.ALIGNMENT.valuesInSelection(editor); } else if (effect instanceof TypefaceEffect) { typefaces = Effects.TYPEFACE.valuesInSelection(editor); } else if (effect instanceof AbsoluteSizeEffect) { sizes = Effects.FONTSIZE.valuesInSelection(editor); } else if (effect instanceof ForegroundColorEffect) { fontColors = Effects.FONTCOLOR.valuesInSelection(editor); } else if (effect instanceof BackgroundColorEffect) { bgColors = Effects.BGCOLOR.valuesInSelection(editor); } } } // update toolbar(s) for (RTToolbar toolbar : mToolbars.values()) { toolbar.setBold(isBold); toolbar.setItalic(isItalic); toolbar.setUnderline(isUnderLine); toolbar.setStrikethrough(isStrikethrough); toolbar.setSuperscript(isSuperscript); toolbar.setSubscript(isSubscript); toolbar.setBullet(isBullet); toolbar.setNumber(isNumber); // alignment (left, center, right) if (alignments != null && alignments.size() == 1) { toolbar.setAlignment(alignments.get(0)); } else { boolean isRTL = Helper.isRTL(editor.getText(), start, end); toolbar.setAlignment(isRTL ? Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_NORMAL); } // fonts if (typefaces != null && typefaces.size() == 1) { toolbar.setFont(typefaces.get(0)); } else { toolbar.setFont(null); } // text size if (sizes == null) { toolbar.setFontSize(Math.round(editor.getTextSize())); } else if (sizes.size() == 1) { toolbar.setFontSize(sizes.get(0)); } else { toolbar.setFontSize(-1); } // font color if (fontColors != null && fontColors.size() == 1) { toolbar.setFontColor(fontColors.get(0)); } else { toolbar.removeFontColor(); } // background color if (bgColors != null && bgColors.size() == 1) { toolbar.setBGColor(bgColors.get(0)); } else { toolbar.removeBGColor(); } } } @Override /* @inheritDoc */ public void onTextChanged(RTEditText editor, Spannable before, Spannable after, int selStartBefore, int selEndBefore, int selStartAfter, int selEndAfter) { TextChangeOperation op = new TextChangeOperation(before, after, selStartBefore, selEndBefore, selStartAfter, selEndAfter); mOPManager.executed(editor, op); } @Override /* @inheritDoc */ public void onClick(RTEditText editor, LinkSpan span) { if (editor != null) { String linkText = getLinkText(editor, span); mRTApi.openDialogFragment(ID_01_LINK_FRAGMENT, LinkFragment.newInstance(linkText, span.getURL())); } } private String getLinkText(RTEditText editor, RTSpan<String> span) { Spannable text = editor.getText(); final int spanStart = text.getSpanStart(span); final int spanEnd = text.getSpanEnd(span); String linkText = null; if (spanStart >= 0 && spanEnd >= 0 && spanEnd <= text.length()) { linkText = text.subSequence(spanStart, spanEnd).toString(); mLinkSelection = new Selection(spanStart, spanEnd); } else { mLinkSelection = editor.getSelection(); } return linkText; } /** * Media file was picked -> process the result. */ @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) public void onEventMainThread(MediaEvent event) { RTEditText editor = mEditors.get(mActiveEditor); RTMedia media = event.getMedia(); if (editor != null && media instanceof RTImage) { insertImage(editor, (RTImage) media); EventBus.getDefault().removeStickyEvent(event); mActiveEditor = Integer.MAX_VALUE; } } /** * LinkFragment has closed -> process the result. */ @Subscribe(threadMode = ThreadMode.MAIN) public void onEventMainThread(LinkEvent event) { final String fragmentTag = event.getFragmentTag(); mRTApi.removeFragment(fragmentTag); if (!event.wasCancelled() && ID_01_LINK_FRAGMENT.equals(fragmentTag)) { RTEditText editor = getActiveEditor(); if (editor != null) { Link link = event.getLink(); String url = null; if (link != null && link.isValid()) { // the mLinkSelection.end() <= editor.length() check is necessary since // the editor text can change when the link fragment is open Selection selection = mLinkSelection != null && mLinkSelection.end() <= editor.length() ? mLinkSelection : new Selection(editor); String linkText = link.getLinkText(); // if no text is selected this inserts the entered link text // if text is selected we replace it by the link text Editable str = editor.getText(); str.replace(selection.start(), selection.end(), linkText); editor.setSelection(selection.start(), selection.start() + linkText.length()); url = link.getUrl(); } editor.applyEffect(Effects.LINK, url); // if url == null -> remove the link } } } @Override public void onRichTextEditingChanged(RTEditText editor, boolean useRichText) { updateToolbarVisibility(); } }