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.
- package/LICENSE +21 -0
- package/README.en.md +270 -0
- package/README.md +272 -0
- package/cordis.patch.yml +10 -0
- package/docs/ARCHITECTURE.md +88 -0
- package/docs/AUDIT-0.16.md +74 -0
- package/docs/CHANGELOG.md +120 -0
- package/docs/CONTRACT.md +544 -0
- package/docs/GUIDE.md +177 -0
- package/docs/KNOWN_ISSUES.md +113 -0
- package/icon.svg +7 -0
- package/lib/ai.js +161 -0
- package/lib/capture.js +175 -0
- package/lib/client.js +6994 -0
- package/lib/config-picker.js +29 -0
- package/lib/filename.js +33 -0
- package/lib/files.js +88 -0
- package/lib/host/ai-jobs.js +118 -0
- package/lib/host/configuration.js +151 -0
- package/lib/host/dispatch.js +32 -0
- package/lib/host/environment.js +24 -0
- package/lib/host/folders.js +77 -0
- package/lib/host/images.js +116 -0
- package/lib/host/mutations.js +123 -0
- package/lib/host/note-content.js +44 -0
- package/lib/host/notes.js +148 -0
- package/lib/host/rpc.js +82 -0
- package/lib/host/scanning.js +67 -0
- package/lib/host/session.js +67 -0
- package/lib/host/state.js +68 -0
- package/lib/host/vault.js +38 -0
- package/lib/host/writes.js +21 -0
- package/lib/index.js +88 -0
- package/lib/notes.js +454 -0
- package/lib/obsidian.js +93 -0
- package/lib/scan.js +269 -0
- package/lib/settings.js +228 -0
- package/lib/shared.js +309 -0
- package/lib/trash.js +205 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -0
- package/scripts/benchmark.mjs +58 -0
- package/scripts/build-client.mjs +39 -0
- package/src/client/ai/use-comments.js +75 -0
- package/src/client/ai/use-job-notifications.js +53 -0
- package/src/client/ai/use-project-dialog.js +58 -0
- package/src/client/capture/composer.js +266 -0
- package/src/client/capture/images.js +64 -0
- package/src/client/capture/keyboard.js +34 -0
- package/src/client/components/dialogs.js +67 -0
- package/src/client/components/feedback.js +50 -0
- package/src/client/components/icons.js +160 -0
- package/src/client/components/use-toasts.js +58 -0
- package/src/client/constants.js +127 -0
- package/src/client/data/state.js +116 -0
- package/src/client/data/use-notes-data.js +137 -0
- package/src/client/data/visibility.js +14 -0
- package/src/client/drag/drop-actions.js +47 -0
- package/src/client/drag/motion.js +84 -0
- package/src/client/drag/overlay.js +33 -0
- package/src/client/drag/use-card-drag.js +149 -0
- package/src/client/i18n/en.js +219 -0
- package/src/client/i18n/index.js +11 -0
- package/src/client/i18n/zh.js +219 -0
- package/src/client/images/cache.js +55 -0
- package/src/client/images/components.js +121 -0
- package/src/client/images/queue.js +106 -0
- package/src/client/index.js +40 -0
- package/src/client/panel/header.js +146 -0
- package/src/client/panel/use-shortcuts.js +79 -0
- package/src/client/panel.js +424 -0
- package/src/client/preferences.js +198 -0
- package/src/client/react.js +8 -0
- package/src/client/reader/blocks.js +51 -0
- package/src/client/reader/note-drawer.js +270 -0
- package/src/client/reader/use-dock-offset.js +23 -0
- package/src/client/reader/use-drawer-layout.js +96 -0
- package/src/client/reader/use-note-editor.js +230 -0
- package/src/client/reader/use-pinned-note.js +41 -0
- package/src/client/runtime.js +30 -0
- package/src/client/settings/ai-budget-row.js +65 -0
- package/src/client/settings/ai-budget.js +42 -0
- package/src/client/settings/callouts.js +21 -0
- package/src/client/settings/config-file.js +74 -0
- package/src/client/settings/image-processing.js +31 -0
- package/src/client/settings/official-page.js +32 -0
- package/src/client/settings/paths.js +131 -0
- package/src/client/settings/settings-drawer.js +244 -0
- package/src/client/settings/style-select.js +58 -0
- package/src/client/settings/trash.js +30 -0
- package/src/client/settings/use-settings-draft.js +207 -0
- package/src/client/styles/base.js +63 -0
- package/src/client/styles/capture.js +94 -0
- package/src/client/styles/index.js +26 -0
- package/src/client/styles/overrides.js +116 -0
- package/src/client/styles/reader.js +66 -0
- package/src/client/styles/settings.js +118 -0
- package/src/client/styles/tokens.js +29 -0
- package/src/client/styles/wall.js +83 -0
- package/src/client/testing.js +139 -0
- package/src/client/utils.js +66 -0
- package/src/client/wall/model.js +158 -0
- package/src/client/wall/note-card.js +201 -0
- package/src/client/wall/use-note-actions.js +107 -0
- package/src/client/wall/use-subfolders.js +42 -0
- package/src/client/wall/use-wall-content.js +135 -0
- package/src/client/wall/use-wall-filters.js +92 -0
- 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 };
|