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,146 @@
|
|
|
1
|
+
/** 顶栏的纯渲染函数:版本、计数、日期、子目录、排序和设置入口。 */
|
|
2
|
+
import { CalendarIcon, FolderTreeIcon, GearIcon, SortIcon } from '../components/icons.js';
|
|
3
|
+
import { DATE_FILTERS, SORT_OPTIONS } from '../constants.js';
|
|
4
|
+
import { h } from '../react.js';
|
|
5
|
+
import { formatVersionLabel } from '../utils.js';
|
|
6
|
+
|
|
7
|
+
function renderPanelHeader({
|
|
8
|
+
chooseDateFilter, chooseSort, dateEnabled, dateFilter, dateMenu, dateRangeLabel, dateRealtime,
|
|
9
|
+
dateWrapRef, dirLabel, filteredNotes, includeSubfolders, openDateMenu, openSettings, setDateMenu,
|
|
10
|
+
settingsOpen, sortId, sortMenu, sortWrapRef, subfoldersBusy, t, toggleDateEnabled, toggleDateRealtime,
|
|
11
|
+
toggleSortMenu, toggleSubfolders, view,
|
|
12
|
+
}) {
|
|
13
|
+
return h('header', { className: 'SN_head' },
|
|
14
|
+
h('div', { className: 'SN_headMain' },
|
|
15
|
+
h('div', { className: 'SN_titleRow' },
|
|
16
|
+
h('h1', { className: 'SN_title' }, t('panel.title')),
|
|
17
|
+
// 0.13:byline 只在顶栏大标题之后(h1 之后、计数之前);版本号自动取插件实际版本。
|
|
18
|
+
h('span', { className: 'SN_headByline' }, formatVersionLabel(view.version) ? 'v' + formatVersionLabel(view.version) : ''),
|
|
19
|
+
view.loaded ? h('span', { className: 'SN_count' }, t('panel.count', { count: filteredNotes.length })) : null,
|
|
20
|
+
),
|
|
21
|
+
dirLabel ? h('div', { className: 'SN_headPath', title: dirLabel }, dirLabel) : null,
|
|
22
|
+
),
|
|
23
|
+
h('div', { className: 'SN_headActions' },
|
|
24
|
+
// 0.14 日期筛选(顶栏第一个):点击切换,悬停或方向键打开档位菜单。
|
|
25
|
+
h('div', { className: 'SN_sortWrap SN_dateGroup', ref: dateWrapRef,
|
|
26
|
+
onMouseEnter: openDateMenu,
|
|
27
|
+
onMouseLeave: () => { if (!dateWrapRef.current?.querySelector('[role="menu"]')?.contains(document.activeElement)) setDateMenu(false); },
|
|
28
|
+
onBlur: (event) => { if (!event.currentTarget.contains(event.relatedTarget)) setDateMenu(false); },
|
|
29
|
+
},
|
|
30
|
+
h('button', {
|
|
31
|
+
type: 'button',
|
|
32
|
+
className: 'SN_iconBtn',
|
|
33
|
+
'data-active': dateEnabled ? 'true' : 'false',
|
|
34
|
+
'aria-pressed': dateEnabled ? 'true' : 'false',
|
|
35
|
+
'aria-label': dateEnabled
|
|
36
|
+
? t('date.toggleOn', { range: dateRangeLabel })
|
|
37
|
+
: t('date.toggleOff', { range: dateRangeLabel }),
|
|
38
|
+
title: dateEnabled
|
|
39
|
+
? t('date.toggleOn', { range: dateRangeLabel })
|
|
40
|
+
: t('date.toggleOff', { range: dateRangeLabel }),
|
|
41
|
+
'aria-haspopup': 'menu',
|
|
42
|
+
'aria-expanded': dateMenu ? 'true' : 'false',
|
|
43
|
+
onKeyDown: (event) => {
|
|
44
|
+
if (event.key === 'ArrowDown') {
|
|
45
|
+
event.preventDefault(); openDateMenu();
|
|
46
|
+
requestAnimationFrame(() => dateWrapRef.current?.querySelector('[role="menuitemradio"]')?.focus());
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
onContextMenu: (event) => { event.preventDefault(); openDateMenu(); },
|
|
50
|
+
onClick: toggleDateEnabled,
|
|
51
|
+
}, h(CalendarIcon, null)),
|
|
52
|
+
dateMenu ? h('div', {
|
|
53
|
+
className: 'SN_sortMenu',
|
|
54
|
+
role: 'menu',
|
|
55
|
+
'aria-label': t('date.label'),
|
|
56
|
+
},
|
|
57
|
+
DATE_FILTERS.map((option) => {
|
|
58
|
+
// 开关熄灭时菜单里不显示勾选(选任意档位会自动点亮)。
|
|
59
|
+
const current = dateEnabled && option.id === dateFilter;
|
|
60
|
+
return h('button', {
|
|
61
|
+
key: option.id,
|
|
62
|
+
type: 'button',
|
|
63
|
+
className: 'SN_sortItem',
|
|
64
|
+
role: 'menuitemradio',
|
|
65
|
+
'aria-checked': current ? 'true' : 'false',
|
|
66
|
+
'data-active': current ? 'true' : 'false',
|
|
67
|
+
onClick: () => chooseDateFilter(option.id),
|
|
68
|
+
},
|
|
69
|
+
h('span', { className: 'SN_sortCheck', 'aria-hidden': 'true' }, current ? '✓' : ''),
|
|
70
|
+
h('span', null, t(option.labelKey)),
|
|
71
|
+
);
|
|
72
|
+
}),
|
|
73
|
+
h('div', { className: 'SN_menuDivider', 'aria-hidden': 'true' }),
|
|
74
|
+
h('button', {
|
|
75
|
+
type: 'button',
|
|
76
|
+
className: 'SN_dateItem',
|
|
77
|
+
role: 'menuitemcheckbox',
|
|
78
|
+
'aria-checked': dateRealtime ? 'true' : 'false',
|
|
79
|
+
onClick: toggleDateRealtime,
|
|
80
|
+
},
|
|
81
|
+
h('span', {
|
|
82
|
+
className: 'SN_checkBox',
|
|
83
|
+
'data-checked': dateRealtime ? 'true' : 'false',
|
|
84
|
+
'aria-hidden': 'true',
|
|
85
|
+
}, dateRealtime ? '✓' : ''),
|
|
86
|
+
h('span', { className: 'SN_dateItemMain' },
|
|
87
|
+
h('span', { className: 'SN_dateItemLabel' }, t('date.realtime')),
|
|
88
|
+
h('span', { className: 'SN_dateItemHint' }, t('date.realtimeHint')),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
) : null,
|
|
92
|
+
),
|
|
93
|
+
// 0.13 子文件夹开关(§7.1):日期筛选右侧;aria-pressed 反映 settings.includeSubfolders。
|
|
94
|
+
h('button', {
|
|
95
|
+
type: 'button',
|
|
96
|
+
className: 'SN_iconBtn',
|
|
97
|
+
'data-active': includeSubfolders ? 'true' : 'false',
|
|
98
|
+
'aria-pressed': includeSubfolders ? 'true' : 'false',
|
|
99
|
+
'aria-label': includeSubfolders ? t('panel.subfolders') : t('panel.topOnly'),
|
|
100
|
+
title: includeSubfolders ? t('panel.subfolders') : t('panel.topOnly'),
|
|
101
|
+
disabled: !view.loaded || subfoldersBusy,
|
|
102
|
+
onClick: toggleSubfolders,
|
|
103
|
+
}, h(FolderTreeIcon, null)),
|
|
104
|
+
h('div', { className: 'SN_sortWrap', ref: sortWrapRef },
|
|
105
|
+
h('button', {
|
|
106
|
+
type: 'button',
|
|
107
|
+
className: 'SN_iconBtn',
|
|
108
|
+
'data-active': sortMenu ? 'true' : 'false',
|
|
109
|
+
'aria-haspopup': 'menu',
|
|
110
|
+
'aria-expanded': sortMenu ? 'true' : 'false',
|
|
111
|
+
'aria-label': t('sort.label'),
|
|
112
|
+
title: t('sort.label'),
|
|
113
|
+
onClick: toggleSortMenu,
|
|
114
|
+
}, h(SortIcon, null)),
|
|
115
|
+
sortMenu ? h('div', {
|
|
116
|
+
className: 'SN_sortMenu',
|
|
117
|
+
role: 'menu',
|
|
118
|
+
'aria-label': t('sort.label'),
|
|
119
|
+
},
|
|
120
|
+
SORT_OPTIONS.map((option) => h('button', {
|
|
121
|
+
key: option.id,
|
|
122
|
+
type: 'button',
|
|
123
|
+
className: 'SN_sortItem',
|
|
124
|
+
role: 'menuitemradio',
|
|
125
|
+
'aria-checked': option.id === sortId ? 'true' : 'false',
|
|
126
|
+
'data-active': option.id === sortId ? 'true' : 'false',
|
|
127
|
+
onClick: () => chooseSort(option.id),
|
|
128
|
+
},
|
|
129
|
+
h('span', { className: 'SN_sortCheck', 'aria-hidden': 'true' }, option.id === sortId ? '✓' : ''),
|
|
130
|
+
h('span', null, t(option.labelKey)),
|
|
131
|
+
)),
|
|
132
|
+
) : null,
|
|
133
|
+
),
|
|
134
|
+
h('button', {
|
|
135
|
+
type: 'button',
|
|
136
|
+
className: 'SN_iconBtn',
|
|
137
|
+
'data-active': settingsOpen ? 'true' : 'false',
|
|
138
|
+
onClick: openSettings,
|
|
139
|
+
'aria-label': t('panel.settings'),
|
|
140
|
+
title: t('panel.settings'),
|
|
141
|
+
}, h(GearIcon, null)),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { renderPanelHeader };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** 管理浮层快捷键的监听生命周期与 Esc 优先级。 */
|
|
2
|
+
import { panelIsVisible } from '../data/visibility.js';
|
|
3
|
+
import { React } from '../react.js';
|
|
4
|
+
|
|
5
|
+
function usePanelShortcuts({
|
|
6
|
+
anyOverlay, cancelEditing, closeCommentConfirm, closeNoteDrawer, closeProjectDialog, closeSettings,
|
|
7
|
+
commentConfirm, currentDrawerNote, dateMenu, drawerArchive, editing, noteDrawer, panelRef, pinned,
|
|
8
|
+
pinnedUserClosedRef, projectBusy, projectTarget, runDelete, saveEditing, savingSettings, setDateMenu,
|
|
9
|
+
setDrawerArchive, setSortMenu, settingsOpen, sortMenu,
|
|
10
|
+
}) {
|
|
11
|
+
const keyHandlerRef = React.useRef(null);
|
|
12
|
+
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
if (!anyOverlay) return undefined;
|
|
15
|
+
const listener = (event) => {
|
|
16
|
+
if (typeof keyHandlerRef.current === 'function') keyHandlerRef.current(event);
|
|
17
|
+
};
|
|
18
|
+
document.addEventListener('keydown', listener);
|
|
19
|
+
return () => document.removeEventListener('keydown', listener);
|
|
20
|
+
}, [anyOverlay]);
|
|
21
|
+
|
|
22
|
+
keyHandlerRef.current = function handleGlobalKeyDown(event) {
|
|
23
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Backspace' && !event.isComposing && event.keyCode !== 229 && !event.defaultPrevented) {
|
|
24
|
+
if (settingsOpen || projectTarget || commentConfirm || editing || event.target?.closest?.('input,textarea,[contenteditable]:not([contenteditable="false"])')) return;
|
|
25
|
+
const panel = panelRef.current;
|
|
26
|
+
if (!panel || !panelIsVisible(panel)) return;
|
|
27
|
+
const doc = panel.ownerDocument;
|
|
28
|
+
if (event.target !== doc?.body && !panel.contains(event.target)) return;
|
|
29
|
+
const note = currentDrawerNote();
|
|
30
|
+
if (note) { event.preventDefault(); event.stopPropagation(); runDelete(note); }
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (event.key === 'Escape') {
|
|
34
|
+
event.stopPropagation();
|
|
35
|
+
// 0.13 同类评论确认框在最上层:Esc 只关它(§7.7)。
|
|
36
|
+
if (commentConfirm) {
|
|
37
|
+
closeCommentConfirm();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (drawerArchive) {
|
|
41
|
+
setDrawerArchive(false);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (dateMenu) {
|
|
45
|
+
setDateMenu(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (sortMenu) {
|
|
49
|
+
setSortMenu(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (projectTarget) {
|
|
53
|
+
if (!projectBusy) closeProjectDialog();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (settingsOpen) {
|
|
57
|
+
if (!savingSettings) closeSettings();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (noteDrawer) {
|
|
61
|
+
if (editing) cancelEditing();
|
|
62
|
+
else {
|
|
63
|
+
// 锁定态用 Esc 主动关闭:本次挂载内不要自动重开(重进面板仍会恢复/选第一篇)。
|
|
64
|
+
if (pinned) pinnedUserClosedRef.current = true;
|
|
65
|
+
closeNoteDrawer();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if ((event.metaKey || event.ctrlKey) && (event.key === 's' || event.key === 'S') && editing) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.stopPropagation();
|
|
73
|
+
saveEditing();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { usePanelShortcuts };
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/** 主面板组合入口:连接数据、编辑和交互 hooks,再组织视图与操作回调。 */
|
|
2
|
+
import { useComments } from './ai/use-comments.js';
|
|
3
|
+
import { useJobNotifications } from './ai/use-job-notifications.js';
|
|
4
|
+
import { useProjectDialog } from './ai/use-project-dialog.js';
|
|
5
|
+
import { CaptureComposer } from './capture/composer.js';
|
|
6
|
+
import { ConfirmDialog, ProjectDialog } from './components/dialogs.js';
|
|
7
|
+
import { ToastStack } from './components/feedback.js';
|
|
8
|
+
import { useToasts } from './components/use-toasts.js';
|
|
9
|
+
import { EMPTY_SETTINGS } from './constants.js';
|
|
10
|
+
import { useNotesData } from './data/use-notes-data.js';
|
|
11
|
+
import { CardDragOverlay } from './drag/overlay.js';
|
|
12
|
+
import { useCardDrag } from './drag/use-card-drag.js';
|
|
13
|
+
import { clearImageCache } from './images/cache.js';
|
|
14
|
+
import { clearImageQueue } from './images/queue.js';
|
|
15
|
+
import { renderPanelHeader } from './panel/header.js';
|
|
16
|
+
import { usePanelShortcuts } from './panel/use-shortcuts.js';
|
|
17
|
+
import { React, h } from './react.js';
|
|
18
|
+
import { NoteDrawer } from './reader/note-drawer.js';
|
|
19
|
+
import { useDockOffset } from './reader/use-dock-offset.js';
|
|
20
|
+
import { useDrawerLayout } from './reader/use-drawer-layout.js';
|
|
21
|
+
import { useNoteEditor } from './reader/use-note-editor.js';
|
|
22
|
+
import { usePinnedNote } from './reader/use-pinned-note.js';
|
|
23
|
+
import { runtime } from './runtime.js';
|
|
24
|
+
import { SettingsDrawer } from './settings/settings-drawer.js';
|
|
25
|
+
import { useSettingsDraft } from './settings/use-settings-draft.js';
|
|
26
|
+
import { isAbortError } from './utils.js';
|
|
27
|
+
import { dateFilterOption, filterNotesByDate, replaceNoteAtPath, sortNotes } from './wall/model.js';
|
|
28
|
+
import { useNoteActions } from './wall/use-note-actions.js';
|
|
29
|
+
import { useSubfolders } from './wall/use-subfolders.js';
|
|
30
|
+
import { useWallContent } from './wall/use-wall-content.js';
|
|
31
|
+
import { useWallFilters } from './wall/use-wall-filters.js';
|
|
32
|
+
import { useWallPaging } from './wall/use-wall-paging.js';
|
|
33
|
+
|
|
34
|
+
function StickyNotesPanel(props) {
|
|
35
|
+
const t = (props && props.t) || (runtime && runtime.t) || ((key) => key);
|
|
36
|
+
const tRef = React.useRef(t);
|
|
37
|
+
|
|
38
|
+
tRef.current = t;
|
|
39
|
+
const panelRef = React.useRef(null);
|
|
40
|
+
const {
|
|
41
|
+
resizingDrawer, setResizingDrawer, resizingRef, resizeCleanupRef, pinned, pinnedUserClosedRef,
|
|
42
|
+
effectiveDrawerWidth, startDrawerResize, resetDrawerWidth, togglePinned,
|
|
43
|
+
} = useDrawerLayout({ panelRef });
|
|
44
|
+
const { toasts, dismissToast, pushToast, pushErrorToast } = useToasts();
|
|
45
|
+
const {
|
|
46
|
+
sortId, sortMenu, setSortMenu, sortWrapRef, dateFilter, dateEnabled, dateRealtime, dateMenu,
|
|
47
|
+
setDateMenu, dateWrapRef, nowTick, setNowTick, toggleSortMenu, chooseSort, openDateMenu,
|
|
48
|
+
toggleDateEnabled, chooseDateFilter, toggleDateRealtime,
|
|
49
|
+
} = useWallFilters();
|
|
50
|
+
const refreshingRef = React.useRef(false);
|
|
51
|
+
const [drawerArchive, setDrawerArchive] = React.useState(false);
|
|
52
|
+
const drawerMenuRef = React.useRef(null);
|
|
53
|
+
const actionsRef = React.useRef({});
|
|
54
|
+
const [hoverPath, setHoverPath] = React.useState(null);
|
|
55
|
+
const [showBackTop, setShowBackTop] = React.useState(false);
|
|
56
|
+
const suppressCardOpenRef = React.useRef(false);
|
|
57
|
+
const { view, viewRef, loadError, aliveRef, controllersRef, updateView, request, refreshNow } = useNotesData({
|
|
58
|
+
panelRef,
|
|
59
|
+
onError: pushErrorToast,
|
|
60
|
+
onVisible() { setNowTick(Date.now()); },
|
|
61
|
+
onState(next, prev) {
|
|
62
|
+
jobNotifications.processJobs(next.jobs);
|
|
63
|
+
if (next.revision !== prev.revision && noteDrawerRef.current && !editingRef.current) {
|
|
64
|
+
loadNote(noteDrawerRef.current.path);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const {
|
|
69
|
+
noteDrawer, noteDrawerRef, editing, editingRef, bodyDraft, titleDraft, setTitleDraft, savingNote,
|
|
70
|
+
savingNoteRef, taskBusy, closeNoteDrawer, loadNote, openNoteDrawer, handleBodyChange, startEditing,
|
|
71
|
+
toggleTask, cancelEditing, saveEditing,
|
|
72
|
+
} = useNoteEditor({
|
|
73
|
+
tRef, aliveRef, controllersRef, request, refreshNow, replaceNote, replaceRenamedNote, pushToast, pushErrorToast,
|
|
74
|
+
onOpen() {
|
|
75
|
+
pinnedUserClosedRef.current = false;
|
|
76
|
+
setDrawerArchive(false);
|
|
77
|
+
setSortMenu(false);
|
|
78
|
+
setDateMenu(false);
|
|
79
|
+
},
|
|
80
|
+
onClose() {
|
|
81
|
+
if (resizeCleanupRef.current) resizeCleanupRef.current();
|
|
82
|
+
resizeCleanupRef.current = null;
|
|
83
|
+
resizingRef.current = false;
|
|
84
|
+
setResizingDrawer(false);
|
|
85
|
+
setDrawerArchive(false);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const {
|
|
89
|
+
settingsOpen, settingsDraft, picking, savingSettings, settingsTab, acceptConfigSettings, openSettings,
|
|
90
|
+
closeSettings, patchDraft, patchEmoji, removeEmoji, addEmoji, removeArchiveTarget, systemPick,
|
|
91
|
+
saveSettingsDraft,
|
|
92
|
+
} = useSettingsDraft({
|
|
93
|
+
t, tRef, viewRef, updateView, request, refreshNow, aliveRef, panelRef, pushToast, pushErrorToast,
|
|
94
|
+
onOpen() { setSortMenu(false); },
|
|
95
|
+
});
|
|
96
|
+
const jobNotifications = useJobNotifications({ tRef, pushToast, pushErrorToast, onOpenSettings: () => openSettings('ai') });
|
|
97
|
+
const { watchedJobsRef } = jobNotifications;
|
|
98
|
+
const { subfoldersBusy, toggleSubfolders } = useSubfolders({ aliveRef, pushErrorToast, pushToast, refreshNow, request, t, updateView, viewRef });
|
|
99
|
+
const currentSettings = view.settings && typeof view.settings === 'object' ? view.settings : EMPTY_SETTINGS;
|
|
100
|
+
const emojis = Array.isArray(currentSettings.emojis) ? currentSettings.emojis : [];
|
|
101
|
+
const archiveTargets = Array.isArray(currentSettings.archiveTargets) ? currentSettings.archiveTargets : [];
|
|
102
|
+
const { projectTarget, projectName, setProjectName, projectBusy, openProjectDialog, closeProjectDialog, confirmProject } = useProjectDialog({ aliveRef, pushErrorToast, pushToast, refreshNow, request, setDrawerArchive, setSortMenu, t, tRef, watchedJobsRef });
|
|
103
|
+
const { commentConfirm, localPending, requestComment, closeCommentConfirm, confirmComment } = useComments({ aliveRef, emojis, pushErrorToast, pushToast, refreshNow, request, setDrawerArchive, setHoverPath, tRef, viewRef, watchedJobsRef });
|
|
104
|
+
const { findTarget, runDelete, copyNote, runArchive, openInObsidian } = useNoteActions({
|
|
105
|
+
aliveRef, archiveTargets, closeNoteDrawer, localPending, noteDrawerRef, pushErrorToast, pushToast,
|
|
106
|
+
refreshNow, request, setDrawerArchive, setHoverPath, tRef, updateView, viewRef,
|
|
107
|
+
});
|
|
108
|
+
const {
|
|
109
|
+
dragSession, dragSessionRef, dragMotionRef, dragNote, dropAction, dropTargetId, endCardDrag,
|
|
110
|
+
startCardDrag, hoverDragAction, deleteDrop, archiveDrop, chooseArchiveTarget, projectDrop,
|
|
111
|
+
} = useCardDrag({
|
|
112
|
+
aliveRef, archiveTargets, findTarget, openProjectDialog, panelRef, pushToast, runArchive, runDelete,
|
|
113
|
+
setDateMenu, setDrawerArchive, setHoverPath, setSortMenu, tRef,
|
|
114
|
+
});
|
|
115
|
+
const filteredNotes = React.useMemo(
|
|
116
|
+
() => filterNotesByDate(view.notes, dateFilter, dateRealtime, nowTick, undefined, dateEnabled),
|
|
117
|
+
[view.notes, dateFilter, dateRealtime, nowTick, dateEnabled],
|
|
118
|
+
);
|
|
119
|
+
const sortedNotes = React.useMemo(() => sortNotes(filteredNotes, sortId), [filteredNotes, sortId]);
|
|
120
|
+
|
|
121
|
+
usePinnedNote({ closeNoteDrawer, editing, editingRef, noteDrawer, noteDrawerRef, openNoteDrawer, pinned, pinnedUserClosedRef, savingNoteRef, sortedNotes, view });
|
|
122
|
+
const { visibleLimit, loadingMore, loadMoreRef } = useWallPaging({ aliveRef, dateEnabled, dateFilter, dateRealtime, panelRef, pinned, sortId, sortedNotes, view });
|
|
123
|
+
const dateRangeLabel = t(dateFilterOption(dateFilter).labelKey);
|
|
124
|
+
const includeSubfolders = currentSettings.includeSubfolders !== false;
|
|
125
|
+
const { dockRight } = useDockOffset({ effectiveDrawerWidth, noteDrawer, panelRef, pinned });
|
|
126
|
+
const anyOverlay = Boolean(sortMenu || dateMenu || drawerArchive || commentConfirm || projectTarget || settingsOpen || noteDrawer);
|
|
127
|
+
|
|
128
|
+
function replaceNote(summary) {
|
|
129
|
+
if (!summary || typeof summary.path !== 'string') return;
|
|
130
|
+
const prev = viewRef.current;
|
|
131
|
+
let changed = false;
|
|
132
|
+
const notes = prev.notes.map((note) => {
|
|
133
|
+
if (note.path !== summary.path) return note;
|
|
134
|
+
changed = true;
|
|
135
|
+
return Object.assign({}, note, summary, { pending: note.pending || summary.pending || null });
|
|
136
|
+
});
|
|
137
|
+
if (changed) updateView(Object.assign({}, prev, { notes: notes }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function replaceRenamedNote(oldPath, summary) {
|
|
141
|
+
const prev = viewRef.current;
|
|
142
|
+
const notes = replaceNoteAtPath(prev.notes, oldPath, summary);
|
|
143
|
+
if (notes !== null) updateView(Object.assign({}, prev, { notes: notes }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleRefresh() {
|
|
147
|
+
if (refreshingRef.current) return;
|
|
148
|
+
refreshingRef.current = true;
|
|
149
|
+
Promise.resolve()
|
|
150
|
+
.then(() => request('rescan', {}))
|
|
151
|
+
.then(() => refreshNow(false))
|
|
152
|
+
.catch((error) => {
|
|
153
|
+
if (aliveRef.current && !isAbortError(error)) pushErrorToast(error);
|
|
154
|
+
})
|
|
155
|
+
.then(() => {
|
|
156
|
+
if (!aliveRef.current) return;
|
|
157
|
+
refreshingRef.current = false;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
return () => {
|
|
163
|
+
// 0.13:面板卸载时清空模块级缩略图缓存与图片队列(§7.2/§7.8)。
|
|
164
|
+
clearImageQueue();
|
|
165
|
+
clearImageCache();
|
|
166
|
+
};
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
React.useEffect(() => {
|
|
170
|
+
if (!drawerArchive) return undefined;
|
|
171
|
+
const onPointerDown = (event) => {
|
|
172
|
+
const target = event.target;
|
|
173
|
+
if (drawerMenuRef.current && target && drawerMenuRef.current.contains(target)) return;
|
|
174
|
+
if (target && typeof target.closest === 'function' && target.closest('[data-sn-drawer-menu-trigger]')) return;
|
|
175
|
+
setDrawerArchive(false);
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener('mousedown', onPointerDown, true);
|
|
178
|
+
return () => document.removeEventListener('mousedown', onPointerDown, true);
|
|
179
|
+
}, [drawerArchive]);
|
|
180
|
+
|
|
181
|
+
function handleWallMouseDown() {
|
|
182
|
+
// 归档菜单在墙内:不要在这里关闭(mousedown 关闭会让随后的菜单项 click 丢失),
|
|
183
|
+
// 外部点击已由 document capture 监听负责。
|
|
184
|
+
if (noteDrawer && editingRef.current) {
|
|
185
|
+
suppressCardOpenRef.current = true;
|
|
186
|
+
cancelEditing();
|
|
187
|
+
} else {
|
|
188
|
+
suppressCardOpenRef.current = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleWallClick(event) {
|
|
193
|
+
if (suppressCardOpenRef.current) {
|
|
194
|
+
suppressCardOpenRef.current = false;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (event.target !== event.currentTarget) return;
|
|
198
|
+
if (!noteDrawer || pinned) return;
|
|
199
|
+
closeNoteDrawer();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleCardOpen(note) {
|
|
203
|
+
if (!note) return;
|
|
204
|
+
if (suppressCardOpenRef.current) {
|
|
205
|
+
suppressCardOpenRef.current = false;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const current = noteDrawerRef.current;
|
|
209
|
+
if (current && current.path === note.path) {
|
|
210
|
+
if (!pinned) closeNoteDrawer();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
openNoteDrawer(note);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function enterCard(note) {
|
|
217
|
+
// 抽屉打开时也记录 hover:menuVisible 已用 !drawerOpen 屏蔽菜单,
|
|
218
|
+
// 但关闭抽屉的那一刻 hoverPath 必须正好是鼠标/焦点所在的卡片(见 0.13.4 P1/P2 修复)。
|
|
219
|
+
if (!note || note.pending || dragSessionRef.current) return;
|
|
220
|
+
setHoverPath((current) => (current && current.path === note.path ? current : { path: note.path }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function leaveCard(note, keepForFocus) {
|
|
224
|
+
// keepForFocus:鼠标移出但焦点仍在卡片内 → 不收起菜单(否则图片预览会闪一下)。
|
|
225
|
+
if (keepForFocus === true || dragSessionRef.current) return;
|
|
226
|
+
setHoverPath((current) => {
|
|
227
|
+
if (!current) return current;
|
|
228
|
+
if (note && current.path !== note.path) return current;
|
|
229
|
+
return null;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function currentDrawerNote() {
|
|
234
|
+
const current = noteDrawerRef.current;
|
|
235
|
+
if (!current) return null;
|
|
236
|
+
return viewRef.current.notes.find((item) => item.path === current.path)
|
|
237
|
+
|| current.full
|
|
238
|
+
|| current.summary
|
|
239
|
+
|| null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function toggleDrawerArchive() {
|
|
243
|
+
if (!noteDrawerRef.current) return;
|
|
244
|
+
if (archiveTargets.length === 0) {
|
|
245
|
+
pushToast({ text: t('archive.emptyHint'), kind: 'info', timeout: 6000 });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
setDrawerArchive((value) => !value);
|
|
249
|
+
setSortMenu(false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
usePanelShortcuts({
|
|
253
|
+
anyOverlay, cancelEditing, closeCommentConfirm, closeNoteDrawer, closeProjectDialog, closeSettings,
|
|
254
|
+
commentConfirm, currentDrawerNote, dateMenu, drawerArchive, editing, noteDrawer, panelRef, pinned,
|
|
255
|
+
pinnedUserClosedRef, projectBusy, projectTarget, runDelete, saveEditing, savingSettings, setDateMenu,
|
|
256
|
+
setDrawerArchive, setSortMenu, settingsOpen, sortMenu,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
actionsRef.current = {
|
|
260
|
+
t: t,
|
|
261
|
+
onOpen: handleCardOpen,
|
|
262
|
+
onPreset: requestComment,
|
|
263
|
+
onCardEnter: enterCard,
|
|
264
|
+
onCardLeave: leaveCard,
|
|
265
|
+
onOpenObsidian: openInObsidian,
|
|
266
|
+
onCopy: copyNote,
|
|
267
|
+
onDelete: runDelete,
|
|
268
|
+
onDragStart: startCardDrag,
|
|
269
|
+
onDragEnd: () => dragSessionRef.current?.parked ? undefined : endCardDrag(),
|
|
270
|
+
};
|
|
271
|
+
const dirLabel = currentSettings.notesDir || currentSettings.vaultPath || '';
|
|
272
|
+
const errorBanner = view.loaded
|
|
273
|
+
? (loadError || (view.notes.length > 0 ? view.error : null))
|
|
274
|
+
: null;
|
|
275
|
+
const { renderContent } = useWallContent({
|
|
276
|
+
actionsRef, dateRangeLabel, dragNote, emojis, filteredNotes, handleRefresh, handleWallClick,
|
|
277
|
+
handleWallMouseDown, hoverPath, loadError, loadMoreRef, loadingMore, localPending, noteDrawer,
|
|
278
|
+
openSettings, setDateMenu, setShowBackTop, sortedNotes, t, view, visibleLimit,
|
|
279
|
+
});
|
|
280
|
+
const drawerNote = noteDrawer
|
|
281
|
+
? (view.notes.find((item) => item.path === noteDrawer.path) || noteDrawer.full || noteDrawer.summary || null)
|
|
282
|
+
: null;
|
|
283
|
+
const drawerPending = noteDrawer
|
|
284
|
+
? (localPending[noteDrawer.path] || (drawerNote && drawerNote.pending) || null)
|
|
285
|
+
: null;
|
|
286
|
+
const noteDrawerProps = noteDrawer
|
|
287
|
+
? {
|
|
288
|
+
t: t,
|
|
289
|
+
drawer: noteDrawer,
|
|
290
|
+
editing: editing,
|
|
291
|
+
bodyDraft: bodyDraft,
|
|
292
|
+
saving: savingNote,
|
|
293
|
+
drawerWidth: effectiveDrawerWidth,
|
|
294
|
+
resizing: resizingDrawer,
|
|
295
|
+
pinned: pinned,
|
|
296
|
+
drawerActions: {
|
|
297
|
+
emojis: emojis,
|
|
298
|
+
pending: drawerPending,
|
|
299
|
+
archiveOpen: drawerArchive,
|
|
300
|
+
targets: archiveTargets,
|
|
301
|
+
menuRef: drawerMenuRef,
|
|
302
|
+
onComment: (emoji) => requestComment(currentDrawerNote(), emoji),
|
|
303
|
+
onToggleArchive: toggleDrawerArchive,
|
|
304
|
+
onArchive: (targetId) => runArchive(currentDrawerNote(), targetId),
|
|
305
|
+
onProject: () => openProjectDialog(currentDrawerNote()),
|
|
306
|
+
onOpenObsidian: () => openInObsidian(currentDrawerNote()),
|
|
307
|
+
onCopy: () => copyNote(currentDrawerNote()),
|
|
308
|
+
onDelete: () => runDelete(currentDrawerNote()),
|
|
309
|
+
},
|
|
310
|
+
onTogglePinned: togglePinned,
|
|
311
|
+
onToggleTask: toggleTask,
|
|
312
|
+
taskBusy: taskBusy,
|
|
313
|
+
titleDraft: titleDraft,
|
|
314
|
+
onTitleChange: setTitleDraft,
|
|
315
|
+
onResizeStart: startDrawerResize,
|
|
316
|
+
onResizeReset: resetDrawerWidth,
|
|
317
|
+
onClose: closeNoteDrawer,
|
|
318
|
+
onRetry: () => loadNote(noteDrawer.path),
|
|
319
|
+
onStartEdit: startEditing,
|
|
320
|
+
onBodyChange: handleBodyChange,
|
|
321
|
+
onSave: saveEditing,
|
|
322
|
+
onCancelEdit: cancelEditing,
|
|
323
|
+
}
|
|
324
|
+
: null;
|
|
325
|
+
const confirmEmoji = commentConfirm
|
|
326
|
+
? emojis.find((item) => item && item.id === commentConfirm.emojiId) || null
|
|
327
|
+
: null;
|
|
328
|
+
const confirmEmojiLabel = confirmEmoji ? (confirmEmoji.label || confirmEmoji.emoji || '') : '';
|
|
329
|
+
|
|
330
|
+
return h('div', {
|
|
331
|
+
className: 'SN_panel',
|
|
332
|
+
'data-sticky-notes': '',
|
|
333
|
+
'data-card-dragging': dragNote ? 'true' : 'false',
|
|
334
|
+
'data-resizing': resizingDrawer ? 'true' : 'false',
|
|
335
|
+
ref: panelRef,
|
|
336
|
+
role: 'region',
|
|
337
|
+
'aria-label': t('panel.title'),
|
|
338
|
+
},
|
|
339
|
+
renderPanelHeader({
|
|
340
|
+
chooseDateFilter, chooseSort, dateEnabled, dateFilter, dateMenu, dateRangeLabel, dateRealtime,
|
|
341
|
+
dateWrapRef, dirLabel, filteredNotes, includeSubfolders, openDateMenu, openSettings, setDateMenu,
|
|
342
|
+
settingsOpen, sortId, sortMenu, sortWrapRef, subfoldersBusy, t, toggleDateEnabled,
|
|
343
|
+
toggleDateRealtime, toggleSortMenu, toggleSubfolders, view,
|
|
344
|
+
}),
|
|
345
|
+
errorBanner ? h('div', { className: 'SN_banner' },
|
|
346
|
+
h('span', { className: 'SN_bannerText', title: errorBanner }, errorBanner),
|
|
347
|
+
h('button', { type: 'button', className: 'SN_textBtn', onClick: handleRefresh }, t('panel.retry')),
|
|
348
|
+
) : null,
|
|
349
|
+
// 0.13 面板锁(§7.3):锁定 = SN_body flex row,抽屉作为普通 flex 子项与墙并排。
|
|
350
|
+
h('div', { className: 'SN_body', 'data-pinned': pinned ? 'true' : 'false' },
|
|
351
|
+
h('div', { className: 'SN_wallWrap', 'data-capture': view.configured ? 'true' : 'false', style: { '--sn-dock-right': dockRight + 'px' } },
|
|
352
|
+
renderContent(),
|
|
353
|
+
h(CaptureComposer, {
|
|
354
|
+
t, enabled: view.configured, imageCompression: currentSettings.imageCompression,
|
|
355
|
+
dragNote, archiveParked: Boolean(dragSession?.parked), dropAction, dropTargetId, archiveTargets,
|
|
356
|
+
onDragHover: hoverDragAction, onArchiveDrop: archiveDrop, onArchiveChoose: chooseArchiveTarget, onProjectDrop: projectDrop, onDeleteDrop: deleteDrop,
|
|
357
|
+
showBackTop,
|
|
358
|
+
onBackTop: () => panelRef.current?.querySelector('.SN_wall')?.scrollTo({ top: 0, behavior: 'smooth' }),
|
|
359
|
+
onCreate: (payload) => request('createNote', payload),
|
|
360
|
+
onCreated: (result) => {
|
|
361
|
+
if (result && result.note) {
|
|
362
|
+
const prev = viewRef.current;
|
|
363
|
+
if (prev.notes.some((note) => note.path === result.note.path)) replaceNote(result.note);
|
|
364
|
+
else updateView(Object.assign({}, prev, { notes: [result.note].concat(prev.notes) }));
|
|
365
|
+
}
|
|
366
|
+
pushToast({ text: t('capture.created'), kind: 'success' });
|
|
367
|
+
refreshNow(true);
|
|
368
|
+
},
|
|
369
|
+
onError: pushErrorToast,
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
pinned && noteDrawerProps
|
|
373
|
+
? h(NoteDrawer, Object.assign({ key: 'inline', inline: true }, noteDrawerProps))
|
|
374
|
+
: null,
|
|
375
|
+
),
|
|
376
|
+
// 未锁定 = 原有 overlay 态(绝对定位盖在便签墙之上)。
|
|
377
|
+
!pinned && noteDrawerProps
|
|
378
|
+
? h(NoteDrawer, Object.assign({ key: 'overlay', inline: false }, noteDrawerProps))
|
|
379
|
+
: null,
|
|
380
|
+
h(ToastStack, { toasts: toasts, onDismiss: dismissToast }),
|
|
381
|
+
dragNote ? h(CardDragOverlay, { note: dragNote, point: dragSession.point, motionRef: dragMotionRef, action: dropAction, targetId: dropTargetId, targets: archiveTargets, t }) : null,
|
|
382
|
+
settingsOpen && settingsDraft
|
|
383
|
+
? h(SettingsDrawer, {
|
|
384
|
+
t: t,
|
|
385
|
+
draft: settingsDraft, request, initialTab: settingsTab, onConfigSettings: acceptConfigSettings,
|
|
386
|
+
picking: picking,
|
|
387
|
+
saving: savingSettings,
|
|
388
|
+
onPatch: patchDraft,
|
|
389
|
+
onPick: systemPick,
|
|
390
|
+
onRemoveArchive: removeArchiveTarget,
|
|
391
|
+
onPatchEmoji: patchEmoji,
|
|
392
|
+
onRemoveEmoji: removeEmoji,
|
|
393
|
+
onAddEmoji: addEmoji,
|
|
394
|
+
onSave: saveSettingsDraft,
|
|
395
|
+
onCancel: closeSettings,
|
|
396
|
+
})
|
|
397
|
+
: null,
|
|
398
|
+
projectTarget
|
|
399
|
+
? h(ProjectDialog, {
|
|
400
|
+
t: t,
|
|
401
|
+
note: projectTarget,
|
|
402
|
+
name: projectName,
|
|
403
|
+
busy: projectBusy,
|
|
404
|
+
onName: setProjectName,
|
|
405
|
+
onConfirm: confirmProject,
|
|
406
|
+
onCancel: closeProjectDialog,
|
|
407
|
+
})
|
|
408
|
+
: null,
|
|
409
|
+
// 0.13 同类评论确认(§7.7):确认后才发 comment。
|
|
410
|
+
commentConfirm
|
|
411
|
+
? h(ConfirmDialog, {
|
|
412
|
+
t: t,
|
|
413
|
+
title: t('comment.confirmTitle'),
|
|
414
|
+
text: t('comment.confirmText', { label: confirmEmojiLabel }),
|
|
415
|
+
confirmLabel: t('comment.confirmAgain'),
|
|
416
|
+
cancelLabel: t('comment.cancel'),
|
|
417
|
+
onConfirm: confirmComment,
|
|
418
|
+
onCancel: closeCommentConfirm,
|
|
419
|
+
})
|
|
420
|
+
: null,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export { StickyNotesPanel };
|