/* * Copyright 2015 Emanuel Moecklin * * 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.onegravity.rteditor; import android.content.Context; import android.graphics.Rect; import android.os.Parcel; import android.os.Parcelable; import android.text.Editable; import android.text.SpanWatcher; import android.text.Spannable; import android.text.Spanned; import android.text.TextWatcher; import android.text.style.ParagraphStyle; import android.util.AttributeSet; import android.widget.EditText; import android.widget.TextView; import com.onegravity.rteditor.api.RTMediaFactory; import com.onegravity.rteditor.api.format.RTEditable; import com.onegravity.rteditor.api.format.RTFormat; import com.onegravity.rteditor.api.format.RTHtml; import com.onegravity.rteditor.api.format.RTPlainText; import com.onegravity.rteditor.api.format.RTText; import com.onegravity.rteditor.api.media.RTAudio; import com.onegravity.rteditor.api.media.RTImage; import com.onegravity.rteditor.api.media.RTMedia; import com.onegravity.rteditor.api.media.RTVideo; import com.onegravity.rteditor.effects.Effect; import com.onegravity.rteditor.effects.Effects; import com.onegravity.rteditor.spans.LinkSpan; import com.onegravity.rteditor.spans.LinkSpan.LinkSpanListener; import com.onegravity.rteditor.spans.MediaSpan; import com.onegravity.rteditor.spans.RTSpan; import com.onegravity.rteditor.utils.Paragraph; import com.onegravity.rteditor.utils.RTLayout; import com.onegravity.rteditor.utils.Selection; import java.util.ArrayList; import java.util.HashSet; import java.util.Set; /** * The actual rich text editor (extending android.widget.EditText). */ public class RTEditText extends EditText implements TextWatcher, SpanWatcher, LinkSpanListener { // don't allow any formatting in text mode private boolean mUseRTFormatting = true; // for performance reasons we compute a new layout only if the text has changed private boolean mLayoutChanged; private RTLayout mRTLayout; // don't call this mLayout because TextView has a mLayout too (no shadowing as both are private but still...) // while onSaveInstanceState() is running, don't modify any spans private boolean mIsSaving; /// while selection is changing don't apply any effects private boolean mIsSelectionChanging = false; // text has changed private boolean mTextChanged; // this indicates whether text is selected or not -> ignore window focus changes (by spinners) private boolean mTextSelected; private RTEditTextListener mListener; private RTMediaFactory<RTImage, RTAudio, RTVideo> mMediaFactory; // used to check if selection has changed private int mOldSelStart = -1; private int mOldSelEnd = -1; // we don't want to call Effects.cleanupParagraphs() if the paragraphs are already up to date private boolean mParagraphsAreUp2Date; // while Effects.cleanupParagraphs() is called, we ignore changes that would alter mParagraphsAreUp2Date private boolean mIgnoreParagraphChanges; /* Used for the undo / redo functions */ // if True then text changes are not registered for undo/redo // we need this during the actual undo/redo operation (or an undo would create a change event itself) private boolean mIgnoreTextChanges; private int mSelStartBefore; // selection start before text changed private int mSelEndBefore; // selection end before text changed private String mOldText; // old text before it changed private String mNewText; // new text after it changed (needed in afterTextChanged to see if the text has changed) private Spannable mOldSpannable; // undo/redo // we need to keep track of the media for this editor to be able to clean up after we're done private Set<RTMedia> mOriginalMedia = new HashSet<RTMedia>(); private Set<RTMedia> mAddedMedia = new HashSet<RTMedia>(); // ****************************************** Lifecycle Methods ******************************************* public RTEditText(Context context) { super(context); init(); } public RTEditText(Context context, AttributeSet attrs) { super(context, attrs); init(); } public RTEditText(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } private void init() { addTextChangedListener(this); // we need this or links won't be clickable setMovementMethod(RTEditorMovementMethod.getInstance()); } /** * @param isSaved True if the text is saved, False if it's dismissed */ void onDestroy(boolean isSaved) { // make sure all obsolete MediaSpan files are removed from the file system: // - when saving the text delete the MediaSpan if it was deleted // - when dismissing the text delete the MediaSpan if it was deleted and not saved before // collect all media the editor contains currently Set<RTMedia> mCurrentMedia = new HashSet<RTMedia>(); Spannable text = getText(); for (MediaSpan span : text.getSpans(0, text.length(), MediaSpan.class)) { mCurrentMedia.add(span.getMedia()); } // now delete all those that aren't needed any longer Set<RTMedia> mMedia2Delete = isSaved ? mOriginalMedia : mCurrentMedia; mMedia2Delete.addAll(mAddedMedia); Set<RTMedia> mMedia2Keep = isSaved ? mCurrentMedia : mOriginalMedia; for (RTMedia media : mMedia2Delete) { if (!mMedia2Keep.contains(media)) { media.remove(); } } } /** * Needs to be called if a media is added to the editor. * Important to be able to delete obsolete media once we're done editing. */ void onAddMedia(RTMedia media) { mAddedMedia.add(media); } /** * This needs to be called before anything else because we need the media * factory. * * @param listener The RTEditTextListener (the RTManager) * @param mediaFactory The RTMediaFactory */ void register(RTEditTextListener listener, RTMediaFactory<RTImage, RTAudio, RTVideo> mediaFactory) { mListener = listener; mMediaFactory = mediaFactory; } /** * Usually called from the RTManager.onDestroy() method */ void unregister() { mListener = null; mMediaFactory = null; } /** * Return all paragraphs as as array of selection objects */ public ArrayList<Paragraph> getParagraphs() { return getRTLayout().getParagraphs(); } /** * Find the start and end of the paragraph(s) encompassing the current selection. * A paragraph spans from one \n (exclusive) to the next one (inclusive) */ public Selection getParagraphsInSelection() { RTLayout layout = getRTLayout(); Selection selection = new Selection(this); int firstLine = layout.getLineForOffset(selection.start()); int end = selection.isEmpty() ? selection.end() : selection.end() - 1; int lastLine = layout.getLineForOffset(end); return new Selection(layout.getLineStart(firstLine), layout.getLineEnd(lastLine)); } private RTLayout getRTLayout() { synchronized (this) { if (mRTLayout == null || mLayoutChanged) { mRTLayout = new RTLayout(getText()); mLayoutChanged = false; } } return mRTLayout; } /** * This method returns the Selection which makes sure that selection start is <= selection end. * Note: getSelectionStart()/getSelectionEnd() refer to the order in which text was selected. */ Selection getSelection() { int selStart = getSelectionStart(); int selEnd = getSelectionEnd(); return new Selection(selStart, selEnd); } /** * @return the selected text (needed when creating links) */ String getSelectedText() { Spannable text = getText(); Selection sel = getSelection(); if (sel.start() >= 0 && sel.end() >= 0 && sel.end() <= text.length()) { return text.subSequence(sel.start(), sel.end()).toString(); } return null; } public Spannable cloneSpannable() { CharSequence text = super.getText(); return new ClonedSpannableString(text != null ? text : ""); } // ****************************************** Set/Get Text Methods ******************************************* /** * Sets the edit mode to plain or rich text. The text will be converted * automatically to rich/plain text if autoConvert is True. * * @param useRTFormatting True if the edit mode should be rich text, False if the edit * mode should be plain text * @param autoConvert Automatically convert the content to plain or rich text if * this is True */ public void setRichTextEditing(boolean useRTFormatting, boolean autoConvert) { assertRegistration(); if (useRTFormatting != mUseRTFormatting) { mUseRTFormatting = useRTFormatting; if (autoConvert) { RTFormat targetFormat = useRTFormatting ? RTFormat.PLAIN_TEXT : RTFormat.HTML; setText(getRichText(targetFormat)); } if (mListener != null) { mListener.onRichTextEditingChanged(this, mUseRTFormatting); } } } /** * Sets the edit mode to plain or rich text and updates the content at the * same time. The caller needs to make sure the content matches the correct * format (if you pass in html code as plain text the editor will show the * html code). * * @param useRTFormatting True if the edit mode should be rich text, False if the edit * mode should be plain text * @param content The new content */ public void setRichTextEditing(boolean useRTFormatting, String content) { assertRegistration(); if (useRTFormatting != mUseRTFormatting) { mUseRTFormatting = useRTFormatting; if (mListener != null) { mListener.onRichTextEditingChanged(this, mUseRTFormatting); } } RTText rtText = useRTFormatting ? new RTHtml<RTImage, RTAudio, RTVideo>(RTFormat.HTML, content) : new RTPlainText(content); setText(rtText); } /** * Set the text for this editor. * <p> * It will convert the text from rich text to plain text if the editor's * mode is set to use plain text. or to a spanned text (only supported * formatting) if the editor's mode is set to use rich text * <p> * We need to prevent onSelectionChanged() to do anything as long as * setText() hasn't finished because the Layout doesn't seem to update * before setText has finished but onSelectionChanged will still be called * during setText and will receive the out-dated Layout which doesn't allow * us to apply styles and such. */ public void setText(RTText rtText) { assertRegistration(); if (rtText.getFormat() instanceof RTFormat.Html) { if (mUseRTFormatting) { RTText rtSpanned = rtText.convertTo(RTFormat.SPANNED, mMediaFactory); super.setText(rtSpanned.getText(), TextView.BufferType.EDITABLE); addSpanWatcher(); // collect all current media Spannable text = getText(); for (MediaSpan span : text.getSpans(0, text.length(), MediaSpan.class)) { mOriginalMedia.add(span.getMedia()); } Effects.cleanupParagraphs(this); } else { RTText rtPlainText = rtText.convertTo(RTFormat.PLAIN_TEXT, mMediaFactory); super.setText(rtPlainText.getText()); } } else if (rtText.getFormat() instanceof RTFormat.PlainText) { CharSequence text = rtText.getText(); super.setText(text == null ? "" : text.toString()); } onSelectionChanged(0, 0); } public boolean usesRTFormatting() { return mUseRTFormatting; } /** * Returns the content of this editor as a String. The caller is responsible * to call only formats that are supported by RTEditable (which is the rich * text editor's format and always the source format). * * @param format The RTFormat the text should be converted to. * @throws UnsupportedOperationException if the target format isn't supported. */ public String getText(RTFormat format) { return getRichText(format).getText().toString(); } /** * Same as "String getText(RTFormat format)" but this method returns the * RTText instead of just the actual text. */ public RTText getRichText(RTFormat format) { assertRegistration(); RTEditable rtEditable = new RTEditable(this); return rtEditable.convertTo(format, mMediaFactory); } private void assertRegistration() { if (mMediaFactory == null) { throw new IllegalStateException("The RTMediaFactory is null. Please make sure to register the editor at the RTManager before using it."); } } // ****************************************** TextWatcher / SpanWatcher ******************************************* public boolean hasChanged() { return mTextChanged; } public void resetHasChanged() { mTextChanged = false; setParagraphsAreUp2Date(false); } /** * Ignore changes that would trigger a RTEditTextListener.onTextChanged() * method call. We need this during the actual undo/redo operation (or an * undo would create a change event itself). */ synchronized void ignoreTextChanges() { mIgnoreTextChanges = true; } /** * If changes happen call RTEditTextListener.onTextChanged(). * This is needed for the undo/redo functionality. */ synchronized void registerTextChanges() { mIgnoreTextChanges = false; } @Override /* TextWatcher */ public synchronized void beforeTextChanged(CharSequence s, int start, int count, int after) { // we use a String to get a static copy of the CharSequence (the CharSequence changes when the text changes...) String oldText = mOldText == null ? "" : mOldText; if (!mIgnoreTextChanges && !s.toString().equals(oldText)) { mSelStartBefore = getSelectionStart(); mSelEndBefore = getSelectionEnd(); mOldText = s.toString(); mNewText = mOldText; mOldSpannable = cloneSpannable(); } mLayoutChanged = true; } @Override /* TextWatcher */ public synchronized void onTextChanged(CharSequence s, int start, int before, int count) { mLayoutChanged = true; } @Override /* TextWatcher */ public synchronized void afterTextChanged(Editable s) { String theText = s.toString(); String newText = mNewText == null ? "" : mNewText; if (mListener != null && !mIgnoreTextChanges && !newText.equals(theText)) { Spannable newSpannable = cloneSpannable(); mListener.onTextChanged(this, mOldSpannable, newSpannable, mSelStartBefore, mSelEndBefore, getSelectionStart(), getSelectionEnd()); mNewText = theText; } mLayoutChanged = true; mTextChanged = true; setParagraphsAreUp2Date(false); addSpanWatcher(); } @Override /* SpanWatcher */ public void onSpanAdded(Spannable text, Object what, int start, int end) { mTextChanged = true; if (what instanceof RTSpan && what instanceof ParagraphStyle) { setParagraphsAreUp2Date(false); } } @Override /* SpanWatcher */ public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) { mTextChanged = true; if (what instanceof RTSpan && what instanceof ParagraphStyle) { setParagraphsAreUp2Date(false); } } @Override /* SpanWatcher */ public void onSpanRemoved(Spannable text, Object what, int start, int end) { mTextChanged = true; if (what instanceof RTSpan && what instanceof ParagraphStyle) { setParagraphsAreUp2Date(false); } } /** * Add a SpanWatcher for the Changeable implementation */ private void addSpanWatcher() { Spannable spannable = getText(); if (spannable.getSpans(0, spannable.length(), getClass()) != null) { spannable.setSpan(this, 0, spannable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); } } synchronized private void setParagraphsAreUp2Date(boolean value) { if (! mIgnoreParagraphChanges) { mParagraphsAreUp2Date = value; } } // ****************************************** Listener Methods ******************************************* @Override public void onWindowFocusChanged(boolean hasWindowFocus) { // if text is selected we ignore a loss of focus to prevent Android from terminating // text selection when one of the spinners opens (text size, color, bg color) if (!mUseRTFormatting || hasWindowFocus || !mTextSelected) { super.onWindowFocusChanged(hasWindowFocus); } } @Override protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { super.onFocusChanged(focused, direction, previouslyFocusedRect); if (mUseRTFormatting && mListener != null) { mListener.onFocusChanged(this, focused); } } @Override public Parcelable onSaveInstanceState() { mIsSaving = true; Parcelable superState = super.onSaveInstanceState(); String content = getText(mUseRTFormatting ? RTFormat.HTML : RTFormat.PLAIN_TEXT); SavedState savedState = new SavedState(superState, mUseRTFormatting, content); mIsSaving = false; return savedState; } @Override public void onRestoreInstanceState(Parcelable state) { if(state instanceof SavedState) { SavedState savedState = (SavedState)state; super.onRestoreInstanceState(savedState.getSuperState()); setRichTextEditing(savedState.useRTFormatting(), savedState.getContent()); } else { super.onRestoreInstanceState(state); } if (mListener != null) { mListener.onRestoredInstanceState(this); } } private static class SavedState extends BaseSavedState { private String mContent; private boolean mUseRTFormatting; SavedState(Parcelable superState, boolean useRTFormatting, String content) { super(superState); mUseRTFormatting = useRTFormatting; mContent = content; } private String getContent() { return mContent; } private boolean useRTFormatting() { return mUseRTFormatting; } private SavedState(Parcel in) { super(in); mUseRTFormatting = in.readInt() == 1; mContent = in.readString(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeInt(mUseRTFormatting ? 1 : 0); out.writeString(mContent); } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } @Override protected void onSelectionChanged(int start, int end) { if (mOldSelStart != start || mOldSelEnd != end) { mOldSelStart = start; mOldSelEnd = end; mTextSelected = (end > start); super.onSelectionChanged(start, end); if (mUseRTFormatting) { if (!mIsSaving && !mParagraphsAreUp2Date) { mIgnoreParagraphChanges = true; Effects.cleanupParagraphs(this); mIgnoreParagraphChanges = false; setParagraphsAreUp2Date(true); } if (mListener != null) { mIsSelectionChanging = true; mListener.onSelectionChanged(this, start, end); mIsSelectionChanging = false; } } } } /** * Call this to have an effect applied to the current selection. * You get the Effect object via the static data members (e.g., RTEditText.BOLD). * The value for most effects is a Boolean, indicating whether to add or remove the effect. */ public <V extends Object, C extends RTSpan<V>> void applyEffect(Effect<V, C> effect, V value) { if (mUseRTFormatting && !mIsSelectionChanging && !mIsSaving) { Spannable oldSpannable = mIgnoreTextChanges ? null : cloneSpannable(); effect.applyToSelection(this, value); synchronized (this) { if (mListener != null && !mIgnoreTextChanges) { Spannable newSpannable = cloneSpannable(); mListener.onTextChanged(this, oldSpannable, newSpannable, getSelectionStart(), getSelectionEnd(), getSelectionStart(), getSelectionEnd()); } mLayoutChanged = true; } } } @Override /* LinkSpanListener */ public void onClick(LinkSpan linkSpan) { if (mUseRTFormatting && mListener != null) { mListener.onClick(this, linkSpan); } } }