dsh-obsidian-memo 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +270 -0
  3. package/README.md +272 -0
  4. package/cordis.patch.yml +10 -0
  5. package/docs/ARCHITECTURE.md +88 -0
  6. package/docs/AUDIT-0.16.md +74 -0
  7. package/docs/CHANGELOG.md +120 -0
  8. package/docs/CONTRACT.md +544 -0
  9. package/docs/GUIDE.md +177 -0
  10. package/docs/KNOWN_ISSUES.md +113 -0
  11. package/icon.svg +7 -0
  12. package/lib/ai.js +161 -0
  13. package/lib/capture.js +175 -0
  14. package/lib/client.js +6994 -0
  15. package/lib/config-picker.js +29 -0
  16. package/lib/filename.js +33 -0
  17. package/lib/files.js +88 -0
  18. package/lib/host/ai-jobs.js +118 -0
  19. package/lib/host/configuration.js +151 -0
  20. package/lib/host/dispatch.js +32 -0
  21. package/lib/host/environment.js +24 -0
  22. package/lib/host/folders.js +77 -0
  23. package/lib/host/images.js +116 -0
  24. package/lib/host/mutations.js +123 -0
  25. package/lib/host/note-content.js +44 -0
  26. package/lib/host/notes.js +148 -0
  27. package/lib/host/rpc.js +82 -0
  28. package/lib/host/scanning.js +67 -0
  29. package/lib/host/session.js +67 -0
  30. package/lib/host/state.js +68 -0
  31. package/lib/host/vault.js +38 -0
  32. package/lib/host/writes.js +21 -0
  33. package/lib/index.js +88 -0
  34. package/lib/notes.js +454 -0
  35. package/lib/obsidian.js +93 -0
  36. package/lib/scan.js +269 -0
  37. package/lib/settings.js +228 -0
  38. package/lib/shared.js +309 -0
  39. package/lib/trash.js +205 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +76 -0
  43. package/scripts/benchmark.mjs +58 -0
  44. package/scripts/build-client.mjs +39 -0
  45. package/src/client/ai/use-comments.js +75 -0
  46. package/src/client/ai/use-job-notifications.js +53 -0
  47. package/src/client/ai/use-project-dialog.js +58 -0
  48. package/src/client/capture/composer.js +266 -0
  49. package/src/client/capture/images.js +64 -0
  50. package/src/client/capture/keyboard.js +34 -0
  51. package/src/client/components/dialogs.js +67 -0
  52. package/src/client/components/feedback.js +50 -0
  53. package/src/client/components/icons.js +160 -0
  54. package/src/client/components/use-toasts.js +58 -0
  55. package/src/client/constants.js +127 -0
  56. package/src/client/data/state.js +116 -0
  57. package/src/client/data/use-notes-data.js +137 -0
  58. package/src/client/data/visibility.js +14 -0
  59. package/src/client/drag/drop-actions.js +47 -0
  60. package/src/client/drag/motion.js +84 -0
  61. package/src/client/drag/overlay.js +33 -0
  62. package/src/client/drag/use-card-drag.js +149 -0
  63. package/src/client/i18n/en.js +219 -0
  64. package/src/client/i18n/index.js +11 -0
  65. package/src/client/i18n/zh.js +219 -0
  66. package/src/client/images/cache.js +55 -0
  67. package/src/client/images/components.js +121 -0
  68. package/src/client/images/queue.js +106 -0
  69. package/src/client/index.js +40 -0
  70. package/src/client/panel/header.js +146 -0
  71. package/src/client/panel/use-shortcuts.js +79 -0
  72. package/src/client/panel.js +424 -0
  73. package/src/client/preferences.js +198 -0
  74. package/src/client/react.js +8 -0
  75. package/src/client/reader/blocks.js +51 -0
  76. package/src/client/reader/note-drawer.js +270 -0
  77. package/src/client/reader/use-dock-offset.js +23 -0
  78. package/src/client/reader/use-drawer-layout.js +96 -0
  79. package/src/client/reader/use-note-editor.js +230 -0
  80. package/src/client/reader/use-pinned-note.js +41 -0
  81. package/src/client/runtime.js +30 -0
  82. package/src/client/settings/ai-budget-row.js +65 -0
  83. package/src/client/settings/ai-budget.js +42 -0
  84. package/src/client/settings/callouts.js +21 -0
  85. package/src/client/settings/config-file.js +74 -0
  86. package/src/client/settings/image-processing.js +31 -0
  87. package/src/client/settings/official-page.js +32 -0
  88. package/src/client/settings/paths.js +131 -0
  89. package/src/client/settings/settings-drawer.js +244 -0
  90. package/src/client/settings/style-select.js +58 -0
  91. package/src/client/settings/trash.js +30 -0
  92. package/src/client/settings/use-settings-draft.js +207 -0
  93. package/src/client/styles/base.js +63 -0
  94. package/src/client/styles/capture.js +94 -0
  95. package/src/client/styles/index.js +26 -0
  96. package/src/client/styles/overrides.js +116 -0
  97. package/src/client/styles/reader.js +66 -0
  98. package/src/client/styles/settings.js +118 -0
  99. package/src/client/styles/tokens.js +29 -0
  100. package/src/client/styles/wall.js +83 -0
  101. package/src/client/testing.js +139 -0
  102. package/src/client/utils.js +66 -0
  103. package/src/client/wall/model.js +158 -0
  104. package/src/client/wall/note-card.js +201 -0
  105. package/src/client/wall/use-note-actions.js +107 -0
  106. package/src/client/wall/use-subfolders.js +42 -0
  107. package/src/client/wall/use-wall-content.js +135 -0
  108. package/src/client/wall/use-wall-filters.js +92 -0
  109. package/src/client/wall/use-wall-paging.js +55 -0
@@ -0,0 +1,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 };