diff options
Diffstat (limited to 'libs/editor/WordPressEditor/src/main/java')
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(" $", "")); + } + + /** + * 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 = "("|&|'|<|>| |¡|¢|£" + + "|¤|¥|¦|§|¨|©|ª|«|¬|­|®|¯|°|±" + + "|²|³|´|µ|¶|·|¸|¹|º|»|¼|½|¾|¿" + + "|À|Á|Â|Ã|Ä|Å|Æ|Ç|È|É|Ê|Ë|Ì|Í" + + "|Î|Ï|Ð|Ñ|Ò|Ó|Ô|Õ|Ö|×|Ø|Ù|Ú|Û" + + "|Ü|Ý|Þ|ß|à|á|â|ã|ä|å|æ|ç|è|é" + + "|ê|ë|ì|í|î|ï|ð|ñ|ò|ó|ô|õ|ö|÷" + + "|ø|ù|ú|û|ü|ý|þ|ÿ|Œ|œ|Š|š|Ÿ|ƒ" + + "|ˆ|˜|Α|Β|Γ|Δ|Ε|Ζ|Η|Θ|Ι|Κ|Λ|Μ" + + "|Ν|Ξ|Ο|Π|Ρ|Σ|Τ|Υ|Φ|Χ|Ψ|Ω|α|β" + + "|γ|δ|ε|ζ|η|θ|ι|κ|λ|μ|ν|ξ|ο|π" + + "|ρ|ς|σ|τ|υ|φ|χ|ψ|ω|ϑ|ϒ|ϖ| | " + + "| |‌|‍|‎|‏|–|—|‘|’|‚|“|”|„" + + "|†|‡|•|…|‰|′|″|‹|›|‾|⁄|€|ℑ" + + "|℘|ℜ|™|ℵ|←|↑|→|↓|↔|↵|⇐|⇑|⇒" + + "|⇓|⇔|∀|∂|∃|∅|∇|∈|∉|∋|∏|∑|−" + + "|∗|√|∝|∞|∠|∧|∨|∩|∪|∫|∴|∼|≅" + + "|≈|≠|≡|≤|≥|⊂|⊃|⊄|⊆|⊇|⊕|⊗|⊥" + + "|⋅|⌈|⌉|⌊|⌋|〈|〉|◊|♠|♣|♥|♦|"" + + "|&|'|<|>| |¡|¢|£|¤|¥|¦|§|¨|©|ª" + + "|«|¬|­|®|¯|°|±|²|³|´|µ|¶|·|¸" + + "|¹|º|»|¼|½|¾|¿|À|Á|Â|Ã|Ä" + + "|Å|Æ|Ç|È|É|Ê|Ë|Ì|Í|Î|Ï|Ð" + + "|Ñ|Ò|Ó|Ô|Õ|Ö|×|Ø|Ù|Ú|Û|Ü" + + "|Ý|Þ|ß|à|á|â|ã|ä|å|æ|ç|è" + + "|é|ê|ë|ì|í|î|ï|ð|ñ|ò|ó|ô" + + "|õ|ö|÷|ø|Ù|Ú|Û|Ü|ý|þ|ÿ|Œ" + + "|œ|Š|š|Ÿ|ƒ|ˆ|˜|Α|Β|Γ|Δ|Ε|Ζ" + + "|Η|Θ|Ι|Κ|Λ|Μ|Ν|Ξ|Ο|Π|Ρ|Σ|Τ|Υ|Φ" + + "|Χ|Ψ|Ω|α|β|γ|δ|ε|ζ|η|θ|ι|κ" + + "|λ|μ|ν|ξ|ο|π|ρ|ς|σ|τ|υ|φ|χ|ψ|ω" + + "|ϑ|&Upsih;|ϖ| | | |‌|‍|‎|‏|–|—|‘" + + "|’|‚|“|”|„|†|‡|•|…|‰|′|″" + + "|‹|›|‾|⁄|€|ℑ|℘|ℜ|™|ℵ|←|↑|→" + + "|↓|↔|↵|⇐|&UArr;|⇒|⇓|⇔|∀|∂|∃|∅|∇|∈" + + "|∉|∋|∏|∑|−|∗|√|∝|∞|∠|∧|∨|∩|∪|∫" + + "|∴|∼|≅|≈|≠|≡|≤|≥|⊂|⊃|⊄|⊆|⊇|⊕|⊗" + + "|⊥|⋅|⌈|⌉|⌊|⌋|⟨|⟩|◊|♠|♣|♥|♦)"; + + 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]; + } + }; +} |