Package org.waveprotocol.wave.client.doodad.selection

Source Code of org.waveprotocol.wave.client.doodad.selection.SelectionAnnotationHandler$SessionData

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.  See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership.  The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License.  You may obtain a copy of the License at
*
*   http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.waveprotocol.wave.client.doodad.selection;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;

import org.waveprotocol.wave.client.account.Profile;
import org.waveprotocol.wave.client.account.ProfileListener;
import org.waveprotocol.wave.client.account.ProfileManager;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter.BoundaryFunction;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter.PaintFunction;
import org.waveprotocol.wave.client.editor.content.PainterRegistry;
import org.waveprotocol.wave.client.editor.content.Registries;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.client.scheduler.TimerService;
import org.waveprotocol.wave.model.conversation.AnnotationConstants;
import org.waveprotocol.wave.model.document.AnnotationMutationHandler;
import org.waveprotocol.wave.model.document.MutableDocument;
import org.waveprotocol.wave.model.document.util.DocumentContext;
import org.waveprotocol.wave.model.document.util.LocalDocument;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.ReadableStringMap.ProcV;
import org.waveprotocol.wave.model.util.StringMap;
import org.waveprotocol.wave.model.wave.InvalidParticipantAddress;
import org.waveprotocol.wave.model.wave.ParticipantId;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Queue;

/**
* Deals with rendering of selections.
*
* Currently, a user's selection is defined as a group of two or three annotations.
*
*  - Data annotation, with the prefix {@link #AnnotationConstants.USER_DATA}
*    This annotation always covers the entire document.
*    Its value is of the form "address,timestamp[,compositionstate]" where address is
*    the user's id, timestamp is the number of milliseconds since the Epoch, UTC.
*    An optional composition state may also be included, for indicating uncommitted
*    IME composition text.
*  - Hotspot annotation, with the prefix {@link #AnnotationConstants.USER_END}
*    This annotation starts from where the user's blinking caret would be, and
*    extends to the end of the document.
*    Its value is their address
*  - Range annotation, with the prefix {@link #AnnotationConstants.USER_RANGE}
*    This annotation extends over the user's selected range. if their selection
*    is collapsed, this annotation is not present.
*    Its value is their address.
*
* Each key is suffixed with a globally unique value identifying the current session
* (e.g. one value per browser tab).
*
* Note: This class maintains a permanent mapping of session id to colour
*
* TODO(danilatos): Make this a "per wave" mapping
*
* @author danilatos@google.com (Daniel Danilatos)
*/
public class SelectionAnnotationHandler implements AnnotationMutationHandler, ProfileListener {
  /** Time out for not showing stale carets */
  public static final int STALE_CARET_TIMEOUT_MS = 15 * 1000;

  /**
   * Don't do a stale check more frequently than this
   */
  private static final int MINIMUM_STALE_CHECK_GAP_MS = Math.max(
      STALE_CARET_TIMEOUT_MS / 3, // More frequent than the stale timeout
      5 * 1000); // But, lower bound on the frequency, as this is not a high priority thing.

  public static final int MAX_NAME_LENGTH_FOR_SELECTION_ANNOTATION = 15;

  /**
   * Interface for dealing with marker doodads
   */
  public interface CaretViewFactory {

    /**
     * @return a new marker view
     */
    CaretView createMarker();

    /**
     * Associate a marker with the given element
     *
     * Note that this is not really type safe - the E parameter is more for
     * documentation.
     */
    void setMarker(Object element, CaretView marker);
  }

  /**
   * Installs this doodad.
   */
  public static void register(
      Registries registries, String sessionId, ProfileManager profiles) {
    CaretMarkerRenderer carets = CaretMarkerRenderer.getInstance();
    registries.getElementHandlerRegistry().registerRenderer(
        CaretMarkerRenderer.FULL_TAGNAME, carets);
    register(registries, SchedulerInstance.getLowPriorityTimer(), carets, sessionId, profiles);
  }

  @VisibleForTesting
  static SelectionAnnotationHandler register(Registries registries, TimerService timer,
      CaretViewFactory carets, String sessionId, ProfileManager profiles) {
    Preconditions.checkNotNull(sessionId, "Session Id to ignore must not be null");
    SelectionAnnotationHandler selection = new SelectionAnnotationHandler(
        registries.getPaintRegistry(), sessionId, profiles, timer, carets);
    registries.getAnnotationHandlerRegistry().
      registerHandler(AnnotationConstants.USER_PREFIX, selection);
    profiles.addListener(selection);
    return selection;
  }

  // Do proper random colours at some point...
  private static final RgbColor[] COLOURS = new RgbColor[] {
    new RgbColor(252, 146, 41), // Orange
    new RgbColor(81, 209, 63), // Green
    new RgbColor(183, 68, 209), // Purple
    new RgbColor(59, 201, 209), // Cyan
    new RgbColor(209, 59, 69), // Pinky Red
    new RgbColor(70, 95, 230), // Blue
    new RgbColor(244, 27, 219), // Magenta
    new RgbColor(183, 172, 74), // Vomit
    new RgbColor(114, 50, 38) // Poo
  };

  /**
   * Handy method for getting the full annotation key, given a session id
   *
   * Session id does not have to be THE session id - it can just be any
   * globally unique key for the current client.
   *
   * @param sessionId
   * @return full annotation key
   */
  public static String rangeKey(String sessionId) {
    return AnnotationConstants.USER_RANGE + sessionId;
  }

  public static String endKey(String sessionId) {
    return AnnotationConstants.USER_END + sessionId;
  }

  public static String dataKey(String sessionId) {
    return AnnotationConstants.USER_DATA + sessionId;
  }

  public static String rangeSuffix(String rangeKey) {
    return rangeKey.substring(AnnotationConstants.USER_RANGE.length());
  }

  public static String endSuffix(String endKey) {
    return endKey.substring(AnnotationConstants.USER_END.length());
  }

  public static String dataSuffix(String dataKey) {
    return dataKey.substring(AnnotationConstants.USER_DATA.length());
  }

  private final String ignoreSessionId;

  private final PainterRegistry painterRegistry;

  private final TimerService scheduler;

  // Used for getting profiles, which are needed for choosing names.
  private final ProfileManager profileManager;

  private int currentColourIndex = 0;

  RgbColor grey = new RgbColor(128, 128, 128);
  /** Resolve a single session id into a css colour. */
  public RgbColor getSessionColour(String sessionId) {
    if (!sessions.containsKey(sessionId)) {
      return grey;
    }
    return sessions.get(sessionId).getColour();
  }

  /** Internal helper that rotates through the colours. */
  private RgbColor getNextColour() {
    RgbColor colour = COLOURS[currentColourIndex];
    currentColourIndex = (currentColourIndex + 1) % COLOURS.length;
    return colour;
  }

  /**
   * Information required for book-keeping and managing the logic of rendering
   * each session's caret marker and selection.
   */
  class SessionData {
    /** UI for rendering the marker associated with this user session */
    private final CaretView ui;

    /** The address of the user session (1:n mapping of address:session) */
    private final String address;

    /** Session connected to this user. */
    private final String sessionId;

    /** Assigned colour */
    private final RgbColor color;

    /** Time at which caret will expire */
    private double expiry;

    /**
     * Implementation detail, the value of {@link #expiry} when this object was
     * placed in the expiry queue, to ensure queue stability.
     */
    private double originallyScheduledExpiry;

    /**
     * Document in which the session's caret is currently rendered. This is used
     * for book-keeping and to be able to re-render relevant sections of the
     * document.
     */
    private DocumentContext<?, ?, ?> bundle;

    /**
     * Cache of the name reported in the UI - to avoid re-setting the name if it
     * does not change
     */
    private String name;

    SessionData(CaretView ui, String address, String sessionId, RgbColor color) {
      if (sessions.containsKey(sessionId)) {
        throw new IllegalArgumentException("Session data already exists");
      }

      this.address = address;

      sessions.put(sessionId, this);

      this.ui = ui;
      this.sessionId = sessionId;
      this.color = color;

      ui.setColor(color);
    }

    void replaceName(Profile profile) {
      String newName = profile.getFirstName().replace(' ', '\u00a0');
      if (!newName.equals(name)) {
        name = newName;
        ui.setName(name);
      }
    }

    public void compositionStateUpdated(String newState) {
      ui.setCompositionState(newState);
    }

    public boolean isStale() {
      return scheduler.currentTimeMillis() > expiry;
    }

    public RgbColor getColour() {
      return color;
    }
  }

  private final StringMap<String> highlightCache = CollectionUtils.createStringMap();

  private final CaretViewFactory markerFactory;

  private String getUsersHighlight(String sessions) {
    if (!highlightCache.containsKey(sessions)) {
      // comma-split:
      String[] sessionIDs = sessions.split(",");
      List<RgbColor> colours = new ArrayList<RgbColor>();
      for (String id : sessionIDs) {
        if (!"".equals(id)) {
          colours.add(getSessionColour(id));
        }
      }
      // average out the colours, then reduce opacity by averaging against white.
      RgbColor lighter = average(Arrays.asList(average(colours), RgbColor.WHITE));
      highlightCache.put(sessions, lighter.getCssColor());
    }
    return highlightCache.get(sessions);
  }

  private static RgbColor average(Collection<RgbColor> colors) {
    int size = colors.size();
    int red = 0, green = 0, blue = 0;
    for (RgbColor color : colors) {
      red += color.red;
      green += color.green;
      blue += color.blue;
    }
    return size == 0 ? RgbColor.BLACK : new RgbColor(red / size, green / size, blue / size);
  }

  private final PaintFunction spreadFunc = new PaintFunction() {
    public Map<String, String> apply(Map<String, Object> from, boolean isEditing) {
      // discover which sessions have hilighted this range:
      String sessions = "";
      for (Map.Entry<String, Object> entry : from.entrySet()) {
        if (entry.getKey().startsWith(AnnotationConstants.USER_RANGE)) {
          String sessionId = endSuffix(entry.getKey());
          String address = (String) entry.getValue();
          if (address == null || getActiveSessionData(sessionId) == null) {
            continue;
          }
          sessions += sessionId + ",";
        }
      }

      // combine them together and hilight the range accordingly:
      if (!sessions.equals("")) {
        return Collections.singletonMap("backgroundColor", getUsersHighlight(sessions));
      } else {
        return Collections.emptyMap();
      }
    }
  };

  private final BoundaryFunction boundaryFunc = new BoundaryFunction() {
    public <N, E extends N, T extends N> E apply(LocalDocument<N, E, T> localDoc, E parent,
        N nodeAfter, Map<String, Object> before, Map<String, Object> after, boolean isEditing) {

      E ret = null;
      E usersContainer = null;

      for (Map.Entry<String, Object> entry : after.entrySet()) {
        if (entry.getKey().startsWith(AnnotationConstants.USER_END)) {
          // get the user's address:
          String address = (String) entry.getValue();
          if (address == null) {
            continue;
          }

          // get the session ID:
          String sessionId = endSuffix(entry.getKey());
          SessionData data = getActiveSessionData(sessionId);
          if (data == null) {
            continue;
          }

          // if needed, first create a simple container to put caret DOMs into:
          if (usersContainer == null) {
            ret = localDoc.transparentCreate(
                CaretMarkerRenderer.FULL_TAGNAME, Collections.<String, String>emptyMap(),
                parent, nodeAfter);
            usersContainer = ret;

          }

          markerFactory.setMarker(usersContainer, data.ui);
        }
      }
      return ret;
    }
  };

  public SessionData getActiveSessionData(String sessionId) {
    SessionData data = sessions.get(sessionId);
    return data != null && !data.isStale() ? data : null;
  }

  /** Seed the annotation handler with all required config objects. */
  public SelectionAnnotationHandler(PainterRegistry registry,
      String ignoreSessionId,
      ProfileManager profileManager,
      TimerService timer, CaretViewFactory markerFactory) {
    this.painterRegistry = registry;
    this.ignoreSessionId = ignoreSessionId;
    this.profileManager = profileManager;
    this.scheduler = timer;
    this.markerFactory = markerFactory;
  }

  private void updateCaretData(String sessionId, String value, DocumentContext<?, ?, ?> doc) {
    String[] components = value.split(",");
    if (components.length < 2) {
      return; // invalid input
    }

    double timeStamp;
    try {
      // split into session address and time
      timeStamp = Double.parseDouble(components[1]);
    } catch (NumberFormatException nfe) {
      return; // invalid input
    }

    String address = components[0];

    // Access directly from the map because the high level getter filters stale carets,
    // and this could result in memory leaks.
    SessionData data = sessions.get(sessionId);
    if (data == null) {
      data = new SessionData(markerFactory.createMarker(), address, sessionId, getNextColour());
    }
    double expiry = Math.min(timeStamp, scheduler.currentTimeMillis()) + STALE_CARET_TIMEOUT_MS;
    activate(data, expiry, doc);

    data.compositionStateUpdated(components.length >= 3 ? components[2] : "");
  }

  private final Scheduler.IncrementalTask expiryTask = new Scheduler.IncrementalTask() {
    @Override
    public boolean execute() {
      while (!expiries.isEmpty()) {
        SessionData data = expiries.element();

        if (data.originallyScheduledExpiry > scheduler.currentTimeMillis()) {
          return true;
        }

        expiries.remove();

        if (data.expiry > scheduler.currentTimeMillis()) {
          data.originallyScheduledExpiry = data.expiry;
          expiries.add(data);
        } else {
          expire(data);
        }
      }

      return false;
    }
  };

  /**
   * Cleanup any state associated with expired selection annotation data
   *
   * @param data expired data
   */
  @SuppressWarnings("unchecked")
  private void expire(SessionData data) {
    DocumentContext<?, ?, ?> bundle = data.bundle;
    MutableDocument<?, ?, ?> document = bundle.document();

    data.bundle = null;
    painterRegistry.unregisterBoundaryFunction(
        CollectionUtils.newStringSet(AnnotationConstants.USER_END + data.sessionId), boundaryFunc);
    painterRegistry.unregisterPaintFunction(
        CollectionUtils.newStringSet(AnnotationConstants.USER_RANGE + data.sessionId), spreadFunc);

    int size = document.size();
    int rangeStart = document.firstAnnotationChange(0, size, AnnotationConstants.USER_RANGE + data.sessionId, null);
    int rangeEnd = document.lastAnnotationChange(0, size, AnnotationConstants.USER_RANGE + data.sessionId, null);
    int hotSpot = document.firstAnnotationChange(0, size, AnnotationConstants.USER_END + data.sessionId, null);

    if (rangeStart == -1) {
      rangeStart = rangeEnd = hotSpot;
    }

    /*
    TODO(danilatos): Enable this code. Problems to resolve:
    1. It causes mutations just from the renderer. Rather the cleanup is best done
       in the same place the annotations are set - move it to another class.
    2. It could result in a large number of operations being generated at the same time
       by multiple clients
    3. It will cause the handleAnnotationChange method to get called, which will
       re-register the paint functions we just cleaned up.

    if (data.address.equals(currentUserAddress)) {
      document.setAnnotation(0, size, AnnotationConstants.USER_DATA + data.sessionId, null);
      if (rangeStart >= 0) {
        assert rangeEnd > rangeStart;
        document.setAnnotation(rangeStart, rangeEnd, AnnotationConstants.USER_RANGE + data.sessionId, null);
      }
      if (hotSpot >= 0) {
        document.setAnnotation(hotSpot, size, AnnotationConstants.USER_END + data.sessionId, null);
      }
    }
    */

    if (hotSpot >= 0) {
      AnnotationPainter.maybeScheduleRepaint((DocumentContext) bundle, rangeStart, rangeEnd);
    }
  }

  private void activate(SessionData data, double expiry, DocumentContext<?, ?, ?> doc) {
    data.expiry = expiry;
    data.originallyScheduledExpiry = expiry;

    if (data.bundle == null) {
      Profile profile;
      try {
        profile = profileManager.getProfile(ParticipantId.of(data.address));
      } catch (InvalidParticipantAddress e) {
        profile = null;
      }
      if (profile != null) {
        data.replaceName(profile);
      }
      expiries.add(data);
    }

    data.bundle = doc;

    if (!scheduler.isScheduled(expiryTask)) {
      scheduler.scheduleRepeating(expiryTask, MINIMUM_STALE_CHECK_GAP_MS,
          MINIMUM_STALE_CHECK_GAP_MS);
    }
  }

  private final StringMap<SessionData> sessions = CollectionUtils.createStringMap();

  private final Queue<SessionData> expiries = new PriorityQueue<SessionData>(10,
      new Comparator<SessionData>() {
        @Override
        public int compare(SessionData o1, SessionData o2) {
          return (int) Math.signum(o1.originallyScheduledExpiry - o2.originallyScheduledExpiry);
        }
      });

  @VisibleForTesting CaretView getUiForSession(String session) {
    return sessions.get(session).ui;
  }

  @Override
  public <N, E extends N, T extends N> void handleAnnotationChange(DocumentContext<N, E, T> bundle,
      int start, int end, String key, Object newValue) {
    // skip if we shouldn't render any carets, or this particular caret.
    if (key.endsWith("/" + ignoreSessionId)) {
      return;
    }

    if (key.startsWith(AnnotationConstants.USER_DATA) && newValue != null) {
      updateCaretData(dataSuffix(key), (String) newValue, bundle);
    } else if (key.startsWith(AnnotationConstants.USER_RANGE)) {
      painterRegistry.registerPaintFunction(
          CollectionUtils.newStringSet(key), spreadFunc);
      painterRegistry.getPainter().scheduleRepaint(bundle, start, end);

    } else {
      painterRegistry.registerBoundaryFunction(
          CollectionUtils.newStringSet(key), boundaryFunc);
      painterRegistry.getPainter().scheduleRepaint(bundle, start, start + 1);

      if (end == bundle.document().size()) {
        end--;
      }
      painterRegistry.getPainter().scheduleRepaint(bundle, end, end + 1);
    }
  }

  //
  // Profile events.
  //

  @Override
  public void onProfileUpdated(final Profile profile) {
    final String profileAddress = profile.getAddress();
    sessions.each(new ProcV<SessionData>() {
      @Override
      public void apply(String _, SessionData value) {
        if (value.address.equals(profileAddress) && !value.isStale()) {
          value.replaceName(profile);
        }
      }
    });
  }
}
TOP

Related Classes of org.waveprotocol.wave.client.doodad.selection.SelectionAnnotationHandler$SessionData

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.