|
|
|
@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|