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,131 @@
|
|
|
1
|
+
/** 仓库字段和库内文件夹下拉,支持历史、浏览和新建。 */
|
|
2
|
+
import { Fragment, React, h } from '../react.js';
|
|
3
|
+
import { isAbsolutePath, joinPath } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
function vaultRelative(vault, value) {
|
|
6
|
+
const clean = (path) => String(path || '').split(String.fromCharCode(92)).join('/');
|
|
7
|
+
let root = clean(vault);
|
|
8
|
+
while (root.endsWith('/')) root = root.slice(0, -1);
|
|
9
|
+
const path = clean(value);
|
|
10
|
+
if (root && path === root) return '.';
|
|
11
|
+
return root && path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function VaultFolderPicker({ t, label, value, vaultPath, history, disabled, onChange, onPick, request, placeholder }) {
|
|
15
|
+
const [open, setOpen] = React.useState(false);
|
|
16
|
+
const [folders, setFolders] = React.useState([]);
|
|
17
|
+
const [loading, setLoading] = React.useState(false);
|
|
18
|
+
const [creating, setCreating] = React.useState(false);
|
|
19
|
+
const [name, setName] = React.useState('');
|
|
20
|
+
const [query, setQuery] = React.useState('');
|
|
21
|
+
const [error, setError] = React.useState('');
|
|
22
|
+
const [created, setCreated] = React.useState('');
|
|
23
|
+
const [creatingBusy, setCreatingBusy] = React.useState(false);
|
|
24
|
+
const wrap = React.useRef(null);
|
|
25
|
+
const scope = React.useRef({ vaultPath, alive: true });
|
|
26
|
+
scope.current.vaultPath = vaultPath;
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
scope.current.alive = true;
|
|
29
|
+
return () => { scope.current.alive = false; };
|
|
30
|
+
}, []);
|
|
31
|
+
const isCurrent = () => scope.current.alive && scope.current.vaultPath === vaultPath;
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
setOpen(false); setCreating(false); setCreatingBusy(false); setLoading(false); setCreated(''); setError('');
|
|
34
|
+
}, [vaultPath]);
|
|
35
|
+
const [menuLayout, setMenuLayout] = React.useState({ up: false, height: 340 });
|
|
36
|
+
React.useLayoutEffect(() => {
|
|
37
|
+
if (!open || !wrap.current?.getBoundingClientRect) return undefined;
|
|
38
|
+
const measure = () => {
|
|
39
|
+
const rect = wrap.current.getBoundingClientRect();
|
|
40
|
+
const body = wrap.current.closest('.SN_drawerBody');
|
|
41
|
+
const bounds = body?.getBoundingClientRect();
|
|
42
|
+
const top = Math.max(8, bounds?.top || 0);
|
|
43
|
+
const bottom = Math.min(window.innerHeight - 8, bounds?.bottom || window.innerHeight);
|
|
44
|
+
const below = bottom - rect.bottom - 6, above = rect.top - top - 6;
|
|
45
|
+
const up = below < 260 && above > below;
|
|
46
|
+
setMenuLayout({ up, height: Math.max(80, Math.min(340, up ? above : below)) });
|
|
47
|
+
};
|
|
48
|
+
measure();
|
|
49
|
+
window.addEventListener('resize', measure);
|
|
50
|
+
return () => window.removeEventListener('resize', measure);
|
|
51
|
+
}, [open]);
|
|
52
|
+
const fail = (cause) => setError(cause.message || String(cause));
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
if (!open) return undefined;
|
|
55
|
+
const outside = (event) => { if (!wrap.current?.contains(event.target)) setOpen(false); };
|
|
56
|
+
document.addEventListener('mousedown', outside, true);
|
|
57
|
+
return () => document.removeEventListener('mousedown', outside, true);
|
|
58
|
+
}, [open]);
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
if (!open || !request || !vaultPath) return undefined;
|
|
61
|
+
let alive = true;
|
|
62
|
+
setLoading(true); setError(''); setFolders([]);
|
|
63
|
+
request('listFolders', { vaultPath }).then((result) => { if (alive) setFolders(result.folders || []); })
|
|
64
|
+
.catch((cause) => { if (alive) fail(cause); }).finally(() => { if (alive) setLoading(false); });
|
|
65
|
+
return () => { alive = false; };
|
|
66
|
+
}, [open, vaultPath]);
|
|
67
|
+
const choose = (path) => { onChange(path); setOpen(false); setQuery(''); setCreating(false); setCreated(''); };
|
|
68
|
+
const recent = [...new Set((history || []).map((path) => vaultRelative(vaultPath, path)).filter((path) => !isAbsolutePath(path)))];
|
|
69
|
+
const options = [...new Set(recent.concat(folders))].filter((path) => path.toLowerCase().includes(query.toLowerCase()));
|
|
70
|
+
return h('div', { className: 'SN_folderPicker', ref: wrap, onKeyDown: (event) => { if (event.key === 'Escape') { event.stopPropagation(); setOpen(false); } } },
|
|
71
|
+
h('button', { type: 'button', className: 'SN_folderTrigger', disabled: disabled || !vaultPath, title: value || '',
|
|
72
|
+
'aria-label': label, 'aria-haspopup': 'dialog', 'aria-expanded': open, onClick: () => setOpen(!open) },
|
|
73
|
+
h('span', null, value ? vaultRelative(vaultPath, value) : placeholder || t('settings.chooseFolder')), h('span', { 'aria-hidden': 'true' }, '⌄')),
|
|
74
|
+
open ? h('div', { className: 'SN_folderMenu', role: 'dialog', 'aria-label': label, 'data-up': menuLayout.up ? 'true' : 'false', style: { maxHeight: menuLayout.height } },
|
|
75
|
+
h('button', { type: 'button', className: 'SN_folderOption', disabled: loading, onClick: () => setCreating(!creating) }, '+ ', t('settings.newFolder')),
|
|
76
|
+
creating ? h('div', { className: 'SN_folderCreateSection' }, h('form', { className: 'SN_folderCreate', onSubmit: (event) => {
|
|
77
|
+
event.preventDefault(); if (!name.trim() || loading) return; setLoading(true); setCreatingBusy(true); setError(''); setCreated('');
|
|
78
|
+
request('createFolder', { vaultPath, path: name.trim() }).then((result) => { if (isCurrent()) { choose(result.path); setCreated(vaultRelative(vaultPath, result.path)); setName(''); } })
|
|
79
|
+
.catch((cause) => { if (isCurrent()) fail(cause); }).finally(() => { if (isCurrent()) { setLoading(false); setCreatingBusy(false); } });
|
|
80
|
+
} }, h('input', { className: 'SN_textInput', value: name, autoFocus: true, 'aria-label': t('settings.folderName'),
|
|
81
|
+
placeholder: t('settings.folderName'), onChange: (event) => setName(event.target.value), disabled: loading }),
|
|
82
|
+
h('button', { type: 'submit', className: 'SN_ghostBtn SN_smallBtn', disabled: loading || !name.trim() }, t(creatingBusy ? 'settings.creatingFolder' : 'settings.create'))),
|
|
83
|
+
h('p', { className: 'SN_fieldHint' }, t('settings.newFolderHint')),
|
|
84
|
+
name.trim() ? h('code', { className: 'SN_configPath' }, t('settings.createAt') + ': ' + joinPath(vaultPath, name.trim())) : null) : null,
|
|
85
|
+
h('button', { type: 'button', className: 'SN_folderOption', disabled: loading, onClick: () => {
|
|
86
|
+
Promise.resolve().then(onPick).then((path) => { if (isCurrent()) { if (path) choose(path); else setOpen(false); } })
|
|
87
|
+
.catch((cause) => { if (isCurrent()) fail(cause); });
|
|
88
|
+
} }, t('settings.browseFolder')),
|
|
89
|
+
value ? h('button', { type: 'button', className: 'SN_folderOption', onClick: () => choose('') }, t('settings.clearFolder')) : null,
|
|
90
|
+
h('input', { className: 'SN_textInput', value: query, 'aria-label': t('settings.searchFolder'), placeholder: t('settings.searchFolder'), onChange: (event) => setQuery(event.target.value) }),
|
|
91
|
+
loading ? h('p', { className: 'SN_fieldHint', role: 'status' }, t('panel.loading')) : null,
|
|
92
|
+
error ? h('p', { className: 'SN_settingsError', role: 'alert' }, error) : null,
|
|
93
|
+
h('div', { className: 'SN_folderOptions' }, options.map((path) => h('button', { key: path, type: 'button', className: 'SN_folderOption',
|
|
94
|
+
onClick: () => choose(path), title: path }, h('span', null, path === '.' ? t('settings.vaultRoot') : path),
|
|
95
|
+
recent.includes(path) ? h('small', null, t('settings.recent')) : null))),
|
|
96
|
+
) : null,
|
|
97
|
+
created ? h('p', { className: 'SN_fieldHint SN_folderCreated', role: 'status' }, t('settings.folderCreated', { path: created })) : null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function SettingsPathField(props) {
|
|
101
|
+
const {
|
|
102
|
+
t, label, hint, value, field, disabled, picking,
|
|
103
|
+
onPatch, onPick, vaultPath, history, request,
|
|
104
|
+
} = props;
|
|
105
|
+
return h('div', { className: 'SN_field' },
|
|
106
|
+
h('span', { className: 'SN_fieldLabel' }, label),
|
|
107
|
+
field !== 'vaultPath' && vaultPath ? h(VaultFolderPicker, { t, label, value, vaultPath, history, disabled: disabled || picking !== null, request,
|
|
108
|
+
onChange: (path) => onPatch({ [field]: path }), onPick: () => onPick(field) }) : h(Fragment, null,
|
|
109
|
+
h('input', {
|
|
110
|
+
type: 'text',
|
|
111
|
+
className: 'SN_textInput',
|
|
112
|
+
value: value || '',
|
|
113
|
+
spellCheck: false,
|
|
114
|
+
disabled: disabled,
|
|
115
|
+
'aria-label': label,
|
|
116
|
+
onChange: (event) => onPatch({ [field]: event.target.value }),
|
|
117
|
+
}),
|
|
118
|
+
h('div', { className: 'SN_pathActions' },
|
|
119
|
+
h('button', {
|
|
120
|
+
type: 'button',
|
|
121
|
+
className: 'SN_ghostBtn SN_smallBtn',
|
|
122
|
+
disabled: disabled || picking !== null,
|
|
123
|
+
onClick: () => onPick(field),
|
|
124
|
+
}, picking === field ? t('settings.picking') : t('settings.pick')),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
hint ? h('p', { className: 'SN_fieldHint' }, hint) : null,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { vaultRelative, VaultFolderPicker, SettingsPathField };
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/** 设置页标签与字段组合;官方页面和面板共用。 */
|
|
2
|
+
import { CloseIcon } from '../components/icons.js';
|
|
3
|
+
import { Fragment, React, h } from '../react.js';
|
|
4
|
+
import { AiBudgetRow } from './ai-budget-row.js';
|
|
5
|
+
import { ConfigFileSettings } from './config-file.js';
|
|
6
|
+
import { ImageProcessingSettings } from './image-processing.js';
|
|
7
|
+
import { SettingsPathField, VaultFolderPicker } from './paths.js';
|
|
8
|
+
import { StyleSelect } from './style-select.js';
|
|
9
|
+
import { TrashSettings } from './trash.js';
|
|
10
|
+
import { baseName, resolvedPath } from '../utils.js';
|
|
11
|
+
|
|
12
|
+
function SettingsDrawer(props) {
|
|
13
|
+
const {
|
|
14
|
+
t, draft, picking, saving, onPatch, onPick, onRemoveArchive, onPatchEmoji, onRemoveEmoji, onAddEmoji,
|
|
15
|
+
onSave, onCancel, request, inline, onConfigSettings,
|
|
16
|
+
} = props;
|
|
17
|
+
const [tab, setTab] = React.useState(props.initialTab || 'folders');
|
|
18
|
+
const [activeEmoji, setActiveEmoji] = React.useState(null);
|
|
19
|
+
const previousCount = React.useRef(draft?.emojis?.length || 0);
|
|
20
|
+
const emojis = Array.isArray(draft?.emojis) ? draft.emojis : [];
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
if (emojis.length > previousCount.current) setActiveEmoji(emojis[emojis.length - 1]?.id);
|
|
23
|
+
previousCount.current = emojis.length;
|
|
24
|
+
}, [emojis.length]);
|
|
25
|
+
React.useEffect(() => { if (props.initialTab) setTab(props.initialTab); }, [props.initialTab]);
|
|
26
|
+
if (!draft) return null;
|
|
27
|
+
const archiveTargets = Array.isArray(draft.archiveTargets) ? draft.archiveTargets : [];
|
|
28
|
+
const emojiIndex = Math.max(0, emojis.findIndex((item) => item.id === activeEmoji));
|
|
29
|
+
const folderProps = { vaultPath: draft.vaultPath, history: draft.folderHistory, request };
|
|
30
|
+
const disabled = Boolean(saving);
|
|
31
|
+
|
|
32
|
+
return h(Fragment, null,
|
|
33
|
+
!inline ? h('div', { className: 'SN_scrim', role: 'presentation', onClick: saving ? undefined : onCancel }) : null,
|
|
34
|
+
h('aside', {
|
|
35
|
+
className: inline ? 'SN_inlineSettings' : 'SN_drawer',
|
|
36
|
+
role: inline ? undefined : 'dialog',
|
|
37
|
+
'aria-modal': inline ? undefined : 'true',
|
|
38
|
+
'aria-label': t('settings.title'),
|
|
39
|
+
},
|
|
40
|
+
h('div', { className: 'SN_drawerHead' },
|
|
41
|
+
h('h2', { className: 'SN_drawerTitle' }, t('settings.title')),
|
|
42
|
+
h('button', {
|
|
43
|
+
type: 'button',
|
|
44
|
+
className: 'SN_iconBtn',
|
|
45
|
+
onClick: onCancel,
|
|
46
|
+
disabled: disabled,
|
|
47
|
+
'aria-label': t('settings.close'),
|
|
48
|
+
title: t('settings.close'),
|
|
49
|
+
}, h(CloseIcon, null)),
|
|
50
|
+
),
|
|
51
|
+
h('div', { className: 'SN_settingsTabs', role: 'tablist', 'aria-label': t('settings.title') },
|
|
52
|
+
['folders', 'ai', 'images', 'config'].map((id) => h('button', { key: id, type: 'button', role: 'tab', className: 'SN_settingsTab',
|
|
53
|
+
'aria-selected': tab === id, onClick: () => setTab(id) }, t('settings.tab.' + id)))),
|
|
54
|
+
h('div', { className: 'SN_drawerBody' },
|
|
55
|
+
h('div', { className: 'SN_drawerSection', hidden: tab !== 'folders', 'data-settings-tab': 'folders' },
|
|
56
|
+
h(SettingsPathField, { ...folderProps,
|
|
57
|
+
t: t,
|
|
58
|
+
label: t('settings.vault'),
|
|
59
|
+
hint: t('settings.vaultHint'),
|
|
60
|
+
value: draft.vaultPath,
|
|
61
|
+
field: 'vaultPath',
|
|
62
|
+
disabled: disabled,
|
|
63
|
+
picking: picking,
|
|
64
|
+
onPatch: onPatch,
|
|
65
|
+
onPick: onPick,
|
|
66
|
+
}),
|
|
67
|
+
h(SettingsPathField, { ...folderProps,
|
|
68
|
+
t: t,
|
|
69
|
+
label: t('settings.notes'),
|
|
70
|
+
hint: t('settings.notesHint'),
|
|
71
|
+
value: draft.notesDir,
|
|
72
|
+
field: 'notesDir',
|
|
73
|
+
disabled: disabled,
|
|
74
|
+
picking: picking,
|
|
75
|
+
onPatch: onPatch,
|
|
76
|
+
onPick: onPick,
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
h('div', { className: 'SN_drawerSection', hidden: tab !== 'folders', 'data-settings-tab': 'folders' },
|
|
80
|
+
h(SettingsPathField, { ...folderProps,
|
|
81
|
+
t: t,
|
|
82
|
+
label: t('settings.project'),
|
|
83
|
+
hint: t('settings.projectHint'),
|
|
84
|
+
value: draft.projectDir,
|
|
85
|
+
field: 'projectDir',
|
|
86
|
+
disabled: disabled,
|
|
87
|
+
picking: picking,
|
|
88
|
+
onPatch: onPatch,
|
|
89
|
+
onPick: onPick,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
h('label', { className: 'SN_imageSettingToggle', hidden: tab !== 'folders' },
|
|
93
|
+
h('input', { type: 'checkbox', disabled, checked: draft.includeSubfolders !== false,
|
|
94
|
+
onChange: (event) => onPatch({ includeSubfolders: event.target.checked }) }), t('settings.includeSubfolders')),
|
|
95
|
+
h('div', { className: 'SN_drawerSection', hidden: tab !== 'folders', 'data-settings-tab': 'folders' },
|
|
96
|
+
h('div', { className: 'SN_drawerSectionHead' },
|
|
97
|
+
h('h3', { className: 'SN_drawerSectionTitle' }, t('settings.archive')),
|
|
98
|
+
),
|
|
99
|
+
h('p', { className: 'SN_drawerSectionHint' }, t('settings.archiveHint')),
|
|
100
|
+
archiveTargets.length === 0
|
|
101
|
+
? null
|
|
102
|
+
: h('div', { className: 'SN_targetList' },
|
|
103
|
+
archiveTargets.map((target, index) => h('div', { key: target.id || index, className: 'SN_targetRow' },
|
|
104
|
+
h(VaultFolderPicker, { t, ...folderProps, label: t('settings.archive'), value: target.path, disabled,
|
|
105
|
+
onChange: (path) => path ? onPatch({ archiveTargets: archiveTargets.map((item, position) => position === index ? { ...item, path, label: baseName(path) } : item) }) : onRemoveArchive(index),
|
|
106
|
+
onPick: () => request('pickDirectory', { vaultPath: draft.vaultPath }).then((result) => result.path) }),
|
|
107
|
+
h('button', {
|
|
108
|
+
type: 'button',
|
|
109
|
+
className: 'SN_removeBtn',
|
|
110
|
+
disabled: disabled,
|
|
111
|
+
'aria-label': t('settings.remove'),
|
|
112
|
+
title: t('settings.remove'),
|
|
113
|
+
onClick: () => onRemoveArchive(index),
|
|
114
|
+
}, '×'),
|
|
115
|
+
)),
|
|
116
|
+
),
|
|
117
|
+
h(VaultFolderPicker, { t, ...folderProps, label: t('settings.addArchive'), placeholder: t('settings.addArchive'), value: '', disabled,
|
|
118
|
+
onChange: (path) => { if (path && !archiveTargets.some((item) => resolvedPath(draft.vaultPath, item.path) === resolvedPath(draft.vaultPath, path))) onPatch({ archiveTargets: archiveTargets.concat({ id: 'archive-' + Date.now().toString(36), label: baseName(path), path }) }); },
|
|
119
|
+
onPick: () => request('pickDirectory', { vaultPath: draft.vaultPath }).then((result) => result.path) }),
|
|
120
|
+
),
|
|
121
|
+
h('div', { hidden: tab !== 'images', 'data-settings-tab': 'images' }, h(ImageProcessingSettings, { t, value: draft.imageCompression, disabled, onChange: (imageCompression) => onPatch({ imageCompression }) },
|
|
122
|
+
h(SettingsPathField, { ...folderProps,
|
|
123
|
+
t, label: t('settings.attachmentDir'), hint: t('settings.attachmentDirHint'),
|
|
124
|
+
value: draft.attachmentDir, field: 'attachmentDir', disabled, picking, onPatch, onPick,
|
|
125
|
+
}))),
|
|
126
|
+
h('div', { className: 'SN_drawerSection', hidden: tab !== 'ai', 'data-settings-tab': 'ai' },
|
|
127
|
+
h('div', { className: 'SN_drawerSectionHead' },
|
|
128
|
+
h('h3', { className: 'SN_drawerSectionTitle' }, t('settings.emoji')),
|
|
129
|
+
),
|
|
130
|
+
h('p', { className: 'SN_drawerSectionHint' }, t('settings.emojiHint')),
|
|
131
|
+
h('div', { className: 'SN_emojiTabs', role: 'tablist', 'aria-label': t('settings.emoji') },
|
|
132
|
+
h('div', { className: 'SN_emojiTabScroll' }, emojis.map((item, index) => h('button', { key: item.id, type: 'button', role: 'tab',
|
|
133
|
+
'aria-selected': emojiIndex === index, 'aria-label': item.label || item.emoji, title: item.label,
|
|
134
|
+
className: 'SN_emojiTab', disabled, onClick: () => setActiveEmoji(item.id) }, item.emoji))),
|
|
135
|
+
h('button', { type: 'button', className: 'SN_emojiTab SN_emojiAdd', disabled: disabled || emojis.length >= 12,
|
|
136
|
+
onClick: onAddEmoji, 'aria-label': t('settings.addEmoji'), title: t('settings.addEmoji') }, '+')),
|
|
137
|
+
emojis.map((emoji, index) => h('div', { key: emoji.id || index, className: 'SN_emojiGroup', hidden: index !== emojiIndex },
|
|
138
|
+
h('div', { className: 'SN_emojiTop' },
|
|
139
|
+
h('input', {
|
|
140
|
+
type: 'text',
|
|
141
|
+
className: 'SN_emojiInput',
|
|
142
|
+
value: emoji.emoji || '',
|
|
143
|
+
disabled: disabled,
|
|
144
|
+
'aria-label': t('settings.emoji'),
|
|
145
|
+
placeholder: t('settings.emojiPlaceholder'),
|
|
146
|
+
onChange: (event) => onPatchEmoji(index, { emoji: event.target.value }),
|
|
147
|
+
}),
|
|
148
|
+
h('input', {
|
|
149
|
+
type: 'text',
|
|
150
|
+
className: 'SN_labelInput',
|
|
151
|
+
value: emoji.label || '',
|
|
152
|
+
disabled: disabled,
|
|
153
|
+
'aria-label': t('settings.labelPlaceholder'),
|
|
154
|
+
placeholder: t('settings.labelPlaceholder'),
|
|
155
|
+
onChange: (event) => onPatchEmoji(index, { label: event.target.value }),
|
|
156
|
+
}),
|
|
157
|
+
// 0.13.8:callout 样式按钮与 emoji / 名称同排(和 emoji 输入同高),点开菜单打勾。
|
|
158
|
+
h(StyleSelect, {
|
|
159
|
+
t: t,
|
|
160
|
+
value: emoji.callout || 'note',
|
|
161
|
+
disabled: disabled,
|
|
162
|
+
onChange: (callout) => onPatchEmoji(index, { callout: callout }),
|
|
163
|
+
}),
|
|
164
|
+
h('button', {
|
|
165
|
+
type: 'button',
|
|
166
|
+
className: 'SN_removeBtn',
|
|
167
|
+
disabled: disabled || emojis.length <= 1,
|
|
168
|
+
'aria-label': t('settings.remove'),
|
|
169
|
+
title: t('settings.remove'),
|
|
170
|
+
onClick: () => onRemoveEmoji(index),
|
|
171
|
+
}, '×'),
|
|
172
|
+
),
|
|
173
|
+
h('div', { className: 'SN_emojiMain' },
|
|
174
|
+
h('textarea', {
|
|
175
|
+
className: 'SN_textArea',
|
|
176
|
+
rows: 7,
|
|
177
|
+
value: emoji.prompt || '',
|
|
178
|
+
disabled: disabled,
|
|
179
|
+
'aria-label': t('settings.promptPlaceholder'),
|
|
180
|
+
placeholder: t('settings.promptPlaceholder'),
|
|
181
|
+
onChange: (event) => onPatchEmoji(index, { prompt: event.target.value }),
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
184
|
+
// 0.13:这枚 emoji 的 AI 预算(低/中/高/自定义)。
|
|
185
|
+
h(AiBudgetRow, {
|
|
186
|
+
t: t,
|
|
187
|
+
ai: emoji.ai,
|
|
188
|
+
disabled: disabled,
|
|
189
|
+
onChange: (ai) => onPatchEmoji(index, { ai: ai }),
|
|
190
|
+
}),
|
|
191
|
+
)),
|
|
192
|
+
|
|
193
|
+
),
|
|
194
|
+
h('div', { className: 'SN_drawerSection', hidden: tab !== 'ai', 'data-settings-tab': 'ai' },
|
|
195
|
+
h('div', { className: 'SN_drawerSectionHead' },
|
|
196
|
+
h('h3', { className: 'SN_drawerSectionTitle' }, t('settings.projectPrompt')),
|
|
197
|
+
),
|
|
198
|
+
h('details', { className: 'SN_projectSettings' }, h('summary', null, t('settings.projectPromptHint')),
|
|
199
|
+
h('p', { className: 'SN_drawerSectionHint' }, t('settings.projectPromptHint')),
|
|
200
|
+
h('textarea', {
|
|
201
|
+
className: 'SN_textArea',
|
|
202
|
+
rows: 6,
|
|
203
|
+
value: draft.projectPrompt || '',
|
|
204
|
+
disabled: disabled,
|
|
205
|
+
'aria-label': t('settings.projectPrompt'),
|
|
206
|
+
onChange: (event) => onPatch({ projectPrompt: event.target.value }),
|
|
207
|
+
}),
|
|
208
|
+
h('p', { className: 'SN_fieldHint' }, t('settings.aiProjectHint')),
|
|
209
|
+
// 0.13:开档(项目文档)自己的 AI 预算。
|
|
210
|
+
h(AiBudgetRow, {
|
|
211
|
+
t: t,
|
|
212
|
+
ai: draft.projectAi,
|
|
213
|
+
disabled: disabled,
|
|
214
|
+
onChange: (ai) => onPatch({ projectAi: ai }),
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
|
|
219
|
+
h('div', { className: 'SN_settingsPage', hidden: tab !== 'config', 'data-settings-tab': 'config' },
|
|
220
|
+
h(ConfigFileSettings, { t, config: draft.configFile, disabled, request, onSettings: onConfigSettings }),
|
|
221
|
+
h(TrashSettings, { t, request, disabled: disabled || !draft.vaultPath }),
|
|
222
|
+
h('section', { className: 'SN_drawerSection SN_about', 'aria-label': t('settings.about') },
|
|
223
|
+
h('a', { href: 'https://github.com/cavenzhong/dsh-obsidian-memo', target: '_blank', rel: 'noopener noreferrer' }, 'GitHub'),
|
|
224
|
+
h('p', null, t('settings.author')))),
|
|
225
|
+
),
|
|
226
|
+
h('div', { className: 'SN_drawerFoot' },
|
|
227
|
+
h('button', {
|
|
228
|
+
type: 'button',
|
|
229
|
+
className: 'SN_ghostBtn',
|
|
230
|
+
disabled: disabled,
|
|
231
|
+
onClick: onCancel,
|
|
232
|
+
}, t('settings.cancel')),
|
|
233
|
+
h('button', {
|
|
234
|
+
type: 'button',
|
|
235
|
+
className: 'SN_primaryBtn',
|
|
236
|
+
disabled: disabled,
|
|
237
|
+
onClick: onSave,
|
|
238
|
+
}, saving ? t('settings.saving') : t('settings.save')),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { SettingsDrawer };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** callout 样式选择菜单。 */
|
|
2
|
+
import { React, h } from '../react.js';
|
|
3
|
+
import { CALLOUT_IDS } from './callouts.js';
|
|
4
|
+
|
|
5
|
+
function StyleSelect(props) {
|
|
6
|
+
const { t, value, disabled, onChange } = props;
|
|
7
|
+
const [open, setOpen] = React.useState(false);
|
|
8
|
+
const wrapRef = React.useRef(null);
|
|
9
|
+
React.useEffect(() => {
|
|
10
|
+
if (!open) return undefined;
|
|
11
|
+
const onPointerDown = (event) => {
|
|
12
|
+
const target = event.target;
|
|
13
|
+
if (wrapRef.current && target && wrapRef.current.contains(target)) return;
|
|
14
|
+
setOpen(false);
|
|
15
|
+
};
|
|
16
|
+
document.addEventListener('mousedown', onPointerDown, true);
|
|
17
|
+
return () => document.removeEventListener('mousedown', onPointerDown, true);
|
|
18
|
+
}, [open]);
|
|
19
|
+
const current = CALLOUT_IDS.indexOf(value) >= 0 ? value : 'note';
|
|
20
|
+
const labelFor = (id) => t('settings.calloutValue', { style: t('callout.' + id) });
|
|
21
|
+
return h('div', { className: 'SN_styleWrap', ref: wrapRef },
|
|
22
|
+
h('button', {
|
|
23
|
+
type: 'button',
|
|
24
|
+
className: 'SN_styleBtn',
|
|
25
|
+
disabled: disabled,
|
|
26
|
+
'aria-haspopup': 'menu',
|
|
27
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
28
|
+
'aria-label': t('settings.callout'),
|
|
29
|
+
title: labelFor(current),
|
|
30
|
+
onClick: () => setOpen((flag) => !flag),
|
|
31
|
+
}, h('span', { className: 'SN_styleText' }, labelFor(current))),
|
|
32
|
+
open
|
|
33
|
+
? h('div', {
|
|
34
|
+
className: 'SN_styleMenu',
|
|
35
|
+
role: 'menu',
|
|
36
|
+
'aria-label': t('settings.callout'),
|
|
37
|
+
},
|
|
38
|
+
CALLOUT_IDS.map((id) => h('button', {
|
|
39
|
+
key: id,
|
|
40
|
+
type: 'button',
|
|
41
|
+
className: 'SN_styleItem',
|
|
42
|
+
role: 'menuitemradio',
|
|
43
|
+
'aria-checked': id === current ? 'true' : 'false',
|
|
44
|
+
'data-active': id === current ? 'true' : 'false',
|
|
45
|
+
onClick: () => {
|
|
46
|
+
onChange(id);
|
|
47
|
+
setOpen(false);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
h('span', { className: 'SN_styleCheck', 'aria-hidden': 'true' }, id === current ? '✓' : ''),
|
|
51
|
+
h('span', null, labelFor(id)),
|
|
52
|
+
)),
|
|
53
|
+
)
|
|
54
|
+
: null,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { StyleSelect };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** 库内回收站管理视图,确认后请求转移到系统回收站。 */
|
|
2
|
+
import { React, h } from '../react.js';
|
|
3
|
+
|
|
4
|
+
function TrashSettings({ t, request, disabled }) {
|
|
5
|
+
const [confirm, setConfirm] = React.useState(false);
|
|
6
|
+
const [busy, setBusy] = React.useState(false);
|
|
7
|
+
const [message, setMessage] = React.useState('');
|
|
8
|
+
const [error, setError] = React.useState('');
|
|
9
|
+
const run = (action) => {
|
|
10
|
+
if (busy || disabled) return; setBusy(true); setError(''); setMessage('');
|
|
11
|
+
request('trash', { action, confirm: action === 'empty' }).then((result) => {
|
|
12
|
+
setConfirm(false); if (action === 'empty') setMessage(t('settings.trashMoved', { count: result.moved }));
|
|
13
|
+
}).catch((cause) => setError(cause.message || String(cause))).finally(() => setBusy(false));
|
|
14
|
+
};
|
|
15
|
+
return h('section', { className: 'SN_drawerSection' },
|
|
16
|
+
h('h3', { className: 'SN_drawerSectionTitle' }, t('settings.trash')),
|
|
17
|
+
h('p', { className: 'SN_drawerSectionHint' }, t('settings.trashHint')),
|
|
18
|
+
h('div', { className: 'SN_pathActions' },
|
|
19
|
+
h('button', { type: 'button', className: 'SN_ghostBtn', disabled: disabled || busy, onClick: () => run('reveal') }, t('settings.revealTrash')),
|
|
20
|
+
h('button', { type: 'button', className: 'SN_ghostBtn SN_dangerBtn', disabled: disabled || busy, onClick: () => setConfirm(true) }, t('settings.emptyTrash'))),
|
|
21
|
+
confirm ? h('div', { className: 'SN_trashConfirm', role: 'alert' },
|
|
22
|
+
h('p', { className: 'SN_fieldHint' }, t('settings.trashConfirm')),
|
|
23
|
+
h('div', { className: 'SN_pathActions' },
|
|
24
|
+
h('button', { type: 'button', className: 'SN_ghostBtn', disabled: busy, onClick: () => setConfirm(false) }, t('settings.cancel')),
|
|
25
|
+
h('button', { type: 'button', className: 'SN_ghostBtn SN_dangerBtn', disabled: busy, onClick: () => run('empty') }, busy ? t('settings.saving') : t('settings.emptyTrash')))) : null,
|
|
26
|
+
error ? h('p', { className: 'SN_settingsError', role: 'alert' }, error) : null,
|
|
27
|
+
message ? h('p', { className: 'SN_fieldHint', role: 'status' }, message) : null);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { TrashSettings };
|