Update NoteEditText.java

main
mxvwfs5gq 2 months ago
parent 480eadedd3
commit 5de73e0648

@ -17,124 +17,292 @@
package net.micode.notes.ui;
import android.content.Context;
import android.content.Intent;
import android.graphics.Rect;
import android.net.Uri;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ActionMode;
import android.view.ContextMenu;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
import android.view.MotionEvent;
import android.widget.EditText;
import android.widget.Toast;
import androidx.core.text.util.LinkifyCompat;
import net.micode.notes.R;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* -
*
* :
* 1. (////)
* 2.
* 3. (/)
* 4.
* 5.
* 6.
* 7. /
*
* :
* 1.
* 2.
* 3.
* 4.
*/
public class NoteEditText extends EditText {
private static final String TAG = "NoteEditText";
private int mIndex;
private int mSelectionStartBeforeDelete;
private static final String SCHEME_TEL = "tel:" ;
private static final String SCHEME_HTTP = "http:" ;
private static final String SCHEME_EMAIL = "mailto:" ;
// 段落管理常量
private static final int NO_INDEX = -1;
private int mParagraphIndex = NO_INDEX;
private int mSelectionStartBeforeDelete;
private boolean mIsLastParagraph = false;
private static final Map<String, Integer> sSchemaActionResMap = new HashMap<String, Integer>();
// 链接类型定义
private static final String SCHEME_TEL = "tel:";
private static final String SCHEME_MAILTO = "mailto:";
private static final String SCHEME_HTTP = "http:";
private static final String SCHEME_HTTPS = "https:";
private static final String SCHEME_GEO = "geo:";
private static final String SCHEME_NOTE = "note://";
// 链接模式定义
private static final Pattern USER_MENTION_PATTERN = Pattern.compile("@\\w+");
private static final Pattern NOTE_REF_PATTERN = Pattern.compile("#\\w+");
// 链接操作映射表
private static final Map<String, Integer> sSchemaActionResMap = new HashMap<>();
static {
sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel);
sSchemaActionResMap.put(SCHEME_MAILTO, R.string.note_link_email);
sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web);
sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email);
sSchemaActionResMap.put(SCHEME_HTTPS, R.string.note_link_web);
sSchemaActionResMap.put(SCHEME_GEO, R.string.note_link_map);
sSchemaActionResMap.put(SCHEME_NOTE, R.string.note_link_note);
}
// 智能编辑标记
private static final int UNDO_HISTORY_SIZE = 50;
private int mIndentLevel = 0;
private boolean mInBulletList = false;
/**
* Call by the {@link NoteEditActivity} to delete or add edit text
*
*/
public interface OnTextViewChangeListener {
/**
* Delete current edit text when {@link KeyEvent#KEYCODE_DEL} happens
* and the text is null
* ()
*
* @param paragraphIndex
*/
void onEditTextDelete(int index, String text);
void onParagraphDelete(int paragraphIndex);
/**
* Add edit text after current edit text when {@link KeyEvent#KEYCODE_ENTER}
* happen
* ()
*
* @param fromParagraphIndex
* @param newParagraphText
* @param offset
*/
void onEditTextEnter(int index, String text);
void onParagraphSplit(int fromParagraphIndex, String newParagraphText, int offset);
/**
* Hide or show item option when text change
*
*
* @param paragraphIndex
* @param hasText
*/
void onTextChange(int index, boolean hasText);
void onTextChange(int paragraphIndex, boolean hasText);
/**
*
*
* @param url URL
* @return
*/
boolean onLinkClick(String url);
}
private OnTextViewChangeListener mOnTextViewChangeListener;
// 历史管理类
private static class EditHistory {
private static final int MAX_UNDO = UNDO_HISTORY_SIZE;
private final HistoryItem[] items = new HistoryItem[MAX_UNDO];
private int position = -1;
private int size = 0;
static class HistoryItem {
String before;
String after;
int selectionStart;
int selectionEnd;
HistoryItem(CharSequence before, CharSequence after, int selStart, int selEnd) {
this.before = before.toString();
this.after = after.toString();
this.selectionStart = selStart;
this.selectionEnd = selEnd;
}
}
void add(HistoryItem item) {
if (size < MAX_UNDO) {
size++;
}
position = (position + 1) % MAX_UNDO;
items[position] = item;
}
HistoryItem getLast() {
if (size == 0) return null;
return items[position];
}
boolean canUndo() {
return size > 0;
}
}
private final EditHistory mEditHistory = new EditHistory();
private boolean mIsUndoRedoInProgress = false;
public NoteEditText(Context context) {
super(context, null);
mIndex = 0;
this(context, null);
}
public void setIndex(int index) {
mIndex = index;
public NoteEditText(Context context, AttributeSet attrs) {
this(context, attrs, android.R.attr.editTextStyle);
}
public void setOnTextViewChangeListener(OnTextViewChangeListener listener) {
mOnTextViewChangeListener = listener;
public NoteEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public NoteEditText(Context context, AttributeSet attrs) {
super(context, attrs, android.R.attr.editTextStyle);
/**
*
*/
private void init() {
// 配置富文本支持
setMovementMethod(new CustomLinkMovementMethod());
// 设置自动链接类型
int linkifyMask = LinkifyCompat.WEB_URLS
| LinkifyCompat.EMAIL_ADDRESSES
| LinkifyCompat.PHONE_NUMBERS
| LinkifyCompat.MAP_ADDRESSES;
LinkifyCompat.addLinks(this, linkifyMask);
// 添加自定义链接
addCustomLinkPatterns();
// 启用撤销支持
enableUndoRedo();
}
/**
*
*
* @param paragraphIndex
* @param isLastParagraph
*/
public void setParagraphInfo(int paragraphIndex, boolean isLastParagraph) {
this.mParagraphIndex = paragraphIndex;
this.mIsLastParagraph = isLastParagraph;
}
public NoteEditText(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
// TODO Auto-generated constructor stub
/**
*
*/
public void setOnTextViewChangeListener(OnTextViewChangeListener listener) {
this.mOnTextViewChangeListener = listener;
}
/**
*
*
* @param isDarkMode
*/
public void setDarkMode(boolean isDarkMode) {
if (isDarkMode) {
setBackgroundColor(getResources().getColor(R.color.note_bg_dark));
setTextColor(getResources().getColor(R.color.text_color_dark));
} else {
setBackgroundColor(getResources().getColor(R.color.note_bg_light));
setTextColor(getResources().getColor(R.color.text_color_light));
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
int x = (int) event.getX();
int y = (int) event.getY();
x -= getTotalPaddingLeft();
y -= getTotalPaddingTop();
x += getScrollX();
y += getScrollY();
Layout layout = getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
Selection.setSelection(getText(), off);
break;
try {
return super.onTouchEvent(event);
} catch (Exception e) {
Log.e(TAG, "触摸事件处理异常", e);
return false;
}
return super.onTouchEvent(event);
}
@Override
public boolean onTextContextMenuItem(int id) {
// 处理自定义上下文菜单项
if (id == R.id.note_menu_undo) {
undo();
return true;
} else if (id == R.id.note_menu_redo) {
redo();
return true;
}
return super.onTextContextMenuItem(id);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_ENTER:
if (mOnTextViewChangeListener != null) {
return false;
}
break;
case KeyEvent.KEYCODE_DEL:
mSelectionStartBeforeDelete = getSelectionStart();
break;
default:
break;
// 检测删除键,记录删除前的光标位置
if (keyCode == KeyEvent.KEYCODE_DEL) {
mSelectionStartBeforeDelete = getSelectionStart();
}
// 处理自定义快捷键
if (event.isCtrlPressed()) {
switch (keyCode) {
case KeyEvent.KEYCODE_Z:
if (event.isShiftPressed()) {
redo();
} else {
undo();
}
return true;
case KeyEvent.KEYCODE_B:
toggleBulletList();
return true;
case KeyEvent.KEYCODE_L:
indent();
return true;
case KeyEvent.KEYCODE_M:
dedent();
return true;
}
}
return super.onKeyDown(keyCode, event);
}
@ -142,25 +310,17 @@ public class NoteEditText extends EditText {
public boolean onKeyUp(int keyCode, KeyEvent event) {
switch(keyCode) {
case KeyEvent.KEYCODE_DEL:
if (mOnTextViewChangeListener != null) {
if (0 == mSelectionStartBeforeDelete && mIndex != 0) {
mOnTextViewChangeListener.onEditTextDelete(mIndex, getText().toString());
return true;
}
} else {
Log.d(TAG, "OnTextViewChangeListener was not seted");
}
break;
handleDeleteAction();
return true;
case KeyEvent.KEYCODE_ENTER:
if (mOnTextViewChangeListener != null) {
int selectionStart = getSelectionStart();
String text = getText().subSequence(selectionStart, length()).toString();
setText(getText().subSequence(0, selectionStart));
mOnTextViewChangeListener.onEditTextEnter(mIndex + 1, text);
} else {
Log.d(TAG, "OnTextViewChangeListener was not seted");
}
break;
handleEnterAction();
return true;
case KeyEvent.KEYCODE_TAB:
handleTabAction();
return true;
default:
break;
}
@ -169,49 +329,531 @@ public class NoteEditText extends EditText {
@Override
protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(focused, direction, previouslyFocusedRect);
if (mOnTextViewChangeListener != null) {
if (!focused && TextUtils.isEmpty(getText())) {
mOnTextViewChangeListener.onTextChange(mIndex, false);
} else {
mOnTextViewChangeListener.onTextChange(mIndex, true);
}
boolean hasText = !TextUtils.isEmpty(getText());
mOnTextViewChangeListener.onTextChange(mParagraphIndex, hasText);
}
super.onFocusChanged(focused, direction, previouslyFocusedRect);
}
@Override
protected void onCreateContextMenu(ContextMenu menu) {
if (getText() instanceof Spanned) {
int selStart = getSelectionStart();
int selEnd = getSelectionEnd();
int min = Math.min(selStart, selEnd);
int max = Math.max(selStart, selEnd);
final URLSpan[] urls = ((Spanned) getText()).getSpans(min, max, URLSpan.class);
if (urls.length == 1) {
int defaultResId = 0;
for(String schema: sSchemaActionResMap.keySet()) {
if(urls[0].getURL().indexOf(schema) >= 0) {
defaultResId = sSchemaActionResMap.get(schema);
break;
}
// 添加自定义菜单项
menu.add(Menu.NONE, R.id.note_menu_undo, 0, R.string.note_undo)
.setIcon(R.drawable.ic_undo)
.setEnabled(canUndo());
menu.add(Menu.NONE, R.id.note_menu_redo, 1, R.string.note_redo)
.setIcon(R.drawable.ic_redo)
.setEnabled(canRedo());
// 添加分割线
menu.add(Menu.NONE, -1, Menu.NONE, "");
// 处理链接菜单
handleLinkContextMenu(menu);
// 添加系统默认菜单
super.onCreateContextMenu(menu);
}
/**
* /
*/
private void enableUndoRedo() {
addTextChangedListener(new TextWatcher() {
private EditHistory.HistoryItem mItem;
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
if (mIsUndoRedoInProgress) return;
mItem = new EditHistory.HistoryItem(
s,
"",
getSelectionStart(),
getSelectionEnd()
);
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
// 不做处理
}
@Override
public void afterTextChanged(android.text.Editable s) {
if (mIsUndoRedoInProgress) return;
if (mItem != null) {
mItem.after = s.toString();
mItem.selectionStart = getSelectionStart();
mItem.selectionEnd = getSelectionEnd();
mEditHistory.add(mItem);
mItem = null;
}
}
});
}
/**
*
*/
public void undo() {
if (!canUndo()) return;
EditHistory.HistoryItem item = mEditHistory.getLast();
if (item != null) {
mIsUndoRedoInProgress = true;
setText(item.before);
setSelection(item.selectionStart, item.selectionEnd);
mIsUndoRedoInProgress = false;
}
}
/**
* ()
*/
public void redo() {
// 实际实现需要维护重做栈
Toast.makeText(getContext(), "重做功能正在开发中", Toast.LENGTH_SHORT).show();
}
public boolean canUndo() {
return mEditHistory.canUndo();
}
public boolean canRedo() {
return false; // 未实现
}
if (defaultResId == 0) {
defaultResId = R.string.note_link_other;
/*************************** 链接处理 ***************************/
/**
*
*/
private void addCustomLinkPatterns() {
addLinkPattern(NOTE_REF_PATTERN, SCHEME_NOTE,
new LinkifyCompat.MatchFilter() {
@Override
public boolean acceptMatch(CharSequence s, int start, int end) {
return start == 0 || s.charAt(start - 1) != '!';
}
});
addLinkPattern(USER_MENTION_PATTERN, "user://",
new LinkifyCompat.MatchFilter() {
@Override
public boolean acceptMatch(CharSequence s, int start, int end) {
return true;
}
});
}
/**
*
*/
private void addLinkPattern(Pattern pattern, String scheme, LinkifyCompat.MatchFilter filter) {
LinkifyCompat.addLinks(this, pattern, scheme, null, filter, null);
}
/**
*
*/
private void handleLinkContextMenu(ContextMenu menu) {
Spannable text = getText();
int min = Math.max(0, getSelectionStart());
int max = Math.min(text.length(), getSelectionEnd());
final URLSpan[] urls = text.getSpans(min, max, URLSpan.class);
if (urls.length > 0) {
URLSpan primaryUrl = getPrimaryUrl(urls);
if (primaryUrl != null) {
addLinkActionsToMenu(menu, primaryUrl);
}
}
}
/**
* ()
*/
private URLSpan getPrimaryUrl(URLSpan[] urls) {
if (urls.length == 1) {
return urls[0];
}
// 寻找覆盖选区最长的链接
URLSpan bestMatch = null;
int maxOverlap = 0;
int selStart = getSelectionStart();
int selEnd = getSelectionEnd();
for (URLSpan url : urls) {
int spanStart = getText().getSpanStart(url);
int spanEnd = getText().getSpanEnd(url);
int overlap = Math.min(selEnd, spanEnd) - Math.max(selStart, spanStart);
if (overlap > maxOverlap) {
maxOverlap = overlap;
bestMatch = url;
}
}
return bestMatch;
}
/**
*
*/
private void addLinkActionsToMenu(ContextMenu menu, final URLSpan urlSpan) {
String url = urlSpan.getURL();
String displayText = getDisplayTextForUrl(urlSpan);
// 添加标题项
menu.setHeaderTitle(displayText);
// 添加默认操作
Integer resId = sSchemaActionResMap.get(getScheme(url));
if (resId == null) {
resId = R.string.note_link_open;
}
menu.add(0, 0, 0, resId).setOnMenuItemClickListener(item -> {
handleLinkClick(url);
return true;
});
// 添加复制链接操作
menu.add(0, 1, 1, R.string.note_link_copy).setOnMenuItemClickListener(item -> {
copyToClipboard(displayText, url);
return true;
});
}
/**
*
*/
private String getDisplayTextForUrl(URLSpan urlSpan) {
String url = urlSpan.getURL();
if (url.startsWith(SCHEME_NOTE)) {
return url.substring(SCHEME_NOTE.length());
}
return url;
}
/**
* scheme
*/
private String getScheme(String url) {
int colonPos = url.indexOf(':');
if (colonPos != -1) {
return url.substring(0, colonPos + 1);
}
return url;
}
/**
*
*/
private boolean handleLinkClick(String url) {
if (mOnTextViewChangeListener != null) {
if (mOnTextViewChangeListener.onLinkClick(url)) {
return true;
}
}
// 默认链接处理
if (url.startsWith(SCHEME_TEL)) {
callPhoneNumber(url.substring(SCHEME_TEL.length()));
} else if (url.startsWith(SCHEME_MAILTO)) {
sendEmail(url.substring(SCHEME_MAILTO.length()));
} else if (url.startsWith(SCHEME_HTTP) || url.startsWith(SCHEME_HTTPS)) {
openWebPage(url);
} else if (url.startsWith(SCHEME_GEO)) {
openMap(url.substring(SCHEME_GEO.length()));
} else if (url.startsWith(SCHEME_NOTE)) {
openNoteReference(url.substring(SCHEME_NOTE.length()));
} else {
openWebPage(url); // 尝试打开
}
return true;
}
/**
* ()
*/
private class CustomLinkMovementMethod extends LinkMovementMethod {
@Override
public boolean onTouchEvent(android.widget.TextView widget, Spannable buffer, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
Layout layout = widget.getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
URLSpan[] links = buffer.getSpans(off, off, URLSpan.class);
if (links.length != 0) {
String url = links[0].getURL();
if (handleLinkClick(url)) {
return true;
}
}
}
return super.onTouchEvent(widget, buffer, event);
}
}
menu.add(0, 0, 0, defaultResId).setOnMenuItemClickListener(
new OnMenuItemClickListener() {
public boolean onMenuItemClick(MenuItem item) {
// goto a new intent
urls[0].onClick(NoteEditText.this);
return true;
}
});
/*************************** 编辑操作处理 ***************************/
/**
*
*/
private void handleDeleteAction() {
if (mOnTextViewChangeListener == null) {
return;
}
// 处理空段落删除逻辑
if (mSelectionStartBeforeDelete == 0 && mParagraphIndex != NO_INDEX) {
// 只有整个段落为空时才删除
if (TextUtils.isEmpty(getText())) {
mOnTextViewChangeListener.onParagraphDelete(mParagraphIndex);
}
}
super.onCreateContextMenu(menu);
}
}
/**
*
*/
private void handleEnterAction() {
if (mOnTextViewChangeListener == null) {
return;
}
int selectionStart = getSelectionStart();
String remainingText = "";
// 获取光标后的文本
if (selectionStart < length()) {
remainingText = getText().subSequence(selectionStart, length()).toString();
}
// 分割前的文本
String currentText = getText().subSequence(0, selectionStart).toString();
// 应用智能回车规则
if (currentText.endsWith("- - ") || currentText.endsWith("-- ")) {
// 智能列表结束
setText(currentText.substring(0, currentText.length() - 3));
} else if (mInBulletList) {
// 项目符号自动继续
setText(currentText + "\n• ");
setSelection(getText().length());
} else if (mIndentLevel > 0) {
// 保持缩进
String indent = createIndentString(mIndentLevel);
setText(currentText + "\n" + indent);
setSelection(getText().length());
} else {
// 普通回车
setText(currentText);
mOnTextViewChangeListener.onParagraphSplit(mParagraphIndex, remainingText, selectionStart);
}
}
/**
* Tab
*/
private void handleTabAction() {
int start = getSelectionStart();
int end = getSelectionEnd();
// 有选中文本时增加缩进
if (start != end) {
String selectedText = getText().subSequence(start, end).toString();
String replacement = createIndentString(mIndentLevel + 1) + selectedText;
getText().replace(start, end, replacement);
setSelection(start, start + replacement.length());
mIndentLevel++;
} else {
// 光标位置插入Tab
getText().insert(start, " ");
setSelection(start + 4);
}
}
/**
*
*/
private String createIndentString(int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append(" "); // 4空格缩进
}
return sb.toString();
}
/**
*
*/
private void toggleBulletList() {
mInBulletList = !mInBulletList;
int selectionStart = getSelectionStart();
if (mInBulletList) {
getText().insert(0, "• ");
setSelection(selectionStart + 2);
} else {
String text = getText().toString();
if (text.startsWith("• ")) {
getText().replace(0, 2, "");
setSelection(Math.max(0, selectionStart - 2));
}
}
}
/**
*
*/
public void indent() {
if (mIndentLevel < 5) {
mIndentLevel++;
updateIndent();
}
}
/**
*
*/
public void dedent() {
if (mIndentLevel > 0) {
mIndentLevel--;
updateIndent();
}
}
/**
*
*/
private void updateIndent() {
setText(getText()); // 触发重绘
// 实际应用中可以设置段落缩进
}
/*************************** 辅助功能 ***************************/
/**
*
*/
private void copyToClipboard(String label, String url) {
android.content.ClipboardManager clipboard =
(android.content.ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
android.content.ClipData clip = android.content.ClipData.newPlainText(label, url);
clipboard.setPrimaryClip(clip);
Toast.makeText(getContext(), R.string.note_link_copied, Toast.LENGTH_SHORT).show();
}
/**
*
*/
private void callPhoneNumber(String phoneNumber) {
try {
Intent intent = new Intent(Intent.ACTION_DIAL);
intent.setData(Uri.parse(SCHEME_TEL + phoneNumber));
getContext().startActivity(intent);
} catch (Exception e) {
showActionError(R.string.note_cant_call);
}
}
/**
*
*/
private void sendEmail(String email) {
try {
Intent intent = new Intent(Intent.ACTION_SENDTO);
intent.setData(Uri.parse(SCHEME_MAILTO + email));
getContext().startActivity(intent);
} catch (Exception e) {
showActionError(R.string.note_cant_email);
}
}
/**
*
*/
private void openWebPage(String url) {
try {
// 确保有协议前缀
if (!url.startsWith(SCHEME_HTTP) && !url.startsWith(SCHEME_HTTPS)) {
url = "http://" + url;
}
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
getContext().startActivity(intent);
} catch (Exception e) {
showActionError(R.string.note_cant_open_link);
}
}
/**
*
*/
private void openMap(String location) {
try {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("geo:0,0?q=" + Uri.encode(location)));
getContext().startActivity(intent);
} catch (Exception e) {
showActionError(R.string.note_cant_open_map);
}
}
/**
*
*/
private void openNoteReference(String noteRef) {
Toast.makeText(
getContext(),
getContext().getString(R.string.note_link_jump, noteRef),
Toast.LENGTH_SHORT
).show();
// 实际实现应该跳转到对应笔记
}
/**
*
*/
private void showActionError(int resId) {
Toast.makeText(getContext(), resId, Toast.LENGTH_SHORT).show();
}
/**
* (使)
*/
public int getParagraphIndex() {
return mParagraphIndex;
}
/**
* ()
*/
@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
// 添加自定义action mode支持
return super.startActionMode(callback, type);
}
}
Loading…
Cancel
Save