Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

656 lines
24 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
IcNavBack,
IcCheck,
IcCheckBox,
IcDrawingBoard,
IcImage,
IcMic,
IcMoreVert,
IcPenTool,
IcEdit,
IcRedo,
IcShare,
IcTextType,
IcUndo,
} from '../res/icons';
import { useKeyboard } from '../../../os/keyboard/useKeyboard';
import { KeyboardService } from '../../../os/keyboard/KeyboardService';
import { SIMULATOR_CONFIG } from '@/os/data';
const { statusBarHeight, bottomGestureHeight } = SIMULATOR_CONFIG.framework;
import * as TimeService from '../../../os/TimeService';
import { NOTES_CONFIG } from '../data';
import { useShallow } from 'zustand/react/shallow';
import { useNotesStore } from '../state';
import { Toast } from '@/os/components/Toast';
import { ActionSheet } from '../components/ActionSheet';
import { DateTimeDialog } from '../components/DateTimeDialog';
import { IcFolderIndicator, IcNoteLock } from '../res/icons';
import { colors } from '../res/colors';
import { dimens } from '../res/dimens';
import { strings } from '../res/strings';
import { stringsEn } from '../res/strings.en';
import { useAppStrings } from '../../../os/useAppStrings';
import { useNotesGestures } from '../hooks/useNotesGestures';
import { getFolderDisplayName } from '../utils';
// 智能字数统计:中文按字符,英文按单词
function countWords(text: string): number {
if (!text.trim()) return 0;
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
const englishText = text.replace(/[\u4e00-\u9fa5]/g, ' ').trim();
const englishWords = englishText ? englishText.split(/\s+/).filter(w => w.length > 0) : [];
return chineseChars.length + englishWords.length;
}
function formatHeaderTime(timestamp: number, s: typeof strings) {
const d = TimeService.fromTimestamp(timestamp);
const mm = d.getMonth() + 1;
const dd = d.getDate();
const hh = d.getHours(); // 真机展示不补零
const mi = d.getMinutes().toString().padStart(2, '0');
return `${mm}${s.date_suffix_month}${dd}${s.date_suffix_day} ${hh}:${mi}`;
}
export const NoteEditorPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { back, bindBack, go, navigateTo } = useNotesGestures();
const {
notes,
folders,
settings,
addNote,
updateNote,
deleteNote,
restoreNote,
deleteNoteForever,
hideNote,
unhideNote,
} = useNotesStore(
useShallow(s => ({
notes: s.notes,
folders: s.folders,
settings: s.settings,
addNote: s.addNote,
updateNote: s.updateNote,
deleteNote: s.deleteNote,
restoreNote: s.restoreNote,
deleteNoteForever: s.deleteNoteForever,
hideNote: s.hideNote,
unhideNote: s.unhideNote,
}))
);
const s = useAppStrings(strings, stringsEn);
const { visible: keyboardVisible } = useKeyboard();
const contentTextareaRef = useRef<HTMLTextAreaElement>(null);
const isNew = id === 'new';
const existing = useMemo(
() => (isNew || !id ? undefined : notes.find(n => n.id === id)),
[id, isNew, notes],
);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [folderId, setFolderId] = useState<string>('unfiled');
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
const [toast, setToast] = useState({ message: '', visible: false });
const toastTimerRef = useRef<number | null>(null);
const saveTimerRef = useRef<number | null>(null);
const isFirstLoadRef = useRef(true);
const initTimestampRef = useRef(0);
const loadedContentRef = useRef({ title: '', content: '', folderId: 'unfiled' });
const isLocked = !!existing?.locked;
const isTrashed = !!existing?.trashedAt;
const isPrivate = !!existing?.isPrivate;
const showToast = (message: string) => {
setToast({ message, visible: true });
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast({ message: '', visible: false }), 1600);
};
// Init from route
useEffect(() => {
if (isNew || !id) {
setTitle('');
setContent('');
setFolderId('unfiled');
isFirstLoadRef.current = true;
return;
}
if (!existing) return;
setTitle(existing.title ?? '');
setContent(existing.content ?? '');
setFolderId(existing.folderId ?? 'unfiled');
loadedContentRef.current = { title: existing.title ?? '', content: existing.content ?? '', folderId: existing.folderId ?? 'unfiled' };
isFirstLoadRef.current = true;
initTimestampRef.current = TimeService.realNow();
}, [existing, id, isNew]);
// 新建笔记页:进入后默认聚焦内容区并弹出键盘
useEffect(() => {
if (!isNew) return;
const t = setTimeout(() => {
contentTextareaRef.current?.focus();
KeyboardService.show();
}, 100);
return () => clearTimeout(t);
}, [isNew]);
// 收起键盘时失去焦点
const prevKeyboardVisible = useRef(keyboardVisible);
useEffect(() => {
if (prevKeyboardVisible.current && !keyboardVisible) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
prevKeyboardVisible.current = keyboardVisible;
}, [keyboardVisible]);
// Auto-save
useEffect(() => {
if (!id) return;
if (!isNew && isTrashed) return;
// 跳过初始化阶段的所有 setState 触发(title/content/folderId 分别触发)
if (isFirstLoadRef.current) {
isFirstLoadRef.current = false;
return;
}
if (TimeService.realNow() - initTimestampRef.current < 200) return;
// 只有内容实际变化时才保存(避免打开笔记就更新 updatedAt)
if (!isNew && existing) {
const loaded = loadedContentRef.current;
if (title === loaded.title && content === loaded.content && folderId === loaded.folderId) {
return;
}
}
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
const t = title.trim();
const c = content.trim();
if (isNew) {
if (!t && !c) return;
const created = addNote({ title, content, folderId });
navigateTo(`/note/${created.id}`, { replace: true });
return;
}
if (!existing) return;
updateNote(existing.id, { title, content, folderId });
}, NOTES_CONFIG.autoSaveDelayMs);
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
}, [addNote, content, existing, folderId, id, isNew, isTrashed, navigateTo, title, updateNote]);
useEffect(() => {
return () => {
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
}, []);
const currentTime = existing?.updatedAt ?? TimeService.now();
const wordCount = countWords(content);
const topPad = statusBarHeight;
const toolbarHeight = 56 + bottomGestureHeight;
const editorPad = dimens.note_editor_padding;
const currentFolderName = useMemo(() => {
const found = folders.find(f => f.id === folderId);
return found ? getFolderDisplayName(found, s) : s.folder_unfiled;
}, [folderId, folders, s]);
const saveNow = () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
if (!isNew && isTrashed) return;
const t = title.trim();
const c = content.trim();
if (isNew) {
if (!t && !c) return;
addNote({ title, content, folderId });
return;
}
if (!existing) return;
// 只有内容实际变化时才保存
const loaded = loadedContentRef.current;
if (title === loaded.title && content === loaded.content && folderId === loaded.folderId) return;
updateNote(existing.id, { title, content, folderId });
};
const handleDone = () => {
saveNow();
back();
};
if (!isNew && id && !existing) {
return (
<div className="h-full w-full bg-app-surface flex flex-col">
<div className="shrink-0 bg-app-surface" style={{ paddingTop: topPad }}>
<div className="h-12 px-3 flex items-center">
<button
{...bindBack()}
className="w-10 h-10 rounded-full flex items-center justify-center active:bg-black/5"
>
<IcNavBack size={24} className="text-black" />
</button>
</div>
</div>
<div className="flex-1 flex items-center justify-center text-[#9aa0aa]">
{s.editor_note_not_found}
</div>
</div>
);
}
return (
<div className="h-full w-full bg-app-surface relative flex flex-col overflow-hidden" data-keep-keyboard>
{/* Top bar (back / undo / redo / done) */}
<div className="shrink-0 bg-app-surface" style={{ paddingTop: topPad }}>
<div className="h-12 px-4 flex items-center">
<button
onClick={handleDone}
className="w-10 h-10 -ml-2 flex items-center justify-center active:opacity-70"
aria-label={s.action_back}
>
<IcNavBack size={24} className="text-black" />
</button>
<div className="flex-1" />
{keyboardVisible ? (
<>
<button
onClick={() => showToast(s.toast_undo_dev)}
className="w-10 h-10 flex items-center justify-center active:opacity-70"
aria-label={s.action_undo}
>
<IcUndo size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={() => showToast(s.toast_redo_dev)}
className="w-10 h-10 flex items-center justify-center active:opacity-70"
aria-label={s.action_redo}
>
<IcRedo size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={handleDone}
className="w-10 h-10 -mr-1 flex items-center justify-center active:opacity-70"
aria-label={s.action_done}
>
<IcCheck size={26} className="text-black" />
</button>
</>
) : (
<>
<button
onClick={() => showToast(s.toast_share_dev)}
className="w-10 h-10 flex items-center justify-center active:opacity-70"
aria-label={s.action_share}
>
<IcShare size={22} className="text-black" />
</button>
<button
onClick={() => showToast(s.toast_drawing_board_dev)}
className="w-10 h-10 flex items-center justify-center active:opacity-70"
aria-label={s.action_drawing_board}
>
<IcDrawingBoard size={22} className="text-black" />
</button>
<button
onClick={() => existing && setMenuOpen(true)}
className="w-10 h-10 -mr-1 flex items-center justify-center active:opacity-70"
aria-label={s.action_more}
>
<IcMoreVert size={22} className="text-black" />
</button>
</>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto no-scrollbar" style={{ paddingBottom: toolbarHeight + 18 }}>
<div className="pt-2" style={{ paddingLeft: editorPad, paddingRight: editorPad }}>
{existing && isTrashed ? (
<div className="mb-3 rounded-[14px] px-4 py-3" style={{ backgroundColor: '#fff4d6' }}>
<div className="text-[13px] text-black">{s.editor_trash_banner}</div>
<div className="mt-2 flex items-center gap-2">
<button
className="h-9 px-4 rounded-[14px] bg-app-surface text-[14px] active:opacity-80"
onClick={() => {
restoreNote(existing.id);
showToast(s.toast_restored);
}}
>
{s.action_restore}
</button>
<button
className="h-9 px-4 rounded-[14px] bg-app-surface text-[14px] text-[#ff3b30] active:opacity-80"
onClick={() => {
const ok = window.confirm(s.confirm_permanent_delete);
if (!ok) return;
deleteNoteForever(existing.id);
go('trash.open', {}, { mode: 'replace' });
}}
>
{s.action_permanent_delete}
</button>
</div>
</div>
) : null}
{/* Folder indicator (from note_edit_folder_layout.xml) */}
<div className="mt-1 mb-3">
<button
type="button"
className="inline-flex items-center gap-1 h-[36px] rounded-[12px] active:opacity-80"
style={{
backgroundColor: colors.page_bg,
border: `0.5px solid ${colors.hairline_border}`,
}}
onClick={() => {
if (existing && isTrashed) return;
setFolderPickerOpen(true);
}}
aria-label={s.folders}
>
<span className="ml-4 mr-1">
<IcFolderIndicator size={15} />
</span>
<span
className="text-[12px] max-w-[200px] truncate"
style={{ color: colors.text_secondary_strong }}
>
{currentFolderName}
</span>
<span className="mr-4" />
</button>
</div>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={s.editor_title_placeholder}
className="w-full font-semibold text-black placeholder:text-[#cccccc] outline-none"
style={{
fontSize: dimens.note_editor_title_size,
lineHeight: '30px',
}}
disabled={!!existing && isTrashed}
/>
<div className="mt-2 text-[12px] flex items-center gap-2" style={{ color: colors.text_secondary }}>
<span>{formatHeaderTime(currentTime, s)}</span>
{settings.showWordCount ? (
<>
<span className="opacity-70">|</span>
<span>{wordCount}{s.editor_word_count_suffix}</span>
</>
) : null}
{existing && typeof existing.alarmAt === 'number' ? (
<>
<span className="opacity-70">|</span>
<span>{s.alarm_label_prefix}{formatHeaderTime(existing.alarmAt, s)}</span>
</>
) : null}
{existing && isPrivate ? (
<>
<span className="opacity-70">|</span>
<span>{s.label_private}</span>
</>
) : null}
{isLocked ? (
<span className="ml-1 inline-flex items-center" aria-label={s.label_encrypted}>
<IcNoteLock size={14} color={colors.text_secondary} />
</span>
) : null}
</div>
<div className="mt-2 relative">
{content.trim().length === 0 ? (
<div
className="absolute inset-0 flex items-baseline gap-2 pointer-events-none"
style={{ zIndex: 1, paddingTop: 12, fontSize: 16, lineHeight: '26px', fontFamily: 'inherit' }}
>
<span className="text-[#d7d7d7] text-[16px] leading-[26px]">{s.editor_start_writing}</span>
<button
type="button"
onClick={() => showToast(s.toast_mind_note_dev)}
className="pointer-events-auto inline-flex items-center gap-2 h-9 px-4 rounded-full bg-[#fff2d7] text-[#f6c444] text-[14px] font-medium active:opacity-80"
>
{s.editor_create_mind_note}
</button>
</div>
) : null}
<textarea
ref={contentTextareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full min-h-[520px] outline-none resize-none border-0 bg-transparent"
style={{
color: 'rgba(0,0,0,0.87)',
paddingTop: 12,
paddingLeft: 0,
fontSize: 16,
lineHeight: '26px',
fontFamily: 'inherit',
}}
disabled={!!existing && isTrashed}
/>
</div>
</div>
</div>
{/* Bottom toolbar */}
<div className="absolute left-0 right-0 bottom-0 z-30 bg-app-surface border-t border-black/5">
<div className="flex items-center justify-around" style={{ height: toolbarHeight, paddingBottom: bottomGestureHeight }}>
<button
onClick={() => showToast(s.toast_handwrite_dev)}
className="w-11 h-11 flex items-center justify-center active:opacity-60"
aria-label={s.toolbar_handwrite}
>
<IcEdit size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={() => showToast(s.toast_recording_dev)}
className="w-11 h-11 flex items-center justify-center active:opacity-60"
aria-label={s.toolbar_recording}
>
<IcMic size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={() => showToast(s.toast_image_dev)}
className="w-11 h-11 flex items-center justify-center active:opacity-60"
aria-label={s.toolbar_image}
>
<IcImage size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={() => showToast(s.toast_doodle_dev)}
className="w-11 h-11 flex items-center justify-center active:opacity-60"
aria-label={s.toolbar_doodle}
>
<IcPenTool size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={() => showToast(s.toast_checklist_dev)}
className="w-11 h-11 flex items-center justify-center active:opacity-60"
aria-label={s.toolbar_checklist}
>
<IcCheckBox size={22} className="text-[#c8c8c8]" />
</button>
<button
onClick={() => showToast(s.toast_text_style_dev)}
className="w-11 h-11 flex items-center justify-center active:opacity-60"
aria-label={s.toolbar_text_style}
>
<IcTextType size={22} className="text-[#c8c8c8]" />
</button>
</div>
</div>
<Toast message={toast.message} visible={toast.visible} />
{/* Folder picker (best-effort from common_folder_list_dialog_fragment.xml) */}
{folderPickerOpen ? (
<div className="fixed inset-0 z-[70]">
<button
className="absolute inset-0 bg-black/35"
onClick={() => setFolderPickerOpen(false)}
aria-label={s.action_close}
/>
<div
className="absolute left-0 right-0 bottom-0 bg-app-surface rounded-t-[24px] overflow-hidden"
style={{ paddingBottom: bottomGestureHeight + 12 }}
>
<div className="px-5 py-4 text-[14px] font-medium text-black">{s.select_folder_title}</div>
<div className="px-3 pb-4">
{folders
.filter(f => f.id !== 'all')
.map((f) => {
const active = f.id === folderId;
return (
<button
key={f.id}
onClick={() => {
setFolderId(f.id);
setFolderPickerOpen(false);
showToast(s.toast_moved_to_editor_prefix + getFolderDisplayName(f, s) + s.toast_moved_to_suffix);
}}
className="w-full flex items-center justify-between px-3 h-12 rounded-[14px] active:bg-black/5"
>
<div className="text-[15px] text-black">{getFolderDisplayName(f, s)}</div>
{active ? <IcCheck size={20} className="text-black" /> : <div className="w-5 h-5" />}
</button>
);
})}
</div>
</div>
</div>
) : null}
<ActionSheet
visible={menuOpen && !isNew && !!existing}
title={s.action_more}
cancelLabel={s.action_cancel}
onClose={() => setMenuOpen(false)}
items={
!existing
? []
: isTrashed
? [
{
key: 'restore',
label: s.action_restore,
onClick: () => {
restoreNote(existing.id);
setMenuOpen(false);
showToast(s.toast_restored);
},
},
{
key: 'delete_forever',
label: s.action_permanent_delete,
danger: true,
onClick: () => {
const ok = window.confirm(s.confirm_permanent_delete);
if (!ok) return;
deleteNoteForever(existing.id);
setMenuOpen(false);
go('trash.open', {}, { mode: 'replace' });
},
},
]
: [
{
key: 'alarm',
label: s.action_set_reminder,
onClick: () => {
setMenuOpen(false);
setReminderOpen(true);
},
},
{
key: 'hide',
label: isPrivate ? s.action_unset_private : s.action_set_private,
onClick: () => {
if (isPrivate) unhideNote(existing.id);
else hideNote(existing.id);
setMenuOpen(false);
showToast(isPrivate ? s.toast_unset_private : s.toast_set_private);
if (!isPrivate) go('private.open', {}, { mode: 'replace' });
},
},
{
key: 'send_to_desktop',
label: s.action_send_to_desktop,
onClick: () => {
setMenuOpen(false);
showToast(s.toast_send_desktop_dev);
},
},
{
key: 'move_to',
label: s.action_move_to,
onClick: () => {
setMenuOpen(false);
setFolderPickerOpen(true);
},
},
{
key: 'delete',
label: s.action_delete,
danger: true,
onClick: () => {
deleteNote(existing.id);
setMenuOpen(false);
showToast(s.toast_moved_to_trash);
back();
},
},
]
}
/>
{reminderOpen && existing && !isTrashed ? (
<DateTimeDialog
title={s.action_set_reminder}
initialTimestamp={existing.alarmAt}
onCancel={() => setReminderOpen(false)}
onConfirm={(ts) => {
updateNote(existing.id, { alarmAt: ts });
setReminderOpen(false);
showToast(s.toast_reminder_set);
}}
onClear={
typeof existing.alarmAt === 'number'
? () => {
updateNote(existing.id, { alarmAt: undefined });
setReminderOpen(false);
showToast(s.toast_reminder_cleared);
}
: undefined
}
/>
) : null}
</div>
);
};
export default NoteEditorPage;