/* * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) * * 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 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.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 static final int NO_INDEX = -1; private int mParagraphIndex = NO_INDEX; private int mSelectionStartBeforeDelete; private boolean mIsLastParagraph = false; // 链接类型定义 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 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_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; /** * 文本变更监听器接口 */ public interface OnTextViewChangeListener { /** * 删除当前段落 (当段落为空且位于开头时) * * @param paragraphIndex 段落索引 */ void onParagraphDelete(int paragraphIndex); /** * 分割当前段落 (按回车时) * * @param fromParagraphIndex 原始段落索引 * @param newParagraphText 新段落文本 * @param offset 分割位置 */ void onParagraphSplit(int fromParagraphIndex, String newParagraphText, int offset); /** * 文本变更回调 * * @param paragraphIndex 段落索引 * @param 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) { this(context, null); } public NoteEditText(Context context, AttributeSet attrs) { this(context, attrs, android.R.attr.editTextStyle); } public NoteEditText(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } /** * 初始化编辑框 */ 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 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) { try { return super.onTouchEvent(event); } catch (Exception e) { Log.e(TAG, "触摸事件处理异常", e); return false; } } @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) { // 检测删除键,记录删除前的光标位置 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); } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { switch(keyCode) { case KeyEvent.KEYCODE_DEL: handleDeleteAction(); return true; case KeyEvent.KEYCODE_ENTER: handleEnterAction(); return true; case KeyEvent.KEYCODE_TAB: handleTabAction(); return true; default: break; } return super.onKeyUp(keyCode, event); } @Override protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { super.onFocusChanged(focused, direction, previouslyFocusedRect); if (mOnTextViewChangeListener != null) { boolean hasText = !TextUtils.isEmpty(getText()); mOnTextViewChangeListener.onTextChange(mParagraphIndex, hasText); } } @Override protected void onCreateContextMenu(ContextMenu menu) { // 添加自定义菜单项 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; // 未实现 } /*************************** 链接处理 ***************************/ /** * 添加自定义链接模式 */ 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); } } /*************************** 编辑操作处理 ***************************/ /** * 处理删除操作 */ private void handleDeleteAction() { if (mOnTextViewChangeListener == null) { return; } // 处理空段落删除逻辑 if (mSelectionStartBeforeDelete == 0 && mParagraphIndex != NO_INDEX) { // 只有整个段落为空时才删除 if (TextUtils.isEmpty(getText())) { mOnTextViewChangeListener.onParagraphDelete(mParagraphIndex); } } } /** * 处理回车操作 */ 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); } }