From d0fdcb79bba16d9f5faefe07a8dc24c7e315a51d Mon Sep 17 00:00:00 2001 From: gy <2293314358@qq.com> Date: Fri, 30 Jan 2026 19:35:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E8=A6=81=E6=9B=B4=E6=94=B9=E4=BA=86No?= =?UTF-8?q?teEditActivity=E7=B1=BB=E4=B8=AD=E7=9A=84=E5=A4=A7=E9=83=A8?= =?UTF-8?q?=E5=88=86=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=A2=9E=E5=8A=A0=E4=BA=86?= =?UTF-8?q?ui=E5=B1=82=E4=BE=BF=E7=AD=BE=E7=BD=AE=E9=A1=B6/=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E7=BD=AE=E9=A1=B6=E3=80=81=E6=92=A4=E5=9B=9E/?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=92=A4=E5=9B=9E=E3=80=81=E8=AE=BE=E4=B8=BA?= =?UTF-8?q?=E7=A7=81=E5=AF=86/=E5=8F=96=E6=B6=88=E7=A7=81=E5=AF=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../net/micode/notes/ui/NoteEditActivity.java | 828 +++++++++++++++++- 1 file changed, 817 insertions(+), 11 deletions(-) diff --git a/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java b/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java index 07d2794..cbf0aae 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java @@ -51,6 +51,16 @@ import android.widget.TextView; import android.widget.Toast; import android.app.AlarmManager; // 修复 AlarmManager 符号丢失 import android.app.PendingIntent; // 确保 PendingIntent 也能正常使用 +import android.content.ContentValues; +// [新增] 用于支持富文本和编辑逻辑 +import android.text.Editable; +import android.text.Spanned; +import android.text.TextUtils; +import androidx.appcompat.widget.PopupMenu; +import android.view.MotionEvent; +import android.net.Uri; +import android.graphics.drawable.Drawable; +import android.graphics.Color; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; @@ -73,7 +83,8 @@ import java.util.HashSet; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; - +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class NoteEditActivity extends AppCompatActivity implements OnClickListener, NoteSettingChangedListener, OnTextViewChangeListener { @@ -140,6 +151,12 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen private SharedPreferences mSharedPrefs; private int mFontSizeId; + private UndoManager mUndoManager; + + private TextView mTvCharCount; // [新增] 字数统计控件 + + private androidx.activity.result.ActivityResultLauncher mGetContent; + private static final String PREFERENCE_FONT_SIZE = "pref_font_size"; private static final int SHORTCUT_ICON_TITLE_MAX_LEN = 10; @@ -152,6 +169,29 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen private String mUserQuery; private Pattern mPattern; + private android.widget.HorizontalScrollView mAttachmentScrollView; + private LinearLayout mAttachmentContainer; + + private View mFormatToolbar; + + private androidx.activity.result.ActivityResultLauncher mGetNoteBg; + + // [新增] 用于记录待删除的图片数据库 ID + private java.util.HashSet mDeletedImages = new java.util.HashSet<>(); + + private boolean mIsFirstLoad = true; + + // [新增] 简单的内部类,用于 Tag 绑定 + private static class ImageItem { + long dbId; // 数据库中的 ID,如果是新图片则为 0 + android.net.Uri uri; + + ImageItem(long id, android.net.Uri u) { + dbId = id; + uri = u; + } + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -162,6 +202,8 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen return; } initResources(); + + mUndoManager = new UndoManager(); // 初始化撤销管理器 // 1. 绑定 Toolbar Toolbar toolbar = findViewById(R.id.toolbar); @@ -172,6 +214,29 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setTitle(""); // 编辑页通常不显示标题 } + + // [新增] 注册图片选择器回调 + mGetContent = registerForActivityResult(new androidx.activity.result.contract.ActivityResultContracts.GetContent(), + uri -> { + if (uri != null) { + insertImageToEditor(uri); + } + }); + + mGetNoteBg = registerForActivityResult(new androidx.activity.result.contract.ActivityResultContracts.GetContent(), uri -> { + if (uri != null) { + try { + // 获取永久权限 + getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + // [核心修正] 仅更新背景数据,禁止调用 insertImageToEditor 或 addImageViewToContainer + mWorkingNote.setCustomBgUri(uri.toString()); + // 刷新背景显示 + applyNoteBackground(); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); } /** @@ -287,8 +352,15 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { switchToListMode(mWorkingNote.getContent()); } else { - mNoteEditor.setText(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery)); - mNoteEditor.setSelection(mNoteEditor.getText().length()); + // 【关键修复】从 HTML 恢复样式 + String content = mWorkingNote.getContent(); + if (content != null && content.contains("<")) { + // 【核心修复】使用 FROM_HTML_MODE_LEGACY 确保最大程度兼容标签解析 + mNoteEditor.setText(android.text.Html.fromHtml(content, android.text.Html.FROM_HTML_MODE_LEGACY)); + refreshChecklistStyles(); + } else { + mNoteEditor.setText(getHighlightQueryResult(content, mUserQuery)); + } } for (Integer id : sBgSelectorSelectionMap.keySet()) { findViewById(sBgSelectorSelectionMap.get(id)).setVisibility(View.GONE); @@ -301,11 +373,84 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_YEAR)); + // 【修改点】只有在第一次进入页面,或者确实需要刷新时才 loadImages + if (mIsFirstLoad) { + loadImages(); + mIsFirstLoad = false; + } + // [新增] 初始化字数显示 + updateCharCount(); /** * TODO: Add the menu for setting alert. Currently disable it because the DateTimePicker * is not ready */ showAlertHeader(); + + loadImages(); + + applyNoteBackground(); + } + + private void loadImages() { + // 【关键修改】如果容器里已经有“待保存”的图片(dbId == 0), + // 说明是刚从照片选择器回来的,绝不能清空! + // 我们只在没有任何图片时,或者初始化时执行加载。 + + if (mWorkingNote.getNoteId() <= 0) return; + + android.database.Cursor cursor = null; + try { + cursor = getContentResolver().query( + Notes.CONTENT_DATA_URI, + new String[]{Notes.DataColumns.ID, Notes.DataColumns.CONTENT}, + Notes.DataColumns.NOTE_ID + "=? AND " + Notes.DataColumns.MIME_TYPE + "=?", + new String[]{String.valueOf(mWorkingNote.getNoteId()), Notes.ImageNote.CONTENT_ITEM_TYPE}, + null + ); + + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + String uriString = cursor.getString(1); + + // 【关键修复】检查这个数据库 ID 是否已经在 UI 列表里了,防止重复加载 + boolean alreadyInUI = false; + for (int i = 0; i < mAttachmentContainer.getChildCount(); i++) { + Object tag = mAttachmentContainer.getChildAt(i).getTag(); + if (tag instanceof ImageItem && ((ImageItem) tag).dbId == id) { + alreadyInUI = true; + break; + } + } + + if (!alreadyInUI && !TextUtils.isEmpty(uriString)) { + android.net.Uri uri = android.net.Uri.parse(uriString); + // 复用之前的稳健解码逻辑 + loadBitmapAndAdd(uri, id); + } + } + } + } catch (Exception e) { + Log.e(TAG, "Load images failed", e); + } finally { + if (cursor != null) cursor.close(); + } + } + + // 辅助方法:专门用于加载数据库已有的图片 + private void loadBitmapAndAdd(android.net.Uri uri, long dbId) { + try { + java.io.InputStream is = getContentResolver().openInputStream(uri); + android.graphics.BitmapFactory.Options opts = new android.graphics.BitmapFactory.Options(); + opts.inSampleSize = 4; // 数据库加载直接采样 + android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeStream(is, null, opts); + is.close(); + if (bitmap != null) { + addImageViewToContainer(bitmap, uri, dbId); + } + } catch (Exception e) { + e.printStackTrace(); + } } private void showAlertHeader() { @@ -384,14 +529,122 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen mNoteHeaderHolder.tvAlertDate = (TextView) findViewById(R.id.tv_alert_date); mNoteHeaderHolder.ibSetBgColor = (ImageView) findViewById(R.id.btn_set_bg_color); mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); + // 2. 关键:先通过 findViewById 找到对象!! mNoteEditor = (EditText) findViewById(R.id.note_edit_view); + // 绑定撤销/重做按钮逻辑 + findViewById(R.id.btn_undo).setOnClickListener(v -> mUndoManager.undo(mNoteEditor)); + findViewById(R.id.btn_redo).setOnClickListener(v -> mUndoManager.redo(mNoteEditor)); + // 重构 TextWatcher 逻辑 + // 替换原本只负责 updateCharCount 的 TextWatcher + if (mNoteEditor != null) { + mNoteEditor.addTextChangedListener(new android.text.TextWatcher() { + private android.text.Spannable beforeText; + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // 核心:在变化前记录快照 + if (mUndoManager != null && !mUndoManager.isWorking) { + beforeText = new android.text.SpannableStringBuilder(mNoteEditor.getText()); + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + updateCharCount(); // 保留原有的字数统计功能 + } + + @Override + public void afterTextChanged(android.text.Editable s) { + // 核心:在变化后将快照压入撤销栈 + if (mUndoManager != null && !mUndoManager.isWorking && beforeText != null) { + mUndoManager.undoStack.addFirst(beforeText); + if (mUndoManager.undoStack.size() > 30) mUndoManager.undoStack.removeLast(); + mUndoManager.redoStack.clear(); + mUndoManager.updateButtons(); + beforeText = null; + } + } + }); + } + + // [新增] 绑定附件栏 + mAttachmentScrollView = (android.widget.HorizontalScrollView) findViewById(R.id.attachment_bar_scroll); + mAttachmentContainer = (LinearLayout) findViewById(R.id.attachment_bar_container); + // 【新增】强制该控件使用软件层渲染,确保 ImageSpan 稳定显示 + if (mNoteEditor != null) { + mNoteEditor.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + } mNoteEditorPanel = findViewById(R.id.sv_note_edit); + // [新增] 找到字数统计控件 + mTvCharCount = (TextView) findViewById(R.id.tv_char_count); + // 3. 增加防御性判断,确保对象不为 null 再设置监听器 + if (mNoteEditor != null) { + mNoteEditor.addTextChangedListener(new android.text.TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + updateCharCount(); // 调用更新方法 + } + + @Override + public void afterTextChanged(android.text.Editable s) {} + }); + } + mNoteBgColorSelector = findViewById(R.id.note_bg_color_selector); for (int id : sBgSelectorBtnsMap.keySet()) { ImageView iv = (ImageView) findViewById(id); iv.setOnClickListener(this); } + findViewById(R.id.iv_bg_custom).setOnClickListener(v -> { + mGetNoteBg.launch("image/*"); // 必须是背景选择器 + mNoteBgColorSelector.setVisibility(View.GONE); + }); + + // [彻底重写] 清单交互逻辑:包含完整的变量定义与坐标计算 + mNoteEditor.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_UP) { + Editable et = mNoteEditor.getText(); + if (et == null) return false; + String content = et.toString(); + int offset = mNoteEditor.getOffsetForPosition(event.getX(), event.getY()); + int lineStart = content.lastIndexOf('\n', offset - 1) + 1; + + // 使用 ⬜ 替代之前的方块 + if (offset >= lineStart && offset < lineStart + 3) { + if (content.startsWith("⬜", lineStart)) { + et.replace(lineStart, lineStart + 1, "✅"); + int lineEnd = et.toString().indexOf('\n', lineStart); + if (lineEnd == -1) lineEnd = et.length(); + et.setSpan(new android.text.style.ForegroundColorSpan(android.graphics.Color.LTGRAY), + lineStart + 2, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + et.setSpan(new android.text.style.StrikethroughSpan(), + lineStart + 2, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return true; + } else if (content.startsWith("✅", lineStart)) { + et.replace(lineStart, lineStart + 1, "⬜"); + int lineEnd = et.toString().indexOf('\n', lineStart); + if (lineEnd == -1) lineEnd = et.length(); + Object[] spans = et.getSpans(lineStart, lineEnd, Object.class); + for (Object s : spans) { + if (s instanceof android.text.style.ForegroundColorSpan || + s instanceof android.text.style.StrikethroughSpan) { + et.removeSpan(s); + } + } + return true; + } + } + } + return false; + } + }); + mFontSizeSelector = findViewById(R.id.font_size_selector); for (int id : sFontSizeBtnsMap.keySet()) { View view = findViewById(id); @@ -408,6 +661,60 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen mFontSizeId = ResourceParser.BG_DEFAULT_FONT_SIZE; } mEditTextList = (LinearLayout) findViewById(R.id.note_edit_list); + + // [新增] 绑定工具栏按钮 + findViewById(R.id.btn_insert_image).setOnClickListener(v -> { + mGetContent.launch("image/*"); // 这个才是插附件 + }); + + findViewById(R.id.btn_toggle_list).setOnClickListener(v -> { + toggleChecklist(); + }); + + findViewById(R.id.btn_format_text).setOnClickListener(v -> { + // 下一步(4.3)实现,目前先给个提示 + handleTextFormat(); + }); + + + mFormatToolbar = findViewById(R.id.format_toolbar); + // 基础工具栏按钮:点击切换到二级工具栏 + findViewById(R.id.btn_format_text).setOnClickListener(v -> { + mFormatToolbar.setVisibility(View.VISIBLE); + findViewById(R.id.bottom_toolbar).setVisibility(View.GONE); + }); + // 二级工具栏:关闭 + findViewById(R.id.btn_close_format).setOnClickListener(v -> { + mFormatToolbar.setVisibility(View.GONE); + findViewById(R.id.bottom_toolbar).setVisibility(View.VISIBLE); + }); + // 绑定富文本处理逻辑 + findViewById(R.id.btn_bold).setOnClickListener(v -> toggleSpan(new android.text.style.StyleSpan(android.graphics.Typeface.BOLD))); + findViewById(R.id.btn_italic).setOnClickListener(v -> toggleSpan(new android.text.style.StyleSpan(android.graphics.Typeface.ITALIC))); + findViewById(R.id.btn_underline).setOnClickListener(v -> toggleSpan(new android.text.style.UnderlineSpan())); + findViewById(R.id.btn_strike).setOnClickListener(v -> toggleSpan(new android.text.style.StrikethroughSpan())); + findViewById(R.id.btn_highlight).setOnClickListener(v -> toggleSpan(new android.text.style.BackgroundColorSpan(android.graphics.Color.YELLOW))); + // 颜色切换逻辑 + findViewById(R.id.btn_color_toggle).setOnClickListener(v -> { + Editable et = mNoteEditor.getText(); + int start = mNoteEditor.getSelectionStart(); + int end = mNoteEditor.getSelectionEnd(); + if (start >= end) return; + + android.text.style.ForegroundColorSpan[] spans = et.getSpans(start, end, android.text.style.ForegroundColorSpan.class); + if (spans.length > 0) { + et.removeSpan(spans[0]); // 恢复默认(黑色) + } else { + et.setSpan(new android.text.style.ForegroundColorSpan(android.graphics.Color.WHITE), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + }); + + // [修改] 在 initResources 中,修改字体大小按钮的点击逻辑 + // 注意:参数改为 float 类型 + findViewById(R.id.btn_font_small).setOnClickListener(v -> setFontSize(0.8f)); // S + findViewById(R.id.btn_font_normal).setOnClickListener(v -> setFontSize(1.0f)); // M + findViewById(R.id.btn_font_large).setOnClickListener(v -> setFontSize(1.5f)); // L + findViewById(R.id.btn_font_super).setOnClickListener(v -> setFontSize(2.0f)); // XL } @Override @@ -419,6 +726,318 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen clearSettingState(); } + // [新增] 更新字数统计显示 + private void updateCharCount() { + if (mTvCharCount == null) return; + + int totalLength = 0; + if (mNoteEditor.getVisibility() == View.VISIBLE) { + totalLength = mNoteEditor.getText().length(); + } else { + // 清单模式统计所有输入框的总字数 + for (int i = 0; i < mEditTextList.getChildCount(); i++) { + View itemView = mEditTextList.getChildAt(i); + EditText et = itemView.findViewById(R.id.et_edit_text); + if (et != null) totalLength += et.getText().length(); + } + } + mTvCharCount.setText(getString(R.string.char_count_format, totalLength)); + } + + private void applyNoteBackground() { + View rootView = findViewById(R.id.note_edit_root); + if (rootView == null) return; + + String customUri = mWorkingNote.getCustomBgUri(); + + if (!TextUtils.isEmpty(customUri)) { + try { + Uri uri = Uri.parse(customUri); + java.io.InputStream is = getContentResolver().openInputStream(uri); + Drawable drawable = Drawable.createFromStream(is, uri.toString()); + + // 只设置最底层根布局的背景 + rootView.setBackground(drawable); + + // 确保中间层透明,才能看到底层的图片 + mNoteEditorPanel.setBackgroundColor(Color.TRANSPARENT); + mHeadViewPanel.setBackgroundColor(Color.TRANSPARENT); + + if (is != null) is.close(); + return; + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 如果没有图片背景,恢复颜色背景 + rootView.setBackground(null); + mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); + mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId()); + } + + // [重写] 稳健的图片插入逻辑 + private void insertImageToEditor(android.net.Uri uri) { + if (uri == null) return; + + try { + java.io.InputStream input = getContentResolver().openInputStream(uri); + android.graphics.BitmapFactory.Options options = new android.graphics.BitmapFactory.Options(); + + // 【关键修复】将 InSampleSize 设为 4 或 8 + // 附件栏只需要缩略图,大幅度压缩可以显著提高加载成功率 + options.inSampleSize = 4; + options.inPreferredConfig = android.graphics.Bitmap.Config.RGB_565; // 减少内存占用 + + android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeStream(input, null, options); + if (input != null) input.close(); + + if (bitmap != null) { + addImageViewToContainer(bitmap, uri, 0); + } else { + android.widget.Toast.makeText(this, "图片读取失败", android.widget.Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + android.util.Log.e("NoteEdit", "Insert error", e); + } + } + + + // [重写] 强制计算宽度,修复图片不显示的问题 +// [重写] 增加保底逻辑,解决“看得见空行看不见图”的问题 + private void addImageViewToContainer(final android.graphics.Bitmap bitmap, final android.net.Uri uri, final long dbId) { + if (mAttachmentContainer == null || bitmap == null) { + android.util.Log.e("NoteEdit", "Add failed: container or bitmap is null"); + return; + } + + // 1. 确保外层容器可见 + mAttachmentScrollView.setVisibility(View.VISIBLE); + + final ImageView imageView = new ImageView(this); + imageView.setTag(new ImageItem(dbId, uri)); + + // 2. 强制计算宽高,并设置保底值 + int heightPx = (int) (150 * getResources().getDisplayMetrics().density); + float bitmapWidth = bitmap.getWidth(); + float bitmapHeight = bitmap.getHeight(); + + // 防御性编程:防止高度为0导致计算崩溃 + if (bitmapHeight <= 0) bitmapHeight = 1; + float ratio = bitmapWidth / bitmapHeight; + + int widthPx = (int) (heightPx * ratio); + + // 【关键修复】保底宽度:如果计算出来的宽度太小,给它 100dp 的保底 + int minWidthPx = (int) (100 * getResources().getDisplayMetrics().density); + if (widthPx < minWidthPx) widthPx = minWidthPx; + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(widthPx, heightPx); + params.rightMargin = (int) (12 * getResources().getDisplayMetrics().density); + imageView.setLayoutParams(params); + + // 3. 增强视觉表现 + imageView.setImageBitmap(bitmap); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); // 改为 CENTER_CROP 强制填充 + + // 【调试专用】设置一个明显的背景色。如果你看到红色方块说明 View 在,但图没渲染 + imageView.setBackgroundColor(android.graphics.Color.RED); + + // 强制使用软件渲染,绕过模拟器 GPU 驱动问题 + imageView.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + + // 4. 事件监听 + imageView.setOnLongClickListener(v -> { + showDeleteImageDialog(v); + return true; + }); + + // 5. 执行添加并强制刷新 + mAttachmentContainer.addView(imageView); + + // 【核心修复】强制请求布局刷新,解决“空行”不显示的玄学问题 + mAttachmentContainer.requestLayout(); + imageView.invalidate(); + + if (dbId == 0) { + mAttachmentScrollView.postDelayed(() -> + mAttachmentScrollView.fullScroll(View.FOCUS_RIGHT), 100); + } + } + + // [新增] 删除确认弹窗 + private void showDeleteImageDialog(final View imageView) { + new AlertDialog.Builder(this) + .setTitle(R.string.menu_delete) + .setMessage("删除这张图片?") // 建议放入 strings.xml + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + performDeleteImage(imageView); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + // [新增] 执行删除动作 + private void performDeleteImage(View view) { + ImageItem item = (ImageItem) view.getTag(); + // 如果是已保存到数据库的图片,记录 ID 等待 saveNote 时删除 + if (item.dbId > 0) { + mDeletedImages.add(item.dbId); + } + // 从 UI 移除 + mAttachmentContainer.removeView(view); + + // 如果没有图片了,隐藏滚动条 + if (mAttachmentContainer.getChildCount() == 0) { + mAttachmentScrollView.setVisibility(View.GONE); + } + } + + // [新增辅助方法] 自动识别当前是普通模式还是清单模式,返回活跃的编辑器 + private EditText getActiveEditor() { + if (mNoteEditor.getVisibility() == View.VISIBLE) { + return mNoteEditor; // 普通模式 + } else { + // 清单模式:遍历列表,找到当前拥有焦点的 NoteEditText + for (int i = 0; i < mEditTextList.getChildCount(); i++) { + View itemView = mEditTextList.getChildAt(i); + EditText et = itemView.findViewById(R.id.et_edit_text); + if (et != null && et.isFocused()) { + return et; + } + } + } + return mNoteEditor; // 兜底 + } + + // 辅助:切换 Span 状态(再次点击取消) + private void toggleSpan(Object spanObj) { + Editable et = mNoteEditor.getText(); + int start = mNoteEditor.getSelectionStart(); + int end = mNoteEditor.getSelectionEnd(); + if (start >= end) return; + + Object[] existing = et.getSpans(start, end, spanObj.getClass()); + if (existing.length > 0) { + for (Object s : existing) et.removeSpan(s); + } else { + et.setSpan(spanObj, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + // [重写] 设置字体大小(使用相对倍率,确保能被 HTML 保存) + private void setFontSize(float scale) { + Editable et = mNoteEditor.getText(); + int start = mNoteEditor.getSelectionStart(); + int end = mNoteEditor.getSelectionEnd(); + + if (start > end) { int temp = start; start = end; end = temp; } + if (start == end) return; // 未选中不处理 + + // 1. 移除选区内已有的相对大小 Span,避免叠加(比如 1.5 * 1.5 = 2.25) + android.text.style.RelativeSizeSpan[] spans = et.getSpans(start, end, android.text.style.RelativeSizeSpan.class); + for (android.text.style.RelativeSizeSpan s : spans) { + // 获取这个 span 的起止位置 + int sStart = et.getSpanStart(s); + int sEnd = et.getSpanEnd(s); + + et.removeSpan(s); + } + + // 2. 设置新的倍率 Span + // 1.0f 代表恢复默认大小,其实就是移除 Span,但为了逻辑统一,我们不设 Span 即可 + if (scale != 1.0f) { + et.setSpan(new android.text.style.RelativeSizeSpan(scale), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + // [新增] 处理富文本格式 + private void handleTextFormat() { + final EditText editor = mNoteEditor; + final int start = editor.getSelectionStart(); + final int end = editor.getSelectionEnd(); + + if (start == end) { + Toast.makeText(this, "请先选择一段文字", Toast.LENGTH_SHORT).show(); + return; + } + + // 弹出样式选择菜单 + PopupMenu popup = new PopupMenu(this, findViewById(R.id.btn_format_text)); + popup.getMenu().add("加粗"); + popup.getMenu().add("红色文字"); + popup.getMenu().add("清除样式"); + + popup.setOnMenuItemClickListener(item -> { + Editable et = editor.getText(); + if (item.getTitle().equals("加粗")) { + et.setSpan(new android.text.style.StyleSpan(android.graphics.Typeface.BOLD), + start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } else if (item.getTitle().equals("红色文字")) { + et.setSpan(new android.text.style.ForegroundColorSpan(android.graphics.Color.RED), + start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } else if (item.getTitle().equals("清除样式")) { + Object[] spans = et.getSpans(start, end, Object.class); + for (Object span : spans) { + if (span instanceof android.text.style.CharacterStyle) et.removeSpan(span); + } + } + return true; + }); + popup.show(); + } + + // [重写] 智能清单逻辑:在行首插入,并支持自动识别 + private void toggleChecklist() { + EditText editor = mNoteEditor; + if (editor == null) return; + + Editable editable = editor.getText(); + int selectionStart = editor.getSelectionStart(); + + // 1. 寻找当前行的起始位置 + String text = editable.toString(); + int lineStart = text.lastIndexOf('\n', selectionStart - 1) + 1; + + // 2. 判断该行是否已经有清单前缀 + String checkPrefix = "⬜ "; + String checkedPrefix = "√ "; + + if (text.startsWith(checkPrefix, lineStart)) { + // 如果已经是未完成清单,则移除它 + editable.delete(lineStart, lineStart + checkPrefix.length()); + } else if (text.startsWith(checkedPrefix, lineStart)) { + // 如果已经是完成清单,则移除它 + editable.delete(lineStart, lineStart + checkedPrefix.length()); + } else { + // 否则,在行首插入中空方块 + editable.insert(lineStart, checkPrefix); + } + } + + // [新增辅助方法] 扫描文本,为所有已勾选的行恢复置灰和中划线样式 + private void refreshChecklistStyles() { + Editable et = mNoteEditor.getText(); + String content = et.toString(); + String[] lines = content.split("\n"); + int currentPos = 0; + + for (String line : lines) { + if (line.startsWith("✅")) { + int lineEnd = currentPos + line.length(); + // 重新应用样式 + et.setSpan(new android.text.style.ForegroundColorSpan(android.graphics.Color.LTGRAY), + currentPos + 2, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + et.setSpan(new android.text.style.StrikethroughSpan(), + currentPos + 2, lineEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + currentPos += line.length() + 1; // +1 是换行符 + } + } + private void updateWidget() { Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); if (mWorkingNote.getWidgetType() == Notes.TYPE_WIDGET_2X) { @@ -834,7 +1453,51 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen } mWorkingNote.setWorkingText(sb.toString()); } else { - mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + // 1. 生成基础 HTML + String htmlContent = android.text.Html.toHtml((Spannable) mNoteEditor.getText(), + android.text.Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE); + + /** + * [Bug 2 修复] 字体大小保存修复 + * 问题:Html.toHtml 生成的是 ,但 Html.fromHtml 不识别 style。 + * 修复:使用更健壮的正则将 CSS font-size 转换为 标签。 + * + * 映射关系: + * Small (0.8) -> size 2 + * Normal (1.0) -> size 3 (默认,无需处理) + * Large (1.5) -> size 5 + * Super (2.0) -> size 6 + */ + + // 替换 Small (0.8em -> size 2) + // 正则解释:匹配 style="..." 中包含 font-size:0.8...em 的情况,允许前后有其他分号或空格 + htmlContent = htmlContent.replaceAll("(]*style=\"[^>]*)(font-size:\\s*0\\.8[0-9]*em;?)([^>]*\">)", ""); + htmlContent = htmlContent.replace("", ""); // 简单的闭合标签替换可能不严谨,但在 Android Html 生成器结构中通常有效 + + // 针对 Android Html.toHtml 的特定行为,更稳健的做法是直接针对 font-size 进行替换, + // 无论它包裹在什么标签里。但为了兼容现有逻辑结构,我们采用针对性替换: + + // 方案 B:直接替换特定模式的 span 开头。 + // 注意:Android 的 toHtml 输出通常非常标准。 + + // 修复 0.8em -> size 2 + htmlContent = htmlContent.replaceAll("style=\"[^\"]*font-size:0\\.8[0-9]*em;?[^\"]*\"", "size=\"2\""); + + // 修复 1.5em -> size 5 + htmlContent = htmlContent.replaceAll("style=\"[^\"]*font-size:1\\.5[0-9]*em;?[^\"]*\"", "size=\"5\""); + + // 修复 2.0em -> size 6 + htmlContent = htmlContent.replaceAll("style=\"[^\"]*font-size:2\\.0[0-9]*em;?[^\"]*\"", "size=\"6\""); + + // [重要] 由于上面的替换将 style="..." 变成了 size="...",原本的 变成了 。 + // Android 的 Html.fromHtml 并不支持 。它支持 。 + // 因此我们需要把这些带有 size 属性的 span 变成 font。 + htmlContent = htmlContent.replaceAll("

Body" 变成 "TitleBody" + String text = html.replaceAll("(?i)", " ") + .replaceAll("(?i)", " ") + .replaceAll("(?i)

", " "); + + // 2. 使用 Android 原生工具解析实体 (如   -> 空格, < -> <) + // 使用 LEGACY 模式以最大程度兼容 + text = android.text.Html.fromHtml(text, android.text.Html.FROM_HTML_MODE_LEGACY).toString(); + + // 3. 再次使用正则清洗可能残留的标签(Html.fromHtml 有时会遗漏自定义标签或属性) + // 同时也过滤掉 &#...; 这种未被解码的实体 + text = text.replaceAll("<[^>]+>", "") // 去除尖括号标签 + .replaceAll("&#[0-9]+;", "") // 去除数字实体 + .replaceAll("&[a-zA-Z]+;", ""); // 去除字符实体 + + // 4. 清理多余的空白字符(换行符转空格,多个空格合并为一个) + // 列表预览通常只需要单行或紧凑的多行 + text = text.replaceAll("\\s+", " ").trim(); + + // 5. 截取长度 (保持与之前逻辑一致,最多 60 字符) + if (text.length() > 60) { + text = text.substring(0, 60); + } + + return text; + } + + // [新增] 保存图片数据 + private void saveImages() { + long noteId = mWorkingNote.getNoteId(); + if (noteId <= 0) return; // 还没有生成 Note ID,无法保存附件 + + // 1. 处理删除 + if (!mDeletedImages.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (Long id : mDeletedImages) { + if (sb.length() > 0) sb.append(","); + sb.append(id); + } + getContentResolver().delete(Notes.CONTENT_DATA_URI, + Notes.DataColumns.ID + " IN (" + sb.toString() + ")", null); + mDeletedImages.clear(); + } + + // 2. 处理新增 (遍历容器中 ID 为 0 的项) + for (int i = 0; i < mAttachmentContainer.getChildCount(); i++) { + View view = mAttachmentContainer.getChildAt(i); + ImageItem item = (ImageItem) view.getTag(); + + if (item.dbId == 0) { // 新图片 + ContentValues values = new ContentValues(); + values.put(Notes.DataColumns.NOTE_ID, noteId); + values.put(Notes.DataColumns.MIME_TYPE, Notes.ImageNote.CONTENT_ITEM_TYPE); + values.put(Notes.DataColumns.CONTENT, item.uri.toString()); + // 记录创建/修改时间 + values.put(Notes.DataColumns.CREATED_DATE, System.currentTimeMillis()); + values.put(Notes.DataColumns.MODIFIED_DATE, System.currentTimeMillis()); + + android.net.Uri newUri = getContentResolver().insert(Notes.CONTENT_DATA_URI, values); + if (newUri != null) { + // 更新 View 中的 ID,防止重复保存 + try { + long newId = Long.parseLong(newUri.getPathSegments().get(1)); + item.dbId = newId; + } catch (NumberFormatException e) { e.printStackTrace(); } + } + } + } + } + private void sendToDesktop() { /** * Before send message to home, we should make sure that current @@ -904,4 +1652,62 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen private void showToast(int resId, int duration) { Toast.makeText(this, resId, duration).show(); } + + // NoteEditActivity.java 内部类 + private class UndoManager { + private final int MAX_STACK_SIZE = 30; + private java.util.LinkedList undoStack = new java.util.LinkedList<>(); + private java.util.LinkedList redoStack = new java.util.LinkedList<>(); + private boolean isWorking = false; // 防止监听循环 + + public void pushState(Editable content) { + if (isWorking) return; + // 存入快照(必须深拷贝 SpannableStringBuilder) + undoStack.addFirst(new android.text.SpannableStringBuilder(content)); + if (undoStack.size() > MAX_STACK_SIZE) undoStack.removeLast(); + redoStack.clear(); // 只要有新操作,清空 redo 栈 + updateButtons(); + } + + public void undo(EditText editor) { + if (undoStack.isEmpty()) return; + isWorking = true; + // 将当前状态压入 redo 栈 + redoStack.addFirst(new android.text.SpannableStringBuilder(editor.getText())); + + // 弹出上一状态并应用 + Spannable previous = undoStack.removeFirst(); + editor.setText(previous); + editor.setSelection(editor.length()); // 光标移至末尾 + + isWorking = false; + updateButtons(); + } + + public void redo(EditText editor) { + if (redoStack.isEmpty()) return; + isWorking = true; + // 将当前状态压回 undo 栈 + undoStack.addFirst(new android.text.SpannableStringBuilder(editor.getText())); + + // 还原状态 + Spannable next = redoStack.removeFirst(); + editor.setText(next); + editor.setSelection(editor.length()); + + isWorking = false; + updateButtons(); + } + + private void updateButtons() { + ImageView ivUndo = findViewById(R.id.btn_undo); + ImageView ivRedo = findViewById(R.id.btn_redo); + + ivUndo.setAlpha(undoStack.isEmpty() ? 0.3f : 1.0f); + ivUndo.setClickable(!undoStack.isEmpty()); + + ivRedo.setAlpha(redoStack.isEmpty() ? 0.3f : 1.0f); + ivRedo.setClickable(!redoStack.isEmpty()); + } + } } -- 2.34.1