aboutsummaryrefslogtreecommitdiff
path: root/libs/editor/WordPressEditor/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'libs/editor/WordPressEditor/src/main/java')
-rwxr-xr-xlibs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragment.java1659
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java180
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorMediaUploadListener.java10
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebView.java35
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewAbstract.java256
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewCompatibility.java130
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleTextWatcher.java245
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleUtils.java150
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/ImageSettingsDialogFragment.java431
-rwxr-xr-xlibs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/JsCallbackReceiver.java236
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LegacyEditorFragment.java1194
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LinkDialogFragment.java76
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnImeBackListener.java5
-rwxr-xr-xlibs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnJsEditorStateChangedListener.java20
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/RippleToggleButton.java95
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/SourceViewEditText.java60
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/Utils.java247
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/EditLinkActivity.java76
-rw-r--r--libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/WPEditImageSpan.java74
19 files changed, 5179 insertions, 0 deletions
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragment.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragment.java
new file mode 100755
index 000000000..9ff8df7d8
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragment.java
@@ -0,0 +1,1659 @@
+package org.wordpress.android.editor;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.AlertDialog;
+import android.support.v7.app.AppCompatActivity;
+import android.text.Editable;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.view.DragEvent;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.webkit.URLUtil;
+import android.webkit.WebView;
+import android.widget.RelativeLayout.LayoutParams;
+import android.widget.ToggleButton;
+
+import com.android.volley.toolbox.ImageLoader;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.editor.EditorWebViewAbstract.ErrorListener;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.JSONUtils;
+import org.wordpress.android.util.ProfilingUtils;
+import org.wordpress.android.util.ShortcodeUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.UrlUtils;
+import org.wordpress.android.util.helpers.MediaFile;
+import org.wordpress.android.util.helpers.MediaGallery;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+public class EditorFragment extends EditorFragmentAbstract implements View.OnClickListener, View.OnTouchListener,
+ OnJsEditorStateChangedListener, OnImeBackListener, EditorWebViewAbstract.AuthHeaderRequestListener,
+ EditorMediaUploadListener {
+ private static final String ARG_PARAM_TITLE = "param_title";
+ private static final String ARG_PARAM_CONTENT = "param_content";
+
+ private static final String JS_CALLBACK_HANDLER = "nativeCallbackHandler";
+
+ private static final String KEY_TITLE = "title";
+ private static final String KEY_CONTENT = "content";
+
+ private static final String TAG_FORMAT_BAR_BUTTON_MEDIA = "media";
+ private static final String TAG_FORMAT_BAR_BUTTON_LINK = "link";
+
+ private static final float TOOLBAR_ALPHA_ENABLED = 1;
+ private static final float TOOLBAR_ALPHA_DISABLED = 0.5f;
+
+ private static final List<String> DRAGNDROP_SUPPORTED_MIMETYPES_TEXT = Arrays.asList(ClipDescription.MIMETYPE_TEXT_PLAIN,
+ ClipDescription.MIMETYPE_TEXT_HTML);
+ private static final List<String> DRAGNDROP_SUPPORTED_MIMETYPES_IMAGE = Arrays.asList("image/jpeg", "image/png");
+
+ public static final int MAX_ACTION_TIME_MS = 2000;
+
+ private String mTitle = "";
+ private String mContentHtml = "";
+
+ private EditorWebViewAbstract mWebView;
+ private View mSourceView;
+ private SourceViewEditText mSourceViewTitle;
+ private SourceViewEditText mSourceViewContent;
+
+ private int mSelectionStart;
+ private int mSelectionEnd;
+
+ private String mFocusedFieldId;
+
+ private String mTitlePlaceholder = "";
+ private String mContentPlaceholder = "";
+
+ private boolean mDomHasLoaded = false;
+ private boolean mIsKeyboardOpen = false;
+ private boolean mEditorWasPaused = false;
+ private boolean mHideActionBarOnSoftKeyboardUp = false;
+ private boolean mIsFormatBarDisabled = false;
+
+ private ConcurrentHashMap<String, MediaFile> mWaitingMediaFiles;
+ private Set<MediaGallery> mWaitingGalleries;
+ private Map<String, MediaType> mUploadingMedia;
+ private Set<String> mFailedMediaIds;
+ private MediaGallery mUploadingMediaGallery;
+
+ private String mJavaScriptResult = "";
+
+ private CountDownLatch mGetTitleCountDownLatch;
+ private CountDownLatch mGetContentCountDownLatch;
+ private CountDownLatch mGetSelectedTextCountDownLatch;
+
+ private final Map<String, ToggleButton> mTagToggleButtonMap = new HashMap<>();
+
+ private long mActionStartedAt = -1;
+
+ private final View.OnDragListener mOnDragListener = new View.OnDragListener() {
+ private long lastSetCoordsTimestamp;
+
+ private boolean isSupported(ClipDescription clipDescription, List<String> mimeTypesToCheck) {
+ if (clipDescription == null) {
+ return false;
+ }
+
+ for (String supportedMimeType : mimeTypesToCheck) {
+ if (clipDescription.hasMimeType(supportedMimeType)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onDrag(View view, DragEvent dragEvent) {
+ switch (dragEvent.getAction()) {
+ case DragEvent.ACTION_DRAG_STARTED:
+ return isSupported(dragEvent.getClipDescription(), DRAGNDROP_SUPPORTED_MIMETYPES_TEXT) ||
+ isSupported(dragEvent.getClipDescription(), DRAGNDROP_SUPPORTED_MIMETYPES_IMAGE);
+ case DragEvent.ACTION_DRAG_ENTERED:
+ // would be nice to start marking the place the item will drop
+ break;
+ case DragEvent.ACTION_DRAG_LOCATION:
+ int x = DisplayUtils.pxToDp(getActivity(), (int) dragEvent.getX());
+ int y = DisplayUtils.pxToDp(getActivity(), (int) dragEvent.getY());
+
+ // don't call into JS too often
+ long currentTimestamp = SystemClock.uptimeMillis();
+ if ((currentTimestamp - lastSetCoordsTimestamp) > 150) {
+ lastSetCoordsTimestamp = currentTimestamp;
+
+ mWebView.execJavaScriptFromString("ZSSEditor.moveCaretToCoords(" + x + ", " + y + ");");
+ }
+ break;
+ case DragEvent.ACTION_DRAG_EXITED:
+ // clear any drop marking maybe
+ break;
+ case DragEvent.ACTION_DROP:
+ if (mSourceView.getVisibility() == View.VISIBLE) {
+ if (isSupported(dragEvent.getClipDescription(), DRAGNDROP_SUPPORTED_MIMETYPES_IMAGE)) {
+ // don't allow dropping images into the HTML source
+ ToastUtils.showToast(getActivity(), R.string.editor_dropped_html_images_not_allowed,
+ ToastUtils.Duration.LONG);
+ return true;
+ } else {
+ // let the system handle the text drop
+ return false;
+ }
+ }
+
+ if (isSupported(dragEvent.getClipDescription(), DRAGNDROP_SUPPORTED_MIMETYPES_IMAGE) &&
+ ("zss_field_title".equals(mFocusedFieldId))) {
+ // don't allow dropping images into the title field
+ ToastUtils.showToast(getActivity(), R.string.editor_dropped_title_images_not_allowed,
+ ToastUtils.Duration.LONG);
+ return true;
+ }
+
+ if (isAdded()) {
+ mEditorDragAndDropListener.onRequestDragAndDropPermissions(dragEvent);
+ }
+
+ ClipDescription clipDescription = dragEvent.getClipDescription();
+ if (clipDescription.getMimeTypeCount() < 1) {
+ break;
+ }
+
+ ContentResolver contentResolver = getActivity().getContentResolver();
+ ArrayList<Uri> uris = new ArrayList<>();
+ boolean unsupportedDropsFound = false;
+
+ for (int i = 0; i < dragEvent.getClipData().getItemCount(); i++) {
+ ClipData.Item item = dragEvent.getClipData().getItemAt(i);
+ Uri uri = item.getUri();
+
+ final String uriType = uri != null ? contentResolver.getType(uri) : null;
+ if (uriType != null && DRAGNDROP_SUPPORTED_MIMETYPES_IMAGE.contains(uriType)) {
+ uris.add(uri);
+ continue;
+ } else if (item.getText() != null) {
+ insertTextToEditor(item.getText().toString());
+ continue;
+ } else if (item.getHtmlText() != null) {
+ insertTextToEditor(item.getHtmlText());
+ continue;
+ }
+
+ // any other drop types are not supported, including web URLs. We cannot proactively
+ // determine their mime type for filtering
+ unsupportedDropsFound = true;
+ }
+
+ if (unsupportedDropsFound) {
+ ToastUtils.showToast(getActivity(), R.string.editor_dropped_unsupported_files, ToastUtils
+ .Duration.LONG);
+ }
+
+ if (uris.size() > 0) {
+ mEditorDragAndDropListener.onMediaDropped(uris);
+ }
+
+ break;
+ case DragEvent.ACTION_DRAG_ENDED:
+ // clear any drop marking maybe
+ default:
+ break;
+ }
+ return true;
+ }
+
+ private void insertTextToEditor(String text) {
+ if (text != null) {
+ mWebView.execJavaScriptFromString("ZSSEditor.insertText('" + Utils.escapeHtml(text) + "', true);");
+ } else {
+ ToastUtils.showToast(getActivity(), R.string.editor_dropped_text_error, ToastUtils.Duration.SHORT);
+ AppLog.d(T.EDITOR, "Dropped text was null!");
+ }
+ }
+ };
+
+ public static EditorFragment newInstance(String title, String content) {
+ EditorFragment fragment = new EditorFragment();
+ Bundle args = new Bundle();
+ args.putString(ARG_PARAM_TITLE, title);
+ args.putString(ARG_PARAM_CONTENT, content);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ public EditorFragment() {
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ProfilingUtils.start("Visual Editor Startup");
+ ProfilingUtils.split("EditorFragment.onCreate");
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.fragment_editor, container, false);
+
+ // Setup hiding the action bar when the soft keyboard is displayed for narrow viewports
+ if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE
+ && !getResources().getBoolean(R.bool.is_large_tablet_landscape)) {
+ mHideActionBarOnSoftKeyboardUp = true;
+ }
+
+ mWaitingMediaFiles = new ConcurrentHashMap<>();
+ mWaitingGalleries = Collections.newSetFromMap(new ConcurrentHashMap<MediaGallery, Boolean>());
+ mUploadingMedia = new HashMap<>();
+ mFailedMediaIds = new HashSet<>();
+
+ // -- WebView configuration
+
+ mWebView = (EditorWebViewAbstract) view.findViewById(R.id.webview);
+
+ // Revert to compatibility WebView for custom ROMs using a 4.3 WebView in Android 4.4
+ if (mWebView.shouldSwitchToCompatibilityMode()) {
+ ViewGroup parent = (ViewGroup) mWebView.getParent();
+ int index = parent.indexOfChild(mWebView);
+ parent.removeView(mWebView);
+ mWebView = new EditorWebViewCompatibility(getActivity(), null);
+ mWebView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
+ parent.addView(mWebView, index);
+ }
+
+ mWebView.setOnTouchListener(this);
+ mWebView.setOnImeBackListener(this);
+ mWebView.setAuthHeaderRequestListener(this);
+
+ mWebView.setOnDragListener(mOnDragListener);
+
+ if (mCustomHttpHeaders != null && mCustomHttpHeaders.size() > 0) {
+ for (Map.Entry<String, String> entry : mCustomHttpHeaders.entrySet()) {
+ mWebView.setCustomHeader(entry.getKey(), entry.getValue());
+ }
+ }
+
+ // Ensure that the content field is always filling the remaining screen space
+ mWebView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString("try {ZSSEditor.refreshVisibleViewportSize();} catch (e) " +
+ "{console.log(e)}");
+ }
+ });
+ }
+ });
+
+ mEditorFragmentListener.onEditorFragmentInitialized();
+
+ initJsEditor();
+
+ if (savedInstanceState != null) {
+ setTitle(savedInstanceState.getCharSequence(KEY_TITLE));
+ setContent(savedInstanceState.getCharSequence(KEY_CONTENT));
+ }
+
+ // -- HTML mode configuration
+
+ mSourceView = view.findViewById(R.id.sourceview);
+ mSourceViewTitle = (SourceViewEditText) view.findViewById(R.id.sourceview_title);
+ mSourceViewContent = (SourceViewEditText) view.findViewById(R.id.sourceview_content);
+
+ // Toggle format bar on/off as user changes focus between title and content in HTML mode
+ mSourceViewTitle.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ updateFormatBarEnabledState(!hasFocus);
+ }
+ });
+
+ mSourceViewTitle.setOnTouchListener(this);
+ mSourceViewContent.setOnTouchListener(this);
+
+ mSourceViewTitle.setOnImeBackListener(this);
+ mSourceViewContent.setOnImeBackListener(this);
+
+ mSourceViewContent.addTextChangedListener(new HtmlStyleTextWatcher());
+
+ mSourceViewTitle.setHint(mTitlePlaceholder);
+ mSourceViewContent.setHint("<p>" + mContentPlaceholder + "</p>");
+
+ // attach drag-and-drop handler
+ mSourceViewTitle.setOnDragListener(mOnDragListener);
+ mSourceViewContent.setOnDragListener(mOnDragListener);
+
+ // -- Format bar configuration
+
+ setupFormatBarButtonMap(view);
+
+ return view;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mEditorWasPaused = true;
+ mIsKeyboardOpen = false;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ // If the editor was previously paused and the current orientation is landscape,
+ // hide the actionbar because the keyboard is going to appear (even if it was hidden
+ // prior to being paused).
+ if (mEditorWasPaused
+ && (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE)
+ && !getResources().getBoolean(R.bool.is_large_tablet_landscape)) {
+ mIsKeyboardOpen = true;
+ mHideActionBarOnSoftKeyboardUp = true;
+ hideActionBarIfNeeded();
+ }
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mEditorDragAndDropListener = (EditorDragAndDropListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement EditorDragAndDropListener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ // Soft cancel (delete flag off) all media uploads currently in progress
+ for (String mediaId : mUploadingMedia.keySet()) {
+ mEditorFragmentListener.onMediaUploadCancelClicked(mediaId, false);
+ }
+ super.onDetach();
+ }
+
+ @Override
+ public void setUserVisibleHint(boolean isVisibleToUser) {
+ if (mDomHasLoaded) {
+ mWebView.notifyVisibilityChanged(isVisibleToUser);
+ }
+ super.setUserVisibleHint(isVisibleToUser);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ outState.putCharSequence(KEY_TITLE, getTitle());
+ outState.putCharSequence(KEY_CONTENT, getContent());
+ }
+
+ private ActionBar getActionBar() {
+ if (!isAdded()) {
+ return null;
+ }
+
+ if (getActivity() instanceof AppCompatActivity) {
+ return ((AppCompatActivity) getActivity()).getSupportActionBar();
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ if (getView() != null) {
+ // Reload the format bar to make sure the correct one for the new screen width is being used
+ View formatBar = getView().findViewById(R.id.format_bar);
+
+ if (formatBar != null) {
+ // Remember the currently active format bar buttons so they can be re-activated after the reload
+ ArrayList<String> activeTags = new ArrayList<>();
+ for (Map.Entry<String, ToggleButton> entry : mTagToggleButtonMap.entrySet()) {
+ if (entry.getValue().isChecked()) {
+ activeTags.add(entry.getKey());
+ }
+ }
+
+ ViewGroup parent = (ViewGroup) formatBar.getParent();
+ parent.removeView(formatBar);
+
+ formatBar = getActivity().getLayoutInflater().inflate(R.layout.format_bar, parent, false);
+ formatBar.setId(R.id.format_bar);
+ parent.addView(formatBar);
+
+ setupFormatBarButtonMap(formatBar);
+
+ if (mIsFormatBarDisabled) {
+ updateFormatBarEnabledState(false);
+ }
+
+ // Restore the active format bar buttons
+ for (String tag : activeTags) {
+ mTagToggleButtonMap.get(tag).setChecked(true);
+ }
+
+ if (mSourceView.getVisibility() == View.VISIBLE) {
+ ToggleButton htmlButton = (ToggleButton) formatBar.findViewById(R.id.format_bar_button_html);
+ htmlButton.setChecked(true);
+ }
+ }
+
+ // Reload HTML mode margins
+ View sourceViewTitle = getView().findViewById(R.id.sourceview_title);
+ View sourceViewContent = getView().findViewById(R.id.sourceview_content);
+
+ if (sourceViewTitle != null && sourceViewContent != null) {
+ int sideMargin = (int) getActivity().getResources().getDimension(R.dimen.sourceview_side_margin);
+
+ ViewGroup.MarginLayoutParams titleParams =
+ (ViewGroup.MarginLayoutParams) sourceViewTitle.getLayoutParams();
+ ViewGroup.MarginLayoutParams contentParams =
+ (ViewGroup.MarginLayoutParams) sourceViewContent.getLayoutParams();
+
+ titleParams.setMargins(sideMargin, titleParams.topMargin, sideMargin, titleParams.bottomMargin);
+ contentParams.setMargins(sideMargin, contentParams.topMargin, sideMargin, contentParams.bottomMargin);
+ }
+ }
+
+ // Toggle action bar auto-hiding for the new orientation
+ if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE
+ && !getResources().getBoolean(R.bool.is_large_tablet_landscape)) {
+ mHideActionBarOnSoftKeyboardUp = true;
+ hideActionBarIfNeeded();
+ } else {
+ mHideActionBarOnSoftKeyboardUp = false;
+ showActionBarIfNeeded();
+ }
+ }
+
+ private void setupFormatBarButtonMap(View view) {
+ ToggleButton boldButton = (ToggleButton) view.findViewById(R.id.format_bar_button_bold);
+ mTagToggleButtonMap.put(getString(R.string.format_bar_tag_bold), boldButton);
+
+ ToggleButton italicButton = (ToggleButton) view.findViewById(R.id.format_bar_button_italic);
+ mTagToggleButtonMap.put(getString(R.string.format_bar_tag_italic), italicButton);
+
+ ToggleButton quoteButton = (ToggleButton) view.findViewById(R.id.format_bar_button_quote);
+ mTagToggleButtonMap.put(getString(R.string.format_bar_tag_blockquote), quoteButton);
+
+ ToggleButton ulButton = (ToggleButton) view.findViewById(R.id.format_bar_button_ul);
+ mTagToggleButtonMap.put(getString(R.string.format_bar_tag_unorderedList), ulButton);
+
+ ToggleButton olButton = (ToggleButton) view.findViewById(R.id.format_bar_button_ol);
+ mTagToggleButtonMap.put(getString(R.string.format_bar_tag_orderedList), olButton);
+
+ // Tablet-only
+ ToggleButton strikethroughButton = (ToggleButton) view.findViewById(R.id.format_bar_button_strikethrough);
+ if (strikethroughButton != null) {
+ mTagToggleButtonMap.put(getString(R.string.format_bar_tag_strikethrough), strikethroughButton);
+ }
+
+ ToggleButton mediaButton = (ToggleButton) view.findViewById(R.id.format_bar_button_media);
+ mTagToggleButtonMap.put(TAG_FORMAT_BAR_BUTTON_MEDIA, mediaButton);
+
+ registerForContextMenu(mediaButton);
+
+ ToggleButton linkButton = (ToggleButton) view.findViewById(R.id.format_bar_button_link);
+ mTagToggleButtonMap.put(TAG_FORMAT_BAR_BUTTON_LINK, linkButton);
+
+ ToggleButton htmlButton = (ToggleButton) view.findViewById(R.id.format_bar_button_html);
+ htmlButton.setOnClickListener(this);
+
+ for (ToggleButton button : mTagToggleButtonMap.values()) {
+ button.setOnClickListener(this);
+ }
+ }
+
+ protected void initJsEditor() {
+ if (!isAdded()) {
+ return;
+ }
+
+ ProfilingUtils.split("EditorFragment.initJsEditor");
+
+ String htmlEditor = Utils.getHtmlFromFile(getActivity(), "android-editor.html");
+ if (htmlEditor != null) {
+ htmlEditor = htmlEditor.replace("%%TITLE%%", getString(R.string.visual_editor));
+ htmlEditor = htmlEditor.replace("%%ANDROID_API_LEVEL%%", String.valueOf(Build.VERSION.SDK_INT));
+ htmlEditor = htmlEditor.replace("%%LOCALIZED_STRING_INIT%%",
+ "nativeState.localizedStringEdit = '" + getString(R.string.edit) + "';\n" +
+ "nativeState.localizedStringUploading = '" + getString(R.string.uploading) + "';\n" +
+ "nativeState.localizedStringUploadingGallery = '" + getString(R.string.uploading_gallery_placeholder) + "';\n");
+ }
+
+ // To avoid reflection security issues with JavascriptInterface on API<17, we use an iframe to make URL requests
+ // for callbacks from JS instead. These are received by WebViewClient.shouldOverrideUrlLoading() and then
+ // passed on to the JsCallbackReceiver
+ if (Build.VERSION.SDK_INT < 17) {
+ mWebView.setJsCallbackReceiver(new JsCallbackReceiver(this));
+ } else {
+ mWebView.addJavascriptInterface(new JsCallbackReceiver(this), JS_CALLBACK_HANDLER);
+ }
+
+ mWebView.loadDataWithBaseURL("file:///android_asset/", htmlEditor, "text/html", "utf-8", "");
+
+ if (mDebugModeEnabled) {
+ enableWebDebugging(true);
+ }
+ }
+
+ public void checkForFailedUploadAndSwitchToHtmlMode(final ToggleButton toggleButton) {
+ if (!isAdded()) {
+ return;
+ }
+
+ // Show an Alert Dialog asking the user if he wants to remove all failed media before upload
+ if (hasFailedMediaUploads()) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setMessage(R.string.editor_failed_uploads_switch_html)
+ .setPositiveButton(R.string.editor_remove_failed_uploads, new OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ // Clear failed uploads and switch to HTML mode
+ removeAllFailedMediaUploads();
+ toggleHtmlMode(toggleButton);
+ }
+ }).setNegativeButton(android.R.string.cancel, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ toggleButton.setChecked(false);
+ }
+ });
+ builder.create().show();
+ } else {
+ toggleHtmlMode(toggleButton);
+ }
+ }
+
+ public boolean isActionInProgress() {
+ return System.currentTimeMillis() - mActionStartedAt < MAX_ACTION_TIME_MS;
+ }
+
+ private void toggleHtmlMode(final ToggleButton toggleButton) {
+ if (!isAdded()) {
+ return;
+ }
+
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.HTML_BUTTON_TAPPED);
+
+ // Don't switch to HTML mode if currently uploading media
+ if (!mUploadingMedia.isEmpty() || isActionInProgress()) {
+ toggleButton.setChecked(false);
+ ToastUtils.showToast(getActivity(), R.string.alert_action_while_uploading, ToastUtils.Duration.LONG);
+ return;
+ }
+
+ clearFormatBarButtons();
+ updateFormatBarEnabledState(true);
+
+ if (toggleButton.isChecked()) {
+ Thread thread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ if (!isAdded()) {
+ return;
+ }
+
+ // Update mTitle and mContentHtml with the latest state from the ZSSEditor
+ getTitle();
+ getContent();
+
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Set HTML mode state
+ mSourceViewTitle.setText(mTitle);
+
+ SpannableString spannableContent = new SpannableString(mContentHtml);
+ HtmlStyleUtils.styleHtmlForDisplay(spannableContent);
+ mSourceViewContent.setText(spannableContent);
+
+ mWebView.setVisibility(View.GONE);
+ mSourceView.setVisibility(View.VISIBLE);
+
+ mSourceViewContent.requestFocus();
+ mSourceViewContent.setSelection(0);
+
+ InputMethodManager imm = ((InputMethodManager) getActivity()
+ .getSystemService(Context.INPUT_METHOD_SERVICE));
+ imm.showSoftInput(mSourceViewContent, InputMethodManager.SHOW_IMPLICIT);
+ }
+ });
+ }
+ });
+
+ thread.start();
+
+ } else {
+ mWebView.setVisibility(View.VISIBLE);
+ mSourceView.setVisibility(View.GONE);
+
+ mTitle = mSourceViewTitle.getText().toString();
+ mContentHtml = mSourceViewContent.getText().toString();
+ updateVisualEditorFields();
+
+ // Update the list of failed media uploads
+ mWebView.execJavaScriptFromString("ZSSEditor.getFailedMedia();");
+
+ // Reset selection to avoid buggy cursor behavior
+ mWebView.execJavaScriptFromString("ZSSEditor.resetSelectionOnField('zss_field_content');");
+ }
+ }
+
+ private void displayLinkDialog() {
+ final LinkDialogFragment linkDialogFragment = new LinkDialogFragment();
+ linkDialogFragment.setTargetFragment(this, LinkDialogFragment.LINK_DIALOG_REQUEST_CODE_ADD);
+
+ final Bundle dialogBundle = new Bundle();
+
+ // Pass potential URL from user clipboard
+ String clipboardUri = Utils.getUrlFromClipboard(getActivity());
+ if (clipboardUri != null) {
+ dialogBundle.putString(LinkDialogFragment.LINK_DIALOG_ARG_URL, clipboardUri);
+ }
+
+ // Pass selected text to dialog
+ if (mSourceView.getVisibility() == View.VISIBLE) {
+ // HTML mode
+ mSelectionStart = mSourceViewContent.getSelectionStart();
+ mSelectionEnd = mSourceViewContent.getSelectionEnd();
+
+ String selectedText = mSourceViewContent.getText().toString().substring(mSelectionStart, mSelectionEnd);
+ dialogBundle.putString(LinkDialogFragment.LINK_DIALOG_ARG_TEXT, selectedText);
+
+ linkDialogFragment.setArguments(dialogBundle);
+ linkDialogFragment.show(getFragmentManager(), LinkDialogFragment.class.getSimpleName());
+ } else {
+ // Visual mode
+ Thread thread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ if (!isAdded()) {
+ return;
+ }
+
+ mGetSelectedTextCountDownLatch = new CountDownLatch(1);
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString(
+ "ZSSEditor.execFunctionForResult('getSelectedTextToLinkify');");
+ }
+ });
+
+ try {
+ if (mGetSelectedTextCountDownLatch.await(1, TimeUnit.SECONDS)) {
+ dialogBundle.putString(LinkDialogFragment.LINK_DIALOG_ARG_TEXT, mJavaScriptResult);
+ }
+ } catch (InterruptedException e) {
+ AppLog.d(T.EDITOR, "Failed to obtain selected text from JS editor.");
+ }
+
+ linkDialogFragment.setArguments(dialogBundle);
+ linkDialogFragment.show(getFragmentManager(), LinkDialogFragment.class.getSimpleName());
+ }
+ });
+
+ thread.start();
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (!isAdded()) {
+ return;
+ }
+
+ int id = v.getId();
+ if (id == R.id.format_bar_button_html) {
+ checkForFailedUploadAndSwitchToHtmlMode((ToggleButton) v);
+ } else if (id == R.id.format_bar_button_media) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.MEDIA_BUTTON_TAPPED);
+ ((ToggleButton) v).setChecked(false);
+
+ if (isActionInProgress()) {
+ ToastUtils.showToast(getActivity(), R.string.alert_action_while_uploading, ToastUtils.Duration.LONG);
+ return;
+ }
+
+ if (mSourceView.getVisibility() == View.VISIBLE) {
+ ToastUtils.showToast(getActivity(), R.string.alert_insert_image_html_mode, ToastUtils.Duration.LONG);
+ } else {
+ mEditorFragmentListener.onAddMediaClicked();
+ getActivity().openContextMenu(mTagToggleButtonMap.get(TAG_FORMAT_BAR_BUTTON_MEDIA));
+ }
+ } else if (id == R.id.format_bar_button_link) {
+ if (!((ToggleButton) v).isChecked()) {
+ // The link button was checked when it was pressed; remove the current link
+ mWebView.execJavaScriptFromString("ZSSEditor.unlink();");
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.UNLINK_BUTTON_TAPPED);
+ return;
+ }
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.LINK_BUTTON_TAPPED);
+
+ ((ToggleButton) v).setChecked(false);
+
+ displayLinkDialog();
+ } else {
+ if (v instanceof ToggleButton) {
+ onFormattingButtonClicked((ToggleButton) v);
+ }
+ }
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ // If the WebView or EditText has received a touch event, the keyboard will be displayed and the action bar
+ // should hide
+ mIsKeyboardOpen = true;
+ hideActionBarIfNeeded();
+ }
+ return false;
+ }
+
+ /**
+ * Intercept back button press while soft keyboard is visible.
+ */
+ @Override
+ public void onImeBack() {
+ mIsKeyboardOpen = false;
+ showActionBarIfNeeded();
+ }
+
+ @Override
+ public String onAuthHeaderRequested(String url) {
+ return mEditorFragmentListener.onAuthHeaderRequested(url);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if ((requestCode == LinkDialogFragment.LINK_DIALOG_REQUEST_CODE_ADD ||
+ requestCode == LinkDialogFragment.LINK_DIALOG_REQUEST_CODE_UPDATE)) {
+
+ if (resultCode == LinkDialogFragment.LINK_DIALOG_REQUEST_CODE_DELETE) {
+ mWebView.execJavaScriptFromString("ZSSEditor.unlink();");
+ return;
+ }
+
+ if (data == null) {
+ return;
+ }
+
+ Bundle extras = data.getExtras();
+ if (extras == null) {
+ return;
+ }
+
+ String linkUrl = extras.getString(LinkDialogFragment.LINK_DIALOG_ARG_URL);
+ String linkText = extras.getString(LinkDialogFragment.LINK_DIALOG_ARG_TEXT);
+
+ if (linkText == null || linkText.equals("")) {
+ linkText = linkUrl;
+ }
+
+ if (TextUtils.isEmpty(Uri.parse(linkUrl).getScheme())) linkUrl = "http://" + linkUrl;
+
+ if (mSourceView.getVisibility() == View.VISIBLE) {
+ Editable content = mSourceViewContent.getText();
+ if (content == null) {
+ return;
+ }
+
+ if (mSelectionStart < mSelectionEnd) {
+ content.delete(mSelectionStart, mSelectionEnd);
+ }
+
+ String urlHtml = "<a href=\"" + linkUrl + "\">" + linkText + "</a>";
+
+ content.insert(mSelectionStart, urlHtml);
+ mSourceViewContent.setSelection(mSelectionStart + urlHtml.length());
+ } else {
+ String jsMethod;
+ if (requestCode == LinkDialogFragment.LINK_DIALOG_REQUEST_CODE_ADD) {
+ jsMethod = "ZSSEditor.insertLink";
+ } else {
+ jsMethod = "ZSSEditor.updateLink";
+ }
+ mWebView.execJavaScriptFromString(jsMethod + "('" + Utils.escapeHtml(linkUrl) + "', '" +
+ Utils.escapeHtml(linkText) + "');");
+ }
+ } else if (requestCode == ImageSettingsDialogFragment.IMAGE_SETTINGS_DIALOG_REQUEST_CODE) {
+ if (data == null) {
+ mWebView.execJavaScriptFromString("ZSSEditor.clearCurrentEditingImage();");
+ return;
+ }
+
+ Bundle extras = data.getExtras();
+ if (extras == null) {
+ return;
+ }
+
+ final String imageMeta = Utils.escapeQuotes(StringUtils.notNullStr(extras.getString("imageMeta")));
+ final int imageRemoteId = extras.getInt("imageRemoteId");
+ final boolean isFeaturedImage = extras.getBoolean("isFeatured");
+
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString("ZSSEditor.updateCurrentImageMeta('" + imageMeta + "');");
+ }
+ });
+
+ if (imageRemoteId != 0) {
+ if (isFeaturedImage) {
+ mFeaturedImageId = imageRemoteId;
+ mEditorFragmentListener.onFeaturedImageChanged(mFeaturedImageId);
+ } else {
+ // If this image was unset as featured, clear the featured image id
+ if (mFeaturedImageId == imageRemoteId) {
+ mFeaturedImageId = 0;
+ mEditorFragmentListener.onFeaturedImageChanged(mFeaturedImageId);
+ }
+ }
+ }
+ }
+ }
+
+ @SuppressLint("NewApi")
+ private void enableWebDebugging(boolean enable) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ AppLog.i(T.EDITOR, "Enabling web debugging");
+ WebView.setWebContentsDebuggingEnabled(enable);
+ }
+ mWebView.setDebugModeEnabled(mDebugModeEnabled);
+ }
+
+ @Override
+ public void setTitle(CharSequence text) {
+ mTitle = text.toString();
+ }
+
+ @Override
+ public void setContent(CharSequence text) {
+ mContentHtml = text.toString();
+ }
+
+ /**
+ * Returns the contents of the title field from the JavaScript editor. Should be called from a background thread
+ * where possible.
+ */
+ @Override
+ public CharSequence getTitle() {
+ if (!isAdded()) {
+ return "";
+ }
+
+ if (mSourceView != null && mSourceView.getVisibility() == View.VISIBLE) {
+ mTitle = mSourceViewTitle.getText().toString();
+ return StringUtils.notNullStr(mTitle);
+ }
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ AppLog.d(T.EDITOR, "getTitle() called from UI thread");
+ }
+
+ mGetTitleCountDownLatch = new CountDownLatch(1);
+
+ // All WebView methods must be called from the UI thread
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_title').getHTMLForCallback();");
+ }
+ });
+
+ try {
+ mGetTitleCountDownLatch.await(1, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ AppLog.e(T.EDITOR, e);
+ Thread.currentThread().interrupt();
+ }
+
+ return StringUtils.notNullStr(mTitle.replaceAll("&nbsp;$", ""));
+ }
+
+ /**
+ * Returns the contents of the content field from the JavaScript editor. Should be called from a background thread
+ * where possible.
+ */
+ @Override
+ public CharSequence getContent() {
+ if (!isAdded()) {
+ return "";
+ }
+
+ if (mSourceView != null && mSourceView.getVisibility() == View.VISIBLE) {
+ mContentHtml = mSourceViewContent.getText().toString();
+ return StringUtils.notNullStr(mContentHtml);
+ }
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ AppLog.d(T.EDITOR, "getContent() called from UI thread");
+ }
+
+ mGetContentCountDownLatch = new CountDownLatch(1);
+
+ // All WebView methods must be called from the UI thread
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').getHTMLForCallback();");
+ }
+ });
+
+ try {
+ mGetContentCountDownLatch.await(1, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ AppLog.e(T.EDITOR, e);
+ Thread.currentThread().interrupt();
+ }
+
+ return StringUtils.notNullStr(mContentHtml);
+ }
+
+ @Override
+ public void appendMediaFile(final MediaFile mediaFile, final String mediaUrl, ImageLoader imageLoader) {
+ if (!mDomHasLoaded) {
+ // If the DOM hasn't loaded yet, we won't be able to add media to the ZSSEditor
+ // Place them in a queue to be handled when the DOM loaded callback is received
+ mWaitingMediaFiles.put(mediaUrl, mediaFile);
+ return;
+ }
+
+ final String safeMediaUrl = Utils.escapeQuotes(mediaUrl);
+
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ if (URLUtil.isNetworkUrl(mediaUrl)) {
+ String mediaId = mediaFile.getMediaId();
+ if (mediaFile.isVideo()) {
+ String posterUrl = Utils.escapeQuotes(StringUtils.notNullStr(mediaFile.getThumbnailURL()));
+ String videoPressId = ShortcodeUtils.getVideoPressIdFromShortCode(
+ mediaFile.getVideoPressShortCode());
+
+ mWebView.execJavaScriptFromString("ZSSEditor.insertVideo('" + safeMediaUrl + "', '" +
+ posterUrl + "', '" + videoPressId + "');");
+ } else {
+ mWebView.execJavaScriptFromString("ZSSEditor.insertImage('" + safeMediaUrl + "', '" + mediaId +
+ "');");
+ }
+ mActionStartedAt = System.currentTimeMillis();
+ } else {
+ String id = mediaFile.getMediaId();
+ if (mediaFile.isVideo()) {
+ String posterUrl = Utils.escapeQuotes(StringUtils.notNullStr(mediaFile.getThumbnailURL()));
+ mWebView.execJavaScriptFromString("ZSSEditor.insertLocalVideo(" + id + ", '" + posterUrl +
+ "');");
+ mUploadingMedia.put(id, MediaType.VIDEO);
+ } else {
+ mWebView.execJavaScriptFromString("ZSSEditor.insertLocalImage(" + id + ", '" + safeMediaUrl +
+ "');");
+ mUploadingMedia.put(id, MediaType.IMAGE);
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public void appendGallery(MediaGallery mediaGallery) {
+ if (!mDomHasLoaded) {
+ // If the DOM hasn't loaded yet, we won't be able to add a gallery to the ZSSEditor
+ // Place it in a queue to be handled when the DOM loaded callback is received
+ mWaitingGalleries.add(mediaGallery);
+ return;
+ }
+
+ if (mediaGallery.getIds().isEmpty()) {
+ mUploadingMediaGallery = mediaGallery;
+ mWebView.execJavaScriptFromString("ZSSEditor.insertLocalGallery('" + mediaGallery.getUniqueId() + "');");
+ } else {
+ // Ensure that the content field is in focus (it may not be if we're adding a gallery to a new post by a
+ // share action and not via the format bar button)
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').focus();");
+
+ mWebView.execJavaScriptFromString("ZSSEditor.insertGallery('" + mediaGallery.getIdsStr() + "', '" +
+ mediaGallery.getType() + "', " + mediaGallery.getNumColumns() + ");");
+ }
+ }
+
+ @Override
+ public void setUrlForVideoPressId(final String videoId, final String videoUrl, final String posterUrl) {
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString("ZSSEditor.setVideoPressLinks('" + videoId + "', '" +
+ Utils.escapeQuotes(videoUrl) + "', '" + Utils.escapeQuotes(posterUrl) + "');");
+ }
+ });
+ }
+
+ @Override
+ public boolean isUploadingMedia() {
+ return (mUploadingMedia.size() > 0);
+ }
+
+ @Override
+ public boolean hasFailedMediaUploads() {
+ return (mFailedMediaIds.size() > 0);
+ }
+
+ @Override
+ public void removeAllFailedMediaUploads() {
+ mWebView.execJavaScriptFromString("ZSSEditor.removeAllFailedMediaUploads();");
+ }
+
+ @Override
+ public Spanned getSpannedContent() {
+ return null;
+ }
+
+ @Override
+ public void setTitlePlaceholder(CharSequence placeholderText) {
+ mTitlePlaceholder = placeholderText.toString();
+ }
+
+ @Override
+ public void setContentPlaceholder(CharSequence placeholderText) {
+ mContentPlaceholder = placeholderText.toString();
+ }
+
+ @Override
+ public void onMediaUploadSucceeded(final String localMediaId, final MediaFile mediaFile) {
+ final MediaType mediaType = mUploadingMedia.get(localMediaId);
+ if (mediaType != null) {
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ String remoteUrl = Utils.escapeQuotes(mediaFile.getFileURL());
+ if (mediaType.equals(MediaType.IMAGE)) {
+ String remoteMediaId = mediaFile.getMediaId();
+ mWebView.execJavaScriptFromString("ZSSEditor.replaceLocalImageWithRemoteImage(" + localMediaId +
+ ", '" + remoteMediaId + "', '" + remoteUrl + "');");
+ } else if (mediaType.equals(MediaType.VIDEO)) {
+ String posterUrl = Utils.escapeQuotes(StringUtils.notNullStr(mediaFile.getThumbnailURL()));
+ String videoPressId = ShortcodeUtils.getVideoPressIdFromShortCode(
+ mediaFile.getVideoPressShortCode());
+ mWebView.execJavaScriptFromString("ZSSEditor.replaceLocalVideoWithRemoteVideo(" + localMediaId +
+ ", '" + remoteUrl + "', '" + posterUrl + "', '" + videoPressId + "');");
+ }
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onMediaUploadProgress(final String mediaId, final float progress) {
+ final MediaType mediaType = mUploadingMedia.get(mediaId);
+ if (mediaType != null) {
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ String progressString = String.format(Locale.US, "%.1f", progress);
+ mWebView.execJavaScriptFromString("ZSSEditor.setProgressOnMedia(" + mediaId + ", " +
+ progressString + ");");
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onMediaUploadFailed(final String mediaId, final String errorMessage) {
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ MediaType mediaType = mUploadingMedia.get(mediaId);
+ if (mediaType != null) {
+ switch (mediaType) {
+ case IMAGE:
+ mWebView.execJavaScriptFromString("ZSSEditor.markImageUploadFailed(" + mediaId + ", '"
+ + Utils.escapeQuotes(errorMessage) + "');");
+ break;
+ case VIDEO:
+ mWebView.execJavaScriptFromString("ZSSEditor.markVideoUploadFailed(" + mediaId + ", '"
+ + Utils.escapeQuotes(errorMessage) + "');");
+ }
+ mFailedMediaIds.add(mediaId);
+ mUploadingMedia.remove(mediaId);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onGalleryMediaUploadSucceeded(final long galleryId, String remoteMediaId, int remaining) {
+ if (galleryId == mUploadingMediaGallery.getUniqueId()) {
+ ArrayList<String> mediaIds = mUploadingMediaGallery.getIds();
+ mediaIds.add(remoteMediaId);
+ mUploadingMediaGallery.setIds(mediaIds);
+
+ if (remaining == 0) {
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ mWebView.execJavaScriptFromString("ZSSEditor.replacePlaceholderGallery('" + galleryId + "', '" +
+ mUploadingMediaGallery.getIdsStr() + "', '" +
+ mUploadingMediaGallery.getType() + "', " +
+ mUploadingMediaGallery.getNumColumns() + ");");
+ }
+ });
+ }
+ }
+ }
+
+ public void onDomLoaded() {
+ ProfilingUtils.split("EditorFragment.onDomLoaded");
+
+ mWebView.post(new Runnable() {
+ public void run() {
+ if (!isAdded()) {
+ return;
+ }
+
+ mDomHasLoaded = true;
+
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').setMultiline('true');");
+
+ // Set title and content placeholder text
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_title').setPlaceholderText('" +
+ Utils.escapeQuotes(mTitlePlaceholder) + "');");
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').setPlaceholderText('" +
+ Utils.escapeQuotes(mContentPlaceholder) + "');");
+
+ // Load title and content into ZSSEditor
+ updateVisualEditorFields();
+
+ // If there are images that are still in progress (because the editor exited before they completed),
+ // set them to failed, so the user can restart them (otherwise they will stay stuck in 'uploading' mode)
+ mWebView.execJavaScriptFromString("ZSSEditor.markAllUploadingMediaAsFailed('"
+ + Utils.escapeQuotes(getString(R.string.tap_to_try_again)) + "');");
+
+ // Update the list of failed media uploads
+ mWebView.execJavaScriptFromString("ZSSEditor.getFailedMedia();");
+
+ hideActionBarIfNeeded();
+
+ // Reset all format bar buttons (in case they remained active through activity re-creation)
+ ToggleButton htmlButton = (ToggleButton) getActivity().findViewById(R.id.format_bar_button_html);
+ htmlButton.setChecked(false);
+ for (ToggleButton button : mTagToggleButtonMap.values()) {
+ button.setChecked(false);
+ }
+
+ boolean editorHasFocus = false;
+
+ // Add any media files that were placed in a queue due to the DOM not having loaded yet
+ if (mWaitingMediaFiles.size() > 0) {
+ // Image insertion will only work if the content field is in focus
+ // (for a new post, no field is in focus until user action)
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').focus();");
+ editorHasFocus = true;
+
+ for (Map.Entry<String, MediaFile> entry : mWaitingMediaFiles.entrySet()) {
+ appendMediaFile(entry.getValue(), entry.getKey(), null);
+ }
+ mWaitingMediaFiles.clear();
+ }
+
+ // Add any galleries that were placed in a queue due to the DOM not having loaded yet
+ if (mWaitingGalleries.size() > 0) {
+ // Gallery insertion will only work if the content field is in focus
+ // (for a new post, no field is in focus until user action)
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').focus();");
+ editorHasFocus = true;
+
+ for (MediaGallery mediaGallery : mWaitingGalleries) {
+ appendGallery(mediaGallery);
+ }
+
+ mWaitingGalleries.clear();
+ }
+
+ if (!editorHasFocus) {
+ mWebView.execJavaScriptFromString("ZSSEditor.focusFirstEditableField();");
+ }
+
+ // Show the keyboard
+ ((InputMethodManager)getActivity().getSystemService(Context.INPUT_METHOD_SERVICE))
+ .showSoftInput(mWebView, InputMethodManager.SHOW_IMPLICIT);
+
+ ProfilingUtils.split("EditorFragment.onDomLoaded completed");
+ ProfilingUtils.dump();
+ ProfilingUtils.stop();
+ }
+ });
+ }
+
+ public void onSelectionStyleChanged(final Map<String, Boolean> changeMap) {
+ mWebView.post(new Runnable() {
+ public void run() {
+ for (Map.Entry<String, Boolean> entry : changeMap.entrySet()) {
+ // Handle toggling format bar style buttons
+ ToggleButton button = mTagToggleButtonMap.get(entry.getKey());
+ if (button != null) {
+ button.setChecked(entry.getValue());
+ }
+ }
+ }
+ });
+ }
+
+ public void onSelectionChanged(final Map<String, String> selectionArgs) {
+ mFocusedFieldId = selectionArgs.get("id"); // The field now in focus
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ if (!mFocusedFieldId.isEmpty()) {
+ switch (mFocusedFieldId) {
+ case "zss_field_title":
+ updateFormatBarEnabledState(false);
+ break;
+ case "zss_field_content":
+ updateFormatBarEnabledState(true);
+ break;
+ }
+ }
+ }
+ });
+ }
+
+ public void onMediaTapped(final String mediaId, final MediaType mediaType, final JSONObject meta, String uploadStatus) {
+ if (mediaType == null || !isAdded()) {
+ return;
+ }
+
+ switch (uploadStatus) {
+ case "uploading":
+ // Display 'cancel upload' dialog
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setTitle(getString(R.string.stop_upload_dialog_title));
+ builder.setPositiveButton(R.string.stop_upload_button, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ mEditorFragmentListener.onMediaUploadCancelClicked(mediaId, true);
+
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ switch (mediaType) {
+ case IMAGE:
+ mWebView.execJavaScriptFromString("ZSSEditor.removeImage(" + mediaId + ");");
+ break;
+ case VIDEO:
+ mWebView.execJavaScriptFromString("ZSSEditor.removeVideo(" + mediaId + ");");
+ }
+ mUploadingMedia.remove(mediaId);
+ }
+ });
+ dialog.dismiss();
+ }
+ });
+
+ builder.setNegativeButton(getString(R.string.cancel), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.dismiss();
+ }
+ });
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ break;
+ case "failed":
+ // Retry media upload
+ mEditorFragmentListener.onMediaRetryClicked(mediaId);
+
+ mWebView.post(new Runnable() {
+ @Override
+ public void run() {
+ switch (mediaType) {
+ case IMAGE:
+ mWebView.execJavaScriptFromString("ZSSEditor.unmarkImageUploadFailed(" + mediaId
+ + ");");
+ break;
+ case VIDEO:
+ mWebView.execJavaScriptFromString("ZSSEditor.unmarkVideoUploadFailed(" + mediaId
+ + ");");
+ }
+ mFailedMediaIds.remove(mediaId);
+ mUploadingMedia.put(mediaId, mediaType);
+ }
+ });
+ break;
+ default:
+ if (!mediaType.equals(MediaType.IMAGE)) {
+ return;
+ }
+
+ // Only show image options fragment for image taps
+ FragmentManager fragmentManager = getFragmentManager();
+
+ if (fragmentManager.findFragmentByTag(ImageSettingsDialogFragment.IMAGE_SETTINGS_DIALOG_TAG) != null) {
+ return;
+ }
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.IMAGE_EDITED);
+ ImageSettingsDialogFragment imageSettingsDialogFragment = new ImageSettingsDialogFragment();
+ imageSettingsDialogFragment.setTargetFragment(this,
+ ImageSettingsDialogFragment.IMAGE_SETTINGS_DIALOG_REQUEST_CODE);
+
+ Bundle dialogBundle = new Bundle();
+
+ dialogBundle.putString("maxWidth", mBlogSettingMaxImageWidth);
+ dialogBundle.putBoolean("featuredImageSupported", mFeaturedImageSupported);
+
+ // Request and add an authorization header for HTTPS images
+ // Use https:// when requesting the auth header, in case the image is incorrectly using http://.
+ // If an auth header is returned, force https:// for the actual HTTP request.
+ HashMap<String, String> headerMap = new HashMap<>();
+ if (mCustomHttpHeaders != null) {
+ headerMap.putAll(mCustomHttpHeaders);
+ }
+
+ try {
+ final String imageSrc = meta.getString("src");
+ String authHeader = mEditorFragmentListener.onAuthHeaderRequested(UrlUtils.makeHttps(imageSrc));
+ if (authHeader.length() > 0) {
+ meta.put("src", UrlUtils.makeHttps(imageSrc));
+ headerMap.put("Authorization", authHeader);
+ }
+ } catch (JSONException e) {
+ AppLog.e(T.EDITOR, "Could not retrieve image url from JSON metadata");
+ }
+ dialogBundle.putSerializable("headerMap", headerMap);
+
+ dialogBundle.putString("imageMeta", meta.toString());
+
+ String imageId = JSONUtils.getString(meta, "attachment_id");
+ if (!imageId.isEmpty()) {
+ dialogBundle.putBoolean("isFeatured", mFeaturedImageId == Integer.parseInt(imageId));
+ }
+
+ imageSettingsDialogFragment.setArguments(dialogBundle);
+
+ FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+ fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
+
+ fragmentTransaction.add(android.R.id.content, imageSettingsDialogFragment,
+ ImageSettingsDialogFragment.IMAGE_SETTINGS_DIALOG_TAG)
+ .addToBackStack(null)
+ .commit();
+
+ mWebView.notifyVisibilityChanged(false);
+ break;
+ }
+ }
+
+ public void onLinkTapped(String url, String title) {
+ LinkDialogFragment linkDialogFragment = new LinkDialogFragment();
+ linkDialogFragment.setTargetFragment(this, LinkDialogFragment.LINK_DIALOG_REQUEST_CODE_UPDATE);
+
+ Bundle dialogBundle = new Bundle();
+
+ dialogBundle.putString(LinkDialogFragment.LINK_DIALOG_ARG_URL, url);
+ dialogBundle.putString(LinkDialogFragment.LINK_DIALOG_ARG_TEXT, title);
+
+ linkDialogFragment.setArguments(dialogBundle);
+ linkDialogFragment.show(getFragmentManager(), "LinkDialogFragment");
+ }
+
+ @Override
+ public void onMediaRemoved(String mediaId) {
+ mUploadingMedia.remove(mediaId);
+ mFailedMediaIds.remove(mediaId);
+ mEditorFragmentListener.onMediaUploadCancelClicked(mediaId, true);
+ }
+
+ @Override
+ public void onMediaReplaced(String mediaId) {
+ mUploadingMedia.remove(mediaId);
+ }
+
+ @Override
+ public void onVideoPressInfoRequested(final String videoId) {
+ mEditorFragmentListener.onVideoPressInfoRequested(videoId);
+ }
+
+ public void onGetHtmlResponse(Map<String, String> inputArgs) {
+ String functionId = inputArgs.get("function");
+
+ if (functionId.isEmpty()) {
+ return;
+ }
+
+ switch (functionId) {
+ case "getHTMLForCallback":
+ String fieldId = inputArgs.get("id");
+ String fieldContents = inputArgs.get("contents");
+ if (!fieldId.isEmpty()) {
+ switch (fieldId) {
+ case "zss_field_title":
+ mTitle = fieldContents;
+ mGetTitleCountDownLatch.countDown();
+ break;
+ case "zss_field_content":
+ mContentHtml = fieldContents;
+ mGetContentCountDownLatch.countDown();
+ break;
+ }
+ }
+ break;
+ case "getSelectedTextToLinkify":
+ mJavaScriptResult = inputArgs.get("result");
+ mGetSelectedTextCountDownLatch.countDown();
+ break;
+ case "getFailedMedia":
+ String[] mediaIds = inputArgs.get("ids").split(",");
+ for (String mediaId : mediaIds) {
+ if (!mediaId.equals("")) {
+ mFailedMediaIds.add(mediaId);
+ }
+ }
+ }
+ }
+
+ public void setWebViewErrorListener(ErrorListener errorListener) {
+ mWebView.setErrorListener(errorListener);
+ }
+
+ private void updateVisualEditorFields() {
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_title').setPlainText('" +
+ Utils.escapeHtml(mTitle) + "');");
+ mWebView.execJavaScriptFromString("ZSSEditor.getField('zss_field_content').setHTML('" +
+ Utils.escapeHtml(mContentHtml) + "');");
+ }
+
+ /**
+ * Hide the action bar if needed.
+ */
+ private void hideActionBarIfNeeded() {
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null
+ && !isHardwareKeyboardPresent()
+ && mHideActionBarOnSoftKeyboardUp
+ && mIsKeyboardOpen
+ && actionBar.isShowing()) {
+ getActionBar().hide();
+ }
+ }
+
+ /**
+ * Show the action bar if needed.
+ */
+ private void showActionBarIfNeeded() {
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null && !actionBar.isShowing()) {
+ actionBar.show();
+ }
+ }
+
+ /**
+ * Returns true if a hardware keyboard is detected, otherwise false.
+ */
+ private boolean isHardwareKeyboardPresent() {
+ Configuration config = getResources().getConfiguration();
+ boolean returnValue = false;
+ if (config.keyboard != Configuration.KEYBOARD_NOKEYS) {
+ returnValue = true;
+ }
+ return returnValue;
+ }
+
+ void updateFormatBarEnabledState(boolean enabled) {
+ float alpha = (enabled ? TOOLBAR_ALPHA_ENABLED : TOOLBAR_ALPHA_DISABLED);
+ for(ToggleButton button : mTagToggleButtonMap.values()) {
+ button.setEnabled(enabled);
+ button.setAlpha(alpha);
+ }
+
+ mIsFormatBarDisabled = !enabled;
+ }
+
+ private void clearFormatBarButtons() {
+ for (ToggleButton button : mTagToggleButtonMap.values()) {
+ if (button != null) {
+ button.setChecked(false);
+ }
+ }
+ }
+
+ private void onFormattingButtonClicked(ToggleButton toggleButton) {
+ String tag = toggleButton.getTag().toString();
+ buttonTappedListener(toggleButton);
+ if (mWebView.getVisibility() == View.VISIBLE) {
+ mWebView.execJavaScriptFromString("ZSSEditor.set" + StringUtils.capitalize(tag) + "();");
+ } else {
+ applyFormattingHtmlMode(toggleButton, tag);
+ }
+ }
+
+ private void buttonTappedListener(ToggleButton toggleButton) {
+ int id = toggleButton.getId();
+ if (id == R.id.format_bar_button_bold) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.BOLD_BUTTON_TAPPED);
+ } else if (id == R.id.format_bar_button_italic) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.ITALIC_BUTTON_TAPPED);
+ } else if (id == R.id.format_bar_button_ol) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.OL_BUTTON_TAPPED);
+ } else if (id == R.id.format_bar_button_ul) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.UL_BUTTON_TAPPED);
+ } else if (id == R.id.format_bar_button_quote) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.BLOCKQUOTE_BUTTON_TAPPED);
+ } else if (id == R.id.format_bar_button_strikethrough) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.STRIKETHROUGH_BUTTON_TAPPED);
+ }
+ }
+
+ /**
+ * In HTML mode, applies formatting to selected text, or inserts formatting tag at current cursor position
+ * @param toggleButton format bar button which was clicked
+ * @param tag identifier tag
+ */
+ private void applyFormattingHtmlMode(ToggleButton toggleButton, String tag) {
+ if (mSourceViewContent == null) {
+ return;
+ }
+
+ // Replace style tags with their proper HTML tags
+ String htmlTag;
+ if (tag.equals(getString(R.string.format_bar_tag_bold))) {
+ htmlTag = "b";
+ } else if (tag.equals(getString(R.string.format_bar_tag_italic))) {
+ htmlTag = "i";
+ } else if (tag.equals(getString(R.string.format_bar_tag_strikethrough))) {
+ htmlTag = "del";
+ } else if (tag.equals(getString(R.string.format_bar_tag_unorderedList))) {
+ htmlTag = "ul";
+ } else if (tag.equals(getString(R.string.format_bar_tag_orderedList))) {
+ htmlTag = "ol";
+ } else {
+ htmlTag = tag;
+ }
+
+ int selectionStart = mSourceViewContent.getSelectionStart();
+ int selectionEnd = mSourceViewContent.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ boolean textIsSelected = selectionEnd > selectionStart;
+
+ String startTag = "<" + htmlTag + ">";
+ String endTag = "</" + htmlTag + ">";
+
+ // Add li tags together with ul and ol tags
+ if (htmlTag.equals("ul") || htmlTag.equals("ol")) {
+ startTag = startTag + "\n\t<li>";
+ endTag = "</li>\n" + endTag;
+ }
+
+ Editable content = mSourceViewContent.getText();
+ if (textIsSelected) {
+ // Surround selected text with opening and closing tags
+ content.insert(selectionStart, startTag);
+ content.insert(selectionEnd + startTag.length(), endTag);
+ toggleButton.setChecked(false);
+ mSourceViewContent.setSelection(selectionEnd + startTag.length() + endTag.length());
+ } else if (toggleButton.isChecked()) {
+ // Insert opening tag
+ content.insert(selectionStart, startTag);
+ mSourceViewContent.setSelection(selectionEnd + startTag.length());
+ } else {
+ // Insert closing tag
+ content.insert(selectionEnd, endTag);
+ mSourceViewContent.setSelection(selectionEnd + endTag.length());
+ }
+ }
+
+ @Override
+ public void onActionFinished() {
+ mActionStartedAt = -1;
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java
new file mode 100644
index 000000000..ba15d036a
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java
@@ -0,0 +1,180 @@
+package org.wordpress.android.editor;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Spanned;
+import android.view.DragEvent;
+
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.util.helpers.MediaFile;
+import org.wordpress.android.util.helpers.MediaGallery;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public abstract class EditorFragmentAbstract extends Fragment {
+ public abstract void setTitle(CharSequence text);
+ public abstract void setContent(CharSequence text);
+ public abstract CharSequence getTitle();
+ public abstract CharSequence getContent();
+ public abstract void appendMediaFile(MediaFile mediaFile, String imageUrl, ImageLoader imageLoader);
+ public abstract void appendGallery(MediaGallery mediaGallery);
+ public abstract void setUrlForVideoPressId(String videoPressId, String url, String posterUrl);
+ public abstract boolean isUploadingMedia();
+ public abstract boolean isActionInProgress();
+ public abstract boolean hasFailedMediaUploads();
+ public abstract void removeAllFailedMediaUploads();
+ public abstract void setTitlePlaceholder(CharSequence text);
+ public abstract void setContentPlaceholder(CharSequence text);
+
+ // TODO: remove this as soon as we can (we'll need to drop the legacy editor or fix html2spanned translation)
+ public abstract Spanned getSpannedContent();
+
+ public enum MediaType {
+ IMAGE, VIDEO;
+
+ public static MediaType fromString(String value) {
+ if (value != null) {
+ for (MediaType mediaType : MediaType.values()) {
+ if (value.equalsIgnoreCase(mediaType.toString())) {
+ return mediaType;
+ }
+ }
+ }
+ return null;
+ }
+ }
+
+ private static final String FEATURED_IMAGE_SUPPORT_KEY = "featured-image-supported";
+ private static final String FEATURED_IMAGE_WIDTH_KEY = "featured-image-width";
+
+ protected EditorFragmentListener mEditorFragmentListener;
+ protected EditorDragAndDropListener mEditorDragAndDropListener;
+ protected boolean mFeaturedImageSupported;
+ protected long mFeaturedImageId;
+ protected String mBlogSettingMaxImageWidth;
+ protected ImageLoader mImageLoader;
+ protected boolean mDebugModeEnabled;
+
+ protected HashMap<String, String> mCustomHttpHeaders;
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ try {
+ mEditorFragmentListener = (EditorFragmentListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement EditorFragmentListener");
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putBoolean(FEATURED_IMAGE_SUPPORT_KEY, mFeaturedImageSupported);
+ outState.putString(FEATURED_IMAGE_WIDTH_KEY, mBlogSettingMaxImageWidth);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ if (savedInstanceState.containsKey(FEATURED_IMAGE_SUPPORT_KEY)) {
+ mFeaturedImageSupported = savedInstanceState.getBoolean(FEATURED_IMAGE_SUPPORT_KEY);
+ }
+ if (savedInstanceState.containsKey(FEATURED_IMAGE_WIDTH_KEY)) {
+ mBlogSettingMaxImageWidth = savedInstanceState.getString(FEATURED_IMAGE_WIDTH_KEY);
+ }
+ }
+ }
+
+ public void setImageLoader(ImageLoader imageLoader) {
+ mImageLoader = imageLoader;
+ }
+
+ public void setFeaturedImageSupported(boolean featuredImageSupported) {
+ mFeaturedImageSupported = featuredImageSupported;
+ }
+
+ public void setBlogSettingMaxImageWidth(String blogSettingMaxImageWidth) {
+ mBlogSettingMaxImageWidth = blogSettingMaxImageWidth;
+ }
+
+ public void setFeaturedImageId(long featuredImageId) {
+ mFeaturedImageId = featuredImageId;
+ }
+
+ public void setCustomHttpHeader(String name, String value) {
+ if (mCustomHttpHeaders == null) {
+ mCustomHttpHeaders = new HashMap<>();
+ }
+
+ mCustomHttpHeaders.put(name, value);
+ }
+
+ public void setDebugModeEnabled(boolean debugModeEnabled) {
+ mDebugModeEnabled = debugModeEnabled;
+ }
+
+ /**
+ * Called by the activity when back button is pressed.
+ */
+ public boolean onBackPressed() {
+ return false;
+ }
+
+ /**
+ * The editor may need to differentiate local draft and published articles
+ *
+ * @param isLocalDraft edited post is a local draft
+ */
+ public void setLocalDraft(boolean isLocalDraft) {
+ // Not unused in the new editor
+ }
+
+ /**
+ * Callbacks used to communicate with the parent Activity
+ */
+ public interface EditorFragmentListener {
+ void onEditorFragmentInitialized();
+ void onSettingsClicked();
+ void onAddMediaClicked();
+ void onMediaRetryClicked(String mediaId);
+ void onMediaUploadCancelClicked(String mediaId, boolean delete);
+ void onFeaturedImageChanged(long mediaId);
+ void onVideoPressInfoRequested(String videoId);
+ String onAuthHeaderRequested(String url);
+ // TODO: remove saveMediaFile, it's currently needed for the legacy editor
+ void saveMediaFile(MediaFile mediaFile);
+ void onTrackableEvent(TrackableEvent event);
+ }
+
+ /**
+ * Callbacks for drag and drop support
+ */
+ public interface EditorDragAndDropListener {
+ void onMediaDropped(ArrayList<Uri> mediaUri);
+ void onRequestDragAndDropPermissions(DragEvent dragEvent);
+ }
+
+ public enum TrackableEvent {
+ HTML_BUTTON_TAPPED,
+ UNLINK_BUTTON_TAPPED,
+ LINK_BUTTON_TAPPED,
+ MEDIA_BUTTON_TAPPED,
+ IMAGE_EDITED,
+ BOLD_BUTTON_TAPPED,
+ ITALIC_BUTTON_TAPPED,
+ OL_BUTTON_TAPPED,
+ UL_BUTTON_TAPPED,
+ BLOCKQUOTE_BUTTON_TAPPED,
+ STRIKETHROUGH_BUTTON_TAPPED,
+ UNDERLINE_BUTTON_TAPPED,
+ MORE_BUTTON_TAPPED
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorMediaUploadListener.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorMediaUploadListener.java
new file mode 100644
index 000000000..26ba44150
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorMediaUploadListener.java
@@ -0,0 +1,10 @@
+package org.wordpress.android.editor;
+
+import org.wordpress.android.util.helpers.MediaFile;
+
+public interface EditorMediaUploadListener {
+ void onMediaUploadSucceeded(String localId, MediaFile mediaFile);
+ void onMediaUploadProgress(String localId, float progress);
+ void onMediaUploadFailed(String localId, String errorMessage);
+ void onGalleryMediaUploadSucceeded(long galleryId, String remoteId, int remaining);
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebView.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebView.java
new file mode 100644
index 000000000..f613eb5d5
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebView.java
@@ -0,0 +1,35 @@
+package org.wordpress.android.editor;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.Build;
+import android.util.AttributeSet;
+
+import org.wordpress.android.util.AppLog;
+
+public class EditorWebView extends EditorWebViewAbstract {
+
+ public EditorWebView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @SuppressLint("NewApi")
+ public void execJavaScriptFromString(String javaScript) {
+ this.evaluateJavascript(javaScript, null);
+ }
+
+ @SuppressLint("NewApi")
+ @Override
+ public boolean shouldSwitchToCompatibilityMode() {
+ if (Build.VERSION.SDK_INT <= 19) {
+ try {
+ this.evaluateJavascript("", null);
+ } catch (NoSuchMethodError | IllegalStateException e) {
+ AppLog.d(AppLog.T.EDITOR,
+ "Detected 4.4 ROM using classic WebView, reverting to compatibility EditorWebView.");
+ return true;
+ }
+ }
+ return false;
+ }
+} \ No newline at end of file
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewAbstract.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewAbstract.java
new file mode 100644
index 000000000..64ded937d
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewAbstract.java
@@ -0,0 +1,256 @@
+package org.wordpress.android.editor;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.support.annotation.NonNull;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+import android.webkit.ConsoleMessage;
+import android.webkit.ConsoleMessage.MessageLevel;
+import android.webkit.JsResult;
+import android.webkit.URLUtil;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.HTTPUtils;
+import org.wordpress.android.util.StringUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.UrlUtils;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URLDecoder;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A text editor WebView with support for JavaScript execution.
+ */
+public abstract class EditorWebViewAbstract extends WebView {
+ public abstract void execJavaScriptFromString(String javaScript);
+
+ private OnImeBackListener mOnImeBackListener;
+ private AuthHeaderRequestListener mAuthHeaderRequestListener;
+ private ErrorListener mErrorListener;
+ private JsCallbackReceiver mJsCallbackReceiver;
+ private boolean mDebugModeEnabled;
+
+ private Map<String, String> mHeaderMap = new HashMap<>();
+
+ public EditorWebViewAbstract(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ configureWebView();
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private void configureWebView() {
+ WebSettings webSettings = this.getSettings();
+ webSettings.setJavaScriptEnabled(true);
+ webSettings.setDefaultTextEncodingName("utf-8");
+
+ this.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ if (url != null && url.startsWith("callback") && mJsCallbackReceiver != null) {
+ String data = URLDecoder.decode(url);
+ String[] split = data.split(":", 2);
+ String callbackId = split[0];
+ String params = (split.length > 1 ? split[1] : "");
+ mJsCallbackReceiver.executeCallback(callbackId, params);
+ }
+ return true;
+ }
+
+ @Override
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ AppLog.e(T.EDITOR, description);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
+ String url = request.getUrl().toString();
+
+ if (!URLUtil.isNetworkUrl(url)) {
+ return super.shouldInterceptRequest(view, request);
+ }
+
+ // Request and add an authorization header for HTTPS resource requests.
+ // Use https:// when requesting the auth header, in case the resource is incorrectly using http://.
+ // If an auth header is returned, force https:// for the actual HTTP request.
+ String authHeader = mAuthHeaderRequestListener.onAuthHeaderRequested(UrlUtils.makeHttps(url));
+ if (StringUtils.notNullStr(authHeader).length() > 0) {
+ try {
+ url = UrlUtils.makeHttps(url);
+
+ // Keep any existing request headers from the WebResourceRequest
+ Map<String, String> headerMap = request.getRequestHeaders();
+ for (Map.Entry<String, String> entry : mHeaderMap.entrySet()) {
+ headerMap.put(entry.getKey(), entry.getValue());
+ }
+ headerMap.put("Authorization", authHeader);
+
+ HttpURLConnection conn = HTTPUtils.setupUrlConnection(url, headerMap);
+ return new WebResourceResponse(conn.getContentType(), conn.getContentEncoding(),
+ conn.getInputStream());
+ } catch (IOException e) {
+ AppLog.e(T.EDITOR, e);
+ }
+ }
+
+ return super.shouldInterceptRequest(view, request);
+ }
+
+ /**
+ * Compatibility method for API < 21
+ */
+ @SuppressWarnings("deprecation")
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
+ if (!URLUtil.isNetworkUrl(url)) {
+ return super.shouldInterceptRequest(view, url);
+ }
+
+ // Request and add an authorization header for HTTPS resource requests.
+ // Use https:// when requesting the auth header, in case the resource is incorrectly using http://.
+ // If an auth header is returned, force https:// for the actual HTTP request.
+ String authHeader = mAuthHeaderRequestListener.onAuthHeaderRequested(UrlUtils.makeHttps(url));
+ if (StringUtils.notNullStr(authHeader).length() > 0) {
+ try {
+ url = UrlUtils.makeHttps(url);
+
+ Map<String, String> headerMap = new HashMap<>(mHeaderMap);
+ headerMap.put("Authorization", authHeader);
+
+ HttpURLConnection conn = HTTPUtils.setupUrlConnection(url, headerMap);
+ return new WebResourceResponse(conn.getContentType(), conn.getContentEncoding(),
+ conn.getInputStream());
+ } catch (IOException e) {
+ AppLog.e(T.EDITOR, e);
+ }
+ }
+
+ return super.shouldInterceptRequest(view, url);
+ }
+ });
+
+ this.setWebChromeClient(new WebChromeClient() {
+ @Override
+ public boolean onConsoleMessage(@NonNull ConsoleMessage cm) {
+ if (cm.messageLevel() == MessageLevel.ERROR) {
+ if (mErrorListener != null) {
+ mErrorListener.onJavaScriptError(cm.sourceId(), cm.lineNumber(), cm.message());
+ }
+ AppLog.e(T.EDITOR, cm.message() + " -- From line " + cm.lineNumber() + " of " + cm.sourceId());
+ } else {
+ AppLog.d(T.EDITOR, cm.message() + " -- From line " + cm.lineNumber() + " of " + cm.sourceId());
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
+ AppLog.d(T.EDITOR, message);
+ if (mErrorListener != null) {
+ mErrorListener.onJavaScriptAlert(url, message);
+ }
+ return true;
+ }
+ });
+ }
+
+ @Override
+ public boolean onCheckIsTextEditor() {
+ return true;
+ }
+
+ @Override
+ public void setVisibility(int visibility) {
+ notifyVisibilityChanged(visibility == View.VISIBLE);
+ super.setVisibility(visibility);
+ }
+
+
+ public boolean shouldSwitchToCompatibilityMode() {
+ return false;
+ }
+
+ public void setDebugModeEnabled(boolean enabled) {
+ mDebugModeEnabled = enabled;
+ }
+
+ /**
+ * Handles events that should be triggered when the WebView is hidden or is shown to the user
+ *
+ * @param visible the new visibility status of the WebView
+ */
+ public void notifyVisibilityChanged(boolean visible) {
+ if (!visible) {
+ this.post(new Runnable() {
+ @Override
+ public void run() {
+ execJavaScriptFromString("ZSSEditor.pauseAllVideos();");
+ }
+ });
+ }
+ }
+
+ public void setOnImeBackListener(OnImeBackListener listener) {
+ mOnImeBackListener = listener;
+ }
+
+ public void setAuthHeaderRequestListener(AuthHeaderRequestListener listener) {
+ mAuthHeaderRequestListener = listener;
+ }
+
+ /**
+ * Used on API<17 to handle callbacks as a safe alternative to JavascriptInterface (which has security risks
+ * at those API levels).
+ */
+ public void setJsCallbackReceiver(JsCallbackReceiver jsCallbackReceiver) {
+ mJsCallbackReceiver = jsCallbackReceiver;
+ }
+
+ @Override
+ public boolean onKeyPreIme(int keyCode, KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) {
+ if (mOnImeBackListener != null) {
+ mOnImeBackListener.onImeBack();
+ }
+ }
+ if (mDebugModeEnabled && event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_UP
+ && event.getAction() == KeyEvent.ACTION_DOWN) {
+ // Log the raw html
+ execJavaScriptFromString("console.log(document.body.innerHTML);");
+ ToastUtils.showToast(getContext(), "Debug: Raw HTML has been logged");
+ return true;
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ public void setCustomHeader(String name, String value) {
+ mHeaderMap.put(name, value);
+ }
+
+ public void setErrorListener(ErrorListener errorListener) {
+ mErrorListener = errorListener;
+ }
+
+ public interface AuthHeaderRequestListener {
+ String onAuthHeaderRequested(String url);
+ }
+
+ public interface ErrorListener {
+ void onJavaScriptError(String sourceFile, int lineNumber, String message);
+ void onJavaScriptAlert(String url, String message);
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewCompatibility.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewCompatibility.java
new file mode 100644
index 000000000..72d431a21
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorWebViewCompatibility.java
@@ -0,0 +1,130 @@
+package org.wordpress.android.editor;
+
+import android.content.Context;
+import android.os.Message;
+import android.util.AttributeSet;
+import android.webkit.WebView;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * <p>Compatibility <code>EditorWebView</code> for pre-Chromium WebView (API<19). Provides a custom method for executing
+ * JavaScript, {@link #loadJavaScript(String)}, instead of {@link WebView#loadUrl(String)}. This is needed because
+ * <code>WebView#loadUrl(String)</code> on API<19 eventually calls <code>WebViewClassic#hideSoftKeyboard()</code>,
+ * hiding the keyboard whenever JavaScript is executed.</p>
+ * <p/>
+ * <p>This class uses reflection to access the normally inaccessible <code>WebViewCore#sendMessage(Message)</code>
+ * and use it to execute JavaScript, sidestepping <code>WebView#loadUrl(String)</code> and the keyboard issue.</p>
+ */
+@SuppressWarnings("TryWithIdenticalCatches")
+public class EditorWebViewCompatibility extends EditorWebViewAbstract {
+ public interface ReflectionFailureListener {
+ void onReflectionFailure(ReflectionException e);
+ }
+
+ public class ReflectionException extends Exception {
+ public ReflectionException(Throwable cause) {
+ super(cause);
+ }
+ }
+
+ private static final int EXECUTE_JS = 194; // WebViewCore internal JS message code
+
+ private Object mWebViewCore;
+ private Method mSendMessageMethod;
+
+ // Dirty static listener, but it's impossible to set the listener during the construction if we want to keep
+ // the xml layout
+ private static ReflectionFailureListener mReflectionFailureListener;
+ private boolean mReflectionSucceed = true;
+
+ public static void setReflectionFailureListener(ReflectionFailureListener reflectionFailureListener) {
+ mReflectionFailureListener = reflectionFailureListener;
+ }
+
+ public EditorWebViewCompatibility(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ try {
+ this.initReflection();
+ } catch (ReflectionException e) {
+ AppLog.e(T.EDITOR, e);
+ handleReflectionFailure(e);
+ }
+ }
+
+ private void initReflection() throws ReflectionException {
+ if (!mReflectionSucceed) {
+ // Reflection failed once already, it won't succeed on a second try
+ return;
+ }
+ Object webViewProvider;
+ try {
+ // On API >= 16, the WebViewCore instance is not defined inside WebView itself but inside a
+ // WebViewClassic (implementation of WebViewProvider), referenced from the WebView as mProvider
+
+ // Access WebViewClassic object
+ Field webViewProviderField = WebView.class.getDeclaredField("mProvider");
+ webViewProviderField.setAccessible(true);
+ webViewProvider = webViewProviderField.get(this);
+
+ // Access WebViewCore object
+ Field webViewCoreField = webViewProvider.getClass().getDeclaredField("mWebViewCore");
+ webViewCoreField.setAccessible(true);
+ mWebViewCore = webViewCoreField.get(webViewProvider);
+
+ // Access WebViewCore#sendMessage(Message) method
+ if (mWebViewCore != null) {
+ mSendMessageMethod = mWebViewCore.getClass().getDeclaredMethod("sendMessage", Message.class);
+ mSendMessageMethod.setAccessible(true);
+ }
+ } catch (NoSuchFieldException e) {
+ throw new ReflectionException(e);
+ } catch (NoSuchMethodException e) {
+ throw new ReflectionException(e);
+ } catch (IllegalAccessException e) {
+ throw new ReflectionException(e);
+ }
+ }
+
+ private void loadJavaScript(final String javaScript) throws ReflectionException {
+ if (mSendMessageMethod == null) {
+ initReflection();
+ } else {
+ Message jsMessage = Message.obtain(null, EXECUTE_JS, javaScript);
+ try {
+ mSendMessageMethod.invoke(mWebViewCore, jsMessage);
+ } catch (InvocationTargetException e) {
+ throw new ReflectionException(e);
+ } catch (IllegalAccessException e) {
+ throw new ReflectionException(e);
+ }
+ }
+ }
+
+ public void execJavaScriptFromString(String javaScript) {
+ try {
+ loadJavaScript(javaScript);
+ } catch (ReflectionException e) {
+ AppLog.e(T.EDITOR, e);
+ handleReflectionFailure(e);
+ }
+ }
+
+ private void handleReflectionFailure(ReflectionException e) {
+ if (mReflectionFailureListener != null) {
+ mReflectionFailureListener.onReflectionFailure(e);
+ }
+ mReflectionSucceed = false;
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mReflectionFailureListener = null;
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleTextWatcher.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleTextWatcher.java
new file mode 100644
index 000000000..90ffc7fe0
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleTextWatcher.java
@@ -0,0 +1,245 @@
+package org.wordpress.android.editor;
+
+import android.text.Editable;
+import android.text.Spannable;
+import android.text.TextWatcher;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+
+public class HtmlStyleTextWatcher implements TextWatcher {
+ private enum Operation {
+ INSERT, DELETE, REPLACE, NONE
+ }
+
+ private int mOffset;
+ private CharSequence mModifiedText;
+ private Operation mLastOperation;
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ if (s == null) {
+ return;
+ }
+
+ int lastCharacterLocation = start + count - 1;
+ if (s.length() > lastCharacterLocation && lastCharacterLocation >= 0) {
+ if (after < count) {
+ if (after > 0) {
+ // Text was deleted and replaced by some other text
+ mLastOperation = Operation.REPLACE;
+ } else {
+ // Text was deleted only
+ mLastOperation = Operation.DELETE;
+ }
+
+ mOffset = start;
+ mModifiedText = s.subSequence(start + after, start + count);
+ }
+ }
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (s == null) {
+ return;
+ }
+
+ int lastCharacterLocation = start + count - 1;
+ if (s.length() > lastCharacterLocation) {
+ if (count > 0) {
+ if (before > 0) {
+ // Text was added, replacing some existing text
+ mLastOperation = Operation.REPLACE;
+ mModifiedText = s.subSequence(start, start + count);
+ } else {
+ // Text was added only
+ mLastOperation = Operation.INSERT;
+ mOffset = start;
+ mModifiedText = s.subSequence(start + before, start + count);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (mModifiedText == null || s == null) {
+ return;
+ }
+
+ SpanRange spanRange;
+
+ // If the modified text included a tag or entity symbol ("<", ">", "&" or ";"), find its match and restyle
+ if (mModifiedText.toString().contains("<")) {
+ spanRange = getRespanRangeForChangedOpeningSymbol(s, "<");
+ } else if (mModifiedText.toString().contains(">")) {
+ spanRange = getRespanRangeForChangedClosingSymbol(s, ">");
+ } else if (mModifiedText.toString().contains("&")) {
+ spanRange = getRespanRangeForChangedOpeningSymbol(s, "&");
+ } else if (mModifiedText.toString().contains(";")) {
+ spanRange = getRespanRangeForChangedClosingSymbol(s, ";");
+ } else {
+ // If the modified text didn't include any tag or entity symbols, restyle if the modified text is inside
+ // a tag or entity
+ spanRange = getRespanRangeForNormalText(s, "<");
+ if (spanRange == null) {
+ spanRange = getRespanRangeForNormalText(s, "&");
+ }
+ }
+
+ if (spanRange != null) {
+ updateSpans(s, spanRange);
+ }
+
+ mModifiedText = null;
+ mLastOperation = Operation.NONE;
+ }
+
+ /**
+ * For changes made which contain at least one opening symbol (e.g. '<' or '&'), whether added or deleted, returns
+ * the range of text which should have its style reapplied.
+ * @param content the content after modification
+ * @param openingSymbol the opening symbol recognized (e.g. '<' or '&')
+ * @return the range of characters to re-apply spans to
+ */
+ protected SpanRange getRespanRangeForChangedOpeningSymbol(Editable content, String openingSymbol) {
+ // For simplicity, re-parse the document if text was replaced
+ if (mLastOperation == Operation.REPLACE) {
+ return new SpanRange(0, content.length());
+ }
+
+ String closingSymbol = getMatchingSymbol(openingSymbol);
+
+ int firstOpeningTagLoc = mOffset + mModifiedText.toString().indexOf(openingSymbol);
+ int closingTagLoc;
+ if (mLastOperation == Operation.INSERT) {
+ // Apply span from the first added opening symbol until the closing symbol in the content matching the
+ // last added opening symbol
+ // e.g. pasting "<b><" before "/b>" - we want the span to be applied to all of "<b></b>"
+ int lastOpeningTagLoc = mOffset + mModifiedText.toString().lastIndexOf(openingSymbol);
+ closingTagLoc = content.toString().indexOf(closingSymbol, lastOpeningTagLoc);
+ } else {
+ // Apply span until the first closing tag that appears after the deleted text
+ closingTagLoc = content.toString().indexOf(closingSymbol, mOffset);
+ }
+
+ if (closingTagLoc > 0) {
+ return new SpanRange(firstOpeningTagLoc, closingTagLoc + 1);
+ }
+ return null;
+ }
+
+ /**
+ * For changes made which contain at least one closing symbol (e.g. '>' or ';') and no opening symbols, whether
+ * added or deleted, returns the range of text which should have its style reapplied.
+ * @param content the content after modification
+ * @param closingSymbol the closing symbol recognized (e.g. '>' or ';')
+ * @return the range of characters to re-apply spans to
+ */
+ protected SpanRange getRespanRangeForChangedClosingSymbol(Editable content, String closingSymbol) {
+ // For simplicity, re-parse the document if text was replaced
+ if (mLastOperation == Operation.REPLACE) {
+ return new SpanRange(0, content.length());
+ }
+
+ String openingSymbol = getMatchingSymbol(closingSymbol);
+
+ int firstClosingTagInModLoc = mOffset + mModifiedText.toString().indexOf(closingSymbol);
+ int firstClosingTagAfterModLoc = content.toString().indexOf(closingSymbol, mOffset + mModifiedText.length());
+
+ int openingTagLoc = content.toString().lastIndexOf(openingSymbol, firstClosingTagInModLoc - 1);
+ if (openingTagLoc >= 0) {
+ if (firstClosingTagAfterModLoc >= 0) {
+ return new SpanRange(openingTagLoc, firstClosingTagAfterModLoc + 1);
+ } else {
+ return new SpanRange(openingTagLoc, content.length());
+ }
+ }
+ return null;
+ }
+
+ /**
+ * For changes made which contain no opening or closing symbols, checks whether the changed text is inside a tag,
+ * and if so returns the range of text which should have its style reapplied.
+ * @param content the content after modification
+ * @param openingSymbol the opening symbol of the tag to check for (e.g. '<' or '&')
+ * @return the range of characters to re-apply spans to
+ */
+ protected SpanRange getRespanRangeForNormalText(Editable content, String openingSymbol) {
+ String closingSymbol = getMatchingSymbol(openingSymbol);
+
+ int openingTagLoc = content.toString().lastIndexOf(openingSymbol, mOffset);
+ if (openingTagLoc >= 0) {
+ int closingTagLoc = content.toString().indexOf(closingSymbol, openingTagLoc);
+ if (closingTagLoc >= mOffset) {
+ return new SpanRange(openingTagLoc, closingTagLoc + 1);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Clears and re-applies spans to {@code content} within range {@code spanRange} according to rules in
+ * {@link HtmlStyleUtils}.
+ * @param content the content to re-style
+ * @param spanRange the range within {@code content} to be re-styled
+ */
+ protected void updateSpans(Spannable content, SpanRange spanRange) {
+ int spanStart = spanRange.getOpeningTagLoc();
+ int spanEnd = spanRange.getClosingTagLoc();
+
+ if (spanStart > content.length() || spanEnd > content.length()) {
+ AppLog.d(T.EDITOR, "The specified span range was beyond the Spannable's length");
+ return;
+ } else if (spanStart >= spanEnd) {
+ // If the span start is after the end position (probably due to a multi-line deletion), selective
+ // re-styling won't work
+ // Instead, do a clean re-styling of the whole document
+ spanStart = 0;
+ spanEnd = content.length();
+ }
+
+ HtmlStyleUtils.clearSpans(content, spanStart, spanEnd);
+ HtmlStyleUtils.styleHtmlForDisplay(content, spanStart, spanEnd);
+ }
+
+ /**
+ * Returns the closing/opening symbol corresponding to the given opening/closing symbol.
+ */
+ private String getMatchingSymbol(String symbol) {
+ switch(symbol) {
+ case "<":
+ return ">";
+ case ">":
+ return "<";
+ case "&":
+ return ";";
+ case ";":
+ return "&";
+ default:
+ return "";
+ }
+ }
+
+ /**
+ * Stores a pair of integers describing a range of values.
+ */
+ protected static class SpanRange {
+ private final int mOpeningTagLoc;
+ private final int mClosingTagLoc;
+
+ public SpanRange(int openingTagLoc, int closingTagLoc) {
+ mOpeningTagLoc = openingTagLoc;
+ mClosingTagLoc = closingTagLoc;
+ }
+
+ public int getOpeningTagLoc() {
+ return mOpeningTagLoc;
+ }
+
+ public int getClosingTagLoc() {
+ return mClosingTagLoc;
+ }
+ }
+} \ No newline at end of file
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleUtils.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleUtils.java
new file mode 100644
index 000000000..912781f1f
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/HtmlStyleUtils.java
@@ -0,0 +1,150 @@
+package org.wordpress.android.editor;
+
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.support.annotation.NonNull;
+import android.text.Spannable;
+import android.text.style.CharacterStyle;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StyleSpan;
+
+import org.wordpress.android.util.AppLog;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class HtmlStyleUtils {
+ public static final int TAG_COLOR = Color.rgb(0, 80, 130);
+ public static final int ATTRIBUTE_COLOR = Color.rgb(158, 158, 158);
+
+ public static final String REGEX_HTML_TAGS = "(<\\/?[a-z][^<>]*>)";
+ public static final String REGEX_HTML_ATTRIBUTES = "(?<==)('|\")(.*?\\1)(?=.*?>)";
+ public static final String REGEX_HTML_COMMENTS = "(<!--.*?-->)";
+ public static final String REGEX_HTML_ENTITIES = "(&#34;|&#38;|&#39;|&#60;|&#62;|&#160;|&#161;|&#162;|&#163;" +
+ "|&#164;|&#165;|&#166;|&#167;|&#168;|&#169;|&#170;|&#171;|&#172;|&#173;|&#174;|&#175;|&#176;|&#177;" +
+ "|&#178;|&#179;|&#180;|&#181;|&#182;|&#183;|&#184;|&#185;|&#186;|&#187;|&#188;|&#189;|&#190;|&#191;" +
+ "|&#192;|&#193;|&#194;|&#195;|&#196;|&#197;|&#198;|&#199;|&#200;|&#201;|&#202;|&#203;|&#204;|&#205;" +
+ "|&#206;|&#207;|&#208;|&#209;|&#210;|&#211;|&#212;|&#213;|&#214;|&#215;|&#216;|&#217;|&#218;|&#219;" +
+ "|&#220;|&#221;|&#222;|&#223;|&#224;|&#225;|&#226;|&#227;|&#228;|&#229;|&#230;|&#231;|&#232;|&#233;" +
+ "|&#234;|&#235;|&#236;|&#237;|&#238;|&#239;|&#240;|&#241;|&#242;|&#243;|&#244;|&#245;|&#246;|&#247;" +
+ "|&#248;|&#249;|&#250;|&#251;|&#252;|&#253;|&#254;|&#255;|&#338;|&#339;|&#352;|&#353;|&#376;|&#402;" +
+ "|&#710;|&#732;|&#913;|&#914;|&#915;|&#916;|&#917;|&#918;|&#919;|&#920;|&#921;|&#922;|&#923;|&#924;" +
+ "|&#925;|&#926;|&#927;|&#928;|&#929;|&#931;|&#932;|&#933;|&#934;|&#935;|&#936;|&#937;|&#945;|&#946;" +
+ "|&#947;|&#948;|&#949;|&#950;|&#951;|&#952;|&#953;|&#954;|&#955;|&#956;|&#957;|&#958;|&#959;|&#960;" +
+ "|&#961;|&#962;|&#963;|&#964;|&#965;|&#966;|&#967;|&#968;|&#969;|&#977;|&#978;|&#982;|&#8194;|&#8195;" +
+ "|&#8201;|&#8204;|&#8205;|&#8206;|&#8207;|&#8211;|&#8212;|&#8216;|&#8217;|&#8218;|&#8220;|&#8221;|&#8222;" +
+ "|&#8224;|&#8225;|&#8226;|&#8230;|&#8240;|&#8242;|&#8243;|&#8249;|&#8250;|&#8254;|&#8260;|&#8364;|&#8465;" +
+ "|&#8472;|&#8476;|&#8482;|&#8501;|&#8592;|&#8593;|&#8594;|&#8595;|&#8596;|&#8629;|&#8656;|&#8657;|&#8658;" +
+ "|&#8659;|&#8660;|&#8704;|&#8706;|&#8707;|&#8709;|&#8711;|&#8712;|&#8713;|&#8715;|&#8719;|&#8721;|&#8722;" +
+ "|&#8727;|&#8730;|&#8733;|&#8734;|&#8736;|&#8743;|&#8744;|&#8745;|&#8746;|&#8747;|&#8756;|&#8764;|&#8773;" +
+ "|&#8776;|&#8800;|&#8801;|&#8804;|&#8805;|&#8834;|&#8835;|&#8836;|&#8838;|&#8839;|&#8853;|&#8855;|&#8869;" +
+ "|&#8901;|&#8968;|&#8969;|&#8970;|&#8971;|&#9001;|&#9002;|&#9674;|&#9824;|&#9827;|&#9829;|&#9830;|&quot;" +
+ "|&amp;|&apos;|&lt;|&gt;|&nbsp;|&iexcl;|&cent;|&pound;|&curren;|&yen;|&brvbar;|&sect;|&uml;|&copy;|&ordf;" +
+ "|&laquo;|&not;|&shy;|&reg;|&macr;|&deg;|&plusmn;|&sup2;|&sup3;|&acute;|&micro;|&para;|&middot;|&cedil;" +
+ "|&sup1;|&ordm;|&raquo;|&frac14;|&frac12;|&frac34;|&iquest;|&Agrave;|&Aacute;|&Acirc;|&Atilde;|&Auml;" +
+ "|&Aring;|&AElig;|&Ccedil;|&Egrave;|&Eacute;|&Ecirc;|&Euml;|&Igrave;|&Iacute;|&Icirc;|&Iuml;|&ETH;" +
+ "|&Ntilde;|&Ograve;|&Oacute;|&Ocirc;|&Otilde;|&Ouml;|&times;|&Oslash;|&Ugrave;|&Uacute;|&Ucirc;|&Uuml;" +
+ "|&Yacute;|&THORN;|&szlig;|&agrave;|&aacute;|&acirc;|&atilde;|&auml;|&aring;|&aelig;|&ccedil;|&egrave;" +
+ "|&eacute;|&ecirc;|&euml;|&igrave;|&iacute;|&icirc;|&iuml;|&eth;|&ntilde;|&ograve;|&oacute;|&ocirc;" +
+ "|&otilde;|&ouml;|&divide;|&oslash;|&Ugrave;|&Uacute;|&Ucirc;|&Uuml;|&yacute;|&thorn;|&yuml;|&OElig;" +
+ "|&oelig;|&Scaron;|&scaron;|&Yuml;|&fnof;|&circ;|&tilde;|&Alpha;|&Beta;|&Gamma;|&Delta;|&Epsilon;|&Zeta;" +
+ "|&Eta;|&Theta;|&Iota;|&Kappa;|&Lambda;|&Mu;|&Nu;|&Xi;|&Omicron;|&Pi;|&Rho;|&Sigma;|&Tau;|&Upsilon;|&Phi;" +
+ "|&Chi;|&Psi;|&Omega;|&alpha;|&beta;|&gamma;|&delta;|&epsilon;|&zeta;|&eta;|&theta;|&iota;|&kappa;" +
+ "|&lambda;|&mu;|&nu;|&xi;|&omicron;|&pi;|&rho;|&sigmaf;|&sigma;|&tau;|&upsilon;|&phi;|&chi;|&psi;|&omega;" +
+ "|&thetasym;|&Upsih;|&piv;|&ensp;|&emsp;|&thinsp;|&zwnj;|&zwj;|&lrm;|&rlm;|&ndash;|&mdash;|&lsquo;" +
+ "|&rsquo;|&sbquo;|&ldquo;|&rdquo;|&bdquo;|&dagger;|&Dagger;|&bull;|&hellip;|&permil;|&prime;|&Prime;" +
+ "|&lsaquo;|&rsaquo;|&oline;|&frasl;|&euro;|&image;|&weierp;|&real;|&trade;|&alefsym;|&larr;|&uarr;|&rarr;" +
+ "|&darr;|&harr;|&crarr;|&lArr;|&UArr;|&rArr;|&dArr;|&hArr;|&forall;|&part;|&exist;|&empty;|&nabla;|&isin;" +
+ "|&notin;|&ni;|&prod;|&sum;|&minus;|&lowast;|&radic;|&prop;|&infin;|&ang;|&and;|&or;|&cap;|&cup;|&int;" +
+ "|&there4;|&sim;|&cong;|&asymp;|&ne;|&equiv;|&le;|&ge;|&sub;|&sup;|&nsub;|&sube;|&supe;|&oplus;|&otimes;" +
+ "|&perp;|&sdot;|&lceil;|&rceil;|&lfloor;|&rfloor;|&lang;|&rang;|&loz;|&spades;|&clubs;|&hearts;|&diams;)";
+
+ public static final int SPANNABLE_FLAGS = Spannable.SPAN_EXCLUSIVE_EXCLUSIVE;
+
+ /**
+ * Apply styling rules to {@code content}.
+ */
+ public static void styleHtmlForDisplay(@NonNull Spannable content) {
+ styleHtmlForDisplay(content, 0, content.length());
+ }
+
+ /**
+ * Apply styling rules to {@code content} inside the range from {@code start} to {@code end}.
+ *
+ * @param content the Spannable to apply style rules to
+ * @param start the index in {@code content} to start styling from
+ * @param end the index in {@code content} to style until
+ */
+ public static void styleHtmlForDisplay(@NonNull Spannable content, int start, int end) {
+ if (Build.VERSION.RELEASE.equals("4.1") || Build.VERSION.RELEASE.equals("4.1.1")) {
+ // Avoids crashing bug in Android 4.1 and 4.1.1 triggered when spanned text is line-wrapped
+ // AOSP issue: https://code.google.com/p/android/issues/detail?id=35466
+ return;
+ }
+
+ applySpansByRegex(content, start, end, REGEX_HTML_TAGS);
+ applySpansByRegex(content, start, end, REGEX_HTML_ATTRIBUTES);
+ applySpansByRegex(content, start, end, REGEX_HTML_COMMENTS);
+ applySpansByRegex(content, start, end, REGEX_HTML_ENTITIES);
+ }
+
+ /**
+ * Applies styles to {@code content} from {@code start} to {@code end}, based on rule {@code regex}.
+ * @param content the Spannable to apply style rules to
+ * @param start the index in {@code content} to start styling from
+ * @param end the index in {@code content} to style until
+ * @param regex the pattern to match for styling
+ */
+ private static void applySpansByRegex(Spannable content, int start, int end, String regex) {
+ if (content == null || start < 0 || end < 0 || start > content.length() || end > content.length() ||
+ start >= end) {
+ AppLog.d(AppLog.T.EDITOR, "applySpansByRegex() received invalid input");
+ return;
+ }
+
+ Pattern pattern = Pattern.compile(regex);
+ Matcher matcher = pattern.matcher(content.subSequence(start, end));
+
+ while (matcher.find()) {
+ int matchStart = matcher.start() + start;
+ int matchEnd = matcher.end() + start;
+ switch(regex) {
+ case REGEX_HTML_TAGS:
+ content.setSpan(new ForegroundColorSpan(TAG_COLOR), matchStart, matchEnd, SPANNABLE_FLAGS);
+ break;
+ case REGEX_HTML_ATTRIBUTES:
+ content.setSpan(new ForegroundColorSpan(ATTRIBUTE_COLOR), matchStart, matchEnd, SPANNABLE_FLAGS);
+ break;
+ case REGEX_HTML_COMMENTS:
+ content.setSpan(new ForegroundColorSpan(ATTRIBUTE_COLOR), matchStart, matchEnd, SPANNABLE_FLAGS);
+ content.setSpan(new StyleSpan(Typeface.ITALIC), matchStart, matchEnd, SPANNABLE_FLAGS);
+ content.setSpan(new RelativeSizeSpan(0.75f), matchStart, matchEnd, SPANNABLE_FLAGS);
+ break;
+ case REGEX_HTML_ENTITIES:
+ content.setSpan(new ForegroundColorSpan(TAG_COLOR), matchStart, matchEnd, SPANNABLE_FLAGS);
+ content.setSpan(new StyleSpan(Typeface.BOLD), matchStart, matchEnd, SPANNABLE_FLAGS);
+ content.setSpan(new RelativeSizeSpan(0.75f), matchStart, matchEnd, SPANNABLE_FLAGS);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Clears all relevant spans in {@code content} from {@code start} to {@code end}. Relevant spans are the subclasses
+ * of {@link CharacterStyle} applied by {@link HtmlStyleUtils#applySpansByRegex(Spannable, int, int, String)}.
+ * @param content the Spannable to clear styles from
+ * @param spanStart the index in {@code content} to start clearing styles from
+ * @param spanEnd the index in {@code content} to clear styles until
+ */
+ public static void clearSpans(Spannable content, int spanStart, int spanEnd) {
+ CharacterStyle[] spans = content.getSpans(spanStart, spanEnd, CharacterStyle.class);
+
+ for (CharacterStyle span : spans) {
+ if (span instanceof ForegroundColorSpan || span instanceof StyleSpan || span instanceof RelativeSizeSpan) {
+ content.removeSpan(span);
+ }
+ }
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/ImageSettingsDialogFragment.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/ImageSettingsDialogFragment.java
new file mode 100644
index 000000000..70a995b01
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/ImageSettingsDialogFragment.java
@@ -0,0 +1,431 @@
+package org.wordpress.android.editor;
+
+import android.app.DialogFragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.AlertDialog;
+import android.support.v7.app.AppCompatActivity;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.SeekBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.ToastUtils;
+
+import java.util.Arrays;
+import java.util.Map;
+
+/**
+ * A full-screen DialogFragment with image settings.
+ *
+ * Modifies the action bar - host activity must call {@link ImageSettingsDialogFragment#dismissFragment()}
+ * when the fragment is dismissed to restore it.
+ */
+public class ImageSettingsDialogFragment extends DialogFragment {
+ public static final int IMAGE_SETTINGS_DIALOG_REQUEST_CODE = 5;
+ public static final String IMAGE_SETTINGS_DIALOG_TAG = "image-settings";
+
+ private JSONObject mImageMeta;
+ private int mMaxImageWidth;
+
+ private EditText mTitleText;
+ private EditText mCaptionText;
+ private EditText mAltText;
+ private Spinner mAlignmentSpinner;
+ private String[] mAlignmentKeyArray;
+ private EditText mLinkTo;
+ private EditText mWidthText;
+ private CheckBox mFeaturedCheckBox;
+
+ private boolean mIsFeatured;
+
+ private Map<String, String> mHttpHeaders;
+
+ private CharSequence mPreviousActionBarTitle;
+ private boolean mPreviousHomeAsUpEnabled;
+ private View mPreviousCustomView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar == null) {
+ return;
+ }
+
+ actionBar.show();
+
+ mPreviousActionBarTitle = actionBar.getTitle();
+ mPreviousCustomView = actionBar.getCustomView();
+
+ final int displayOptions = actionBar.getDisplayOptions();
+ mPreviousHomeAsUpEnabled = (displayOptions & ActionBar.DISPLAY_HOME_AS_UP) != 0;
+
+ actionBar.setTitle(R.string.image_settings);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ if (getResources().getBoolean(R.bool.show_extra_side_padding)) {
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_close_padded);
+ } else {
+ actionBar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp);
+ }
+
+ // Show custom view with padded Save button
+ actionBar.setDisplayShowCustomEnabled(true);
+ actionBar.setCustomView(R.layout.image_settings_formatbar);
+
+ actionBar.getCustomView().findViewById(R.id.menu_save).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mImageMeta = extractMetaDataFromFields(mImageMeta);
+
+ String imageRemoteId = "";
+ try {
+ imageRemoteId = mImageMeta.getString("attachment_id");
+ } catch (JSONException e) {
+ AppLog.e(AppLog.T.EDITOR, "Unable to retrieve featured image id from meta data");
+ }
+
+ Intent intent = new Intent();
+ intent.putExtra("imageMeta", mImageMeta.toString());
+
+ mIsFeatured = mFeaturedCheckBox.isChecked();
+ intent.putExtra("isFeatured", mIsFeatured);
+
+ if (!imageRemoteId.isEmpty()) {
+ intent.putExtra("imageRemoteId", Integer.parseInt(imageRemoteId));
+ }
+
+ getTargetFragment().onActivityResult(getTargetRequestCode(), getTargetRequestCode(), intent);
+
+ restorePreviousActionBar();
+ getFragmentManager().popBackStack();
+ ToastUtils.showToast(getActivity(), R.string.image_settings_save_toast);
+ }
+ });
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.dialog_image_options, container, false);
+
+ ImageView thumbnailImage = (ImageView) view.findViewById(R.id.image_thumbnail);
+ TextView filenameLabel = (TextView) view.findViewById(R.id.image_filename);
+ mTitleText = (EditText) view.findViewById(R.id.image_title);
+ mCaptionText = (EditText) view.findViewById(R.id.image_caption);
+ mAltText = (EditText) view.findViewById(R.id.image_alt_text);
+ mAlignmentSpinner = (Spinner) view.findViewById(R.id.alignment_spinner);
+ mLinkTo = (EditText) view.findViewById(R.id.image_link_to);
+ SeekBar widthSeekBar = (SeekBar) view.findViewById(R.id.image_width_seekbar);
+ mWidthText = (EditText) view.findViewById(R.id.image_width_text);
+ mFeaturedCheckBox = (CheckBox) view.findViewById(R.id.featuredImage);
+
+ // Populate the dialog with existing values
+ Bundle bundle = getArguments();
+ if (bundle != null) {
+ try {
+ mImageMeta = new JSONObject(bundle.getString("imageMeta"));
+
+ mHttpHeaders = (Map) bundle.getSerializable("headerMap");
+
+ final String imageSrc = mImageMeta.getString("src");
+ final String imageFilename = imageSrc.substring(imageSrc.lastIndexOf("/") + 1);
+
+ loadThumbnail(imageSrc, thumbnailImage);
+ filenameLabel.setText(imageFilename);
+
+ mTitleText.setText(mImageMeta.getString("title"));
+ mCaptionText.setText(mImageMeta.getString("caption"));
+ mAltText.setText(mImageMeta.getString("alt"));
+
+ String alignment = mImageMeta.getString("align");
+ mAlignmentKeyArray = getResources().getStringArray(R.array.alignment_key_array);
+ int alignmentIndex = Arrays.asList(mAlignmentKeyArray).indexOf(alignment);
+ mAlignmentSpinner.setSelection(alignmentIndex == -1 ? 0 : alignmentIndex);
+
+ mLinkTo.setText(mImageMeta.getString("linkUrl"));
+
+ mMaxImageWidth = MediaUtils.getMaximumImageWidth(mImageMeta.getInt("naturalWidth"),
+ bundle.getString("maxWidth"));
+
+ setupWidthSeekBar(widthSeekBar, mWidthText, mImageMeta.getInt("width"));
+
+ boolean featuredImageSupported = bundle.getBoolean("featuredImageSupported");
+ if (featuredImageSupported) {
+ mFeaturedCheckBox.setVisibility(View.VISIBLE);
+ mIsFeatured = bundle.getBoolean("isFeatured", false);
+ mFeaturedCheckBox.setChecked(mIsFeatured);
+ }
+ } catch (JSONException e1) {
+ AppLog.d(AppLog.T.EDITOR, "Missing JSON properties");
+ }
+ }
+
+ mTitleText.requestFocus();
+
+ return view;
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.show();
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ if (menu != null) {
+ menu.clear();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int id = item.getItemId();
+
+ if (id == android.R.id.home) {
+ dismissFragment();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private ActionBar getActionBar() {
+ if (getActivity() instanceof AppCompatActivity) {
+ return ((AppCompatActivity) getActivity()).getSupportActionBar();
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * To be called when the fragment is being dismissed, either by ActionBar navigation or by pressing back in the
+ * navigation bar.
+ * Displays a confirmation dialog if there are unsaved changes, otherwise undoes the fragment's modifications to
+ * the ActionBar and restores the last visible fragment.
+ */
+ public void dismissFragment() {
+ try {
+ JSONObject newImageMeta = extractMetaDataFromFields(new JSONObject());
+
+ for (int i = 0; i < newImageMeta.names().length(); i++) {
+ String name = newImageMeta.names().getString(i);
+ if (!newImageMeta.getString(name).equals(mImageMeta.getString(name))) {
+ showDiscardChangesDialog();
+ return;
+ }
+ }
+
+ if (mFeaturedCheckBox.isChecked() != mIsFeatured) {
+ // Featured image status has changed
+ showDiscardChangesDialog();
+ return;
+ }
+ } catch (JSONException e) {
+ AppLog.d(AppLog.T.EDITOR, "Unable to update JSON array");
+ }
+
+ getTargetFragment().onActivityResult(getTargetRequestCode(), getTargetRequestCode(), null);
+ restorePreviousActionBar();
+ getFragmentManager().popBackStack();
+ }
+
+ private void restorePreviousActionBar() {
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(mPreviousActionBarTitle);
+ actionBar.setHomeAsUpIndicator(null);
+ actionBar.setDisplayHomeAsUpEnabled(mPreviousHomeAsUpEnabled);
+
+ actionBar.setCustomView(mPreviousCustomView);
+ if (mPreviousCustomView == null) {
+ actionBar.setDisplayShowCustomEnabled(false);
+ }
+ }
+ }
+
+ /**
+ * Displays a dialog asking the user to confirm that they want to exit, discarding unsaved changes.
+ */
+ private void showDiscardChangesDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setTitle(getString(R.string.image_settings_dismiss_dialog_title));
+ builder.setPositiveButton(getString(R.string.discard), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ getTargetFragment().onActivityResult(getTargetRequestCode(), getTargetRequestCode(), null);
+ restorePreviousActionBar();
+ getFragmentManager().popBackStack();
+ }
+ });
+
+ builder.setNegativeButton(getString(R.string.cancel), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.dismiss();
+ }
+ });
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ /**
+ * Extracts the meta data from the dialog fields and updates the entries in the given JSONObject.
+ */
+ private JSONObject extractMetaDataFromFields(JSONObject metaData) {
+ try {
+ metaData.put("title", mTitleText.getText().toString());
+ metaData.put("caption", mCaptionText.getText().toString());
+ metaData.put("alt", mAltText.getText().toString());
+ if (mAlignmentSpinner.getSelectedItemPosition() < mAlignmentKeyArray.length) {
+ metaData.put("align", mAlignmentKeyArray[mAlignmentSpinner.getSelectedItemPosition()]);
+ }
+ metaData.put("linkUrl", mLinkTo.getText().toString());
+
+ int newWidth = getEditTextIntegerClamped(mWidthText, 10, mMaxImageWidth);
+ metaData.put("width", newWidth);
+ metaData.put("height", getRelativeHeightFromWidth(newWidth));
+ } catch (JSONException e) {
+ AppLog.d(AppLog.T.EDITOR, "Unable to build JSON object from new meta data");
+ }
+
+ return metaData;
+ }
+
+ private void loadThumbnail(final String src, final ImageView thumbnailImage) {
+ Thread thread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ if (isAdded()) {
+ final Uri localUri = Utils.downloadExternalMedia(getActivity(), Uri.parse(src), mHttpHeaders);
+
+ if (getActivity() != null) {
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ thumbnailImage.setImageURI(localUri);
+ }
+ });
+ }
+ }
+ }
+ });
+
+ thread.start();
+ }
+
+ /**
+ * Initialize the image width SeekBar and accompanying EditText
+ */
+ private void setupWidthSeekBar(final SeekBar widthSeekBar, final EditText widthText, int imageWidth) {
+ widthSeekBar.setMax(mMaxImageWidth / 10);
+
+ if (imageWidth != 0) {
+ widthSeekBar.setProgress(imageWidth / 10);
+ widthText.setText(String.valueOf(imageWidth) + "px");
+ }
+
+ widthSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (progress == 0) {
+ progress = 1;
+ }
+ widthText.setText(progress * 10 + "px");
+ }
+ });
+
+ widthText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ widthText.setText("");
+ }
+ }
+ });
+
+ widthText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ int width = getEditTextIntegerClamped(widthText, 10, mMaxImageWidth);
+ widthSeekBar.setProgress(width / 10);
+ widthText.setSelection((String.valueOf(width).length()));
+
+ InputMethodManager imm = (InputMethodManager) getActivity()
+ .getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(widthText.getWindowToken(),
+ InputMethodManager.RESULT_UNCHANGED_SHOWN);
+
+ return true;
+ }
+ });
+ }
+
+ /**
+ * Return the integer value of the width EditText, adjusted to be within the given min and max, and stripped of the
+ * 'px' units
+ */
+ private int getEditTextIntegerClamped(EditText editText, int minWidth, int maxWidth) {
+ int width = 10;
+
+ try {
+ if (editText.getText() != null)
+ width = Integer.parseInt(editText.getText().toString().replace("px", ""));
+ } catch (NumberFormatException e) {
+ AppLog.e(AppLog.T.EDITOR, e);
+ }
+
+ width = Math.min(maxWidth, Math.max(width, minWidth));
+
+ return width;
+ }
+
+ /**
+ * Given the new width, return the proportionally adjusted height, given the dimensions of the original image
+ */
+ private int getRelativeHeightFromWidth(int width) {
+ int height = 0;
+
+ try {
+ int naturalHeight = mImageMeta.getInt("naturalHeight");
+ int naturalWidth = mImageMeta.getInt("naturalWidth");
+
+ float ratio = (float) naturalHeight / naturalWidth;
+ height = (int) (ratio * width);
+ } catch (JSONException e) {
+ AppLog.d(AppLog.T.EDITOR, "JSON object missing naturalHeight or naturalWidth property");
+ }
+
+ return height;
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/JsCallbackReceiver.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/JsCallbackReceiver.java
new file mode 100755
index 000000000..b8212fcef
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/JsCallbackReceiver.java
@@ -0,0 +1,236 @@
+package org.wordpress.android.editor;
+
+import android.webkit.JavascriptInterface;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.JSONUtils;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.wordpress.android.editor.EditorFragmentAbstract.MediaType;
+
+public class JsCallbackReceiver {
+ private static final String JS_CALLBACK_DELIMITER = "~";
+
+ private static final String CALLBACK_DOM_LOADED = "callback-dom-loaded";
+ private static final String CALLBACK_NEW_FIELD = "callback-new-field";
+
+ private static final String CALLBACK_INPUT = "callback-input";
+ private static final String CALLBACK_SELECTION_CHANGED = "callback-selection-changed";
+ private static final String CALLBACK_SELECTION_STYLE = "callback-selection-style";
+
+ private static final String CALLBACK_FOCUS_IN = "callback-focus-in";
+ private static final String CALLBACK_FOCUS_OUT = "callback-focus-out";
+
+ private static final String CALLBACK_IMAGE_REPLACED = "callback-image-replaced";
+ private static final String CALLBACK_VIDEO_REPLACED = "callback-video-replaced";
+ private static final String CALLBACK_IMAGE_TAP = "callback-image-tap";
+ private static final String CALLBACK_LINK_TAP = "callback-link-tap";
+ private static final String CALLBACK_MEDIA_REMOVED = "callback-media-removed";
+
+ private static final String CALLBACK_VIDEOPRESS_INFO_REQUEST = "callback-videopress-info-request";
+
+ private static final String CALLBACK_LOG = "callback-log";
+
+ private static final String CALLBACK_RESPONSE_STRING = "callback-response-string";
+
+ private static final String CALLBACK_ACTION_FINISHED = "callback-action-finished";
+
+ private final OnJsEditorStateChangedListener mListener;
+
+ private Set<String> mPreviousStyleSet = new HashSet<>();
+
+ public JsCallbackReceiver(EditorFragmentAbstract editorFragmentAbstract) {
+ mListener = (OnJsEditorStateChangedListener) editorFragmentAbstract;
+ }
+
+ @JavascriptInterface
+ public void executeCallback(String callbackId, String params) {
+ switch (callbackId) {
+ case CALLBACK_DOM_LOADED:
+ mListener.onDomLoaded();
+ break;
+ case CALLBACK_SELECTION_STYLE:
+ // Compare the new styles to the previous ones, and notify the JsCallbackListener of the changeset
+ Set<String> rawStyleSet = Utils.splitDelimitedString(params, JS_CALLBACK_DELIMITER);
+
+ // Strip link details from active style set
+ Set<String> newStyleSet = new HashSet<>();
+ for (String element : rawStyleSet) {
+ if (element.matches("link:(.*)")) {
+ newStyleSet.add("link");
+ } else if (!element.matches("link-title:(.*)")) {
+ newStyleSet.add(element);
+ }
+ }
+
+ mListener.onSelectionStyleChanged(Utils.getChangeMapFromSets(mPreviousStyleSet, newStyleSet));
+ mPreviousStyleSet = newStyleSet;
+ break;
+ case CALLBACK_SELECTION_CHANGED:
+ // Called for changes to the field in current focus and for changes made to selection
+ // (includes moving the caret without selecting text)
+ // TODO: Possibly needed for handling WebView scrolling when caret moves (from iOS)
+ Set<String> selectionKeyValueSet = Utils.splitDelimitedString(params, JS_CALLBACK_DELIMITER);
+ mListener.onSelectionChanged(Utils.buildMapFromKeyValuePairs(selectionKeyValueSet));
+ break;
+ case CALLBACK_INPUT:
+ // Called on key press
+ // TODO: Possibly needed for handling WebView scrolling when caret moves (from iOS)
+ break;
+ case CALLBACK_FOCUS_IN:
+ // TODO: Needed to handle displaying/graying the format bar when focus changes between the title and content
+ AppLog.d(AppLog.T.EDITOR, "Focus in callback received");
+ break;
+ case CALLBACK_FOCUS_OUT:
+ // TODO: Needed to handle displaying/graying the format bar when focus changes between the title and content
+ AppLog.d(AppLog.T.EDITOR, "Focus out callback received");
+ break;
+ case CALLBACK_NEW_FIELD:
+ // TODO: Used for logging/testing purposes on iOS
+ AppLog.d(AppLog.T.EDITOR, "New field created, " + params);
+ break;
+ case CALLBACK_IMAGE_REPLACED:
+ AppLog.d(AppLog.T.EDITOR, "Image replaced, " + params);
+
+ // Extract the local media id from the callback string (stripping the 'id=' part)
+ if (params.length() > 3) {
+ mListener.onMediaReplaced(params.substring(3));
+ }
+ break;
+ case CALLBACK_VIDEO_REPLACED:
+ AppLog.d(AppLog.T.EDITOR, "Video replaced, " + params);
+
+ // Extract the local media id from the callback string (stripping the 'id=' part)
+ if (params.length() > 3) {
+ mListener.onMediaReplaced(params.substring(3));
+ }
+ break;
+ case CALLBACK_IMAGE_TAP:
+ AppLog.d(AppLog.T.EDITOR, "Image tapped, " + params);
+
+ String uploadStatus = "";
+
+ List<String> mediaIds = new ArrayList<>();
+ mediaIds.add("id");
+ mediaIds.add("url");
+ mediaIds.add("meta");
+ mediaIds.add("type");
+
+ Set<String> mediaDataSet = Utils.splitValuePairDelimitedString(params, JS_CALLBACK_DELIMITER, mediaIds);
+ Map<String, String> mediaDataMap = Utils.buildMapFromKeyValuePairs(mediaDataSet);
+
+ String mediaId = mediaDataMap.get("id");
+
+ String mediaUrl = mediaDataMap.get("url");
+ if (mediaUrl != null) {
+ mediaUrl = Utils.decodeHtml(mediaUrl);
+ }
+
+ MediaType mediaType = MediaType.fromString(mediaDataMap.get("type"));
+
+ String mediaMeta = mediaDataMap.get("meta");
+ JSONObject mediaMetaJson = new JSONObject();
+
+ if (mediaMeta != null) {
+ mediaMeta = Utils.decodeHtml(mediaMeta);
+
+ try {
+ mediaMetaJson = new JSONObject(mediaMeta);
+ String classes = JSONUtils.getString(mediaMetaJson, "classes");
+ Set<String> classesSet = Utils.splitDelimitedString(classes, ", ");
+
+ if (classesSet.contains("uploading")) {
+ uploadStatus = "uploading";
+ } else if (classesSet.contains("failed")) {
+ uploadStatus = "failed";
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ AppLog.d(AppLog.T.EDITOR, "Media meta data from callback-image-tap was not JSON-formatted");
+ }
+ }
+
+ mListener.onMediaTapped(mediaId, mediaType, mediaMetaJson, uploadStatus);
+ break;
+ case CALLBACK_LINK_TAP:
+ // Extract and HTML-decode the link data from the callback params
+ AppLog.d(AppLog.T.EDITOR, "Link tapped, " + params);
+
+ List<String> linkIds = new ArrayList<>();
+ linkIds.add("url");
+ linkIds.add("title");
+
+ Set<String> linkDataSet = Utils.splitValuePairDelimitedString(params, JS_CALLBACK_DELIMITER, linkIds);
+ Map<String, String> linkDataMap = Utils.buildMapFromKeyValuePairs(linkDataSet);
+
+ String url = linkDataMap.get("url");
+ if (url != null) {
+ url = Utils.decodeHtml(url);
+ }
+
+ String title = linkDataMap.get("title");
+ if (title != null) {
+ title = Utils.decodeHtml(title);
+ }
+
+ mListener.onLinkTapped(url, title);
+ break;
+ case CALLBACK_MEDIA_REMOVED:
+ AppLog.d(AppLog.T.EDITOR, "Media removed, " + params);
+ // Extract the media id from the callback string (stripping the 'id=' part of the callback string)
+ if (params.length() > 3) {
+ mListener.onMediaRemoved(params.substring(3));
+ }
+ break;
+ case CALLBACK_VIDEOPRESS_INFO_REQUEST:
+ // Extract the VideoPress id from the callback string (stripping the 'id=' part of the callback string)
+ if (params.length() > 3) {
+ mListener.onVideoPressInfoRequested(params.substring(3));
+ }
+ break;
+ case CALLBACK_LOG:
+ // Strip 'msg=' from beginning of string
+ if (params.length() > 4) {
+ AppLog.d(AppLog.T.EDITOR, callbackId + ": " + params.substring(4));
+ }
+ break;
+ case CALLBACK_RESPONSE_STRING:
+ AppLog.d(AppLog.T.EDITOR, callbackId + ": " + params);
+ Set<String> responseDataSet;
+ if (params.startsWith("function=") && params.contains(JS_CALLBACK_DELIMITER)) {
+ String functionName = params.substring("function=".length(), params.indexOf(JS_CALLBACK_DELIMITER));
+
+ List<String> responseIds = new ArrayList<>();
+ switch (functionName) {
+ case "getHTMLForCallback":
+ responseIds.add("id");
+ responseIds.add("contents");
+ break;
+ case "getSelectedTextToLinkify":
+ responseIds.add("result");
+ break;
+ case "getFailedMedia":
+ responseIds.add("ids");
+ }
+
+ responseDataSet = Utils.splitValuePairDelimitedString(params, JS_CALLBACK_DELIMITER, responseIds);
+ } else {
+ responseDataSet = Utils.splitDelimitedString(params, JS_CALLBACK_DELIMITER);
+ }
+ mListener.onGetHtmlResponse(Utils.buildMapFromKeyValuePairs(responseDataSet));
+ break;
+ case CALLBACK_ACTION_FINISHED:
+ mListener.onActionFinished();
+ break;
+ default:
+ AppLog.d(AppLog.T.EDITOR, "Unhandled callback: " + callbackId + ":" + params);
+ }
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LegacyEditorFragment.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LegacyEditorFragment.java
new file mode 100644
index 000000000..75febb3fa
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LegacyEditorFragment.java
@@ -0,0 +1,1194 @@
+package org.wordpress.android.editor;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.AppCompatActivity;
+import android.text.Editable;
+import android.text.Layout;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.method.ArrowKeyMovementMethod;
+import android.text.style.AlignmentSpan;
+import android.text.style.QuoteSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.URLSpan;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.WindowManager;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.webkit.URLUtil;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.ToggleButton;
+
+import com.android.volley.VolleyError;
+import com.android.volley.toolbox.ImageLoader;
+
+import org.wordpress.android.editor.legacy.EditLinkActivity;
+import org.wordpress.android.editor.legacy.WPEditImageSpan;
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.DisplayUtils;
+import org.wordpress.android.util.ImageUtils;
+import org.wordpress.android.util.MediaUtils;
+import org.wordpress.android.util.ToastUtils;
+import org.wordpress.android.util.ToastUtils.Duration;
+import org.wordpress.android.util.helpers.MediaFile;
+import org.wordpress.android.util.helpers.MediaGallery;
+import org.wordpress.android.util.helpers.MediaGalleryImageSpan;
+import org.wordpress.android.util.helpers.WPImageSpan;
+import org.wordpress.android.util.helpers.WPUnderlineSpan;
+import org.wordpress.android.util.widgets.WPEditText;
+
+import java.util.Locale;
+
+public class LegacyEditorFragment extends EditorFragmentAbstract implements TextWatcher,
+ WPEditText.OnSelectionChangedListener, View.OnTouchListener {
+ public static final int ACTIVITY_REQUEST_CODE_CREATE_LINK = 4;
+ public static final String ACTION_MEDIA_GALLERY_TOUCHED = "MEDIA_GALLERY_TOUCHED";
+ public static final String EXTRA_MEDIA_GALLERY = "EXTRA_MEDIA_GALLERY";
+
+ private static final int MIN_THUMBNAIL_WIDTH = 200;
+ private static final int CONTENT_ANIMATION_DURATION = 250;
+ private static final String KEY_IMAGE_SPANS = "image-spans";
+ private static final String KEY_START = "start";
+ private static final String KEY_END = "end";
+ private static final String KEY_CONTENT = "content";
+ private static final String TAG_FORMAT_BAR_BUTTON_STRONG = "strong";
+ private static final String TAG_FORMAT_BAR_BUTTON_EM = "em";
+ private static final String TAG_FORMAT_BAR_BUTTON_UNDERLINE = "u";
+ private static final String TAG_FORMAT_BAR_BUTTON_STRIKE = "strike";
+ private static final String TAG_FORMAT_BAR_BUTTON_QUOTE = "blockquote";
+
+ private View mRootView;
+ private WPEditText mContentEditText;
+ private EditText mTitleEditText;
+ private ToggleButton mBoldToggleButton, mEmToggleButton, mBquoteToggleButton;
+ private ToggleButton mUnderlineToggleButton, mStrikeToggleButton;
+ private LinearLayout mFormatBar, mPostContentLinearLayout, mPostSettingsLinearLayout;
+ private Button mAddPictureButton;
+ private boolean mIsBackspace;
+ private boolean mScrollDetected;
+ private boolean mIsLocalDraft;
+
+ private int mStyleStart, mSelectionStart, mSelectionEnd, mFullViewBottom;
+ private int mLastPosition = -1;
+ private CharSequence mTitle;
+ private CharSequence mContent;
+
+ private float mLastYPos = 0;
+
+ @Override
+ public boolean onBackPressed() {
+ // leave full screen mode back button is pressed
+ if (getActionBar() != null && !getActionBar().isShowing()) {
+ setContentEditingModeVisible(false);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ if (mTitleEditText != null) {
+ return mTitleEditText.getText().toString();
+ }
+ return mTitle;
+ }
+
+ @Override
+ public CharSequence getContent() {
+ if (mContentEditText != null) {
+ return mContentEditText.getText().toString();
+ }
+ return mContent;
+ }
+
+ @Override
+ public void setTitle(CharSequence text) {
+ mTitle = text;
+ if (mTitleEditText != null) {
+ mTitleEditText.setText(text);
+ } else {
+ // TODO
+ }
+ }
+
+ @Override
+ public void setContent(CharSequence text) {
+ mContent = text;
+ if (mContentEditText != null) {
+ mContentEditText.setText(text);
+ mContentEditText.setSelection(mSelectionStart, mSelectionEnd);
+ } else {
+ // TODO
+ }
+ }
+
+ @Override
+ public Spanned getSpannedContent() {
+ return mContentEditText.getText();
+ }
+
+ public void setLocalDraft(boolean isLocalDraft) {
+ mIsLocalDraft = isLocalDraft;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.fragment_edit_post_content, container, false);
+
+ mFormatBar = (LinearLayout) rootView.findViewById(R.id.format_bar);
+ mTitleEditText = (EditText) rootView.findViewById(R.id.post_title);
+ mTitleEditText.setText(mTitle);
+ mTitleEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ // Go to full screen editor when 'next' button is tapped on soft keyboard
+ ActionBar actionBar = getActionBar();
+ if (actionId == EditorInfo.IME_ACTION_NEXT && actionBar != null && actionBar.isShowing()) {
+ setContentEditingModeVisible(true);
+ }
+ return false;
+ }
+ });
+
+ mContentEditText = (WPEditText) rootView.findViewById(R.id.post_content);
+ mContentEditText.setText(mContent);
+
+ mPostContentLinearLayout = (LinearLayout) rootView.findViewById(R.id.post_content_wrapper);
+ mPostSettingsLinearLayout = (LinearLayout) rootView.findViewById(R.id.post_settings_wrapper);
+ Button postSettingsButton = (Button) rootView.findViewById(R.id.post_settings_button);
+ postSettingsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mEditorFragmentListener.onSettingsClicked();
+ }
+ });
+ mBoldToggleButton = (ToggleButton) rootView.findViewById(R.id.bold);
+ mEmToggleButton = (ToggleButton) rootView.findViewById(R.id.em);
+ mBquoteToggleButton = (ToggleButton) rootView.findViewById(R.id.bquote);
+ mUnderlineToggleButton = (ToggleButton) rootView.findViewById(R.id.underline);
+ mStrikeToggleButton = (ToggleButton) rootView.findViewById(R.id.strike);
+ mAddPictureButton = (Button) rootView.findViewById(R.id.addPictureButton);
+ Button linkButton = (Button) rootView.findViewById(R.id.link);
+ Button moreButton = (Button) rootView.findViewById(R.id.more);
+
+ registerForContextMenu(mAddPictureButton);
+ mContentEditText = (WPEditText) rootView.findViewById(R.id.post_content);
+ mContentEditText.setOnSelectionChangedListener(this);
+ mContentEditText.setOnTouchListener(this);
+ mContentEditText.addTextChangedListener(this);
+ mContentEditText.setOnEditTextImeBackListener(new WPEditText.EditTextImeBackListener() {
+ @Override
+ public void onImeBack(WPEditText ctrl, String text) {
+ // Go back to regular editor if IME keyboard is dismissed
+ // Bottom comparison is there to ensure that the keyboard is actually showing
+ ActionBar actionBar = getActionBar();
+ if (mRootView.getBottom() < mFullViewBottom && actionBar != null && !actionBar.isShowing()) {
+ setContentEditingModeVisible(false);
+ }
+ }
+ });
+ mAddPictureButton.setOnClickListener(mFormatBarButtonClickListener);
+ mBoldToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ linkButton.setOnClickListener(mFormatBarButtonClickListener);
+ mEmToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ mUnderlineToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ mStrikeToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ mBquoteToggleButton.setOnClickListener(mFormatBarButtonClickListener);
+ moreButton.setOnClickListener(mFormatBarButtonClickListener);
+ mEditorFragmentListener.onEditorFragmentInitialized();
+
+ if (savedInstanceState != null) {
+ Parcelable[] spans = savedInstanceState.getParcelableArray(KEY_IMAGE_SPANS);
+
+ mContent = savedInstanceState.getString(KEY_CONTENT, "");
+ mContentEditText.setText(mContent);
+ mContentEditText.setSelection(savedInstanceState.getInt(KEY_START, 0),
+ savedInstanceState.getInt(KEY_END, 0));
+
+ if (spans != null && spans.length > 0) {
+ for (Parcelable s : spans) {
+ WPImageSpan editSpan = (WPImageSpan)s;
+ addMediaFile(editSpan.getMediaFile(), editSpan.getMediaFile().getFilePath(),
+ mImageLoader, editSpan.getStartPosition(), editSpan.getEndPosition());
+ }
+ }
+ }
+
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mRootView = view;
+ mRootView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
+ }
+
+ private ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ public void onGlobalLayout() {
+ mRootView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ mFullViewBottom = mRootView.getBottom();
+ }
+ };
+
+ private ActionBar getActionBar() {
+ if (!isAdded()) {
+ return null;
+ }
+ if (getActivity() instanceof AppCompatActivity) {
+ return ((AppCompatActivity) getActivity()).getSupportActionBar();
+ } else {
+ return null;
+ }
+ }
+
+ public void setContentEditingModeVisible(boolean isVisible) {
+ if (!isAdded()) {
+ return;
+ }
+ ActionBar actionBar = getActionBar();
+ if (isVisible) {
+ Animation fadeAnimation = new AlphaAnimation(1, 0);
+ fadeAnimation.setDuration(CONTENT_ANIMATION_DURATION);
+ fadeAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ mTitleEditText.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mPostSettingsLinearLayout.setVisibility(View.GONE);
+ mFormatBar.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ });
+
+ mPostContentLinearLayout.startAnimation(fadeAnimation);
+ if (actionBar != null) {
+ actionBar.hide();
+ }
+ } else {
+ mTitleEditText.setVisibility(View.VISIBLE);
+ mFormatBar.setVisibility(View.GONE);
+ Animation fadeAnimation = new AlphaAnimation(0, 1);
+ fadeAnimation.setDuration(CONTENT_ANIMATION_DURATION);
+ fadeAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ mPostSettingsLinearLayout.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {
+ }
+ });
+ mPostContentLinearLayout.startAnimation(fadeAnimation);
+ getActivity().invalidateOptionsMenu();
+ if (actionBar != null) {
+ actionBar.show();
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == LegacyEditorFragment.ACTIVITY_REQUEST_CODE_CREATE_LINK && data != null) {
+ Bundle extras = data.getExtras();
+ if (extras == null) {
+ return;
+ }
+ String linkURL = extras.getString("linkURL");
+ String linkText = extras.getString("linkText");
+ createLinkFromSelection(linkURL, linkText);
+ }
+ }
+
+ public boolean hasEmptyContentFields() {
+ return TextUtils.isEmpty(mTitleEditText.getText()) && TextUtils.isEmpty(mContentEditText.getText());
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ mFullViewBottom = mRootView.getBottom();
+ }
+
+ private void createLinkFromSelection(String linkURL, String linkText) {
+ try {
+ if (linkURL != null && !linkURL.equals("http://") && !linkURL.equals("")) {
+ if (mSelectionStart > mSelectionEnd) {
+ int temp = mSelectionEnd;
+ mSelectionEnd = mSelectionStart;
+ mSelectionStart = temp;
+ }
+ Editable editable = mContentEditText.getText();
+ if (editable == null) {
+ return;
+ }
+ if (mIsLocalDraft) {
+ if (linkText == null) {
+ if (mSelectionStart < mSelectionEnd) {
+ editable.delete(mSelectionStart, mSelectionEnd);
+ }
+ editable.insert(mSelectionStart, linkURL);
+ editable.setSpan(new URLSpan(linkURL), mSelectionStart, mSelectionStart + linkURL.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mContentEditText.setSelection(mSelectionStart + linkURL.length());
+ } else {
+ if (mSelectionStart < mSelectionEnd) {
+ editable.delete(mSelectionStart, mSelectionEnd);
+ }
+ editable.insert(mSelectionStart, linkText);
+ editable.setSpan(new URLSpan(linkURL), mSelectionStart, mSelectionStart + linkText.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mContentEditText.setSelection(mSelectionStart + linkText.length());
+ }
+ } else {
+ if (linkText == null) {
+ if (mSelectionStart < mSelectionEnd) {
+ editable.delete(mSelectionStart, mSelectionEnd);
+ }
+ String urlHTML = "<a href=\"" + linkURL + "\">" + linkURL + "</a>";
+ editable.insert(mSelectionStart, urlHTML);
+ mContentEditText.setSelection(mSelectionStart + urlHTML.length());
+ } else {
+ if (mSelectionStart < mSelectionEnd) {
+ editable.delete(mSelectionStart, mSelectionEnd);
+ }
+ String urlHTML = "<a href=\"" + linkURL + "\">" + linkText + "</a>";
+ editable.insert(mSelectionStart, urlHTML);
+ mContentEditText.setSelection(mSelectionStart + urlHTML.length());
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+
+ /**
+ * Formatting bar
+ */
+ private View.OnClickListener mFormatBarButtonClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ int id = v.getId();
+ if (id == R.id.bold) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.BOLD_BUTTON_TAPPED);
+ onFormatButtonClick(mBoldToggleButton, TAG_FORMAT_BAR_BUTTON_STRONG);
+ } else if (id == R.id.em) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.ITALIC_BUTTON_TAPPED);
+ onFormatButtonClick(mEmToggleButton, TAG_FORMAT_BAR_BUTTON_EM);
+ } else if (id == R.id.underline) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.UNDERLINE_BUTTON_TAPPED);
+ onFormatButtonClick(mUnderlineToggleButton, TAG_FORMAT_BAR_BUTTON_UNDERLINE);
+ } else if (id == R.id.strike) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.STRIKETHROUGH_BUTTON_TAPPED);
+ onFormatButtonClick(mStrikeToggleButton, TAG_FORMAT_BAR_BUTTON_STRIKE);
+ } else if (id == R.id.bquote) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.BLOCKQUOTE_BUTTON_TAPPED);
+ onFormatButtonClick(mBquoteToggleButton, TAG_FORMAT_BAR_BUTTON_QUOTE);
+ } else if (id == R.id.more) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.MORE_BUTTON_TAPPED);
+ mSelectionEnd = mContentEditText.getSelectionEnd();
+ Editable str = mContentEditText.getText();
+ if (str != null) {
+ if (mSelectionEnd > str.length())
+ mSelectionEnd = str.length();
+ str.insert(mSelectionEnd, "\n<!--more-->\n");
+ }
+ } else if (id == R.id.link) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.LINK_BUTTON_TAPPED);
+ mSelectionStart = mContentEditText.getSelectionStart();
+ mStyleStart = mSelectionStart;
+ mSelectionEnd = mContentEditText.getSelectionEnd();
+ if (mSelectionStart > mSelectionEnd) {
+ int temp = mSelectionEnd;
+ mSelectionEnd = mSelectionStart;
+ mSelectionStart = temp;
+ }
+ Intent i = new Intent(getActivity(), EditLinkActivity.class);
+ if (mSelectionEnd > mSelectionStart) {
+ if (mContentEditText.getText() != null) {
+ String selectedText = mContentEditText.getText().subSequence(mSelectionStart, mSelectionEnd).toString();
+ i.putExtra("selectedText", selectedText);
+ }
+ }
+ startActivityForResult(i, ACTIVITY_REQUEST_CODE_CREATE_LINK);
+ } else if (id == R.id.addPictureButton) {
+ mEditorFragmentListener.onTrackableEvent(TrackableEvent.MEDIA_BUTTON_TAPPED);
+ mEditorFragmentListener.onAddMediaClicked();
+ if (isAdded()) {
+ getActivity().openContextMenu(mAddPictureButton);
+ }
+ }
+ }
+ };
+
+ private WPEditImageSpan createWPEditImageSpanLocal(Context context, MediaFile mediaFile) {
+ if (context == null || mediaFile == null || mediaFile.getFilePath() == null) {
+ return null;
+ }
+ Uri imageUri = Uri.parse(mediaFile.getFilePath());
+ Bitmap thumbnailBitmap;
+ if (MediaUtils.isVideo(imageUri.toString())) {
+ thumbnailBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.media_movieclip);
+ } else {
+ thumbnailBitmap = ImageUtils.getWPImageSpanThumbnailFromFilePath(context, imageUri.getEncodedPath(),
+ ImageUtils.getMaximumThumbnailWidthForEditor(context));
+ if (thumbnailBitmap == null) {
+ // Use a placeholder in case thumbnail can't be decoded (OOM for instance)
+ thumbnailBitmap = BitmapFactory.decodeResource(context.getResources(),
+ R.drawable.legacy_dashicon_format_image_big_grey);
+ }
+ }
+ WPEditImageSpan imageSpan = new WPEditImageSpan(context, thumbnailBitmap, imageUri);
+ mediaFile.setWidth(MediaUtils.getMaximumImageWidth(context, imageUri, mBlogSettingMaxImageWidth));
+ imageSpan.setMediaFile(mediaFile);
+ return imageSpan;
+ }
+
+ private WPEditImageSpan createWPEditImageSpanRemote(Context context, MediaFile mediaFile) {
+ if (context == null || mediaFile == null || mediaFile.getFileURL() == null) {
+ return null;
+ }
+ int drawable = mediaFile.isVideo() ? R.drawable.media_movieclip : R.drawable.legacy_dashicon_format_image_big_grey;
+ Uri uri = Uri.parse(mediaFile.getFileURL());
+ WPEditImageSpan imageSpan = new WPEditImageSpan(context, drawable, uri);
+ imageSpan.setMediaFile(mediaFile);
+ return imageSpan;
+ }
+
+ private WPEditImageSpan createWPEditImageSpan(Context context, MediaFile mediaFile) {
+ if (!URLUtil.isNetworkUrl(mediaFile.getFileURL())) {
+ return createWPEditImageSpanLocal(context, mediaFile);
+ } else {
+ return createWPEditImageSpanRemote(context, mediaFile);
+ }
+ }
+
+ /**
+ * Applies formatting to selected text, or marks the entry for a new text style
+ * at the current cursor position
+ * @param toggleButton button from formatting bar
+ * @param tag HTML tag name for text style
+ */
+ private void onFormatButtonClick(ToggleButton toggleButton, String tag) {
+ Spannable s = mContentEditText.getText();
+ if (s == null)
+ return;
+ int selectionStart = mContentEditText.getSelectionStart();
+ mStyleStart = selectionStart;
+ int selectionEnd = mContentEditText.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ Class styleClass = null;
+ if (tag.equals(TAG_FORMAT_BAR_BUTTON_STRONG) || tag.equals(TAG_FORMAT_BAR_BUTTON_EM))
+ styleClass = StyleSpan.class;
+ else if (tag.equals(TAG_FORMAT_BAR_BUTTON_UNDERLINE))
+ styleClass = WPUnderlineSpan.class;
+ else if (tag.equals(TAG_FORMAT_BAR_BUTTON_STRIKE))
+ styleClass = StrikethroughSpan.class;
+ else if (tag.equals(TAG_FORMAT_BAR_BUTTON_QUOTE))
+ styleClass = QuoteSpan.class;
+
+ if (styleClass == null)
+ return;
+
+ Object[] allSpans = s.getSpans(selectionStart, selectionEnd, styleClass);
+ boolean textIsSelected = selectionEnd > selectionStart;
+ if (mIsLocalDraft) {
+ // Local drafts can use the rich text editor. Yay!
+ boolean shouldAddSpan = true;
+ for (Object span : allSpans) {
+ if (span instanceof StyleSpan) {
+ StyleSpan styleSpan = (StyleSpan)span;
+ if ((styleSpan.getStyle() == Typeface.BOLD && !tag.equals(TAG_FORMAT_BAR_BUTTON_STRONG))
+ || (styleSpan.getStyle() == Typeface.ITALIC && !tag.equals(TAG_FORMAT_BAR_BUTTON_EM))) {
+ continue;
+ }
+ }
+ if (!toggleButton.isChecked() && textIsSelected) {
+ // If span exists and text is selected, remove the span
+ s.removeSpan(span);
+ shouldAddSpan = false;
+ break;
+ } else if (!toggleButton.isChecked()) {
+ // Remove span at cursor point if button isn't checked
+ Object[] spans = s.getSpans(mStyleStart - 1, mStyleStart, styleClass);
+ for (Object removeSpan : spans) {
+ selectionStart = s.getSpanStart(removeSpan);
+ selectionEnd = s.getSpanEnd(removeSpan);
+ s.removeSpan(removeSpan);
+ }
+ }
+ }
+
+ if (shouldAddSpan) {
+ if (tag.equals(TAG_FORMAT_BAR_BUTTON_STRONG)) {
+ s.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), selectionStart, selectionEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else if (tag.equals(TAG_FORMAT_BAR_BUTTON_EM)) {
+ s.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), selectionStart, selectionEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else {
+ try {
+ s.setSpan(styleClass.newInstance(), selectionStart, selectionEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } catch (java.lang.InstantiationException e) {
+ AppLog.e(T.POSTS, e);
+ } catch (IllegalAccessException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ }
+ }
+ } else {
+ // Add HTML tags when editing an existing post
+ String startTag = "<" + tag + ">";
+ String endTag = "</" + tag + ">";
+ Editable content = mContentEditText.getText();
+ if (textIsSelected) {
+ content.insert(selectionStart, startTag);
+ content.insert(selectionEnd + startTag.length(), endTag);
+ toggleButton.setChecked(false);
+ mContentEditText.setSelection(selectionEnd + startTag.length() + endTag.length());
+ } else if (toggleButton.isChecked()) {
+ content.insert(selectionStart, startTag);
+ mContentEditText.setSelection(selectionEnd + startTag.length());
+ } else if (!toggleButton.isChecked()) {
+ content.insert(selectionEnd, endTag);
+ mContentEditText.setSelection(selectionEnd + endTag.length());
+ }
+ }
+ }
+
+ /**
+ * Rich Text Editor
+ */
+ public void showImageSettings(final View alertView, final EditText titleText,
+ final EditText caption, final EditText imageWidthText,
+ final CheckBox featuredCheckBox, final CheckBox featuredInPostCheckBox,
+ final int maxWidth, final Spinner alignmentSpinner, final WPImageSpan imageSpan) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setTitle(getString(R.string.image_settings));
+ builder.setView(alertView);
+ builder.setPositiveButton(getString(android.R.string.ok), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ String title = (titleText.getText() != null) ? titleText.getText().toString() : "";
+ MediaFile mediaFile = imageSpan.getMediaFile();
+ if (mediaFile == null) {
+ return;
+ }
+ mediaFile.setTitle(title);
+ mediaFile.setHorizontalAlignment(alignmentSpinner.getSelectedItemPosition());
+ mediaFile.setWidth(getEditTextIntegerClamped(imageWidthText, 10, maxWidth));
+ String captionText = (caption.getText() != null) ? caption.getText().toString() : "";
+ mediaFile.setCaption(captionText);
+ mediaFile.setFeatured(featuredCheckBox.isChecked());
+ if (featuredCheckBox.isChecked()) {
+ // remove featured flag from all other images
+ Spannable contentSpannable = mContentEditText.getText();
+ WPImageSpan[] imageSpans =
+ contentSpannable.getSpans(0, contentSpannable.length(), WPImageSpan.class);
+ if (imageSpans.length > 1) {
+ for (WPImageSpan postImageSpan : imageSpans) {
+ if (postImageSpan != imageSpan) {
+ MediaFile postMediaFile = postImageSpan.getMediaFile();
+ postMediaFile.setFeatured(false);
+ postMediaFile.setFeaturedInPost(false);
+ // TODO: remove this
+ mEditorFragmentListener.saveMediaFile(postMediaFile);
+ }
+ }
+ }
+ }
+ mediaFile.setFeaturedInPost(featuredInPostCheckBox.isChecked());
+ // TODO: remove this
+ mEditorFragmentListener.saveMediaFile(mediaFile);
+ }
+ });
+ builder.setNegativeButton(getString(android.R.string.cancel), new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int whichButton) {
+ dialog.dismiss();
+ }
+ });
+ AlertDialog alertDialog = builder.create();
+ alertDialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);
+ alertDialog.show();
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ float pos = event.getY();
+
+ if (event.getAction() == 0)
+ mLastYPos = pos;
+
+ if (event.getAction() > 1) {
+ int scrollThreshold = DisplayUtils.dpToPx(getActivity(), 2);
+ if (((mLastYPos - pos) > scrollThreshold) || ((pos - mLastYPos) > scrollThreshold))
+ mScrollDetected = true;
+ }
+
+ mLastYPos = pos;
+
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null && actionBar.isShowing()) {
+ setContentEditingModeVisible(true);
+ return false;
+ }
+ }
+
+ if (event.getAction() == MotionEvent.ACTION_UP && !mScrollDetected) {
+ Layout layout = ((TextView) v).getLayout();
+ int x = (int) event.getX();
+ int y = (int) event.getY();
+
+ x += v.getScrollX();
+ y += v.getScrollY();
+ if (layout != null) {
+ int line = layout.getLineForVertical(y);
+ int charPosition = layout.getOffsetForHorizontal(line, x);
+
+ Spannable spannable = mContentEditText.getText();
+ if (spannable == null) {
+ return false;
+ }
+ // check if image span was tapped
+ WPImageSpan[] imageSpans = spannable.getSpans(charPosition, charPosition, WPImageSpan.class);
+
+ if (imageSpans.length != 0) {
+ final WPImageSpan imageSpan = imageSpans[0];
+ MediaFile mediaFile = imageSpan.getMediaFile();
+ if (mediaFile == null)
+ return false;
+ if (!mediaFile.isVideo()) {
+ LayoutInflater factory = LayoutInflater.from(getActivity());
+ final View alertView = factory.inflate(R.layout.alert_image_options, null);
+ if (alertView == null)
+ return false;
+ final EditText imageWidthText = (EditText) alertView.findViewById(R.id.imageWidthText);
+ final EditText titleText = (EditText) alertView.findViewById(R.id.title);
+ final EditText caption = (EditText) alertView.findViewById(R.id.caption);
+ final CheckBox featuredCheckBox = (CheckBox) alertView.findViewById(R.id.featuredImage);
+ final CheckBox featuredInPostCheckBox = (CheckBox) alertView.findViewById(R.id.featuredInPost);
+
+ // show featured image checkboxes if supported
+ if (mFeaturedImageSupported) {
+ featuredCheckBox.setVisibility(View.VISIBLE);
+ featuredInPostCheckBox.setVisibility(View.VISIBLE);
+ }
+
+ featuredCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ featuredInPostCheckBox.setVisibility(View.VISIBLE);
+ } else {
+ featuredInPostCheckBox.setVisibility(View.GONE);
+ }
+
+ }
+ });
+
+ final SeekBar seekBar = (SeekBar) alertView.findViewById(R.id.imageWidth);
+ final Spinner alignmentSpinner = (Spinner) alertView.findViewById(R.id.alignment_spinner);
+ ArrayAdapter<CharSequence> adapter =
+ ArrayAdapter.createFromResource(getActivity(), R.array.alignment_array,
+ android.R.layout.simple_spinner_item);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ alignmentSpinner.setAdapter(adapter);
+
+ seekBar.setProgress(mediaFile.getWidth());
+ titleText.setText(mediaFile.getTitle());
+ caption.setText(mediaFile.getCaption());
+ featuredCheckBox.setChecked(mediaFile.isFeatured());
+
+ if (mediaFile.isFeatured()) {
+ featuredInPostCheckBox.setVisibility(View.VISIBLE);
+ } else {
+ featuredInPostCheckBox.setVisibility(View.GONE);
+ }
+
+ featuredInPostCheckBox.setChecked(mediaFile.isFeaturedInPost());
+
+ alignmentSpinner.setSelection(mediaFile.getHorizontalAlignment(), true);
+
+ final int maxWidth = MediaUtils.getMaximumImageWidth(getActivity(),
+ imageSpan.getImageSource(), mBlogSettingMaxImageWidth);
+ seekBar.setMax(maxWidth / 10);
+ imageWidthText.setText(String.format(Locale.US, "%dpx", maxWidth));
+ if (mediaFile.getWidth() != 0) {
+ seekBar.setProgress(mediaFile.getWidth() / 10);
+ }
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (progress == 0) {
+ progress = 1;
+ }
+ imageWidthText.setText(String.format(Locale.US, "%dpx", progress * 10));
+ }
+ });
+
+ imageWidthText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ imageWidthText.setText("");
+ }
+ }
+ });
+
+ imageWidthText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ int width = getEditTextIntegerClamped(imageWidthText, 10, maxWidth);
+ seekBar.setProgress(width / 10);
+ imageWidthText.setSelection((String.valueOf(width).length()));
+
+ InputMethodManager imm = (InputMethodManager) getActivity()
+ .getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(imageWidthText.getWindowToken(),
+ InputMethodManager.RESULT_UNCHANGED_SHOWN);
+
+ return true;
+ }
+ });
+
+ showImageSettings(alertView, titleText, caption, imageWidthText, featuredCheckBox,
+ featuredInPostCheckBox, maxWidth, alignmentSpinner, imageSpan);
+ mScrollDetected = false;
+ return true;
+ }
+
+ } else {
+ mContentEditText.setMovementMethod(ArrowKeyMovementMethod.getInstance());
+ int selectionStart = mContentEditText.getSelectionStart();
+ if (selectionStart >= 0 && mContentEditText.getSelectionEnd() >= selectionStart)
+ mContentEditText.setSelection(selectionStart, mContentEditText.getSelectionEnd());
+ }
+
+ // get media gallery spans
+ MediaGalleryImageSpan[] gallerySpans = spannable.getSpans(charPosition, charPosition, MediaGalleryImageSpan.class);
+ if (gallerySpans.length > 0) {
+ final MediaGalleryImageSpan gallerySpan = gallerySpans[0];
+ Intent intent = new Intent(ACTION_MEDIA_GALLERY_TOUCHED);
+ intent.putExtra(EXTRA_MEDIA_GALLERY, gallerySpan.getMediaGallery());
+ getActivity().sendBroadcast(intent);
+ }
+ }
+ } else if (event.getAction() == 1) {
+ mScrollDetected = false;
+ }
+ return false;
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ int position = Selection.getSelectionStart(mContentEditText.getText());
+ if ((mIsBackspace && position != 1) || mLastPosition == position || !mIsLocalDraft)
+ return;
+
+ if (position < 0) {
+ position = 0;
+ }
+ mLastPosition = position;
+ if (position > 0) {
+ if (mStyleStart > position) {
+ mStyleStart = position - 1;
+ }
+
+ boolean shouldBold = mBoldToggleButton.isChecked();
+ boolean shouldEm = mEmToggleButton.isChecked();
+ boolean shouldUnderline = mUnderlineToggleButton.isChecked();
+ boolean shouldStrike = mStrikeToggleButton.isChecked();
+ boolean shouldQuote = mBquoteToggleButton.isChecked();
+
+ Object[] allSpans = s.getSpans(mStyleStart, position, Object.class);
+ for (Object span : allSpans) {
+ if (span instanceof StyleSpan) {
+ StyleSpan styleSpan = (StyleSpan) span;
+ if (styleSpan.getStyle() == Typeface.BOLD)
+ shouldBold = false;
+ else if (styleSpan.getStyle() == Typeface.ITALIC)
+ shouldEm = false;
+ } else if (span instanceof WPUnderlineSpan) {
+ shouldUnderline = false;
+ } else if (span instanceof StrikethroughSpan) {
+ shouldStrike = false;
+ } else if (span instanceof QuoteSpan) {
+ shouldQuote = false;
+ }
+ }
+
+ if (shouldBold)
+ s.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldEm)
+ s.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldUnderline)
+ s.setSpan(new WPUnderlineSpan(), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldStrike)
+ s.setSpan(new StrikethroughSpan(), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ if (shouldQuote)
+ s.setSpan(new QuoteSpan(), mStyleStart, position, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ mIsBackspace = (count - after == 1) || (s.length() == 0);
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void onSelectionChanged() {
+ if (!mIsLocalDraft) {
+ return;
+ }
+
+ final Spannable s = mContentEditText.getText();
+ if (s == null)
+ return;
+ // set toggle buttons if cursor is inside of a matching span
+ mStyleStart = mContentEditText.getSelectionStart();
+ Object[] spans = s.getSpans(mContentEditText.getSelectionStart(), mContentEditText.getSelectionStart(), Object.class);
+
+ mBoldToggleButton.setChecked(false);
+ mEmToggleButton.setChecked(false);
+ mBquoteToggleButton.setChecked(false);
+ mUnderlineToggleButton.setChecked(false);
+ mStrikeToggleButton.setChecked(false);
+ for (Object span : spans) {
+ if (span instanceof StyleSpan) {
+ StyleSpan ss = (StyleSpan) span;
+ if (ss.getStyle() == android.graphics.Typeface.BOLD) {
+ mBoldToggleButton.setChecked(true);
+ }
+ if (ss.getStyle() == android.graphics.Typeface.ITALIC) {
+ mEmToggleButton.setChecked(true);
+ }
+ }
+ if (span instanceof QuoteSpan) {
+ mBquoteToggleButton.setChecked(true);
+ }
+ if (span instanceof WPUnderlineSpan) {
+ mUnderlineToggleButton.setChecked(true);
+ }
+ if (span instanceof StrikethroughSpan) {
+ mStrikeToggleButton.setChecked(true);
+ }
+ }
+ }
+
+ private int getEditTextIntegerClamped(EditText editText, int min, int max) {
+ int width = 10;
+ try {
+ if (editText.getText() != null)
+ width = Integer.parseInt(editText.getText().toString().replace("px", ""));
+ } catch (NumberFormatException e) {
+ AppLog.e(T.POSTS, e);
+ }
+ width = Math.min(max, Math.max(width, min));
+ return width;
+ }
+
+ private void loadWPImageSpanThumbnail(MediaFile mediaFile, String imageURL, ImageLoader imageLoader) {
+ if (mediaFile == null || imageURL == null) {
+ return;
+ }
+ final String mediaId = mediaFile.getMediaId();
+ if (mediaId == null) {
+ return;
+ }
+
+ final int maxThumbWidth = ImageUtils.getMaximumThumbnailWidthForEditor(getActivity());
+
+ imageLoader.get(imageURL, new ImageLoader.ImageListener() {
+ @Override
+ public void onErrorResponse(VolleyError arg0) {
+ }
+
+ @Override
+ public void onResponse(ImageLoader.ImageContainer container, boolean arg1) {
+ Bitmap downloadedBitmap = container.getBitmap();
+ if (downloadedBitmap == null) {
+ // no bitmap downloaded from the server.
+ return;
+ }
+
+ if (downloadedBitmap.getWidth() < MIN_THUMBNAIL_WIDTH) {
+ // Picture is too small. Show the placeholder in this case.
+ return;
+ }
+
+ Bitmap resizedBitmap;
+ // resize the downloaded bitmap
+ resizedBitmap = ImageUtils.getScaledBitmapAtLongestSide(downloadedBitmap, maxThumbWidth);
+
+ if (resizedBitmap == null) {
+ return;
+ }
+
+ final EditText editText = mContentEditText;
+ Editable s = editText.getText();
+ if (s == null) {
+ return;
+ }
+ WPImageSpan[] spans = s.getSpans(0, s.length(), WPImageSpan.class);
+ if (spans.length != 0 && getActivity() != null) {
+ for (WPImageSpan is : spans) {
+ MediaFile mediaFile = is.getMediaFile();
+ if (mediaFile == null) {
+ continue;
+ }
+ if (mediaId.equals(mediaFile.getMediaId()) && !is.isNetworkImageLoaded()) {
+ // replace the existing span with a new one with the correct image, re-add
+ // it to the same position.
+ int spanStart = is.getStartPosition();
+ int spanEnd = is.getEndPosition();
+ WPEditImageSpan imageSpan = new WPEditImageSpan(getActivity(), resizedBitmap,
+ is.getImageSource());
+ imageSpan.setMediaFile(is.getMediaFile());
+ imageSpan.setNetworkImageLoaded(true);
+ imageSpan.setPosition(spanStart, spanEnd);
+ s.removeSpan(is);
+ s.setSpan(imageSpan, spanStart, spanEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ }
+ }
+ }
+ }
+ }, 0, 0);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ WPImageSpan[] spans = mContentEditText.getText().getSpans(0, mContentEditText.getText().length(), WPEditImageSpan.class);
+
+ if (spans != null && spans.length > 0) {
+ outState.putParcelableArray(KEY_IMAGE_SPANS, spans);
+ }
+
+ outState.putInt(KEY_START, mContentEditText.getSelectionStart());
+ outState.putInt(KEY_END, mContentEditText.getSelectionEnd());
+ outState.putString(KEY_CONTENT, mContentEditText.getText().toString());
+ }
+
+ private class AddMediaFileTask extends AsyncTask<Void, Void, WPEditImageSpan> {
+ private MediaFile mMediaFile;
+ private String mImageUrl;
+ private ImageLoader mImageLoader;
+ private int mStart;
+ private int mEnd;
+
+ public AddMediaFileTask(MediaFile mediaFile, String imageUrl, ImageLoader imageLoader, int start, int end) {
+ mMediaFile = mediaFile;
+ mImageUrl = imageUrl;
+ mImageLoader = imageLoader;
+ mStart = start;
+ mEnd = end;
+ }
+
+ protected WPEditImageSpan doInBackground(Void... voids) {
+ mMediaFile.setFileURL(mImageUrl);
+ mMediaFile.setFilePath(mImageUrl);
+ WPEditImageSpan imageSpan = createWPEditImageSpan(getActivity(), mMediaFile);
+ mEditorFragmentListener.saveMediaFile(mMediaFile);
+ return imageSpan;
+ }
+
+ protected void onPostExecute(WPEditImageSpan imageSpan) {
+ if (imageSpan == null) {
+ if (isAdded()) {
+ ToastUtils.showToast(getActivity(), R.string.alert_error_adding_media, Duration.LONG);
+ }
+ return ;
+ }
+ // Insert the WPImageSpan in the content field
+ int selectionStart = mStart;
+ int selectionEnd = mEnd;
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ imageSpan.setPosition(selectionStart, selectionEnd);
+
+ int line, column = 0;
+ if (mContentEditText.getLayout() != null) {
+ line = mContentEditText.getLayout().getLineForOffset(selectionStart);
+ column = selectionStart - mContentEditText.getLayout().getLineStart(line);
+ }
+
+ Editable s = mContentEditText.getText();
+ if (s == null) {
+ return;
+ }
+
+ WPImageSpan[] imageSpans = s.getSpans(selectionStart, selectionEnd, WPImageSpan.class);
+ if (imageSpans.length != 0) {
+ // insert a few line breaks if the cursor is already on an image
+ s.insert(selectionEnd, "\n\n");
+ selectionStart = selectionStart + 2;
+ selectionEnd = selectionEnd + 2;
+ } else if (column != 0) {
+ // insert one line break if the cursor is not at the first column
+ s.insert(selectionEnd, "\n");
+ selectionStart = selectionStart + 1;
+ selectionEnd = selectionEnd + 1;
+ }
+
+ s.insert(selectionStart, " ");
+ s.setSpan(imageSpan, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER);
+ s.setSpan(as, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ s.insert(selectionEnd + 1, "\n\n");
+
+ // Fetch and replace the WPImageSpan if it's a remote media
+ if (mImageLoader != null && URLUtil.isNetworkUrl(mImageUrl)) {
+ loadWPImageSpanThumbnail(mMediaFile, mImageUrl, mImageLoader);
+ }
+ }
+ }
+
+ public void addMediaFile(final MediaFile mediaFile, final String imageUrl, final ImageLoader imageLoader,
+ final int start, final int end) {
+ AddMediaFileTask addMediaFileTask = new AddMediaFileTask(mediaFile, imageUrl, imageLoader, start, end);
+ addMediaFileTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ @Override
+ public void appendMediaFile(final MediaFile mediaFile, final String imageUrl, final ImageLoader imageLoader) {
+ addMediaFile(mediaFile, imageUrl, imageLoader, mContentEditText.getSelectionStart(), mContentEditText.getSelectionEnd());
+ }
+
+ @Override
+ public void appendGallery(MediaGallery mediaGallery) {
+ Editable editableText = mContentEditText.getText();
+ if (editableText == null) {
+ return;
+ }
+
+ int selectionStart = mContentEditText.getSelectionStart();
+ int selectionEnd = mContentEditText.getSelectionEnd();
+
+ if (selectionStart > selectionEnd) {
+ int temp = selectionEnd;
+ selectionEnd = selectionStart;
+ selectionStart = temp;
+ }
+
+ int line, column = 0;
+ if (mContentEditText.getLayout() != null) {
+ line = mContentEditText.getLayout().getLineForOffset(selectionStart);
+ column = mContentEditText.getSelectionStart() - mContentEditText.getLayout().getLineStart(line);
+ }
+
+ if (column != 0) {
+ // insert one line break if the cursor is not at the first column
+ editableText.insert(selectionEnd, "\n");
+ selectionStart = selectionStart + 1;
+ selectionEnd = selectionEnd + 1;
+ }
+
+ editableText.insert(selectionStart, " ");
+ MediaGalleryImageSpan is = new MediaGalleryImageSpan(getActivity(), mediaGallery,
+ R.drawable.legacy_icon_mediagallery_placeholder);
+ editableText.setSpan(is, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ AlignmentSpan.Standard as = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER);
+ editableText.setSpan(as, selectionStart, selectionEnd + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ editableText.insert(selectionEnd + 1, "\n\n");
+ }
+
+ @Override
+ public void setUrlForVideoPressId(String videoPressId, String url, String posterUrl) {
+
+ }
+
+ @Override
+ public boolean isUploadingMedia() {
+ return false;
+ }
+
+ @Override
+ public boolean hasFailedMediaUploads() {
+ return false;
+ }
+
+ @Override
+ public void removeAllFailedMediaUploads() {}
+
+ @Override
+ public void setTitlePlaceholder(CharSequence text) {
+ }
+
+ @Override
+ public void setContentPlaceholder(CharSequence text) {
+ }
+
+ @Override
+ public boolean isActionInProgress() {
+ return false;
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LinkDialogFragment.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LinkDialogFragment.java
new file mode 100644
index 000000000..9be36cb33
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/LinkDialogFragment.java
@@ -0,0 +1,76 @@
+package org.wordpress.android.editor;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.EditText;
+
+public class LinkDialogFragment extends DialogFragment {
+
+ public static final int LINK_DIALOG_REQUEST_CODE_ADD = 1;
+ public static final int LINK_DIALOG_REQUEST_CODE_UPDATE = 2;
+ public static final int LINK_DIALOG_REQUEST_CODE_DELETE = 3;
+
+ public static final String LINK_DIALOG_ARG_URL = "linkURL";
+ public static final String LINK_DIALOG_ARG_TEXT = "linkText";
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ LayoutInflater inflater = getActivity().getLayoutInflater();
+
+ View view = inflater.inflate(R.layout.dialog_link, null);
+
+ final EditText urlEditText = (EditText) view.findViewById(R.id.linkURL);
+ final EditText linkEditText = (EditText) view.findViewById(R.id.linkText);
+
+ builder.setView(view)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ Intent intent = new Intent();
+ intent.putExtra(LINK_DIALOG_ARG_URL, urlEditText.getText().toString());
+ intent.putExtra(LINK_DIALOG_ARG_TEXT, linkEditText.getText().toString());
+ getTargetFragment().onActivityResult(getTargetRequestCode(), getTargetRequestCode(), intent);
+ }
+ })
+ .setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ LinkDialogFragment.this.getDialog().cancel();
+ }
+ });
+
+ // If updating an existing link, add a 'Delete' button
+ if (getTargetRequestCode() == LINK_DIALOG_REQUEST_CODE_UPDATE) {
+ builder.setNeutralButton(R.string.delete, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ getTargetFragment().onActivityResult(getTargetRequestCode(), LINK_DIALOG_REQUEST_CODE_DELETE, null);
+ }
+ });
+ }
+
+ // Prepare initial state of EditTexts
+ Bundle bundle = getArguments();
+ if (bundle != null) {
+ linkEditText.setText(bundle.getString(LINK_DIALOG_ARG_TEXT));
+
+ String url = bundle.getString(LINK_DIALOG_ARG_URL);
+ if (url != null) {
+ urlEditText.setText(url);
+ }
+ urlEditText.selectAll();
+ }
+
+ AlertDialog dialog = builder.create();
+ dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
+
+ return dialog;
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnImeBackListener.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnImeBackListener.java
new file mode 100644
index 000000000..ed7ee0995
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnImeBackListener.java
@@ -0,0 +1,5 @@
+package org.wordpress.android.editor;
+
+public interface OnImeBackListener {
+ void onImeBack();
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnJsEditorStateChangedListener.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnJsEditorStateChangedListener.java
new file mode 100755
index 000000000..ca8cbf514
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/OnJsEditorStateChangedListener.java
@@ -0,0 +1,20 @@
+package org.wordpress.android.editor;
+
+import org.json.JSONObject;
+
+import java.util.Map;
+
+import static org.wordpress.android.editor.EditorFragmentAbstract.MediaType;
+
+public interface OnJsEditorStateChangedListener {
+ void onDomLoaded();
+ void onSelectionChanged(Map<String, String> selectionArgs);
+ void onSelectionStyleChanged(Map<String, Boolean> changeSet);
+ void onMediaTapped(String mediaId, MediaType mediaType, JSONObject meta, String uploadStatus);
+ void onLinkTapped(String url, String title);
+ void onMediaRemoved(String mediaId);
+ void onMediaReplaced(String mediaId);
+ void onVideoPressInfoRequested(String videoId);
+ void onGetHtmlResponse(Map<String, String> responseArgs);
+ void onActionFinished();
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/RippleToggleButton.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/RippleToggleButton.java
new file mode 100644
index 000000000..fd2ac6110
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/RippleToggleButton.java
@@ -0,0 +1,95 @@
+package org.wordpress.android.editor;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.support.annotation.NonNull;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.ToggleButton;
+
+public class RippleToggleButton extends ToggleButton {
+ private static final int FRAME_RATE = 10;
+ private static final int DURATION = 250;
+ private static final int FILL_INITIAL_OPACITY = 200;
+ private static final int STROKE_INITIAL_OPACITY = 255;
+
+ private float mHalfWidth;
+ private boolean mAnimationIsRunning = false;
+ private int mTimer = 0;
+ private Paint mFillPaint;
+ private Paint mStrokePaint;
+
+ public RippleToggleButton(Context context) {
+ this(context, null);
+ }
+
+ public RippleToggleButton(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public RippleToggleButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init();
+ }
+
+ private void init() {
+ if (isInEditMode()) {
+ return;
+ }
+
+ int rippleColor = getResources().getColor(R.color.format_bar_ripple_animation);
+
+ mFillPaint = new Paint();
+ mFillPaint.setAntiAlias(true);
+ mFillPaint.setColor(rippleColor);
+ mFillPaint.setStyle(Paint.Style.FILL);
+ mFillPaint.setAlpha(FILL_INITIAL_OPACITY);
+
+ mStrokePaint = new Paint();
+ mStrokePaint.setAntiAlias(true);
+ mStrokePaint.setColor(rippleColor);
+ mStrokePaint.setStyle(Paint.Style.STROKE);
+ mStrokePaint.setStrokeWidth(2);
+ mStrokePaint.setAlpha(STROKE_INITIAL_OPACITY);
+
+ setWillNotDraw(false);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ super.draw(canvas);
+ if (mAnimationIsRunning) {
+ if (DURATION <= mTimer * FRAME_RATE) {
+ mAnimationIsRunning = false;
+ mTimer = 0;
+ } else {
+ float progressFraction = ((float) mTimer * FRAME_RATE) / DURATION;
+
+ mFillPaint.setAlpha((int) (FILL_INITIAL_OPACITY * (1 - progressFraction)));
+ mStrokePaint.setAlpha((int) (STROKE_INITIAL_OPACITY * (1 - progressFraction)));
+
+ canvas.drawCircle(mHalfWidth, mHalfWidth, mHalfWidth * progressFraction, mFillPaint);
+ canvas.drawCircle(mHalfWidth, mHalfWidth, mHalfWidth * progressFraction, mStrokePaint);
+
+ mTimer++;
+ }
+
+ invalidate();
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(@NonNull MotionEvent event) {
+ startRippleAnimation();
+ return super.onTouchEvent(event);
+ }
+
+ private void startRippleAnimation() {
+ if (this.isEnabled() && !mAnimationIsRunning) {
+ mHalfWidth = getMeasuredWidth() / 2;
+ mAnimationIsRunning = true;
+ invalidate();
+ }
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/SourceViewEditText.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/SourceViewEditText.java
new file mode 100644
index 000000000..12caa31d0
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/SourceViewEditText.java
@@ -0,0 +1,60 @@
+package org.wordpress.android.editor;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.widget.EditText;
+
+import org.wordpress.android.util.AppLog;
+
+/**
+ * An EditText with support for {@link org.wordpress.android.editor.OnImeBackListener} and typeface setting
+ * using a custom XML attribute.
+ */
+public class SourceViewEditText extends EditText {
+
+ private OnImeBackListener mOnImeBackListener;
+
+ public SourceViewEditText(Context context) {
+ super(context);
+ }
+
+ public SourceViewEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setCustomTypeface(attrs);
+ }
+
+ public SourceViewEditText(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ setCustomTypeface(attrs);
+ }
+
+ public boolean onKeyPreIme(int keyCode, KeyEvent event) {
+ if(event.getKeyCode() == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) {
+ if (this.mOnImeBackListener != null) {
+ this.mOnImeBackListener.onImeBack();
+ }
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ public void setOnImeBackListener(OnImeBackListener listener) {
+ this.mOnImeBackListener = listener;
+ }
+
+ private void setCustomTypeface(AttributeSet attrs) {
+ TypedArray values = getContext().obtainStyledAttributes(attrs, R.styleable.SourceViewEditText);
+ String typefaceName = values.getString(R.styleable.SourceViewEditText_fontFile);
+ if (typefaceName != null) {
+ try {
+ Typeface typeface = Typeface.createFromAsset(getContext().getAssets(), "fonts/" + typefaceName);
+ this.setTypeface(typeface);
+ } catch (RuntimeException e) {
+ AppLog.e(AppLog.T.EDITOR, "Could not load typeface " + typefaceName);
+ }
+ }
+ values.recycle();
+ }
+} \ No newline at end of file
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/Utils.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/Utils.java
new file mode 100644
index 000000000..476656320
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/Utils.java
@@ -0,0 +1,247 @@
+package org.wordpress.android.editor;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.net.Uri;
+import android.util.Patterns;
+
+import org.wordpress.android.util.AppLog;
+import org.wordpress.android.util.HTTPUtils;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
+import java.net.URLDecoder;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringTokenizer;
+
+public class Utils {
+ public static String getHtmlFromFile(Activity activity, String filename) {
+ try {
+ AssetManager assetManager = activity.getAssets();
+ InputStream in = assetManager.open(filename);
+ return getStringFromInputStream(in);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.EDITOR, "Unable to load editor HTML (is the assets symlink working?): " + e.getMessage());
+ return null;
+ }
+ }
+
+ public static String getStringFromInputStream(InputStream inputStream) throws IOException {
+ InputStreamReader is = new InputStreamReader(inputStream);
+ StringBuilder sb = new StringBuilder();
+ BufferedReader br = new BufferedReader(is);
+ String read = br.readLine();
+ while (read != null) {
+ sb.append(read);
+ sb.append('\n');
+ read = br.readLine();
+ }
+ return sb.toString();
+ }
+
+ public static String escapeHtml(String html) {
+ if (html != null) {
+ html = html.replace("\\", "\\\\");
+ html = html.replace("\"", "\\\"");
+ html = html.replace("'", "\\'");
+ html = html.replace("\r", "\\r");
+ html = html.replace("\n", "\\n");
+
+ // Escape invisible line separator (U+2028) and paragraph separator (U+2029) characters
+ // https://github.com/wordpress-mobile/WordPress-Editor-Android/issues/405
+ html = html.replace("\u2028", "\\u2028");
+ html = html.replace("\u2029", "\\u2029");
+ }
+ return html;
+ }
+
+ public static String decodeHtml(String html) {
+ if (html != null) {
+ try {
+ html = URLDecoder.decode(html, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ AppLog.e(AppLog.T.EDITOR, "Unsupported encoding exception while decoding HTML.");
+ }
+ }
+ return html;
+ }
+
+ public static String escapeQuotes(String text) {
+ if (text != null) {
+ text = text.replace("'", "\\'").replace("\"", "\\\"");
+ }
+ return text;
+ }
+
+ /**
+ * Splits a delimited string into a set of strings.
+ * @param string the delimited string to split
+ * @param delimiter the string delimiter
+ */
+ public static Set<String> splitDelimitedString(String string, String delimiter) {
+ Set<String> splitString = new HashSet<>();
+
+ StringTokenizer stringTokenizer = new StringTokenizer(string, delimiter);
+ while (stringTokenizer.hasMoreTokens()) {
+ splitString.add(stringTokenizer.nextToken());
+ }
+
+ return splitString;
+ }
+
+ /**
+ * Splits a delimited string of value pairs (of the form identifier=value) into a set of strings.
+ * @param string the delimited string to split
+ * @param delimiter the string delimiter
+ * @param identifiers the identifiers to match for in the string
+ */
+ public static Set<String> splitValuePairDelimitedString(String string, String delimiter, List<String> identifiers) {
+ String identifierSegment = "";
+ for (String identifier : identifiers) {
+ if (identifierSegment.length() != 0) {
+ identifierSegment += "|";
+ }
+ identifierSegment += identifier;
+ }
+
+ String regex = delimiter + "(?=(" + identifierSegment + ")=)";
+
+ return new HashSet<>(Arrays.asList(string.split(regex)));
+ }
+
+ /**
+ * Accepts a set of strings, each string being a key-value pair (<code>id=5</code>,
+ * <code>name=content-filed</code>). Returns a map of all the key-value pairs in the set.
+ * @param keyValueSet the set of key-value pair strings
+ */
+ public static Map<String, String> buildMapFromKeyValuePairs(Set<String> keyValueSet) {
+ Map<String, String> selectionArgs = new HashMap<>();
+ for (String pair : keyValueSet) {
+ int delimLoc = pair.indexOf("=");
+ if (delimLoc != -1) {
+ selectionArgs.put(pair.substring(0, delimLoc), pair.substring(delimLoc + 1));
+ }
+ }
+ return selectionArgs;
+ }
+
+ /**
+ * Compares two <code>Sets</code> and returns a <code>Map</code> of elements not contained in both
+ * <code>Sets</code>. Elements contained in <code>oldSet</code> but not in <code>newSet</code> will be marked
+ * <code>false</code> in the returned map; the converse will be marked <code>true</code>.
+ * @param oldSet the older of the two <code>Sets</code>
+ * @param newSet the newer of the two <code>Sets</code>
+ * @param <E> type of element stored in the <code>Sets</code>
+ * @return a <code>Map</code> containing the difference between <code>oldSet</code> and <code>newSet</code>, and whether the
+ * element was added (<code>true</code>) or removed (<code>false</code>) in <code>newSet</code>
+ */
+ public static <E> Map<E, Boolean> getChangeMapFromSets(Set<E> oldSet, Set<E> newSet) {
+ Map<E, Boolean> changeMap = new HashMap<>();
+
+ Set<E> additions = new HashSet<>(newSet);
+ additions.removeAll(oldSet);
+
+ Set<E> removals = new HashSet<>(oldSet);
+ removals.removeAll(newSet);
+
+ for (E s : additions) {
+ changeMap.put(s, true);
+ }
+
+ for (E s : removals) {
+ changeMap.put(s, false);
+ }
+
+ return changeMap;
+ }
+
+ public static Uri downloadExternalMedia(Context context, Uri imageUri, Map<String, String> headers) {
+ if(context != null && imageUri != null) {
+ File cacheDir = null;
+
+ if (context.getApplicationContext() != null) {
+ cacheDir = context.getCacheDir();
+ }
+
+ try {
+ InputStream inputStream;
+ if (imageUri.toString().startsWith("content://")) {
+ inputStream = context.getContentResolver().openInputStream(imageUri);
+ if (inputStream == null) {
+ AppLog.e(AppLog.T.UTILS, "openInputStream returned null");
+ return null;
+ }
+ } else {
+ if (headers == null) {
+ headers = Collections.emptyMap();
+ }
+
+ HttpURLConnection conn = HTTPUtils.setupUrlConnection(imageUri.toString(), headers);
+
+ // If the HTTP response is a redirect, follow it
+ int responseCode = conn.getResponseCode();
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ if (responseCode == HttpURLConnection.HTTP_MOVED_PERM
+ || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
+ || responseCode == HttpURLConnection.HTTP_SEE_OTHER) {
+ conn = HTTPUtils.setupUrlConnection(conn.getHeaderField("Location"), headers);
+ }
+ }
+
+ inputStream = conn.getInputStream();
+ }
+
+ String fileName = "thumb-" + System.currentTimeMillis();
+
+ File f = new File(cacheDir, fileName);
+ FileOutputStream output = new FileOutputStream(f);
+ byte[] data = new byte[1024];
+
+ int count;
+ while ((count = inputStream.read(data)) != -1) {
+ output.write(data, 0, count);
+ }
+
+ output.flush();
+ output.close();
+ inputStream.close();
+ return Uri.fromFile(f);
+ } catch (IOException e) {
+ AppLog.e(AppLog.T.UTILS, e);
+ }
+
+ return null;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Checks the Clipboard for text that matches the {@link Patterns#WEB_URL} pattern.
+ *
+ * @return the URL text in the clipboard, if it exists; otherwise null
+ */
+ public static String getUrlFromClipboard(Context context) {
+ if (context == null) return null;
+ ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData data = clipboard != null ? clipboard.getPrimaryClip() : null;
+ if (data == null || data.getItemCount() <= 0) return null;
+ String clipText = String.valueOf(data.getItemAt(0).getText());
+ return Patterns.WEB_URL.matcher(clipText).matches() ? clipText : null;
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/EditLinkActivity.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/EditLinkActivity.java
new file mode 100644
index 000000000..e02f98eb2
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/EditLinkActivity.java
@@ -0,0 +1,76 @@
+package org.wordpress.android.editor.legacy;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v7.app.AppCompatActivity;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+
+import org.wordpress.android.editor.R;
+
+public class EditLinkActivity extends AppCompatActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.alert_create_link);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ String selectedText = extras.getString("selectedText");
+ if (selectedText != null) {
+ EditText linkTextET = (EditText) findViewById(R.id.linkText);
+ linkTextET.setText(selectedText);
+ }
+ }
+
+ final Button cancelButton = (Button) findViewById(R.id.cancel);
+ final Button okButton = (Button) findViewById(R.id.ok);
+
+ final EditText urlEditText = (EditText) findViewById(R.id.linkURL);
+ urlEditText.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (urlEditText.getText().toString().equals("")) {
+ urlEditText.setText("http://");
+ urlEditText.setSelection(7);
+ }
+ }
+
+ });
+
+ okButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ EditText linkURLET = (EditText) findViewById(R.id.linkURL);
+ String linkURL = linkURLET.getText().toString();
+
+ EditText linkTextET = (EditText) findViewById(R.id.linkText);
+ String linkText = linkTextET.getText().toString();
+
+ Bundle bundle = new Bundle();
+ bundle.putString("linkURL", linkURL);
+ if (!linkText.equals("")) {
+ bundle.putString("linkText", linkText);
+ }
+
+ Intent mIntent = new Intent();
+ mIntent.putExtras(bundle);
+ setResult(RESULT_OK, mIntent);
+ finish();
+ }
+ });
+
+ cancelButton.setOnClickListener(new Button.OnClickListener() {
+ public void onClick(View v) {
+ Intent mIntent = new Intent();
+ setResult(RESULT_CANCELED, mIntent);
+ finish();
+ }
+ });
+
+ // select end of url
+ urlEditText.performClick();
+ }
+}
diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/WPEditImageSpan.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/WPEditImageSpan.java
new file mode 100644
index 000000000..25bc33894
--- /dev/null
+++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/legacy/WPEditImageSpan.java
@@ -0,0 +1,74 @@
+package org.wordpress.android.editor.legacy;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import org.wordpress.android.editor.R;
+import org.wordpress.android.util.helpers.MediaFile;
+import org.wordpress.android.util.helpers.WPImageSpan;
+
+public class WPEditImageSpan extends WPImageSpan {
+ private Bitmap mEditIconBitmap;
+
+ protected WPEditImageSpan() {
+ super();
+ }
+
+ public WPEditImageSpan(Context context, Bitmap b, Uri src) {
+ super(context, b, src);
+ init(context);
+ }
+
+ public WPEditImageSpan(Context context, int resId, Uri src) {
+ super(context, resId, src);
+ init(context);
+ }
+
+ private void init(Context context) {
+ if (context != null) {
+ mEditIconBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.ab_icon_edit);
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom,
+ Paint paint) {
+ super.draw(canvas, text, start, end, x, top, y, bottom, paint);
+
+ if (mEditIconBitmap != null && !mMediaFile.isVideo()) {
+ // Add 'edit' icon at bottom right of image
+ int width = getSize(paint, text, start, end, paint.getFontMetricsInt());
+ float editIconXPosition = (x + width) - mEditIconBitmap.getWidth();
+ float editIconYPosition = bottom - mEditIconBitmap.getHeight();
+
+ // Add a black background with a bit of alpha
+ Paint bgPaint = new Paint();
+ bgPaint.setColor(Color.argb(200, 0, 0, 0));
+ canvas.drawRect(editIconXPosition, editIconYPosition, editIconXPosition + mEditIconBitmap.getWidth(),
+ editIconYPosition + mEditIconBitmap.getHeight(), bgPaint);
+
+ // Add the icon to the canvas
+ canvas.drawBitmap(mEditIconBitmap, editIconXPosition, editIconYPosition, paint);
+ }
+ }
+
+ public static final Parcelable.Creator<WPEditImageSpan> CREATOR = new Parcelable.Creator<WPEditImageSpan>() {
+ public WPEditImageSpan createFromParcel(Parcel in) {
+ WPEditImageSpan editSpan = new WPEditImageSpan();
+ editSpan.setupFromParcel(in);
+
+ return editSpan;
+ }
+
+ public WPEditImageSpan[] newArray(int size) {
+ return new WPEditImageSpan[size];
+ }
+ };
+}