dsh-obsidian-memo 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +270 -0
  3. package/README.md +272 -0
  4. package/cordis.patch.yml +10 -0
  5. package/docs/ARCHITECTURE.md +88 -0
  6. package/docs/AUDIT-0.16.md +74 -0
  7. package/docs/CHANGELOG.md +120 -0
  8. package/docs/CONTRACT.md +544 -0
  9. package/docs/GUIDE.md +177 -0
  10. package/docs/KNOWN_ISSUES.md +113 -0
  11. package/icon.svg +7 -0
  12. package/lib/ai.js +161 -0
  13. package/lib/capture.js +175 -0
  14. package/lib/client.js +6994 -0
  15. package/lib/config-picker.js +29 -0
  16. package/lib/filename.js +33 -0
  17. package/lib/files.js +88 -0
  18. package/lib/host/ai-jobs.js +118 -0
  19. package/lib/host/configuration.js +151 -0
  20. package/lib/host/dispatch.js +32 -0
  21. package/lib/host/environment.js +24 -0
  22. package/lib/host/folders.js +77 -0
  23. package/lib/host/images.js +116 -0
  24. package/lib/host/mutations.js +123 -0
  25. package/lib/host/note-content.js +44 -0
  26. package/lib/host/notes.js +148 -0
  27. package/lib/host/rpc.js +82 -0
  28. package/lib/host/scanning.js +67 -0
  29. package/lib/host/session.js +67 -0
  30. package/lib/host/state.js +68 -0
  31. package/lib/host/vault.js +38 -0
  32. package/lib/host/writes.js +21 -0
  33. package/lib/index.js +88 -0
  34. package/lib/notes.js +454 -0
  35. package/lib/obsidian.js +93 -0
  36. package/lib/scan.js +269 -0
  37. package/lib/settings.js +228 -0
  38. package/lib/shared.js +309 -0
  39. package/lib/trash.js +205 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +76 -0
  43. package/scripts/benchmark.mjs +58 -0
  44. package/scripts/build-client.mjs +39 -0
  45. package/src/client/ai/use-comments.js +75 -0
  46. package/src/client/ai/use-job-notifications.js +53 -0
  47. package/src/client/ai/use-project-dialog.js +58 -0
  48. package/src/client/capture/composer.js +266 -0
  49. package/src/client/capture/images.js +64 -0
  50. package/src/client/capture/keyboard.js +34 -0
  51. package/src/client/components/dialogs.js +67 -0
  52. package/src/client/components/feedback.js +50 -0
  53. package/src/client/components/icons.js +160 -0
  54. package/src/client/components/use-toasts.js +58 -0
  55. package/src/client/constants.js +127 -0
  56. package/src/client/data/state.js +116 -0
  57. package/src/client/data/use-notes-data.js +137 -0
  58. package/src/client/data/visibility.js +14 -0
  59. package/src/client/drag/drop-actions.js +47 -0
  60. package/src/client/drag/motion.js +84 -0
  61. package/src/client/drag/overlay.js +33 -0
  62. package/src/client/drag/use-card-drag.js +149 -0
  63. package/src/client/i18n/en.js +219 -0
  64. package/src/client/i18n/index.js +11 -0
  65. package/src/client/i18n/zh.js +219 -0
  66. package/src/client/images/cache.js +55 -0
  67. package/src/client/images/components.js +121 -0
  68. package/src/client/images/queue.js +106 -0
  69. package/src/client/index.js +40 -0
  70. package/src/client/panel/header.js +146 -0
  71. package/src/client/panel/use-shortcuts.js +79 -0
  72. package/src/client/panel.js +424 -0
  73. package/src/client/preferences.js +198 -0
  74. package/src/client/react.js +8 -0
  75. package/src/client/reader/blocks.js +51 -0
  76. package/src/client/reader/note-drawer.js +270 -0
  77. package/src/client/reader/use-dock-offset.js +23 -0
  78. package/src/client/reader/use-drawer-layout.js +96 -0
  79. package/src/client/reader/use-note-editor.js +230 -0
  80. package/src/client/reader/use-pinned-note.js +41 -0
  81. package/src/client/runtime.js +30 -0
  82. package/src/client/settings/ai-budget-row.js +65 -0
  83. package/src/client/settings/ai-budget.js +42 -0
  84. package/src/client/settings/callouts.js +21 -0
  85. package/src/client/settings/config-file.js +74 -0
  86. package/src/client/settings/image-processing.js +31 -0
  87. package/src/client/settings/official-page.js +32 -0
  88. package/src/client/settings/paths.js +131 -0
  89. package/src/client/settings/settings-drawer.js +244 -0
  90. package/src/client/settings/style-select.js +58 -0
  91. package/src/client/settings/trash.js +30 -0
  92. package/src/client/settings/use-settings-draft.js +207 -0
  93. package/src/client/styles/base.js +63 -0
  94. package/src/client/styles/capture.js +94 -0
  95. package/src/client/styles/index.js +26 -0
  96. package/src/client/styles/overrides.js +116 -0
  97. package/src/client/styles/reader.js +66 -0
  98. package/src/client/styles/settings.js +118 -0
  99. package/src/client/styles/tokens.js +29 -0
  100. package/src/client/styles/wall.js +83 -0
  101. package/src/client/testing.js +139 -0
  102. package/src/client/utils.js +66 -0
  103. package/src/client/wall/model.js +158 -0
  104. package/src/client/wall/note-card.js +201 -0
  105. package/src/client/wall/use-note-actions.js +107 -0
  106. package/src/client/wall/use-subfolders.js +42 -0
  107. package/src/client/wall/use-wall-content.js +135 -0
  108. package/src/client/wall/use-wall-filters.js +92 -0
  109. package/src/client/wall/use-wall-paging.js +55 -0
@@ -0,0 +1,201 @@
1
+ /** 可 memo 的卡片展示与事件转发;通过稳定 actions ref 调用交互。 */
2
+ import { ActionIcon, ObsidianIcon } from '../components/icons.js';
3
+ import { MAX_CARD_COMMENTS } from '../constants.js';
4
+ import { imageCacheKey } from '../images/cache.js';
5
+ import { CardThumb } from '../images/components.js';
6
+ import { React, h } from '../react.js';
7
+ import { DEFAULT_NOTE_COLOR } from '../styles/tokens.js';
8
+ import { cardImages, commentHead, emojiLabel } from './model.js';
9
+
10
+ const NoteCard = React.memo(function NoteCard(props) {
11
+ const {
12
+ note, emojis, pending, hoverActive, drawerOpen, actions,
13
+ } = props;
14
+ const t = actions.current.t;
15
+ const color = typeof note.color === 'string' && note.color !== '' ? note.color : DEFAULT_NOTE_COLOR;
16
+ // Host 的深色配套纸色;缺失(旧数据/未升级 Host)时回退到浅色纸色。
17
+ const darkColor = typeof note.darkColor === 'string' && note.darkColor !== '' ? note.darkColor : color;
18
+ const title = note.title || note.name || '';
19
+ const excerpt = typeof note.excerpt === 'string' ? note.excerpt : '';
20
+ // 有图时:标题仍 3 行、摘要 1 行、评论 1 条;缩略图排在评论条之后并贴卡片底部左侧。
21
+ const { items: images, more: moreImages } = cardImages(note);
22
+ const hasImages = images.length > 0;
23
+ const comments = Array.isArray(note.comments)
24
+ ? note.comments.slice(0, hasImages ? 1 : MAX_CARD_COMMENTS)
25
+ : [];
26
+ const commentCount = typeof note.commentCount === 'number' ? note.commentCount : comments.length;
27
+ const rest = Math.max(0, commentCount - comments.length);
28
+ const disabled = Boolean(pending);
29
+ // 菜单随整张卡片悬停/聚焦出现;此时统一隐藏图片预览。
30
+ const menuVisible = Boolean(!pending && !drawerOpen && !props.dragging && hoverActive);
31
+ const emojiList = Array.isArray(emojis) ? emojis : [];
32
+ const stop = (event) => event.stopPropagation();
33
+
34
+ return h('div', {
35
+ className: 'SN_card',
36
+ 'data-note-path': note.path,
37
+ 'data-menu': menuVisible ? 'true' : 'false',
38
+ 'data-images': hasImages ? 'true' : 'false',
39
+ 'data-drawer': drawerOpen ? 'true' : 'false',
40
+ 'data-dragging': props.dragging ? 'true' : 'false',
41
+ draggable: !disabled && !props.preview,
42
+ onDragStart: (event) => {
43
+ if (disabled || event.target.closest?.('button,a,input,textarea')) { event.preventDefault(); return; }
44
+ event.dataTransfer.effectAllowed = 'move';
45
+ event.dataTransfer.setData('application/x-dsh-memo', note.path);
46
+ const rect = event.currentTarget?.getBoundingClientRect();
47
+ const doc = event.currentTarget?.ownerDocument;
48
+ if (doc?.body && event.dataTransfer.setDragImage) {
49
+ // 系统拖影始终盖住页面,改用透明拖影 + 同一 portal 内的卡片与动作提示。
50
+ const image = doc.createElement('canvas');
51
+ image.width = image.height = 1;
52
+ image.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none';
53
+ doc.body.appendChild(image);
54
+ event.dataTransfer.setDragImage(image, 0, 0);
55
+ setTimeout(() => image.remove(), 0);
56
+ }
57
+ const started = actions.current.onDragStart(note, {
58
+ x: event.clientX, y: event.clientY, width: rect?.width, height: rect?.height,
59
+ offsetX: rect ? event.clientX - rect.left : 0, offsetY: rect ? event.clientY - rect.top : 0,
60
+ }, event.currentTarget);
61
+ if (started === false) event.preventDefault();
62
+ },
63
+ onDragEnd: () => actions.current.onDragEnd(),
64
+ style: { '--sn-card-bg': color, '--sn-card-bg-dark': darkColor },
65
+ tabIndex: props.preview ? -1 : 0,
66
+ role: 'group',
67
+ 'aria-label': title,
68
+ title: t('card.openHint'),
69
+ // 单击打开右侧抽屉(event.detail===1 守卫:双击的第二次 click 不触发“开→关”)。
70
+ onClick: (event) => {
71
+ if (event.detail !== 1) return;
72
+ actions.current.onOpen(note);
73
+ },
74
+ onKeyDown: (event) => {
75
+ if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) return;
76
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Backspace') {
77
+ event.preventDefault(); event.stopPropagation();
78
+ if (!disabled) actions.current.onDelete(note);
79
+ }
80
+ },
81
+ // 0.13.4:整张卡片 hover/focus 就视为「菜单出现」,不再只监听 emoji 行。
82
+ onMouseEnter: () => actions.current.onCardEnter(note),
83
+ // 鼠标移出但焦点还留在卡片内(比如刚点过 emoji/按钮)时保持菜单状态,避免图片闪现。
84
+ onMouseLeave: (event) => actions.current.onCardLeave(
85
+ note,
86
+ typeof document !== 'undefined' && event.currentTarget.contains(document.activeElement),
87
+ ),
88
+ onFocus: () => actions.current.onCardEnter(note),
89
+ onBlur: (event) => {
90
+ const next = event.relatedTarget;
91
+ if (!next || !event.currentTarget.contains(next)) actions.current.onCardLeave(note);
92
+ },
93
+ },
94
+ h('div', { className: 'SN_cardClip' },
95
+ // 标题独占宽度;文件夹 tag 只在同一菜单激活时覆盖在右上角。
96
+ h('div', { className: 'SN_cardHead' },
97
+ h('h3', { className: 'SN_cardTitle', title: title },
98
+ h('span', { className: 'SN_cardTitleText' }, title),
99
+ ),
100
+ ),
101
+ note.tag ? h('div', { className: 'SN_cardTagLayer' },
102
+ h('span', { className: 'SN_cardTag', title: note.tag }, note.tag)) : null,
103
+ // 0.13.9:没有正文就不渲染摘要行(不再显示占位文案)。
104
+ excerpt ? h('p', {
105
+ className: 'SN_cardExcerpt',
106
+ 'data-compact': hasImages ? 'true' : 'false',
107
+ }, excerpt) : null,
108
+ comments.length === 0 && rest === 0 ? null : h('div', { className: 'SN_cardComments' },
109
+ comments.map((comment, index) => h('div', { className: 'SN_cComment', key: index, title: comment.text || '' },
110
+ h('span', { className: 'SN_cCommentHead' }, commentHead(comment)),
111
+ h('span', { className: 'SN_cCommentText' }, comment.text || ''),
112
+ )),
113
+ rest > 0 ? h('span', { className: 'SN_cCommentMore' }, t('card.moreComments', { count: rest })) : null,
114
+ ),
115
+ // 0.13 修正:图片条排在评论条之后,margin-top:auto 贴卡片底部、align-self:flex-start 靠左。
116
+ hasImages
117
+ ? h('div', { className: 'SN_cardImages' },
118
+ images.map((image, index) => h(CardThumb, {
119
+ key: imageCacheKey(note.path, image.source) + '#' + index,
120
+ path: note.path,
121
+ image: image,
122
+ })),
123
+ moreImages > 0
124
+ ? h('span', { className: 'SN_moreImages' }, t('card.moreImages', { count: moreImages }))
125
+ : null,
126
+ )
127
+ : null,
128
+ pending
129
+ ? h('div', { className: 'SN_cardActions', 'data-visible': 'true', onDoubleClick: stop },
130
+ h('span', { className: 'SN_pending' },
131
+ h('span', { className: 'SN_pendingDot', 'aria-hidden': 'true' }),
132
+ t('card.pending'),
133
+ ),
134
+ )
135
+ : drawerOpen
136
+ ? null
137
+ : h('div', {
138
+ className: 'SN_cardActions',
139
+ 'data-visible': menuVisible ? 'true' : undefined,
140
+ onDoubleClick: stop,
141
+ },
142
+ // 修正轮:所有 AI emoji 预设平铺一行(最多 12 枚,flex-wrap 自动换行),
143
+ // 每枚 27×27 只画 emoji 字形;hover / 键盘 focus 时按钮上方浮出 label 小标签。
144
+ h('div', {
145
+ className: 'SN_aiRow',
146
+ role: 'group',
147
+ 'aria-label': t('card.aiComment'),
148
+ },
149
+ emojiList.length === 0
150
+ ? h('span', { className: 'SN_aiEmpty' }, t('card.noPresets'))
151
+ : emojiList.map((emoji) => h('button', {
152
+ key: emoji.id || emoji.emoji,
153
+ type: 'button',
154
+ className: 'SN_aiBtn',
155
+ disabled: disabled,
156
+ // 标签由上方 .SN_aiLabel 小标签展示,不用原生 title,避免双 tooltip。
157
+ 'aria-label': emojiLabel(emoji) || emoji.emoji || t('card.aiComment'),
158
+ onClick: (event) => {
159
+ event.stopPropagation();
160
+ actions.current.onPreset(note, emoji);
161
+ },
162
+ },
163
+ h('span', { className: 'SN_aiEmoji', 'aria-hidden': 'true' }, emoji.emoji || '✨'),
164
+ h('span', { className: 'SN_aiLabel', 'aria-hidden': 'true' }, emoji.label || emoji.emoji || ''),
165
+ )),
166
+ ),
167
+ h('div', { className: 'SN_actionDivider', 'aria-hidden': 'true' }),
168
+ h('div', { className: 'SN_actionRow' },
169
+ h('button', {
170
+ type: 'button',
171
+ className: 'SN_mini',
172
+ disabled: disabled,
173
+ onClick: (event) => {
174
+ event.stopPropagation();
175
+ actions.current.onCopy(note);
176
+ },
177
+ }, h(ActionIcon, { kind: 'copy', size: 13 }), t('card.copy')),
178
+ // 归档 / 到项目统一从拖拽栏进入;卡片只保留复制与 Obsidian。
179
+ h('button', {
180
+ type: 'button',
181
+ className: 'SN_mini SN_obsidianBtn',
182
+ disabled: disabled,
183
+ title: t('card.obsidian'),
184
+ 'aria-label': t('card.obsidian'),
185
+ onClick: (event) => {
186
+ event.stopPropagation();
187
+ actions.current.onOpenObsidian(note);
188
+ },
189
+ },
190
+ h(ObsidianIcon, null),
191
+ h('span', { className: 'SN_obsidianText' }, t('card.obsidian')),
192
+ ),
193
+ ),
194
+ ),
195
+ ),
196
+ );
197
+ });
198
+
199
+ NoteCard.displayName = 'NoteCard';
200
+
201
+ export { NoteCard };
@@ -0,0 +1,107 @@
1
+ /** 复制、系统打开、归档、删除与撤回;维护乐观视图和操作错误。 */
2
+ import { UNDO_MS } from '../constants.js';
3
+ import { React } from '../react.js';
4
+ import { isAbortError } from '../utils.js';
5
+
6
+ function useNoteActions({
7
+ aliveRef, archiveTargets, closeNoteDrawer, localPending, noteDrawerRef, pushErrorToast, pushToast,
8
+ refreshNow, request, setDrawerArchive, setHoverPath, tRef, updateView, viewRef,
9
+ }) {
10
+ const deletingRef = React.useRef(new Set());
11
+
12
+ function findTarget(targetId) {
13
+ return archiveTargets.find((target) => target.id === targetId) || null;
14
+ }
15
+
16
+ function undoArchive(undoToken) {
17
+ request('undoArchive', { undoToken: undoToken })
18
+ .then(() => {
19
+ if (!aliveRef.current) return;
20
+ pushToast({ text: tRef.current('toast.restored'), kind: 'success' });
21
+ return refreshNow(false);
22
+ })
23
+ .catch((error) => {
24
+ if (aliveRef.current && !isAbortError(error)) pushErrorToast(error);
25
+ });
26
+ }
27
+
28
+ async function undoDelete(undoToken) {
29
+ try {
30
+ await request('undoDelete', { undoToken });
31
+ if (!aliveRef.current) return;
32
+ pushToast({ text: tRef.current('toast.restored'), kind: 'success' });
33
+ await refreshNow(false);
34
+ } catch (error) { if (aliveRef.current && !isAbortError(error)) pushErrorToast(error); }
35
+ }
36
+
37
+ async function runDelete(note) {
38
+ if (!note || deletingRef.current.has(note.path) || note.pending || localPending[note.path]) return;
39
+ deletingRef.current.add(note.path);
40
+ try {
41
+ const result = await request('deleteNote', { path: note.path });
42
+ if (!aliveRef.current) return;
43
+ const previous = viewRef.current;
44
+ updateView(Object.assign({}, previous, { notes: previous.notes.filter((item) => item.path !== note.path) }));
45
+ if (noteDrawerRef.current?.path === note.path) closeNoteDrawer();
46
+ setHoverPath(null);
47
+ pushToast({ text: tRef.current('toast.deleted'), kind: 'success',
48
+ timeout: Math.max(1, Math.min(5000, result.expiresAt - Date.now())),
49
+ action: { label: tRef.current('toast.deleteUndo'), run: () => undoDelete(result.undoToken) } });
50
+ if (result.sharedImageCount) pushToast({ text: tRef.current('toast.sharedImages') });
51
+ refreshNow(false);
52
+ } catch (error) {
53
+ if (aliveRef.current && !isAbortError(error)) { pushErrorToast(error); refreshNow(false); }
54
+ }
55
+ finally { deletingRef.current.delete(note.path); }
56
+ }
57
+
58
+ async function copyNote(note) {
59
+ try {
60
+ const full = await request('note', { path: note.path });
61
+ await navigator.clipboard.writeText((full.title || note.title || note.name || '') + '\n' + (full.body || ''));
62
+ if (aliveRef.current) pushToast({ text: tRef.current('toast.copied'), kind: 'success' });
63
+ } catch (error) { if (aliveRef.current) pushErrorToast(new Error(tRef.current('toast.copyFailed'))); }
64
+ }
65
+
66
+ function runArchive(note, targetId) {
67
+ if (!note || !targetId) return;
68
+ setDrawerArchive(false);
69
+ const target = findTarget(targetId);
70
+ return request('archive', { path: note.path, targetId: targetId })
71
+ .then((value) => {
72
+ if (!aliveRef.current) return;
73
+ const previous = viewRef.current;
74
+ updateView(Object.assign({}, previous, {
75
+ notes: previous.notes.filter((item) => item.path !== note.path),
76
+ }));
77
+ if (noteDrawerRef.current && noteDrawerRef.current.path === note.path) closeNoteDrawer();
78
+ const token = value && typeof value.undoToken === 'string' ? value.undoToken : '';
79
+ if (token !== '') {
80
+ pushToast({
81
+ text: tRef.current('toast.archived', { label: (target && target.label) || targetId }),
82
+ kind: 'success',
83
+ timeout: UNDO_MS,
84
+ action: { label: tRef.current('toast.undo'), run: () => undoArchive(token) },
85
+ });
86
+ } else {
87
+ pushToast({ text: tRef.current('toast.archived', { label: (target && target.label) || targetId }), kind: 'success' });
88
+ }
89
+ refreshNow(false);
90
+ })
91
+ .catch((error) => {
92
+ if (aliveRef.current && !isAbortError(error)) pushErrorToast(error);
93
+ });
94
+ }
95
+
96
+ function openInObsidian(note) {
97
+ if (!note || typeof note.path !== 'string' || note.path === '') return;
98
+ request('openInObsidian', { path: note.path })
99
+ .catch((error) => {
100
+ if (aliveRef.current && !isAbortError(error)) pushErrorToast(error);
101
+ });
102
+ }
103
+
104
+ return { findTarget, runDelete, copyNote, runArchive, openInObsidian };
105
+ }
106
+
107
+ export { useNoteActions };
@@ -0,0 +1,42 @@
1
+ /** 子目录开关的版本化设置保存、乐观更新与失败回滚。 */
2
+ import { React } from '../react.js';
3
+ import { isAbortError } from '../utils.js';
4
+
5
+ function useSubfolders({ aliveRef, pushErrorToast, pushToast, refreshNow, request, t, updateView, viewRef }) {
6
+ const [subfoldersBusy, setSubfoldersBusy] = React.useState(false);
7
+ const subfoldersBusyRef = React.useRef(false);
8
+
9
+ function toggleSubfolders() {
10
+ if (!viewRef.current.loaded || subfoldersBusyRef.current) return;
11
+ const settings = viewRef.current.settings;
12
+ if (!settings) {
13
+ pushToast({ text: t('settings.notReady'), kind: 'error' });
14
+ return;
15
+ }
16
+ const previous = settings;
17
+ const next = Object.assign({}, settings, { includeSubfolders: settings.includeSubfolders === false });
18
+ subfoldersBusyRef.current = true;
19
+ setSubfoldersBusy(true);
20
+ updateView(Object.assign({}, viewRef.current, { settings: next }));
21
+ request('saveSettings', { settings: next, expected: settings.configFile?.revision })
22
+ .then((value) => {
23
+ if (!aliveRef.current) return;
24
+ const normalized = value && value.settings ? value.settings : next;
25
+ updateView(Object.assign({}, viewRef.current, { settings: normalized }));
26
+ return refreshNow(true);
27
+ })
28
+ .catch((error) => {
29
+ if (!aliveRef.current || isAbortError(error)) return;
30
+ updateView(Object.assign({}, viewRef.current, { settings: previous }));
31
+ pushErrorToast(error);
32
+ })
33
+ .then(() => {
34
+ subfoldersBusyRef.current = false;
35
+ if (aliveRef.current) setSubfoldersBusy(false);
36
+ });
37
+ }
38
+
39
+ return { subfoldersBusy, toggleSubfolders };
40
+ }
41
+
42
+ export { useSubfolders };
@@ -0,0 +1,135 @@
1
+ /** 便签墙卡片 memo、加载占位和配置/错误/空目录状态。 */
2
+ import { CenterState } from '../components/feedback.js';
3
+ import { Fragment, React, h } from '../react.js';
4
+ import { NoteCard } from './note-card.js';
5
+
6
+ function useWallContent({
7
+ actionsRef, dateRangeLabel, dragNote, emojis, filteredNotes, handleRefresh, handleWallClick,
8
+ handleWallMouseDown, hoverPath, loadError, loadMoreRef, loadingMore, localPending, noteDrawer,
9
+ openSettings, setDateMenu, setShowBackTop, sortedNotes, t, view, visibleLimit,
10
+ }) {
11
+ const cardLocaleRef = React.useRef({ t: t, tick: 0 });
12
+
13
+ if (cardLocaleRef.current.t !== t) {
14
+ cardLocaleRef.current = { t: t, tick: cardLocaleRef.current.tick + 1 };
15
+ }
16
+
17
+ const wallCards = React.useMemo(() => sortedNotes.slice(0, visibleLimit).map((note) => h(NoteCard, {
18
+ key: cardLocaleRef.current.tick + ':' + note.path,
19
+ note, emojis, pending: note.pending || localPending[note.path] || null,
20
+ hoverActive: Boolean(hoverPath && hoverPath.path === note.path),
21
+ drawerOpen: Boolean(noteDrawer),
22
+ dragging: Boolean(dragNote && dragNote.path === note.path), actions: actionsRef,
23
+ })), [sortedNotes, visibleLimit, emojis, localPending, hoverPath, Boolean(noteDrawer), dragNote?.path, cardLocaleRef.current.tick]);
24
+
25
+ function renderWall() {
26
+ // 0.13.8 动态加载:只渲染前 visibleLimit 张(默认 50);哨兵滚进视口再补下一批。
27
+ const hasMore = sortedNotes.length > visibleLimit;
28
+ return h('div', {
29
+ className: 'SN_wall',
30
+ onScroll: (event) => {
31
+ const wall = event.currentTarget;
32
+ setShowBackTop(wall.scrollTop >= wall.clientHeight && wall.scrollTop > 0);
33
+ },
34
+ onMouseDown: handleWallMouseDown,
35
+ onClick: handleWallClick,
36
+ },
37
+ wallCards,
38
+ h('div', {
39
+ key: 'load-more',
40
+ className: 'SN_loadMore',
41
+ 'data-loading': loadingMore ? 'true' : 'false',
42
+ ref: loadMoreRef,
43
+ role: 'status',
44
+ 'aria-live': 'polite',
45
+ 'aria-hidden': hasMore || loadingMore ? undefined : 'true',
46
+ 'aria-label': loadingMore ? t('panel.loadingMore') : undefined,
47
+ },
48
+ loadingMore
49
+ ? h(Fragment, null,
50
+ h('span', { className: 'SN_loadDots', 'aria-hidden': 'true' },
51
+ h('span', { className: 'SN_loadDot' }),
52
+ h('span', { className: 'SN_loadDot' }),
53
+ h('span', { className: 'SN_loadDot' }),
54
+ ),
55
+ h('span', { className: 'SN_loadText' }, t('panel.loadingMore')),
56
+ )
57
+ : null,
58
+ ),
59
+ );
60
+ }
61
+
62
+ function renderSkeleton() {
63
+ const cards = [];
64
+ for (let index = 0; index < 8; index += 1) {
65
+ cards.push(h('div', { key: index, className: 'SN_skelCard' }));
66
+ }
67
+ return h('div', { className: 'SN_wall', 'aria-busy': 'true', 'aria-label': t('panel.loading') }, cards);
68
+ }
69
+
70
+ function renderContent() {
71
+ if (!view.loaded) {
72
+ if (loadError) {
73
+ return h(CenterState, {
74
+ mark: '!',
75
+ title: t('state.loadErrorTitle'),
76
+ text: loadError,
77
+ actionLabel: t('state.loadErrorAction'),
78
+ onAction: handleRefresh,
79
+ });
80
+ }
81
+ return renderSkeleton();
82
+ }
83
+ if (!view.configured) {
84
+ if (view.error) {
85
+ return h(CenterState, {
86
+ mark: '!',
87
+ title: t('state.errorTitle'),
88
+ text: view.error,
89
+ actionLabel: t('state.unconfiguredAction'),
90
+ onAction: openSettings,
91
+ });
92
+ }
93
+ return h(CenterState, {
94
+ mark: '⚙',
95
+ title: t('state.unconfiguredTitle'),
96
+ text: t('state.unconfiguredText'),
97
+ actionLabel: t('state.unconfiguredAction'),
98
+ onAction: openSettings,
99
+ });
100
+ }
101
+ if (view.error && view.notes.length === 0) {
102
+ return h(CenterState, {
103
+ mark: '!',
104
+ title: t('state.errorTitle'),
105
+ text: view.error,
106
+ actionLabel: t('state.errorAction'),
107
+ onAction: handleRefresh,
108
+ });
109
+ }
110
+ if (view.notes.length === 0) {
111
+ return h(CenterState, {
112
+ mark: '✎',
113
+ title: t('state.emptyTitle'),
114
+ text: t('state.emptyText'),
115
+ actionLabel: t('state.emptyAction'),
116
+ onAction: handleRefresh,
117
+ });
118
+ }
119
+ // 0.13.4:墙上有便签,但当前日期筛选范围内没有 → 给出可操作的空态。
120
+ if (filteredNotes.length === 0) {
121
+ return h(CenterState, {
122
+ mark: '◷',
123
+ title: t('state.dateEmptyTitle'),
124
+ text: t('state.dateEmptyText', { range: dateRangeLabel }),
125
+ actionLabel: t('state.dateEmptyAction'),
126
+ onAction: () => setDateMenu(true),
127
+ });
128
+ }
129
+ return renderWall();
130
+ }
131
+
132
+ return { renderContent };
133
+ }
134
+
135
+ export { useWallContent };
@@ -0,0 +1,92 @@
1
+ /** 持有排序和日期菜单状态,管理本地持久化与日期时钟。 */
2
+ import { SORT_OPTIONS } from '../constants.js';
3
+ import {
4
+ readStoredDateEnabled, readStoredDateFilter, readStoredDateRealtime, readStoredSort, storeDateEnabled,
5
+ storeDateFilter, storeDateRealtime, storeSort,
6
+ } from '../preferences.js';
7
+ import { React } from '../react.js';
8
+ import { isDateFilterId } from './model.js';
9
+
10
+ function useWallFilters() {
11
+ const [sortId, setSortId] = React.useState(readStoredSort);
12
+ const [sortMenu, setSortMenu] = React.useState(false);
13
+ const sortWrapRef = React.useRef(null);
14
+ const [dateFilter, setDateFilter] = React.useState(readStoredDateFilter);
15
+ const [dateEnabled, setDateEnabled] = React.useState(readStoredDateEnabled);
16
+ const [dateRealtime, setDateRealtime] = React.useState(readStoredDateRealtime);
17
+ const [dateMenu, setDateMenu] = React.useState(false);
18
+ const dateWrapRef = React.useRef(null);
19
+ const [nowTick, setNowTick] = React.useState(() => Date.now());
20
+
21
+ React.useEffect(() => {
22
+ const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000);
23
+ return () => clearInterval(timer);
24
+ }, []);
25
+
26
+ React.useEffect(() => {
27
+ if (!sortMenu) return undefined;
28
+ const onPointerDown = (event) => {
29
+ const target = event.target;
30
+ if (sortWrapRef.current && target && sortWrapRef.current.contains(target)) return;
31
+ setSortMenu(false);
32
+ };
33
+ document.addEventListener('mousedown', onPointerDown, true);
34
+ return () => document.removeEventListener('mousedown', onPointerDown, true);
35
+ }, [sortMenu]);
36
+
37
+ React.useEffect(() => {
38
+ if (!dateMenu) return undefined;
39
+ const onPointerDown = (event) => {
40
+ const target = event.target;
41
+ if (dateWrapRef.current && target && dateWrapRef.current.contains(target)) return;
42
+ setDateMenu(false);
43
+ };
44
+ document.addEventListener('mousedown', onPointerDown, true);
45
+ return () => document.removeEventListener('mousedown', onPointerDown, true);
46
+ }, [dateMenu]);
47
+
48
+ function toggleSortMenu() {
49
+ setSortMenu((value) => !value);
50
+ setDateMenu(false);
51
+ }
52
+
53
+ function chooseSort(id) {
54
+ if (!SORT_OPTIONS.some((option) => option.id === id)) return;
55
+ setSortId(id);
56
+ storeSort(id);
57
+ setSortMenu(false);
58
+ }
59
+
60
+ function openDateMenu() {
61
+ setDateMenu(true);
62
+ setSortMenu(false);
63
+ }
64
+
65
+ function toggleDateEnabled() {
66
+ const next = !dateEnabled;
67
+ setDateEnabled(next);
68
+ storeDateEnabled(next);
69
+ setDateMenu(false);
70
+ }
71
+
72
+ function chooseDateFilter(id) {
73
+ if (!isDateFilterId(id)) return;
74
+ setDateFilter(id);
75
+ storeDateFilter(id);
76
+ if (!dateEnabled) {
77
+ setDateEnabled(true);
78
+ storeDateEnabled(true);
79
+ }
80
+ setDateMenu(false);
81
+ }
82
+
83
+ function toggleDateRealtime() {
84
+ const next = !dateRealtime;
85
+ setDateRealtime(next);
86
+ storeDateRealtime(next);
87
+ }
88
+
89
+ return { sortId, sortMenu, setSortMenu, sortWrapRef, dateFilter, dateEnabled, dateRealtime, dateMenu, setDateMenu, dateWrapRef, nowTick, setNowTick, toggleSortMenu, chooseSort, openDateMenu, toggleDateEnabled, chooseDateFilter, toggleDateRealtime };
90
+ }
91
+
92
+ export { useWallFilters };
@@ -0,0 +1,55 @@
1
+ /** 分批渲染和墙底哨兵;筛选变化时回到首屏。 */
2
+ import { WALL_PAGE_SIZE, WALL_PRELOAD_PX } from '../constants.js';
3
+ import { React } from '../react.js';
4
+ import { nextVisibleCount } from './model.js';
5
+
6
+ function useWallPaging({ aliveRef, dateEnabled, dateFilter, dateRealtime, panelRef, pinned, sortId, sortedNotes, view }) {
7
+ const [visibleLimit, setVisibleLimit] = React.useState(WALL_PAGE_SIZE);
8
+ const [loadingMore, setLoadingMore] = React.useState(false);
9
+ const visibleLimitRef = React.useRef(WALL_PAGE_SIZE);
10
+ const loadingMoreRef = React.useRef(false);
11
+ const loadMoreRef = React.useRef(null);
12
+ const sortedNotesRef = React.useRef([]);
13
+
14
+ visibleLimitRef.current = visibleLimit;
15
+
16
+ sortedNotesRef.current = sortedNotes;
17
+
18
+ React.useEffect(() => {
19
+ visibleLimitRef.current = WALL_PAGE_SIZE;
20
+ setVisibleLimit(WALL_PAGE_SIZE);
21
+ }, [sortId, dateFilter, dateRealtime, dateEnabled]);
22
+
23
+ function requestMore() {
24
+ if (loadingMoreRef.current) return;
25
+ const total = sortedNotesRef.current.length;
26
+ if (visibleLimitRef.current >= total) return;
27
+ loadingMoreRef.current = true;
28
+ setLoadingMore(true);
29
+ setTimeout(() => {
30
+ if (!aliveRef.current) return;
31
+ setVisibleLimit((limit) => nextVisibleCount(limit, sortedNotesRef.current.length));
32
+ loadingMoreRef.current = false;
33
+ setLoadingMore(false);
34
+ }, 0);
35
+ }
36
+
37
+ React.useEffect(() => {
38
+ if (!view.loaded) return undefined;
39
+ if (sortedNotes.length <= visibleLimit) return undefined;
40
+ if (typeof IntersectionObserver !== 'function') return undefined;
41
+ const panel = panelRef.current;
42
+ const sentinel = loadMoreRef.current;
43
+ const wall = panel && typeof panel.querySelector === 'function' ? panel.querySelector('.SN_wall') : null;
44
+ if (!wall || !sentinel) return undefined;
45
+ const observer = new IntersectionObserver((entries) => {
46
+ if (entries.some((entry) => entry.isIntersecting)) requestMore();
47
+ }, { root: wall, rootMargin: '0px 0px ' + WALL_PRELOAD_PX + 'px 0px' });
48
+ observer.observe(sentinel);
49
+ return () => observer.disconnect();
50
+ }, [view.loaded, sortedNotes.length, visibleLimit, pinned]);
51
+
52
+ return { visibleLimit, loadingMore, loadMoreRef };
53
+ }
54
+
55
+ export { useWallPaging };