|
|
|
|
@ -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 原生工具解析实体 (如 -> 空格, < -> <)
|
|
|
|
|
// 使用 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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|