package org.wordpress.android.editor; import android.text.Editable; import android.text.Spannable; import android.text.TextWatcher; import org.wordpress.android.util.AppLog; import org.wordpress.android.util.AppLog.T; public class HtmlStyleTextWatcher implements TextWatcher { private enum Operation { INSERT, DELETE, REPLACE, NONE } private int mOffset; private CharSequence mModifiedText; private Operation mLastOperation; @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { if (s == null) { return; } int lastCharacterLocation = start + count - 1; if (s.length() > lastCharacterLocation && lastCharacterLocation >= 0) { if (after < count) { if (after > 0) { // Text was deleted and replaced by some other text mLastOperation = Operation.REPLACE; } else { // Text was deleted only mLastOperation = Operation.DELETE; } mOffset = start; mModifiedText = s.subSequence(start + after, start + count); } } } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { if (s == null) { return; } int lastCharacterLocation = start + count - 1; if (s.length() > lastCharacterLocation) { if (count > 0) { if (before > 0) { // Text was added, replacing some existing text mLastOperation = Operation.REPLACE; mModifiedText = s.subSequence(start, start + count); } else { // Text was added only mLastOperation = Operation.INSERT; mOffset = start; mModifiedText = s.subSequence(start + before, start + count); } } } } @Override public void afterTextChanged(Editable s) { if (mModifiedText == null || s == null) { return; } SpanRange spanRange; // If the modified text included a tag or entity symbol ("<", ">", "&" or ";"), find its match and restyle if (mModifiedText.toString().contains("<")) { spanRange = getRespanRangeForChangedOpeningSymbol(s, "<"); } else if (mModifiedText.toString().contains(">")) { spanRange = getRespanRangeForChangedClosingSymbol(s, ">"); } else if (mModifiedText.toString().contains("&")) { spanRange = getRespanRangeForChangedOpeningSymbol(s, "&"); } else if (mModifiedText.toString().contains(";")) { spanRange = getRespanRangeForChangedClosingSymbol(s, ";"); } else { // If the modified text didn't include any tag or entity symbols, restyle if the modified text is inside // a tag or entity spanRange = getRespanRangeForNormalText(s, "<"); if (spanRange == null) { spanRange = getRespanRangeForNormalText(s, "&"); } } if (spanRange != null) { updateSpans(s, spanRange); } mModifiedText = null; mLastOperation = Operation.NONE; } /** * For changes made which contain at least one opening symbol (e.g. '<' or '&'), whether added or deleted, returns * the range of text which should have its style reapplied. * @param content the content after modification * @param openingSymbol the opening symbol recognized (e.g. '<' or '&') * @return the range of characters to re-apply spans to */ protected SpanRange getRespanRangeForChangedOpeningSymbol(Editable content, String openingSymbol) { // For simplicity, re-parse the document if text was replaced if (mLastOperation == Operation.REPLACE) { return new SpanRange(0, content.length()); } String closingSymbol = getMatchingSymbol(openingSymbol); int firstOpeningTagLoc = mOffset + mModifiedText.toString().indexOf(openingSymbol); int closingTagLoc; if (mLastOperation == Operation.INSERT) { // Apply span from the first added opening symbol until the closing symbol in the content matching the // last added opening symbol // e.g. pasting "<b><" before "/b>" - we want the span to be applied to all of "<b></b>" int lastOpeningTagLoc = mOffset + mModifiedText.toString().lastIndexOf(openingSymbol); closingTagLoc = content.toString().indexOf(closingSymbol, lastOpeningTagLoc); } else { // Apply span until the first closing tag that appears after the deleted text closingTagLoc = content.toString().indexOf(closingSymbol, mOffset); } if (closingTagLoc > 0) { return new SpanRange(firstOpeningTagLoc, closingTagLoc + 1); } return null; } /** * For changes made which contain at least one closing symbol (e.g. '>' or ';') and no opening symbols, whether * added or deleted, returns the range of text which should have its style reapplied. * @param content the content after modification * @param closingSymbol the closing symbol recognized (e.g. '>' or ';') * @return the range of characters to re-apply spans to */ protected SpanRange getRespanRangeForChangedClosingSymbol(Editable content, String closingSymbol) { // For simplicity, re-parse the document if text was replaced if (mLastOperation == Operation.REPLACE) { return new SpanRange(0, content.length()); } String openingSymbol = getMatchingSymbol(closingSymbol); int firstClosingTagInModLoc = mOffset + mModifiedText.toString().indexOf(closingSymbol); int firstClosingTagAfterModLoc = content.toString().indexOf(closingSymbol, mOffset + mModifiedText.length()); int openingTagLoc = content.toString().lastIndexOf(openingSymbol, firstClosingTagInModLoc - 1); if (openingTagLoc >= 0) { if (firstClosingTagAfterModLoc >= 0) { return new SpanRange(openingTagLoc, firstClosingTagAfterModLoc + 1); } else { return new SpanRange(openingTagLoc, content.length()); } } return null; } /** * For changes made which contain no opening or closing symbols, checks whether the changed text is inside a tag, * and if so returns the range of text which should have its style reapplied. * @param content the content after modification * @param openingSymbol the opening symbol of the tag to check for (e.g. '<' or '&') * @return the range of characters to re-apply spans to */ protected SpanRange getRespanRangeForNormalText(Editable content, String openingSymbol) { String closingSymbol = getMatchingSymbol(openingSymbol); int openingTagLoc = content.toString().lastIndexOf(openingSymbol, mOffset); if (openingTagLoc >= 0) { int closingTagLoc = content.toString().indexOf(closingSymbol, openingTagLoc); if (closingTagLoc >= mOffset) { return new SpanRange(openingTagLoc, closingTagLoc + 1); } } return null; } /** * Clears and re-applies spans to {@code content} within range {@code spanRange} according to rules in * {@link HtmlStyleUtils}. * @param content the content to re-style * @param spanRange the range within {@code content} to be re-styled */ protected void updateSpans(Spannable content, SpanRange spanRange) { int spanStart = spanRange.getOpeningTagLoc(); int spanEnd = spanRange.getClosingTagLoc(); if (spanStart > content.length() || spanEnd > content.length()) { AppLog.d(T.EDITOR, "The specified span range was beyond the Spannable's length"); return; } else if (spanStart >= spanEnd) { // If the span start is after the end position (probably due to a multi-line deletion), selective // re-styling won't work // Instead, do a clean re-styling of the whole document spanStart = 0; spanEnd = content.length(); } HtmlStyleUtils.clearSpans(content, spanStart, spanEnd); HtmlStyleUtils.styleHtmlForDisplay(content, spanStart, spanEnd); } /** * Returns the closing/opening symbol corresponding to the given opening/closing symbol. */ private String getMatchingSymbol(String symbol) { switch(symbol) { case "<": return ">"; case ">": return "<"; case "&": return ";"; case ";": return "&"; default: return ""; } } /** * Stores a pair of integers describing a range of values. */ protected static class SpanRange { private final int mOpeningTagLoc; private final int mClosingTagLoc; public SpanRange(int openingTagLoc, int closingTagLoc) { mOpeningTagLoc = openingTagLoc; mClosingTagLoc = closingTagLoc; } public int getOpeningTagLoc() { return mOpeningTagLoc; } public int getClosingTagLoc() { return mClosingTagLoc; } } }