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,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 };
|