主要更改了NoteEditActivity类中的大部分代码,增加了ui层便签置顶/取消置顶、撤回/取消撤回、设为私密/取消私密功能的代码 #22

Merged
pvexk5qol merged 1 commits from caoweiqiong_branch into master 4 weeks ago

@ -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<String> 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<String> mGetNoteBg;
// [新增] 用于记录待删除的图片数据库 ID
private java.util.HashSet<Long> 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 <span style="font-size:1.5em"> Html.fromHtml style
* 使 CSS font-size <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("(<span\\s+[^>]*style=\"[^>]*)(font-size:\\s*0\\.8[0-9]*em;?)([^>]*\">)", "<font size=\"2\">");
htmlContent = htmlContent.replace("</span>", "</font>"); // 简单的闭合标签替换可能不严谨,但在 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="...",原本的 <span> 变成了 <span size="5">。
// Android 的 Html.fromHtml 并不支持 <span size>。它支持 <font size>。
// 因此我们需要把这些带有 size 属性的 span 变成 font。
htmlContent = htmlContent.replaceAll("<span\\s+size=", "<font size=");
// 对应的结束标签 </span> 会由 Android 解析器宽容处理,或者我们可以不做处理,
// 因为 <font>...</font> 是目标,但 <font>...</span> 很多浏览器和解析器也能容忍。
// 为了完美,建议在 saveNote 时不纠结闭合标签Html.fromHtml 解析时会自动修正。
mWorkingNote.setWorkingText(htmlContent);
}
return hasChecked;
}
@ -842,19 +1505,104 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
private boolean saveNote() {
getWorkingText();
boolean saved = mWorkingNote.saveNote();
// [Bug 1 修复] 彻底清洗摘要 (Snippet),确保预览页无乱码
if (saved) {
/**
* There are two modes from List view to edit view, open one note,
* create/edit a node. Opening node requires to the original
* position in the list when back from edit view, while creating a
* new node requires to the top of the list. This code
* {@link #RESULT_OK} is used to identify the create/edit state
*/
// 获取当前完整内容
String content = mWorkingNote.getContent();
// 1. 调用自定义清洗方法
String cleanSnippet = stripHtml(content);
// 2. 更新数据库中的摘要字段
ContentValues values = new ContentValues();
values.put(Notes.NoteColumns.SNIPPET, cleanSnippet);
getContentResolver().update(
ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId()),
values, null, null);
saveImages();
setResult(RESULT_OK);
}
return saved;
}
/**
* [] HTML
*/
private String stripHtml(String html) {
if (TextUtils.isEmpty(html)) return "";
// 1. 将块级元素替换为空格,防止 "Title</h1><p>Body" 变成 "TitleBody"
String text = html.replaceAll("(?i)<br\\s*/?>", " ")
.replaceAll("(?i)</div>", " ")
.replaceAll("(?i)</p>", " ");
// 2. 使用 Android 原生工具解析实体 (如 &nbsp; -> 空格, &lt; -> <)
// 使用 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<Spannable> undoStack = new java.util.LinkedList<>();
private java.util.LinkedList<Spannable> 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());
}
}
}

Loading…
Cancel
Save