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,39 @@
1
+ import { build, context } from 'esbuild'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const root = fileURLToPath(new URL('..', import.meta.url))
6
+ const target = new URL('../lib/client.js', import.meta.url)
7
+ const options = {
8
+ absWorkingDir: root,
9
+ entryPoints: ['src/client/index.js'],
10
+ bundle: true,
11
+ platform: 'browser',
12
+ format: 'cjs',
13
+ target: 'es2022',
14
+ external: ['react', 'react-dom'],
15
+ charset: 'utf8',
16
+ legalComments: 'none',
17
+ banner: { js: `// Generated by pnpm build. Edit src/client/, then rebuild.\nwindow.__ModuleLoader__.load({\n id: 'dsh-obsidian-memo',\n factory: (require) => {\n const module = { exports: {} };\n const exports = module.exports;` },
18
+ footer: { js: ' return module.exports;\n },\n});' },
19
+ outfile: fileURLToPath(target),
20
+ }
21
+
22
+ if (process.argv.includes('--watch')) {
23
+ const watcher = await context(options)
24
+ await watcher.watch()
25
+ console.info('Watching src/client/ → lib/client.js')
26
+ } else {
27
+ const result = await build({ ...options, write: false })
28
+ const output = result.outputFiles[0].text
29
+ if (process.argv.includes('--check')) {
30
+ const current = await readFile(target, 'utf8').catch(() => '')
31
+ if (current !== output) {
32
+ console.error('lib/client.js is out of date. Run pnpm build and include the generated file.')
33
+ process.exitCode = 1
34
+ }
35
+ } else {
36
+ await writeFile(target, output)
37
+ console.info('Built lib/client.js from src/client/')
38
+ }
39
+ }
@@ -0,0 +1,75 @@
1
+ /** 评论的本地 pending、重复确认与异步提交。 */
2
+ import { React } from '../react.js';
3
+ import { isAbortError } from '../utils.js';
4
+ import { emojiLabel } from '../wall/model.js';
5
+
6
+ function useComments({ aliveRef, emojis, pushErrorToast, pushToast, refreshNow, request, setDrawerArchive, setHoverPath, tRef, viewRef, watchedJobsRef }) {
7
+ const [commentConfirm, setCommentConfirm] = React.useState(null);
8
+ const [localPending, setLocalPending] = React.useState({});
9
+ const commentingRef = React.useRef(new Set());
10
+
11
+ function requestComment(note, emoji) {
12
+ // 第 3 轮:允许来自抽屉操作条的调用(卡片操作区在抽屉打开时仍然隐藏)。
13
+ if (!note || !emoji) return;
14
+ if (note.pending || localPending[note.path] || commentingRef.current.has(note.path)) return;
15
+ const kinds = Array.isArray(note.commentKinds) ? note.commentKinds : null;
16
+ const emojiChar = typeof emoji.emoji === 'string' ? emoji.emoji : '';
17
+ if (kinds && emojiChar !== '' && kinds.indexOf(emojiChar) >= 0) {
18
+ setCommentConfirm({ path: note.path, emojiId: emoji.id });
19
+ return;
20
+ }
21
+ runComment(note, emoji);
22
+ }
23
+
24
+ function closeCommentConfirm() {
25
+ setCommentConfirm(null);
26
+ }
27
+
28
+ function confirmComment() {
29
+ const target = commentConfirm;
30
+ if (!target) return;
31
+ setCommentConfirm(null);
32
+ const note = viewRef.current.notes.find((item) => item.path === target.path);
33
+ const emoji = emojis.find((item) => item && item.id === target.emojiId) || null;
34
+ if (note && emoji) runComment(note, emoji);
35
+ }
36
+
37
+ function runComment(note, emoji) {
38
+ // 第 3 轮:允许来自抽屉操作条的调用。
39
+ if (!note || !emoji) return;
40
+ if (note.pending || localPending[note.path] || commentingRef.current.has(note.path)) return;
41
+ commentingRef.current.add(note.path);
42
+ // 进入 pending 后操作区被替换,菜单/悬停状态一并清掉。
43
+ setHoverPath(null);
44
+ setDrawerArchive(false);
45
+ setLocalPending((current) => Object.assign({}, current, {
46
+ [note.path]: { emoji: emoji.emoji || '✍️', label: emoji.label || '' },
47
+ }));
48
+ request('comment', { path: note.path, emojiId: emoji.id })
49
+ .then((value) => {
50
+ if (!aliveRef.current) return;
51
+ if (value && typeof value.jobId === 'string') {
52
+ watchedJobsRef.current.set(value.jobId, { kind: 'comment', at: Date.now() });
53
+ }
54
+ pushToast({ text: tRef.current('toast.commentStarted', { label: emojiLabel(emoji) || emoji.label || '' }) });
55
+ return refreshNow(false);
56
+ })
57
+ .catch((error) => {
58
+ if (aliveRef.current && !isAbortError(error)) pushErrorToast(error);
59
+ })
60
+ .then(() => {
61
+ commentingRef.current.delete(note.path);
62
+ if (!aliveRef.current) return;
63
+ setLocalPending((current) => {
64
+ if (!(note.path in current)) return current;
65
+ const next = Object.assign({}, current);
66
+ delete next[note.path];
67
+ return next;
68
+ });
69
+ });
70
+ }
71
+
72
+ return { commentConfirm, localPending, requestComment, closeCommentConfirm, confirmComment };
73
+ }
74
+
75
+ export { useComments };
@@ -0,0 +1,53 @@
1
+ /** 追踪已订阅任务,显示完成/失败/截断与预算设置提示。 */
2
+ import { React } from '../react.js';
3
+
4
+ function useJobNotifications({ onOpenSettings, pushErrorToast, pushToast, tRef }) {
5
+ const watchedJobsRef = React.useRef(new Map());
6
+
7
+ function pushBudgetToast(text, kind) {
8
+ pushToast({
9
+ text: text,
10
+ kind: kind,
11
+ timeout: 12000,
12
+ actions: [
13
+ { label: tRef.current('toast.budgetAction'), run: () => onOpenSettings() },
14
+ { label: tRef.current('toast.budgetAck'), run: () => {} },
15
+ ],
16
+ });
17
+ }
18
+
19
+ function processJobs(jobs) {
20
+ const list = Array.isArray(jobs) ? jobs : [];
21
+ const watched = watchedJobsRef.current;
22
+ if (watched.size === 0) return;
23
+ for (const job of list) {
24
+ if (!job || typeof job.id !== 'string') continue;
25
+ const info = watched.get(job.id);
26
+ if (!info) continue;
27
+ if (job.status === 'done') {
28
+ watched.delete(job.id);
29
+ if (job.truncated === true) {
30
+ // 有正文但撞上 token 上限:内容已写入,提示可能被截断并引导调预算。
31
+ pushBudgetToast(tRef.current(info.kind === 'project' ? 'toast.budgetProjectTruncated' : 'toast.budgetCommentTruncated'), 'info');
32
+ } else if (info.kind === 'project') {
33
+ pushToast({ text: tRef.current('toast.projectDone'), kind: 'success', timeout: 7000 });
34
+ } else {
35
+ pushToast({ text: tRef.current('toast.commentDone'), kind: 'success' });
36
+ }
37
+ } else if (job.status === 'error') {
38
+ watched.delete(job.id);
39
+ // 0.13:预算类失败给出可操作提示,而不是含糊的「模型没有产出内容」。
40
+ if (job.errorCode === 'llm-token-budget') pushBudgetToast(tRef.current('toast.budgetFailed'), 'error');
41
+ else pushErrorToast(new Error(job.error || tRef.current('toast.jobFailed')));
42
+ }
43
+ }
44
+ const now = Date.now();
45
+ for (const [id, info] of watched) {
46
+ if (now - info.at > 5 * 60000) watched.delete(id);
47
+ }
48
+ }
49
+
50
+ return { watchedJobsRef, processJobs };
51
+ }
52
+
53
+ export { useJobNotifications };
@@ -0,0 +1,58 @@
1
+ /** 开档名称草稿、提交状态与任务订阅。 */
2
+ import { React } from '../react.js';
3
+ import { isAbortError } from '../utils.js';
4
+
5
+ function useProjectDialog({ aliveRef, pushErrorToast, pushToast, refreshNow, request, setDrawerArchive, setSortMenu, t, tRef, watchedJobsRef }) {
6
+ const [projectTarget, setProjectTarget] = React.useState(null);
7
+ const [projectName, setProjectName] = React.useState('');
8
+ const [projectBusy, setProjectBusy] = React.useState(false);
9
+
10
+ function openProjectDialog(note) {
11
+ if (!note) return;
12
+ setDrawerArchive(false);
13
+ setSortMenu(false);
14
+ setProjectName(note.name || note.title || '');
15
+ setProjectTarget(note);
16
+ }
17
+
18
+ function closeProjectDialog() {
19
+ if (projectBusy) return;
20
+ setProjectTarget(null);
21
+ setProjectName('');
22
+ }
23
+
24
+ function confirmProject() {
25
+ const note = projectTarget;
26
+ const name = projectName.trim();
27
+ if (!note || projectBusy) return;
28
+ if (name === '') {
29
+ pushToast({ text: t('project.needName'), kind: 'error' });
30
+ return;
31
+ }
32
+ setProjectBusy(true);
33
+ request('openProject', { path: note.path, name: name })
34
+ .then((value) => {
35
+ if (!aliveRef.current) return;
36
+ if (value && typeof value.jobId === 'string') {
37
+ watchedJobsRef.current.set(value.jobId, { kind: 'project', at: Date.now() });
38
+ }
39
+ setProjectTarget(null);
40
+ setProjectName('');
41
+ pushToast({ text: tRef.current('toast.projectStarted', { name: name }) });
42
+ if (value && typeof value.message === 'string' && value.message !== '') {
43
+ pushToast({ text: value.message, kind: 'info', timeout: 7000 });
44
+ }
45
+ return refreshNow(false);
46
+ })
47
+ .catch((error) => {
48
+ if (aliveRef.current && !isAbortError(error)) pushErrorToast(error);
49
+ })
50
+ .then(() => {
51
+ if (aliveRef.current) setProjectBusy(false);
52
+ });
53
+ }
54
+
55
+ return { projectTarget, projectName, setProjectName, projectBusy, openProjectDialog, closeProjectDialog, confirmProject };
56
+ }
57
+
58
+ export { useProjectDialog };
@@ -0,0 +1,266 @@
1
+ /** 快速记录输入、草稿与附件预览;对象 URL 和提交锁由此持有。 */
2
+ import { captureFiles, compressCaptureImage, readCaptureImage } from './images.js';
3
+ import { captureFileDrag, captureRefocusField, focusCaptureOnTab } from './keyboard.js';
4
+ import { ActionIcon, CloseIcon, RefreshIcon } from '../components/icons.js';
5
+ import { CAPTURE_LIMIT_TEXT } from '../constants.js';
6
+ import { DropActions } from '../drag/drop-actions.js';
7
+ import { React, h } from '../react.js';
8
+ import { makeError } from '../utils.js';
9
+
10
+ function CaptureComposer(props) {
11
+ const {
12
+ t, onCreate, onCreated, onError, enabled, imageCompression, dragNote, archiveParked, dropAction,
13
+ dropTargetId, archiveTargets, onDragHover, onArchiveDrop, onArchiveChoose, onProjectDrop, onDeleteDrop,
14
+ showBackTop, onBackTop,
15
+ } = props;
16
+ const [title, setTitle] = React.useState('');
17
+ const [body, setBody] = React.useState('');
18
+ const [bodyOpen, setBodyOpen] = React.useState(false);
19
+ const [images, setImages] = React.useState([]);
20
+ const [expanded, setExpanded] = React.useState(false);
21
+ const [preview, setPreview] = React.useState(null);
22
+ const [busy, setBusy] = React.useState(false);
23
+ const [dragging, setDragging] = React.useState(false);
24
+ const titleRef = React.useRef(null), bodyRef = React.useRef(null), fileRef = React.useRef(null), dockRef = React.useRef(null), formRef = React.useRef(null), dragDepthRef = React.useRef(0);
25
+ const titleMeasureRef = React.useRef(null);
26
+ const imagesRef = React.useRef([]), busyRef = React.useRef(false), aliveRef = React.useRef(true), previewTimer = React.useRef(null), refocusRef = React.useRef(null);
27
+ React.useEffect(() => {
28
+ aliveRef.current = true;
29
+ return () => {
30
+ aliveRef.current = false;
31
+ clearTimeout(previewTimer.current);
32
+ imagesRef.current.forEach((item) => URL.revokeObjectURL(item.url));
33
+ };
34
+ }, []);
35
+ React.useEffect(() => {
36
+ if (enabled || busy) return;
37
+ clearTimeout(previewTimer.current);
38
+ imagesRef.current.forEach((item) => URL.revokeObjectURL(item.url));
39
+ imagesRef.current = [];
40
+ setImages([]); setPreview(null); setExpanded(false);
41
+ }, [enabled, busy]);
42
+ React.useEffect(() => { if (bodyOpen && bodyRef.current) bodyRef.current.focus(); }, [bodyOpen]);
43
+ React.useEffect(() => {
44
+ if (!enabled || dragNote || typeof document === 'undefined') return undefined;
45
+ const onKeyDown = (event) => focusCaptureOnTab(event, {
46
+ panel: dockRef.current?.closest('.SN_panel'), form: formRef.current, title: titleRef.current, busy: busyRef.current,
47
+ });
48
+ // 捕获阶段先处理 Enter,避免卡片/按钮的处理器或默认点击抢先打开详情。
49
+ document.addEventListener('keydown', onKeyDown, true);
50
+ return () => document.removeEventListener('keydown', onKeyDown, true);
51
+ }, [enabled, dragNote]);
52
+ React.useLayoutEffect(() => {
53
+ const field = titleRef.current;
54
+ if (!field || dragNote) return undefined;
55
+ const measure = () => {
56
+ const mirror = titleMeasureRef.current;
57
+ if (mirror && !bodyOpen) field.style.width = Math.ceil(mirror.getBoundingClientRect().width + 4) + 'px';
58
+ else field.style.width = '';
59
+ field.style.height = 'auto'; field.style.height = field.scrollHeight + 'px';
60
+ };
61
+ measure();
62
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
63
+ if (observer && formRef.current) observer.observe(formRef.current);
64
+ return () => observer?.disconnect();
65
+ }, [title, enabled, dragNote, bodyOpen, t]);
66
+ React.useEffect(() => {
67
+ if (busy) return;
68
+ const field = refocusRef.current;
69
+ if (field === null) return;
70
+ refocusRef.current = null;
71
+ const target = field === 'body' && bodyRef.current ? bodyRef.current : titleRef.current;
72
+ if (target) target.focus();
73
+ }, [busy]);
74
+ React.useLayoutEffect(() => {
75
+ const dock = dockRef.current, wall = dock && dock.parentElement;
76
+ if (!dock || !wall) return undefined;
77
+ const measure = () => wall.style.setProperty('--sn-capture-height', dock.offsetHeight + 'px');
78
+ measure();
79
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
80
+ if (observer) observer.observe(dock);
81
+ return () => { if (observer) observer.disconnect(); wall.style.removeProperty('--sn-capture-height'); };
82
+ }, [enabled]);
83
+ function hidePreview() { clearTimeout(previewTimer.current); setPreview(null); }
84
+ function previewImage(image) {
85
+ hidePreview();
86
+ previewTimer.current = setTimeout(() => { if (aliveRef.current) setPreview(image); }, 600);
87
+ }
88
+ function attach(files) {
89
+ if (busyRef.current) return;
90
+ const error = captureFiles(imagesRef.current.map((item) => item.file), files, imageCompression);
91
+ if (error) { onError(makeError('bad-request', t(error, CAPTURE_LIMIT_TEXT))); return; }
92
+ const added = Array.from(files || []).map((file) => ({ file, url: URL.createObjectURL(file) }));
93
+ imagesRef.current = imagesRef.current.concat(added);
94
+ setImages(imagesRef.current);
95
+ }
96
+ function remove(image) {
97
+ if (busyRef.current) return;
98
+ hidePreview();
99
+ URL.revokeObjectURL(image.url);
100
+ imagesRef.current = imagesRef.current.filter((item) => item !== image);
101
+ setImages(imagesRef.current);
102
+ }
103
+ async function submit(event) {
104
+ if (event) event.preventDefault();
105
+ if (busyRef.current || !enabled || !title.trim()) return;
106
+ refocusRef.current = captureRefocusField(typeof document === 'undefined' ? null : document.activeElement, bodyRef.current);
107
+ busyRef.current = true;
108
+ setBusy(true);
109
+ hidePreview();
110
+ try {
111
+ const inputError = captureFiles([], imagesRef.current.map((item) => item.file), imageCompression);
112
+ if (inputError) throw makeError('bad-request', t(inputError, CAPTURE_LIMIT_TEXT));
113
+ const encoded = [], processed = [];
114
+ for (const item of imagesRef.current) {
115
+ const file = await compressCaptureImage(item.file, imageCompression);
116
+ if (!aliveRef.current) return;
117
+ processed.push(file);
118
+ const error = captureFiles([], processed, { enabled: false });
119
+ if (error) throw makeError('too-large', t(error, CAPTURE_LIMIT_TEXT));
120
+ encoded.push(await readCaptureImage(file));
121
+ }
122
+ if (!aliveRef.current) return;
123
+ const result = await onCreate({ title, body, images: encoded });
124
+ if (!aliveRef.current) return;
125
+ imagesRef.current.forEach((item) => URL.revokeObjectURL(item.url));
126
+ imagesRef.current = [];
127
+ refocusRef.current = 'title';
128
+ setImages([]); setTitle(''); setBody(''); setBodyOpen(false); setExpanded(false);
129
+ onCreated(result);
130
+ } catch (error) {
131
+ if (aliveRef.current) onError(['capture.readFailed', 'capture.compressFailed'].includes(error.message) ? makeError('bad-request', t(error.message)) : error);
132
+ } finally {
133
+ busyRef.current = false;
134
+ if (aliveRef.current) setBusy(false);
135
+ }
136
+ }
137
+ function titleKeyDown(event) {
138
+ if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) return;
139
+ if (event.key === 'Tab' && !event.shiftKey) {
140
+ event.preventDefault();
141
+ event.stopPropagation();
142
+ if (!busyRef.current) {
143
+ setBodyOpen(true);
144
+ if (bodyRef.current) bodyRef.current.focus();
145
+ }
146
+ } else if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); submit(); }
147
+ }
148
+ if (!enabled) return null;
149
+ return h('div', { className: 'SN_captureDock', ref: dockRef, 'data-card-dragging': dragNote ? 'true' : 'false', 'data-drop': dropAction || '' },
150
+ showBackTop && !dragNote ? h('button', { type: 'button', className: 'SN_backTop', title: t('panel.backToTop'), 'aria-label': t('panel.backToTop'), onClick: onBackTop }, h(ActionIcon, { kind: 'up', size: 17 })) : null,
151
+ dragNote ? h(DropActions, {
152
+ t, active: dropAction, activeTargetId: dropTargetId, targets: archiveTargets || [], parked: archiveParked, onHover: onDragHover,
153
+ onArchive: onArchiveDrop, onArchiveChoose, onProject: onProjectDrop, onDelete: onDeleteDrop,
154
+ }) : null,
155
+ h('form', {
156
+ className: 'SN_capture', ref: formRef, 'data-body': bodyOpen ? 'true' : 'false', 'data-dragging': dragging ? 'true' : 'false',
157
+ 'aria-label': t('capture.label'), 'aria-busy': busy, onSubmit: submit,
158
+ onBlur: (event) => {
159
+ if (!event.currentTarget.contains(event.relatedTarget) && !body.trim()) { setBody(''); setBodyOpen(false); }
160
+ },
161
+ onDragEnter: (event) => {
162
+ if (!captureFileDrag(event)) return;
163
+ event.preventDefault(); event.stopPropagation();
164
+ dragDepthRef.current += 1;
165
+ if (!busyRef.current) setDragging(true);
166
+ },
167
+ onDragOver: (event) => {
168
+ if (!captureFileDrag(event)) return;
169
+ event.preventDefault(); event.stopPropagation();
170
+ event.dataTransfer.dropEffect = busyRef.current ? 'none' : 'copy';
171
+ },
172
+ onDragLeave: (event) => {
173
+ if (!dragDepthRef.current) return;
174
+ event.preventDefault(); event.stopPropagation();
175
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
176
+ if (!dragDepthRef.current) setDragging(false);
177
+ },
178
+ onDrop: (event) => {
179
+ if (!captureFileDrag(event)) return;
180
+ event.preventDefault(); event.stopPropagation();
181
+ dragDepthRef.current = 0; setDragging(false);
182
+ if (busyRef.current) return;
183
+ const files = Array.from(event.dataTransfer.files || []).filter((file) => file.type.startsWith('image/'));
184
+ if (!files.length) { onError(makeError('bad-request', t('capture.imageType'))); return; }
185
+ attach(files);
186
+ const field = bodyOpen ? bodyRef.current : titleRef.current;
187
+ if (field) field.focus();
188
+ },
189
+ onPaste: (event) => {
190
+ const files = Array.from(event.clipboardData?.items || []).filter((item) => item.kind === 'file').map((item) => item.getAsFile()).filter(Boolean);
191
+ if (!files.length) return;
192
+ if (!event.clipboardData.getData('text/plain')) event.preventDefault();
193
+ attach(files);
194
+ },
195
+ onKeyDown: (event) => {
196
+ if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) return;
197
+ if (event.key === 'Escape') {
198
+ event.preventDefault(); event.stopPropagation();
199
+ if (!body.trim()) { setBody(''); setBodyOpen(false); }
200
+ if (typeof document !== 'undefined') document.activeElement?.blur();
201
+ return;
202
+ }
203
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); submit(); }
204
+ },
205
+ },
206
+ h('div', {
207
+ className: 'SN_captureAttach', 'data-expanded': expanded ? 'true' : 'false',
208
+ onMouseEnter: () => setExpanded(true),
209
+ onMouseLeave: (event) => { hidePreview(); if (!event.currentTarget.contains(document.activeElement)) setExpanded(false); },
210
+ onFocus: () => setExpanded(true),
211
+ onBlur: (event) => { if (!event.currentTarget.contains(event.relatedTarget)) { hidePreview(); setExpanded(false); } },
212
+ },
213
+ h('button', { type: 'button', className: 'SN_captureAdd', disabled: busy, 'aria-label': t('capture.add'), title: t('capture.add'), onClick: () => fileRef.current && fileRef.current.click() },
214
+ h('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true' }, h('path', { d: 'M12 5v14M5 12h14', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }))),
215
+ h('input', { type: 'file', hidden: true, multiple: true, accept: 'image/png,image/jpeg,image/gif,image/webp', ref: fileRef, disabled: busy,
216
+ onChange: (event) => { attach(event.target.files); event.target.value = ''; } }),
217
+ images.length ? h('div', { className: 'SN_captureStack', 'aria-label': t('capture.images', { count: images.length }) },
218
+ images.map((image, index) => h('div', {
219
+ key: image.url, className: 'SN_captureThumb', tabIndex: 0,
220
+ onMouseEnter: () => previewImage(image), onMouseLeave: hidePreview,
221
+ onFocus: () => previewImage(image), onBlur: hidePreview,
222
+ },
223
+ h('img', { src: image.url, alt: image.file.name || String(index + 1) }),
224
+ h('button', { type: 'button', className: 'SN_captureRemove', disabled: busy, 'aria-label': t('capture.remove', { name: image.file.name || index + 1 }), onClick: () => remove(image) }, h(CloseIcon, null)),
225
+ ))) : null,
226
+ preview ? h('div', { className: 'SN_capturePreview', role: 'tooltip' }, h('img', { src: preview.url, alt: preview.file.name })) : null,
227
+ ),
228
+ h('div', { className: 'SN_captureFields' },
229
+ h('div', { className: 'SN_captureTitleRow' },
230
+ h('span', { ref: titleMeasureRef, className: 'SN_captureTitleMeasure', 'aria-hidden': 'true' }, title || t('capture.placeholder')),
231
+ h('textarea', { className: 'SN_captureTitle', ref: titleRef, rows: 1, value: title, disabled: busy, autoComplete: 'off',
232
+ 'aria-label': t('capture.title'), placeholder: t('capture.placeholder'), onChange: (event) => setTitle(event.target.value.replace(/[\r\n]+/g, ' ')), onKeyDown: titleKeyDown }),
233
+ !bodyOpen ? h('span', { className: 'SN_captureHint', 'aria-hidden': 'true' }, h('kbd', null, 'Tab'), t('capture.tab')) : null,
234
+ ),
235
+ bodyOpen ? h('textarea', { className: 'SN_captureBody', ref: bodyRef, value: body, disabled: busy, 'aria-label': t('capture.body'), placeholder: t('capture.bodyPlaceholder'),
236
+ onChange: (event) => {
237
+ const next = event.target.value;
238
+ setBody(next);
239
+ // 正文删空后回到「只输入标题」的小状态;聚焦回标题,避免 textarea 卸载后焦点掉到 body。
240
+ if (next === '' && !event.nativeEvent?.isComposing) {
241
+ setBodyOpen(false);
242
+ if (titleRef.current) titleRef.current.focus();
243
+ return;
244
+ }
245
+ event.target.style.height = 'auto';
246
+ event.target.style.height = Math.min(160, Math.max(68, event.target.scrollHeight)) + 'px';
247
+ },
248
+ onKeyDown: (event) => {
249
+ if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) return;
250
+ if (event.key === 'Tab' && event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
251
+ event.preventDefault(); event.stopPropagation();
252
+ if (titleRef.current) titleRef.current.focus();
253
+ } else if (event.key === 'Backspace' && !body.trim()) {
254
+ event.preventDefault(); event.stopPropagation(); setBody(''); setBodyOpen(false); titleRef.current?.focus();
255
+ }
256
+ } }) : null,
257
+ bodyOpen ? h('span', { className: 'SN_captureSubmitHint' }, t('capture.submitHint')) : null,
258
+ ),
259
+ h('button', { type: 'submit', className: 'SN_captureSend', disabled: busy || !title.trim(), 'aria-label': t(busy ? 'capture.sending' : 'capture.send'), title: t('capture.shortcut') },
260
+ busy ? h(RefreshIcon, { spinning: true }) : h('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true' }, h('path', { d: 'M12 18V6m-5 5 5-5 5 5', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
261
+ h('span', { className: 'SN_captureStatus', role: 'status', 'aria-live': 'polite' }, busy ? t('capture.sending') : images.length ? t('capture.images', { count: images.length }) : ''),
262
+ ),
263
+ );
264
+ }
265
+
266
+ export { CaptureComposer };
@@ -0,0 +1,64 @@
1
+ /** 图片数量/大小校验、读取与 WebP 编码;不发送 RPC。 */
2
+ import {
3
+ CAPTURE_IMAGES, CAPTURE_IMAGE_BYTES, CAPTURE_SOURCE_IMAGE_BYTES, CAPTURE_SOURCE_TOTAL_BYTES,
4
+ CAPTURE_TOTAL_BYTES,
5
+ } from '../constants.js';
6
+ import { makeError } from '../utils.js';
7
+
8
+ function readImageCompression(raw) {
9
+ const source = raw && typeof raw === 'object' ? raw : {};
10
+ const number = (value, fallback, min, max) => typeof value !== 'number' || !Number.isFinite(value)
11
+ ? fallback : Math.min(max, Math.max(min, Math.round(value)));
12
+ return { enabled: source.enabled !== false, quality: number(source.quality, 70, 1, 100), maxDimension: number(source.maxDimension, 2048, 256, 8192) };
13
+ }
14
+
15
+ function captureImageSize(width, height, maxDimension) {
16
+ const ratio = Math.min(1, maxDimension / Math.max(width, height));
17
+ return { width: Math.max(1, Math.round(width * ratio)), height: Math.max(1, Math.round(height * ratio)) };
18
+ }
19
+
20
+ async function compressCaptureImage(file, options) {
21
+ const config = readImageCompression(options);
22
+ if (!config.enabled) return file;
23
+ const url = URL.createObjectURL(file);
24
+ const image = new Image();
25
+ const canvas = document.createElement('canvas');
26
+ try {
27
+ await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = url; });
28
+ if (!image.naturalWidth || !image.naturalHeight) throw new Error('Invalid dimensions');
29
+ const size = captureImageSize(image.naturalWidth, image.naturalHeight, config.maxDimension);
30
+ canvas.width = size.width; canvas.height = size.height;
31
+ canvas.getContext('2d').drawImage(image, 0, 0, size.width, size.height);
32
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', config.quality / 100));
33
+ if (!blob || blob.type !== 'image/webp') throw new Error('WebP encoding unavailable');
34
+ return blob;
35
+ } catch {
36
+ throw makeError('bad-request', 'capture.compressFailed');
37
+ } finally {
38
+ URL.revokeObjectURL(url);
39
+ canvas.width = 0; canvas.height = 0;
40
+ }
41
+ }
42
+
43
+ function captureFiles(existing, files, compression) {
44
+ const next = Array.from(files || []);
45
+ if (next.some((file) => !/^image\/(png|jpeg|gif|webp)$/.test(file.type))) return 'capture.imageType';
46
+ const compress = readImageCompression(compression).enabled;
47
+ const [maxImageBytes, maxTotalBytes] = compress
48
+ ? [CAPTURE_SOURCE_IMAGE_BYTES, CAPTURE_SOURCE_TOTAL_BYTES]
49
+ : [CAPTURE_IMAGE_BYTES, CAPTURE_TOTAL_BYTES];
50
+ if (existing.length + next.length > CAPTURE_IMAGES || next.some((file) => file.size > maxImageBytes)
51
+ || existing.concat(next).reduce((sum, file) => sum + file.size, 0) > maxTotalBytes) return compress ? 'capture.sourceLimit' : 'capture.imageLimit';
52
+ return '';
53
+ }
54
+
55
+ function readCaptureImage(file) {
56
+ return new Promise((resolve, reject) => {
57
+ const reader = new FileReader();
58
+ reader.onload = () => resolve({ dataUrl: reader.result });
59
+ reader.onerror = reader.onabort = () => reject(makeError('bad-request', 'capture.readFailed'));
60
+ reader.readAsDataURL(file);
61
+ });
62
+ }
63
+
64
+ export { readImageCompression, captureImageSize, compressCaptureImage, captureFiles, readCaptureImage };
@@ -0,0 +1,34 @@
1
+ /** 输入岛焦点和外部图片拖入判断,避免抢占正文编辑快捷键。 */
2
+ import { panelIsVisible } from '../data/visibility.js';
3
+
4
+ const CAPTURE_TAB_PASSTHROUGH = 'button,a,input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="button"],[role="textbox"],[role="menuitem"],[tabindex]';
5
+
6
+ function focusCaptureOnTab(event, { panel, form, title, busy }) {
7
+ if (!panel || !form || !title || busy || event.defaultPrevented || !['Tab', 'Enter'].includes(event.key)
8
+ || event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229 || !panelIsVisible(panel)) return false;
9
+ const target = event.target;
10
+ if (target && (form.contains(target) || target.closest?.('.SN_noteEditor'))) return false;
11
+ const doc = panel.ownerDocument;
12
+ const atPageRoot = Boolean(target) && (target === doc?.body || target === doc?.documentElement || target === doc);
13
+ if (!atPageRoot && !(target && panel.contains(target))) return false;
14
+ if (event.key === 'Tab') {
15
+ if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return false;
16
+ // Tab 仍保留按钮、卡片、输入框与浮层的原生焦点顺序。
17
+ if (target?.closest?.(CAPTURE_TAB_PASSTHROUGH) ||
18
+ panel.querySelector('[aria-modal="true"],[role="menu"],.SN_drawer,.SN_projectOverlay,.SN_noteDrawer,[role="dialog"]')) return false;
19
+ }
20
+ event.preventDefault();
21
+ event.stopPropagation?.();
22
+ title.focus();
23
+ return true;
24
+ }
25
+
26
+ function captureRefocusField(active, bodyElement) {
27
+ return active && active === bodyElement ? 'body' : 'title';
28
+ }
29
+
30
+ function captureFileDrag(event) {
31
+ return Array.from(event.dataTransfer?.types || []).includes('Files') || Boolean(event.dataTransfer?.files?.length);
32
+ }
33
+
34
+ export { focusCaptureOnTab, captureRefocusField, captureFileDrag };
@@ -0,0 +1,67 @@
1
+ /** 项目名称和重复评论确认框;不发送 RPC。 */
2
+ import { h } from '../react.js';
3
+
4
+ function ProjectDialog(props) {
5
+ const { t, note, name, busy, onName, onConfirm, onCancel } = props;
6
+ const title = note.title || note.name || '';
7
+ return h('div', {
8
+ className: 'SN_dialogLayer',
9
+ onMouseDown: (event) => {
10
+ if (event.target === event.currentTarget && !busy) onCancel();
11
+ },
12
+ },
13
+ h('div', { className: 'SN_dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('project.title') },
14
+ h('h2', { className: 'SN_dialogTitle' }, t('project.title')),
15
+ h('p', { className: 'SN_dialogText' }, t('project.desc', { name: title })),
16
+ h('label', { className: 'SN_field' },
17
+ h('span', { className: 'SN_fieldLabel' }, t('project.nameLabel')),
18
+ h('input', {
19
+ type: 'text',
20
+ className: 'SN_textInput',
21
+ autoFocus: true,
22
+ value: name,
23
+ disabled: busy,
24
+ placeholder: t('project.namePlaceholder'),
25
+ onChange: (event) => onName(event.target.value),
26
+ onKeyDown: (event) => {
27
+ if (event.key === 'Enter') {
28
+ event.preventDefault();
29
+ event.stopPropagation();
30
+ if (!busy) onConfirm();
31
+ }
32
+ },
33
+ }),
34
+ ),
35
+ h('div', { className: 'SN_dialogActions' },
36
+ h('button', { type: 'button', className: 'SN_ghostBtn', disabled: busy, onClick: onCancel }, t('project.cancel')),
37
+ h('button', {
38
+ type: 'button',
39
+ className: 'SN_primaryBtn',
40
+ disabled: busy || name.trim() === '',
41
+ onClick: onConfirm,
42
+ }, busy ? t('project.busy') : t('project.confirm')),
43
+ ),
44
+ ),
45
+ );
46
+ }
47
+
48
+ function ConfirmDialog(props) {
49
+ const { title, text, confirmLabel, cancelLabel, onConfirm, onCancel } = props;
50
+ return h('div', {
51
+ className: 'SN_dialogLayer',
52
+ onMouseDown: (event) => {
53
+ if (event.target === event.currentTarget) onCancel();
54
+ },
55
+ },
56
+ h('div', { className: 'SN_dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': title },
57
+ h('h2', { className: 'SN_dialogTitle' }, title),
58
+ h('p', { className: 'SN_dialogText' }, text),
59
+ h('div', { className: 'SN_dialogActions' },
60
+ h('button', { type: 'button', className: 'SN_ghostBtn', onClick: onCancel }, cancelLabel),
61
+ h('button', { type: 'button', className: 'SN_primaryBtn', autoFocus: true, onClick: onConfirm }, confirmLabel),
62
+ ),
63
+ ),
64
+ );
65
+ }
66
+
67
+ export { ProjectDialog, ConfirmDialog };