// 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.shared.document; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.document.TextChange.Type; import com.google.collide.shared.document.anchor.Anchor; import com.google.collide.shared.document.anchor.AnchorManager; import com.google.collide.shared.document.util.LineUtils; import com.google.collide.shared.util.JsonCollections; import com.google.collide.shared.util.StringUtils; import com.google.common.base.Preconditions; /** * Mutator for the document that provides high-level document mutation API. The * {@link Document} delegates to this class to actually perform the mutations. */ class DocumentMutatorImpl implements DocumentMutator { private class TextDeleter { private final JsonArray<Anchor> anchorsInDeletedRangeToRemove = JsonCollections.createArray(); private final JsonArray<Anchor> anchorsInDeletedRangeToShift = JsonCollections.createArray(); private final JsonArray<Anchor> anchorsLeftoverFromLastLine = JsonCollections.createArray(); private int column; private int deleteCountForCurLine; private final int firstLineColumn; private final Line firstLine; private final int firstLineNumber; private final String firstLineChunk; private Line curLine; private int curLineNumber; private int remainingDeleteCount; private TextDeleter(Line line, int lineNumber, int column, String deletedText) { firstLine = this.curLine = line; firstLineNumber = this.curLineNumber = lineNumber; firstLineColumn = this.column = column; this.remainingDeleteCount = deletedText.length(); firstLineChunk = line.getText().substring(0, column); } void delete() { JsonArray<Line> removedLines = JsonCollections.createArray(); boolean wasNewlineCharDeleted = deleteFromCurLine(true); // All deletes on subsequent lines will start at column 0 if (remainingDeleteCount > 0) { column = 0; do { iterateToNextLine(); wasNewlineCharDeleted = deleteFromCurLine(false); removedLines.add(curLine); } while (remainingDeleteCount > 0); } if (wasNewlineCharDeleted) { /* * Must join the next line with the current line. Setting * deleteCountForLine = 0 will have a nice effect of naturally joining * the line. */ iterateToNextLine(); column = 0; deleteCountForCurLine = 0; removeLineImpl(curLine); removedLines.add(curLine); } // Move any leftover text on the last line to the first line boolean lastLineIsEmpty = curLine.getText().length() == 0; boolean lastLineWillHaveLeftoverText = deleteCountForCurLine < curLine.getText().length(); int lastLineFirstUntouchedColumn = column + deleteCountForCurLine; if (lastLineWillHaveLeftoverText || lastLineIsEmpty) { anchorManager.handleTextDeletionLastLineLeftover(anchorsLeftoverFromLastLine, firstLine, curLine, lastLineFirstUntouchedColumn); } String lastLineChunk = curLine.getText().substring(lastLineFirstUntouchedColumn); firstLine.setText(firstLineChunk + lastLineChunk); int numberOfDeletedLines = curLineNumber - firstLineNumber; anchorManager.handleTextDeletionFinished(anchorsInDeletedRangeToRemove, anchorsInDeletedRangeToShift, anchorsLeftoverFromLastLine, firstLine, firstLineNumber, firstLineColumn, numberOfDeletedLines, lastLineFirstUntouchedColumn); if (numberOfDeletedLines > 0) { document.commitLineCountChange(-numberOfDeletedLines); document.dispatchLineRemoved(firstLineNumber + 1, removedLines); } } /** * Deletes the current line's text to be deleted. * * @return whether a newline character was deleted */ private boolean deleteFromCurLine(boolean isFirstLine) { int maxDeleteCountForCurLine = curLine.getText().length() - column; deleteCountForCurLine = Math.min(maxDeleteCountForCurLine, remainingDeleteCount); anchorManager.handleTextPredeletionForLine(curLine, column, deleteCountForCurLine, anchorsInDeletedRangeToRemove, anchorsInDeletedRangeToShift, isFirstLine); /* * All lines but the first should be removed from the document (either * they have no text remaining, or in the case of a partial selection on * the last line, the leftover text will be moved to the first line.) */ if (!isFirstLine) { removeLineImpl(curLine); } remainingDeleteCount -= deleteCountForCurLine; int lastCharDeletedIndex = column + deleteCountForCurLine - 1; return lastCharDeletedIndex >= 0 ? curLine.getText().charAt(lastCharDeletedIndex) == '\n' : false; } private void iterateToNextLine() { curLine = curLine.getNextLine(); curLineNumber++; ensureCurLine(); } private void ensureCurLine() { if (curLine == null) { throw new IndexOutOfBoundsException( "Reached end of document so could not delete the requested remaining " + remainingDeleteCount + " characters"); } } } private AnchorManager anchorManager; private final Document document; private final JsonArray<TextChange> textChanges; DocumentMutatorImpl(Document document) { this.document = document; this.anchorManager = document.getAnchorManager(); textChanges = JsonCollections.createArray(); } @Override public TextChange deleteText(Line line, int column, int deleteCount) { return deleteText(line, document.getLineFinder().findLine(line).number(), column, deleteCount); } @Override public TextChange deleteText(Line line, int lineNumber, int column, int deleteCount) { if (deleteCount == 0) { // Delete 0 is a NOOP. return TextChange.createDeletion(line, lineNumber, column, ""); } if (column >= line.getText().length()) { throw new IndexOutOfBoundsException("Attempt to delete text at column " + column + " which is greater than line length " + line.getText().length() + "(line text is: " + line.getText() + ")"); } String deletedText = document.getText(line, column, deleteCount); beginHighLevelModification(TextChange.Type.DELETE, line, lineNumber, column, deletedText); TextChange textChange = TextChange.createDeletion(line, lineNumber, column, deletedText); textChanges.add(textChange); deleteTextImpl(line, lineNumber, column, deletedText); endHighLevelModification(); return textChange; } @Override public TextChange insertText(Line line, int column, String text) { return insertText(line, document.getLineFinder().findLine(line).number(), column, text); } @Override public TextChange insertText(Line line, int lineNumber, int column, String text) { if (column > LineUtils.getLastCursorColumn(line)) { throw new IndexOutOfBoundsException("Attempt to insert text at column " + column + " which is greater than line length " + line.getText().length() + "(line text is: " + line.getText() + ")"); } beginHighLevelModification(TextChange.Type.INSERT, line, lineNumber, column, text); LineInfo lastLineModified = insertTextImpl(line, lineNumber, column, text); TextChange textChange = TextChange.createInsertion(line, lineNumber, column, lastLineModified.line(), lastLineModified.number(), text); textChanges.add(textChange); endHighLevelModification(); return textChange; } @Override public TextChange insertText(Line line, int lineNumber, int column, String text, boolean canReplaceSelection) { // This (lowest-level) document mutator should never replace the selection return insertText(line, lineNumber, column, text); } private void beginHighLevelModification( Type type, Line line, int lineNumber, int column, String text) { // Clear any change-tracking state textChanges.clear(); // Dispatch the pre-textchange event document.dispatchPreTextChange(type, line, lineNumber, column, text); } private void endHighLevelModification() { // Dispatch callbacks document.dispatchTextChange(textChanges); } private LineInfo insertTextImpl(Line line, int lineNumber, int column, String text) { if (!text.contains("\n")) { insertTextOnOneLineImpl(line, column, text); return new LineInfo(line, lineNumber); } else { return insertMultilineTextImpl(line, lineNumber, column, text); } } private void insertTextOnOneLineImpl(Line line, int column, String text) { // Add the text first String oldText = line.getText(); String newText = oldText.substring(0, column) + text + oldText.substring(column); line.setText(newText); // Update the anchors anchorManager.handleSingleLineTextInsertion(line, column, text.length()); } /** * @return the line info for the last line modified by this insertion */ private LineInfo insertMultilineTextImpl(Line line, int lineNumber, int column, String text) { String lineText = line.getText(); Preconditions.checkArgument(lineText.endsWith("\n") ? column < lineText.length() : column <= lineText.length(), "Given column is out-of-bounds"); JsonArray<Line> linesAdded = JsonCollections.createArray(); /* * The given "line" has two chunks of text: from column 0 to the "column" * (exclusive), and from the "column" to the end. The new contents of this * line will be: its first chunk + the first line of the inserted text + * newline. The second chunk will be used to form a brand new line whose * contents are: the last line of the inserted text + the second chunk * (which still consists of a newline if it had one originally). In between * these two lines will be the inserted text's second line through to the * second-to-last line. */ // First, split the line receiving the text String firstChunk = lineText.substring(0, column); String secondChunk = lineText.substring(column); JsonArray<String> insertionLineTexts = StringUtils.split(text, "\n"); line.setText(firstChunk + insertionLineTexts.get(0) + "\n"); Line prevLine = line; int prevLineNumber = lineNumber; for (int i = 1, nMinusOne = insertionLineTexts.size() - 1; i < nMinusOne; i++) { Line curLine = Line.create(document, insertionLineTexts.get(i) + "\n"); insertLineImpl(prevLine, curLine); linesAdded.add(curLine); prevLine = curLine; prevLineNumber++; } /* * Remember that if e.g. the insertion text is "a\n", the last item in the * array will be the empty string */ String lastInsertionLineText = insertionLineTexts.get(insertionLineTexts.size() - 1); String newLineText = lastInsertionLineText + secondChunk; int secondChunkColumnInNewLine = lastInsertionLineText.length(); Line newLine = Line.create(document, newLineText); insertLineImpl(prevLine, newLine); linesAdded.add(newLine); int newLineNumber = prevLineNumber + 1; anchorManager.handleMultilineTextInsertion(line, lineNumber, column, newLine, newLineNumber, secondChunkColumnInNewLine); document.commitLineCountChange(linesAdded.size()); document.dispatchLineAdded(lineNumber + 1, linesAdded); return new LineInfo(newLine, newLineNumber); } /** * Low-level operation that inserts the given line after the previous line. * * @param previousLine the line after which {@code line} will be inserted */ private void insertLineImpl(Line previousLine, Line line) { Line nextLine = previousLine.getNextLine(); // Update the linked list previousLine.setNextLine(line); line.setPreviousLine(previousLine); if (nextLine != null) { nextLine.setPreviousLine(line); line.setNextLine(nextLine); } // Update document state if (nextLine == document.getFirstLine()) { document.setFirstLine(line); } if (previousLine == document.getLastLine()) { document.setLastLine(line); } line.setAttached(true); } private void deleteTextImpl(final Line firstLine, final int firstLineNumber, final int firstLineColumn, final String deletedText) { Preconditions.checkArgument(firstLineColumn <= LineUtils.getLastCursorColumn(firstLine), "The column is out-of-bounds"); new TextDeleter(firstLine, firstLineNumber, firstLineColumn, deletedText).delete(); } /** * A low-level operation that removes the line from the document. This method * is meant to only be called by * {@link #deleteTextImpl(Line, int, int, String)}. */ private void removeLineImpl(Line line) { /* * TODO: set detached state, and assert/throw exceptions if any * one tries to operate on the detached line */ // Update the linked list and document's first and last lines Line previousLine = line.getPreviousLine(); Line nextLine = line.getNextLine(); if (previousLine != null) { previousLine.setNextLine(nextLine); } else { assert line == document.getFirstLine() : "Line does not have a previous line, but line is not first line in document"; document.setFirstLine(nextLine); } if (nextLine != null) { nextLine.setPreviousLine(previousLine); } else { assert line == document.getLastLine() : "Line does not have a next line, but line is not last line in document"; document.setLastLine(previousLine); } line.setAttached(false); } }