1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/base/TextSelection.java Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,322 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file, 1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +package org.mozilla.gecko; 1.9 + 1.10 +import org.mozilla.gecko.gfx.BitmapUtils; 1.11 +import org.mozilla.gecko.gfx.BitmapUtils.BitmapLoader; 1.12 +import org.mozilla.gecko.gfx.Layer; 1.13 +import org.mozilla.gecko.gfx.LayerView; 1.14 +import org.mozilla.gecko.gfx.LayerView.DrawListener; 1.15 +import org.mozilla.gecko.menu.GeckoMenu; 1.16 +import org.mozilla.gecko.menu.GeckoMenuItem; 1.17 +import org.mozilla.gecko.EventDispatcher; 1.18 +import org.mozilla.gecko.util.FloatUtils; 1.19 +import org.mozilla.gecko.util.GeckoEventListener; 1.20 +import org.mozilla.gecko.util.ThreadUtils; 1.21 +import org.mozilla.gecko.ActionModeCompat.Callback; 1.22 + 1.23 +import android.content.Context; 1.24 +import android.app.Activity; 1.25 +import android.graphics.drawable.Drawable; 1.26 +import android.view.Menu; 1.27 +import android.view.MenuItem; 1.28 + 1.29 +import org.json.JSONArray; 1.30 +import org.json.JSONException; 1.31 +import org.json.JSONObject; 1.32 + 1.33 +import java.util.Timer; 1.34 +import java.util.TimerTask; 1.35 + 1.36 +import android.util.Log; 1.37 +import android.view.View; 1.38 + 1.39 +class TextSelection extends Layer implements GeckoEventListener { 1.40 + private static final String LOGTAG = "GeckoTextSelection"; 1.41 + 1.42 + private final TextSelectionHandle mStartHandle; 1.43 + private final TextSelectionHandle mMiddleHandle; 1.44 + private final TextSelectionHandle mEndHandle; 1.45 + private final EventDispatcher mEventDispatcher; 1.46 + 1.47 + private final DrawListener mDrawListener; 1.48 + private boolean mDraggingHandles; 1.49 + 1.50 + private float mViewLeft; 1.51 + private float mViewTop; 1.52 + private float mViewZoom; 1.53 + 1.54 + private String mCurrentItems; 1.55 + 1.56 + private TextSelectionActionModeCallback mCallback; 1.57 + 1.58 + // These timers are used to avoid flicker caused by selection handles showing/hiding quickly. For isntance 1.59 + // when moving between single handle caret mode and two handle selection mode. 1.60 + private Timer mActionModeTimer = new Timer("actionMode"); 1.61 + private class ActionModeTimerTask extends TimerTask { 1.62 + @Override 1.63 + public void run() { 1.64 + ThreadUtils.postToUiThread(new Runnable() { 1.65 + @Override 1.66 + public void run() { 1.67 + endActionMode(); 1.68 + } 1.69 + }); 1.70 + } 1.71 + }; 1.72 + private ActionModeTimerTask mActionModeTimerTask; 1.73 + 1.74 + TextSelection(TextSelectionHandle startHandle, 1.75 + TextSelectionHandle middleHandle, 1.76 + TextSelectionHandle endHandle, 1.77 + EventDispatcher eventDispatcher, 1.78 + GeckoApp activity) { 1.79 + mStartHandle = startHandle; 1.80 + mMiddleHandle = middleHandle; 1.81 + mEndHandle = endHandle; 1.82 + mEventDispatcher = eventDispatcher; 1.83 + 1.84 + mDrawListener = new DrawListener() { 1.85 + @Override 1.86 + public void drawFinished() { 1.87 + if (!mDraggingHandles) { 1.88 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("TextSelection:LayerReflow", "")); 1.89 + } 1.90 + } 1.91 + }; 1.92 + 1.93 + // Only register listeners if we have valid start/middle/end handles 1.94 + if (mStartHandle == null || mMiddleHandle == null || mEndHandle == null) { 1.95 + Log.e(LOGTAG, "Failed to initialize text selection because at least one handle is null"); 1.96 + } else { 1.97 + registerEventListener("TextSelection:ShowHandles"); 1.98 + registerEventListener("TextSelection:HideHandles"); 1.99 + registerEventListener("TextSelection:PositionHandles"); 1.100 + registerEventListener("TextSelection:Update"); 1.101 + registerEventListener("TextSelection:DraggingHandle"); 1.102 + } 1.103 + } 1.104 + 1.105 + void destroy() { 1.106 + unregisterEventListener("TextSelection:ShowHandles"); 1.107 + unregisterEventListener("TextSelection:HideHandles"); 1.108 + unregisterEventListener("TextSelection:PositionHandles"); 1.109 + unregisterEventListener("TextSelection:Update"); 1.110 + unregisterEventListener("TextSelection:DraggingHandle"); 1.111 + } 1.112 + 1.113 + private TextSelectionHandle getHandle(String name) { 1.114 + if (name.equals("START")) { 1.115 + return mStartHandle; 1.116 + } else if (name.equals("MIDDLE")) { 1.117 + return mMiddleHandle; 1.118 + } else { 1.119 + return mEndHandle; 1.120 + } 1.121 + } 1.122 + 1.123 + @Override 1.124 + public void handleMessage(final String event, final JSONObject message) { 1.125 + if ("TextSelection:DraggingHandle".equals(event)) { 1.126 + mDraggingHandles = message.optBoolean("dragging", false); 1.127 + return; 1.128 + } 1.129 + 1.130 + ThreadUtils.postToUiThread(new Runnable() { 1.131 + @Override 1.132 + public void run() { 1.133 + try { 1.134 + if (event.equals("TextSelection:ShowHandles")) { 1.135 + final JSONArray handles = message.getJSONArray("handles"); 1.136 + for (int i=0; i < handles.length(); i++) { 1.137 + String handle = handles.getString(i); 1.138 + getHandle(handle).setVisibility(View.VISIBLE); 1.139 + } 1.140 + 1.141 + mViewLeft = 0.0f; 1.142 + mViewTop = 0.0f; 1.143 + mViewZoom = 0.0f; 1.144 + 1.145 + // Create text selection layer and add draw-listener for positioning on reflows 1.146 + LayerView layerView = GeckoAppShell.getLayerView(); 1.147 + if (layerView != null) { 1.148 + layerView.addDrawListener(mDrawListener); 1.149 + layerView.addLayer(TextSelection.this); 1.150 + } 1.151 + 1.152 + if (handles.length() > 1) 1.153 + GeckoAppShell.performHapticFeedback(true); 1.154 + } else if (event.equals("TextSelection:Update")) { 1.155 + if (mActionModeTimerTask != null) 1.156 + mActionModeTimerTask.cancel(); 1.157 + showActionMode(message.getJSONArray("actions")); 1.158 + } else if (event.equals("TextSelection:HideHandles")) { 1.159 + // Remove draw-listener and text selection layer 1.160 + LayerView layerView = GeckoAppShell.getLayerView(); 1.161 + if (layerView != null) { 1.162 + layerView.removeDrawListener(mDrawListener); 1.163 + layerView.removeLayer(TextSelection.this); 1.164 + } 1.165 + 1.166 + mActionModeTimerTask = new ActionModeTimerTask(); 1.167 + mActionModeTimer.schedule(mActionModeTimerTask, 250); 1.168 + 1.169 + mStartHandle.setVisibility(View.GONE); 1.170 + mMiddleHandle.setVisibility(View.GONE); 1.171 + mEndHandle.setVisibility(View.GONE); 1.172 + } else if (event.equals("TextSelection:PositionHandles")) { 1.173 + final boolean rtl = message.getBoolean("rtl"); 1.174 + final JSONArray positions = message.getJSONArray("positions"); 1.175 + for (int i=0; i < positions.length(); i++) { 1.176 + JSONObject position = positions.getJSONObject(i); 1.177 + int left = position.getInt("left"); 1.178 + int top = position.getInt("top"); 1.179 + 1.180 + TextSelectionHandle handle = getHandle(position.getString("handle")); 1.181 + handle.setVisibility(position.getBoolean("hidden") ? View.GONE : View.VISIBLE); 1.182 + handle.positionFromGecko(left, top, rtl); 1.183 + } 1.184 + } 1.185 + } catch (JSONException e) { 1.186 + Log.e(LOGTAG, "JSON exception", e); 1.187 + } 1.188 + } 1.189 + }); 1.190 + } 1.191 + 1.192 + private void showActionMode(final JSONArray items) { 1.193 + String itemsString = items.toString(); 1.194 + if (itemsString.equals(mCurrentItems)) { 1.195 + return; 1.196 + } 1.197 + mCurrentItems = itemsString; 1.198 + 1.199 + if (mCallback != null) { 1.200 + mCallback.updateItems(items); 1.201 + return; 1.202 + } 1.203 + 1.204 + final Context context = mStartHandle.getContext(); 1.205 + if (context instanceof ActionModeCompat.Presenter) { 1.206 + final ActionModeCompat.Presenter presenter = (ActionModeCompat.Presenter) context; 1.207 + mCallback = new TextSelectionActionModeCallback(items); 1.208 + presenter.startActionModeCompat(mCallback); 1.209 + } 1.210 + } 1.211 + 1.212 + private void endActionMode() { 1.213 + Context context = mStartHandle.getContext(); 1.214 + if (context instanceof ActionModeCompat.Presenter) { 1.215 + final ActionModeCompat.Presenter presenter = (ActionModeCompat.Presenter) context; 1.216 + presenter.endActionModeCompat(); 1.217 + } 1.218 + mCurrentItems = null; 1.219 + } 1.220 + 1.221 + @Override 1.222 + public void draw(final RenderContext context) { 1.223 + // cache the relevant values from the context and bail out if they are the same. we do this 1.224 + // because this draw function gets called a lot (once per compositor frame) and we want to 1.225 + // avoid doing a lot of extra work in cases where it's not needed. 1.226 + final float viewLeft = context.viewport.left - context.offset.x; 1.227 + final float viewTop = context.viewport.top - context.offset.y; 1.228 + final float viewZoom = context.zoomFactor; 1.229 + 1.230 + if (FloatUtils.fuzzyEquals(mViewLeft, viewLeft) 1.231 + && FloatUtils.fuzzyEquals(mViewTop, viewTop) 1.232 + && FloatUtils.fuzzyEquals(mViewZoom, viewZoom)) { 1.233 + return; 1.234 + } 1.235 + mViewLeft = viewLeft; 1.236 + mViewTop = viewTop; 1.237 + mViewZoom = viewZoom; 1.238 + 1.239 + ThreadUtils.postToUiThread(new Runnable() { 1.240 + @Override 1.241 + public void run() { 1.242 + mStartHandle.repositionWithViewport(viewLeft, viewTop, viewZoom); 1.243 + mMiddleHandle.repositionWithViewport(viewLeft, viewTop, viewZoom); 1.244 + mEndHandle.repositionWithViewport(viewLeft, viewTop, viewZoom); 1.245 + } 1.246 + }); 1.247 + } 1.248 + 1.249 + private void registerEventListener(String event) { 1.250 + mEventDispatcher.registerEventListener(event, this); 1.251 + } 1.252 + 1.253 + private void unregisterEventListener(String event) { 1.254 + mEventDispatcher.unregisterEventListener(event, this); 1.255 + } 1.256 + 1.257 + private class TextSelectionActionModeCallback implements Callback { 1.258 + private JSONArray mItems; 1.259 + private ActionModeCompat mActionMode; 1.260 + 1.261 + public TextSelectionActionModeCallback(JSONArray items) { 1.262 + mItems = items; 1.263 + } 1.264 + 1.265 + public void updateItems(JSONArray items) { 1.266 + mItems = items; 1.267 + if (mActionMode != null) { 1.268 + mActionMode.invalidate(); 1.269 + } 1.270 + } 1.271 + 1.272 + @Override 1.273 + public boolean onPrepareActionMode(final ActionModeCompat mode, final Menu menu) { 1.274 + // Android would normally expect us to only update the state of menu items here 1.275 + // To make the js-java interaction a bit simpler, we just wipe out the menu here and recreate all 1.276 + // the javascript menu items in onPrepare instead. This will be called any time invalidate() is called on the 1.277 + // action mode. 1.278 + menu.clear(); 1.279 + 1.280 + int length = mItems.length(); 1.281 + for (int i = 0; i < length; i++) { 1.282 + try { 1.283 + final JSONObject obj = mItems.getJSONObject(i); 1.284 + final GeckoMenuItem menuitem = (GeckoMenuItem) menu.add(0, i, 0, obj.optString("label")); 1.285 + final int actionEnum = obj.optBoolean("showAsAction") ? GeckoMenuItem.SHOW_AS_ACTION_ALWAYS : GeckoMenuItem.SHOW_AS_ACTION_NEVER; 1.286 + menuitem.setShowAsAction(actionEnum, R.attr.menuItemActionModeStyle); 1.287 + 1.288 + BitmapUtils.getDrawable(mStartHandle.getContext(), obj.optString("icon"), new BitmapLoader() { 1.289 + public void onBitmapFound(Drawable d) { 1.290 + if (d != null) { 1.291 + menuitem.setIcon(d); 1.292 + } 1.293 + } 1.294 + }); 1.295 + } catch(Exception ex) { 1.296 + Log.i(LOGTAG, "Exception building menu", ex); 1.297 + } 1.298 + } 1.299 + return true; 1.300 + } 1.301 + 1.302 + public boolean onCreateActionMode(ActionModeCompat mode, Menu menu) { 1.303 + mActionMode = mode; 1.304 + return true; 1.305 + } 1.306 + 1.307 + public boolean onActionItemClicked(ActionModeCompat mode, MenuItem item) { 1.308 + try { 1.309 + final JSONObject obj = mItems.getJSONObject(item.getItemId()); 1.310 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("TextSelection:Action", obj.optString("id"))); 1.311 + return true; 1.312 + } catch(Exception ex) { 1.313 + Log.i(LOGTAG, "Exception calling action", ex); 1.314 + } 1.315 + return false; 1.316 + } 1.317 + 1.318 + // Called when the user exits the action mode 1.319 + public void onDestroyActionMode(ActionModeCompat mode) { 1.320 + mActionMode = null; 1.321 + mCallback = null; 1.322 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("TextSelection:End", null)); 1.323 + } 1.324 + } 1.325 +}