Skip to content

Commit 6e61867

Browse files
authored
Merge pull request #454 from wordpress-mobile/issue/424-paste-crash
Copy/paste Fix & Improvement
2 parents 8f1c088 + fa43864 commit 6e61867

File tree

13 files changed

+470
-72
lines changed

13 files changed

+470
-72
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
- Quote styling of the paragraph ends, empty and mixed lines
1010
- The missing hint bug if text is empty
1111
- Crash in the URL dialog caused by the non plain text content of a clipboard
12+
- Crash when copy/pasting lists
13+
- Copy/pasting of non-latin unicode characters
1214

1315
### Added
1416
- This changelog
@@ -17,6 +19,9 @@
1719
- Plugin interface for HTML postprocessor
1820
- Plugin module with `[video]`, `[audio]` and `[caption]` WordPress shrotcode support
1921
- OnMediaDeletedListener interface, detection and handling
22+
- Copy/pasting of block element styles
23+
- Copy/pasting of styled text and HTML from external sources
24+
2025

2126
## [1.0-beta.6](https://github.com/wordpress-mobile/AztecEditor-Android/releases/tag/v1.0-beta.6) - 2017-07-25
2227
### Changed

app/src/main/kotlin/org/wordpress/aztec/demo/MainActivity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class MainActivity : AppCompatActivity(),
107107
private val CODE = "<code>if (value == 5) printf(value)</code><br>"
108108
private val IMG = "[caption align=\"alignright\"]<img src=\"https://examplebloge.files.wordpress.com/2017/02/3def4804-d9b5-11e6-88e6-d7d8864392e0.png\" />Caption[/caption]"
109109
private val EMOJI = "&#x1F44D;"
110+
private val NON_LATIN_TEXT = "测试一个"
110111
private val LONG_TEXT = "<br><br>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. "
111112
private val VIDEO = "[video src=\"https://examplebloge.files.wordpress.com/2017/06/d7d88643-88e6-d9b5-11e6-92e03def4804.mp4\"]"
112113
private val AUDIO = "[audio src=\"https://upload.wikimedia.org/wikipedia/commons/9/94/H-Moll.ogg\"]"
@@ -131,6 +132,7 @@ class MainActivity : AppCompatActivity(),
131132
CODE +
132133
UNKNOWN +
133134
EMOJI +
135+
NON_LATIN_TEXT +
134136
LONG_TEXT +
135137
VIDEO +
136138
AUDIO
@@ -314,6 +316,7 @@ class MainActivity : AppCompatActivity(),
314316
val sourceEditor = findViewById(R.id.source) as SourceViewEditText
315317
val toolbar = findViewById(R.id.formatting_toolbar) as AztecToolbar
316318

319+
317320
aztec = Aztec.with(visualEditor, sourceEditor, toolbar, this)
318321
.setImageGetter(PicassoImageLoader(this, visualEditor))
319322
.setVideoThumbnailGetter(GlideVideoThumbnailLoader(this))

aztec/src/main/java/org/wordpress/aztec/Html.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ private void handleStartTag(String tag, Attributes attributes, int nestingLevel)
297297
}
298298
}
299299

300+
//noinspection StatementWithEmptyBody
300301
if (tag.equalsIgnoreCase("br")) {
301302
// We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
302303
// so we can safely emite the linebreaks when we handle the close tag.

aztec/src/main/kotlin/org/wordpress/aztec/AztecParser.kt

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
3434
internal var closeMap: TreeMap<Int, HiddenHtmlSpan> = TreeMap()
3535
internal var openMap: TreeMap<Int, HiddenHtmlSpan> = TreeMap()
3636
internal var hiddenSpans: IntArray = IntArray(0)
37-
internal var spanCursorPosition = -1
3837

3938
fun fromHtml(source: String, context: Context): Spanned {
4039

@@ -80,13 +79,11 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
8079
hiddenIndex = 0
8180
Arrays.sort(hiddenSpans)
8281

83-
if (withCursor) {
82+
if (!withCursor) {
8483
val cursorSpan = data.getSpans(0, data.length, AztecCursorSpan::class.java).firstOrNull()
85-
if (cursorSpan != null) { //there can be only one cursor
86-
spanCursorPosition = data.getSpanStart(cursorSpan)
84+
cursorSpan?.let {
85+
data.removeSpan(cursorSpan)
8786
}
88-
} else {
89-
spanCursorPosition = -1
9087
}
9188

9289
withinHtml(out, data)
@@ -114,7 +111,7 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
114111
val parent = IAztecNestable.getParent(spanned, SpanWrapper(spanned, it))
115112

116113
// a list item "repels" a child list so the list will appear in the next line
117-
val repelling = (parent?.span is AztecListItemSpan) && (it is AztecListSpan)
114+
val repelling = it is AztecListSpan && parent?.span is AztecListItemSpan
118115

119116
val spanStart = spanned.getSpanStart(it)
120117

@@ -140,8 +137,8 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
140137

141138
// expand all same-start parents to include the new newline
142139
SpanWrapper.getSpans<IAztecNestable>(spanned, spanStart + 1, spanStart + 2)
143-
.filter { parent -> parent.span.nestingLevel < it.nestingLevel && parent.start == spanStart + 1 }
144-
.forEach { parent -> parent.start-- }
140+
.filter { subParent -> subParent.span.nestingLevel < it.nestingLevel && subParent.start == spanStart + 1 }
141+
.forEach { subParent -> subParent.start-- }
145142

146143
markBlockElementLineBreak(spanned, spanStart)
147144
}
@@ -211,12 +208,12 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
211208
val parent = IAztecNestable.getParent(spanned, SpanWrapper(spanned, it))
212209

213210
// a list item "repels" a child list so the list will appear in the next line
214-
val repelling = (parent?.span is AztecListItemSpan) && (it is AztecListSpan)
211+
val repelling = it is AztecListSpan && parent?.span is AztecListItemSpan
215212

216213
val spanStart = spanned.getSpanStart(it)
217214

218-
if (!repelling && spanStart < 1) {
219-
// no visual newline if at text start and not repelling so, return
215+
// we're looking for newlines before the spans, no need to continue if span at the beginning
216+
if (spanStart == 0) {
220217
return@forEach
221218
}
222219

@@ -238,7 +235,7 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
238235
return@forEach
239236
}
240237

241-
if (!repelling && spanned[spanStart - 2] == '\n') {
238+
if (spanStart > 1 && !repelling && spanned[spanStart - 2] == '\n') {
242239
// there's another newline before and we're not repelling a parent so, the adjacent one is not a visual one so, return
243240
return@forEach
244241
}
@@ -538,19 +535,6 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
538535
out.append("&gt;")
539536
} else if (c == '&') {
540537
out.append("&amp;")
541-
} else if (c.toInt() in 0xD800..0xDFFF) {
542-
if (c.toInt() < 0xDC00 && i + 1 < end) {
543-
val d = text[i + 1]
544-
if (d.toInt() in 0xDC00..0xDFFF) {
545-
i++
546-
val codepoint = 0x010000 or ((c.toInt() - 0xD800) shl 10) or (d.toInt() - 0xDC00)
547-
out.append("&#").append(codepoint).append(";")
548-
}
549-
}
550-
} else if (c.toInt() > 0x7E || c < ' ') {
551-
if (c != '\n') {
552-
out.append("&#").append(c.toInt()).append(";")
553-
}
554538
} else if (c == ' ') {
555539
while (i + 1 < end && text[i + 1] == ' ') {
556540
out.append("&nbsp;")
@@ -559,7 +543,7 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
559543
}
560544

561545
out.append(' ')
562-
} else {
546+
} else if (c != '\n') {
563547
out.append(c)
564548
}
565549
i++
@@ -581,20 +565,21 @@ class AztecParser(val plugins: List<IAztecPlugin> = ArrayList()) {
581565
* input string.
582566
*/
583567
private fun consumeCursorIfInInput(out: StringBuilder, text: CharSequence, position: Int) {
584-
val cursorSpan = (text as SpannableStringBuilder).getSpans(position, position, AztecCursorSpan::class.java)
585-
.firstOrNull()
586-
if (cursorSpan != null) {
587-
out.append(AztecCursorSpan.AZTEC_CURSOR_TAG)
568+
if (text is SpannableStringBuilder) {
569+
val cursorSpan = text.getSpans(position, position, AztecCursorSpan::class.java).firstOrNull()
570+
if (cursorSpan != null) {
571+
out.append(AztecCursorSpan.AZTEC_CURSOR_TAG)
588572

589-
// remove the cursor mark from the input string. It's work is finished.
590-
text.removeSpan(cursorSpan)
573+
// remove the cursor mark from the input string. It's work is finished.
574+
text.removeSpan(cursorSpan)
575+
}
591576
}
592577
}
593578

594579
private fun tidy(html: String): String {
595580
return html
596-
.replace("&#8203;", "")
597-
.replace("&#65279;", "")
581+
.replace(Constants.ZWJ_STRING, "")
582+
.replace(Constants.MAGIC_STRING, "")
598583
.replace("(</? ?br>)*((aztec_cursor)?)</blockquote>".toRegex(), "$2</blockquote>")
599584
.replace("(</? ?br>)*((aztec_cursor)?)</li>".toRegex(), "$2</li>")
600585
.replace("(</? ?br>)*((aztec_cursor)?)</p>".toRegex(), "$2</p>")

aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import android.support.v4.content.ContextCompat
2929
import android.support.v7.app.AlertDialog
3030
import android.support.v7.widget.AppCompatAutoCompleteTextView
3131
import android.text.*
32-
import android.text.style.ParagraphStyle
3332
import android.text.style.SuggestionSpan
3433
import android.util.AttributeSet
3534
import android.view.KeyEvent
@@ -52,6 +51,7 @@ import org.wordpress.aztec.source.Format
5251
import org.wordpress.aztec.source.SourceViewEditText
5352
import org.wordpress.aztec.spans.*
5453
import org.wordpress.aztec.toolbar.AztecToolbar
54+
import org.wordpress.aztec.util.coerceToHtmlText
5555
import org.wordpress.aztec.watchers.*
5656
import org.xml.sax.Attributes
5757
import java.util.*
@@ -723,9 +723,9 @@ class AztecText : AppCompatAutoCompleteTextView, TextWatcher, UnknownHtmlSpan.On
723723
fun fromHtml(source: String) {
724724
val builder = SpannableStringBuilder()
725725
val parser = AztecParser(plugins)
726-
builder.append(parser.fromHtml(
727-
Format.removeSourceEditorFormatting(
728-
Format.addSourceEditorFormatting(source, isInCalypsoMode), isInCalypsoMode), context))
726+
727+
val cleanSource = Format.removeSourceEditorFormatting(source, isInCalypsoMode)
728+
builder.append(parser.fromHtml(cleanSource, context))
729729

730730
Format.preProcessSpannedText(builder, isInCalypsoMode)
731731

@@ -983,41 +983,73 @@ class AztecText : AppCompatAutoCompleteTextView, TextWatcher, UnknownHtmlSpan.On
983983
val parser = AztecParser(plugins)
984984
val output = SpannableStringBuilder(selectedText)
985985

986-
//Strip block elements until we figure out copy paste completely
987-
output.getSpans(0, output.length, ParagraphStyle::class.java).forEach { output.removeSpan(it) }
988986
clearMetaSpans(output)
989987
parser.syncVisualNewlinesOfBlockElements(output)
990-
val html = Format.removeSourceEditorFormatting(parser.toHtml(output))
988+
Format.postProcessSpanedText(output, isInCalypsoMode)
989+
990+
// do not copy unnecessary block hierarchy, just the minimum required
991+
var deleteNext = false
992+
output.getSpans(0, output.length, IAztecBlockSpan::class.java)
993+
.sortedBy { it.nestingLevel }
994+
.reversed()
995+
.forEach {
996+
if (deleteNext) {
997+
output.removeSpan(it)
998+
} else {
999+
deleteNext = output.getSpanStart(it) == 0 && output.getSpanEnd(it) == output.length
1000+
if (deleteNext && it is AztecListItemSpan) {
1001+
deleteNext = false
1002+
}
1003+
}
1004+
}
1005+
1006+
val html = Format.removeSourceEditorFormatting(parser.toHtml(output), isInCalypsoMode)
9911007

9921008
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
993-
clipboard.primaryClip = ClipData.newPlainText(null, html)
1009+
clipboard.primaryClip = ClipData.newHtmlText("aztec", output.toString(), html)
9941010
}
9951011

9961012
//copied from TextView with some changes
997-
private fun paste(editable: Editable, min: Int, max: Int) {
1013+
fun paste(editable: Editable, min: Int, max: Int) {
9981014
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
9991015
val clip = clipboard.primaryClip
1016+
10001017
if (clip != null) {
1001-
val parser = AztecParser(plugins)
1018+
history.beforeTextChanged(toFormattedHtml())
1019+
1020+
disableTextChangedListener()
10021021

1003-
for (i in 0..clip.itemCount - 1) {
1004-
val textToPaste = clip.getItemAt(i).coerceToText(context)
1022+
if (min == 0 && max == text.length) {
1023+
setText(Constants.REPLACEMENT_MARKER_STRING)
1024+
} else {
1025+
editable.delete(min, max)
1026+
editable.insert(min, Constants.REPLACEMENT_MARKER_STRING)
1027+
}
10051028

1006-
val builder = SpannableStringBuilder()
1007-
builder.append(parser.fromHtml(Format.removeSourceEditorFormatting(textToPaste.toString()), context).trim())
1008-
Selection.setSelection(editable, max)
1029+
// don't let the pasted text be included in any existing style
1030+
editable.getSpans(min, min + 1, Object::class.java)
1031+
.filter { editable.getSpanStart(it) != editable.getSpanEnd(it) && it !is IAztecBlockSpan }
1032+
.forEach {
1033+
if (editable.getSpanStart(it) == min) {
1034+
editable.setSpan(it, min + 1, editable.getSpanEnd(it), editable.getSpanFlags(it))
1035+
}
1036+
else if (editable.getSpanEnd(it) == min + 1) {
1037+
editable.setSpan(it, editable.getSpanStart(it), min, editable.getSpanFlags(it))
1038+
}
1039+
}
10091040

1010-
disableTextChangedListener()
1011-
// FIXME
1012-
try {
1013-
editable.replace(min, max, builder)
1014-
} catch (e: RuntimeException) {
1015-
// try to get more context for this crash: https://github.com/wordpress-mobile/AztecEditor-Android/issues/424
1016-
throw RuntimeException("### MIN: $min, MAX: $max\n---\n### TEXT:${toHtml()}\n---\n### PASTED:${parser.toHtml(builder)}", e)
1017-
}
1018-
enableTextChangedListener()
1041+
enableTextChangedListener()
1042+
1043+
if (clip.itemCount > 0) {
1044+
val textToPaste = clip.getItemAt(0).coerceToHtmlText(context, AztecParser(plugins))
1045+
1046+
val oldHtml = toPlainHtml().replace("<aztec_cursor>", "")
1047+
val newHtml = oldHtml.replace(Constants.REPLACEMENT_MARKER_STRING, textToPaste + "<" + AztecCursorSpan.AZTEC_CURSOR_TAG + ">")
1048+
1049+
fromHtml(newHtml)
1050+
history.handleHistory(this@AztecText)
10191051

1020-
inlineFormatter.joinStyleSpans(0, editable.length) //TODO: see how this affects performance
1052+
inlineFormatter.joinStyleSpans(0, length())
10211053
}
10221054
}
10231055
}

aztec/src/main/kotlin/org/wordpress/aztec/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package org.wordpress.aztec
33
object Constants {
44
val MAGIC_CHAR = '\uFEFF' //'*'
55
val MAGIC_STRING = "" + MAGIC_CHAR
6+
val REPLACEMENT_MARKER_CHAR = '\u202F'
7+
val REPLACEMENT_MARKER_STRING = "" + REPLACEMENT_MARKER_CHAR
68
val ZWJ_CHAR = '\u200B'//'§'
79
val ZWJ_STRING = "" + ZWJ_CHAR
810
val IMG_CHAR = '\uFFFC'

aztec/src/main/kotlin/org/wordpress/aztec/source/Format.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ import java.util.regex.Pattern
1212
object Format {
1313

1414
// list of block elements
15-
private val block = "div|br|blockquote|ul|ol|li|p|pre|h1|h2|h3|h4|h5|h6|iframe|hr|aztec_cursor"
15+
private val block = "div|br|blockquote|ul|ol|li|p|pre|h1|h2|h3|h4|h5|h6|iframe|hr"
1616

1717
private val iframePlaceholder = "iframe-replacement-0x0"
1818

1919
fun addSourceEditorFormatting(content: String, isCalypsoFormat: Boolean = false): String {
2020
var html = replaceAll(content, "iframe", iframePlaceholder)
21+
html = html.replace("<aztec_cursor>", "")
2122

2223
val doc = Jsoup.parseBodyFragment(html).outputSettings(Document.OutputSettings().prettyPrint(!isCalypsoFormat))
2324
if (isCalypsoFormat) {
@@ -27,7 +28,6 @@ object Format {
2728
.forEach { it.remove() }
2829

2930
html = replaceAll(doc.body().html(), iframePlaceholder, "iframe")
30-
html = html.replace("aztec_cursor", "")
3131

3232
html = replaceAll(html, "<p>(?:<br ?/?>|\u00a0|\uFEFF| )*</p>", "<p>&nbsp;</p>")
3333
html = toCalypsoSourceEditorFormat(html)
@@ -251,7 +251,6 @@ object Format {
251251
html = sb.toString()
252252
}
253253

254-
html += "\n\n"
255254

256255
html = replaceAll(html, "(?i)<br ?/?>\\s*<br ?/?>", "\n\n")
257256
html = replaceAll(html, "(?i)(<(?:$blocklist)(?: [^>]*)?>)", "\n$1")

aztec/src/main/kotlin/org/wordpress/aztec/spans/AztecMediaSpan.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,13 @@ abstract class AztecMediaSpan(context: Context, drawable: Drawable?, override va
8989
}
9090

9191
open fun getHtml(): String {
92-
val sb = StringBuilder()
93-
sb.append("<")
94-
sb.append(TAG)
95-
sb.append(' ')
92+
val sb = StringBuilder("<$TAG ")
9693

9794
attributes.removeAttribute("aztec_id")
9895

9996
sb.append(attributes)
100-
sb.append("/>")
97+
sb.trim()
98+
sb.append(" />")
10199

102100
return sb.toString()
103101
}

0 commit comments

Comments
 (0)