// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.collaboration; import com.google.collide.client.AppContext; import com.google.collide.client.bootstrap.BootstrapSession; import com.google.collide.client.code.Participant; import com.google.collide.client.code.ParticipantModel; import com.google.collide.client.editor.Buffer; import com.google.collide.client.editor.selection.CursorView; import com.google.collide.dto.DocumentSelection; import com.google.collide.dto.client.DtoClientImpls.DocumentSelectionImpl; import com.google.collide.dto.client.DtoClientImpls.FilePositionImpl; import com.google.collide.json.shared.JsonStringMap; import com.google.collide.json.shared.JsonStringMap.IterationCallback; import com.google.collide.shared.document.Document; import com.google.collide.shared.document.Line; import com.google.collide.shared.document.LineInfo; import com.google.collide.shared.document.anchor.Anchor; import com.google.collide.shared.document.anchor.AnchorType; import com.google.collide.shared.document.anchor.Anchor.RemovalStrategy; import com.google.collide.shared.util.JsonCollections; import elemental.util.Timer; import javax.annotation.Nullable; /** * A controller for the collaborators' cursors. */ class CollaboratorCursorController { private class CollaboratorState { private Anchor anchor; private CursorView cursorView; private Timer inactiveTimer = new Timer() { @Override public void run() { cursorView.setVisibility(false); } }; CollaboratorState(Anchor anchor, CursorView cursorView) { this.anchor = anchor; this.cursorView = cursorView; } void markAsActive() { inactiveTimer.schedule(INACTIVE_DELAY_MS); if (!cursorView.isVisible()) { cursorView.setVisibility(true); } } } private static final AnchorType COLLABORATOR_CURSOR_ANCHOR_TYPE = AnchorType.create( CollaboratorCursorController.class, "collaboratorCursor"); private static final int INACTIVE_DELAY_MS = 10 * 1000; private final AppContext appContext; private final Buffer buffer; private final JsonStringMap<CollaboratorState> collaboratorStates = JsonCollections.createMap(); private final Document document; private final ParticipantModel participantModel; private final ParticipantModel.Listener participantModelListener = new ParticipantModel.Listener() { @Override public void participantRemoved(Participant participant) { CollaboratorState collaboratorState = collaboratorStates.get(participant.getUserId()); if (collaboratorState == null) { return; } document.getAnchorManager().removeAnchor(collaboratorState.anchor); } @Override public void participantAdded(Participant participant) { CollaboratorState collaboratorState = collaboratorStates.get(participant.getUserId()); if (collaboratorState == null) { return; } collaboratorState.cursorView.setColor(participant.getColor()); } }; CollaboratorCursorController(AppContext appContext, Document document, Buffer buffer, ParticipantModel participantModel, JsonStringMap<DocumentSelection> collaboratorSelections) { this.appContext = appContext; this.buffer = buffer; this.document = document; this.participantModel = participantModel; participantModel.addListener(participantModelListener); collaboratorSelections.iterate(new IterationCallback<DocumentSelection>() { @Override public void onIteration(String userId, DocumentSelection selection) { try { handleSelectionChangeWithUserId(userId, selection); } catch (Throwable t) { /* * TODO: There's a known bug that if a document is not open in the editor, * our cached collaborator selections won't get transformed with incoming doc ops. This * means that it's possible the selection is out of the actual document's range (either * line number or column is too big.) */ } } }); } void handleSelectionChange(String clientId, @Nullable DocumentSelection selection) { handleSelectionChangeWithUserId(participantModel.getUserId(clientId), selection); } private void handleSelectionChangeWithUserId(String userId, @Nullable DocumentSelection selection) { if (BootstrapSession.getBootstrapSession().getUserId().equals(userId)) { // Do not draw a collaborator cursor for our user return; } // If a user is typing, a selection will likely be null (since it isn't an explicit move) if (selection != null) { LineInfo lineInfo = document.getLineFinder().findLine(selection.getCursorPosition().getLineNumber()); int cursorColumn = selection.getCursorPosition().getColumn(); if (!collaboratorStates.containsKey(selection.getUserId())) { createCursor(selection.getUserId(), lineInfo.line(), lineInfo.number(), cursorColumn); } else { document.getAnchorManager().moveAnchor(collaboratorStates.get(selection.getUserId()).anchor, lineInfo.line(), lineInfo.number(), cursorColumn); } } if (collaboratorStates.containsKey(userId)) { collaboratorStates.get(userId).markAsActive(); } } void teardown() { participantModel.removeListener(participantModelListener); collaboratorStates.iterate(new IterationCallback<CollaboratorState>() { @Override public void onIteration(String key, CollaboratorState collaboratorState) { document.getAnchorManager().removeAnchor(collaboratorState.anchor); } }); } private void createCursor(final String userId, Line line, int lineNumber, int column) { final CursorView cursorView = CursorView.create(appContext, false); cursorView.setVisibility(true); Participant participant = participantModel.getParticipantByUserId(userId); if (participant != null) { /* * If the participant exists already, set his color (otherwise the * participant model listener will set the color) */ cursorView.setColor(participant.getColor()); } Anchor anchor = document.getAnchorManager().createAnchor(COLLABORATOR_CURSOR_ANCHOR_TYPE, line, lineNumber, column); anchor.setRemovalStrategy(RemovalStrategy.SHIFT); buffer.addAnchoredElement(anchor, cursorView.getElement()); collaboratorStates.put(userId, new CollaboratorState(anchor, cursorView)); } public JsonStringMap<DocumentSelection> getSelectionsMap() { final JsonStringMap<DocumentSelection> map = JsonCollections.createMap(); collaboratorStates.iterate( new IterationCallback<CollaboratorCursorController.CollaboratorState>() { @Override public void onIteration(String userId, CollaboratorState state) { FilePositionImpl basePosition = FilePositionImpl.make().setColumn( state.anchor.getColumn()).setLineNumber(state.anchor.getLineNumber()); FilePositionImpl cursorPosition = FilePositionImpl.make().setColumn( state.anchor.getColumn()).setLineNumber(state.anchor.getLineNumber()); DocumentSelectionImpl selection = DocumentSelectionImpl.make() .setBasePosition(basePosition).setCursorPosition(cursorPosition).setUserId(userId); map.put(userId, selection); } }); return map; } }