dsh-workspace-explorer 1.1.1 → 1.1.2
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/CHANGELOG.md +7 -0
- package/README.md +6 -6
- package/docs/screenshots/grid-favorites.png +0 -0
- package/docs/screenshots/stl-preview.png +0 -0
- package/docs/screenshots/workspace-search.png +0 -0
- package/lib/client.js +64 -27
- package/package.json +1 -1
- package/src/client/conversation-drop.js +6 -13
- package/src/client/main.js +57 -14
- package/src/client/sidebar-motion.js +69 -69
- package/src/document-types.js +2 -2
- package/src/host/ExplorerShell.cs +183 -183
- package/src/host/files.js +24 -5
- package/src/host/pdf-assets.js +30 -30
- package/src/host/shell-files.js +81 -81
- package/src/thumbnail-types.js +18 -18
- package/src/ui/composer-drop.js +119 -119
- package/src/ui/favorites.js +176 -176
- package/src/ui/file-menu.js +125 -125
- package/src/ui/horizontal-wheel.js +11 -11
- package/src/ui/pdf-preview.js +135 -135
- package/src/ui/preview-unavailable.js +6 -6
- package/src/ui/preview-zoom.js +47 -47
- package/src/ui/thumbnail.js +105 -105
- package/src/vendor/ui/extra-views.js +1 -1
package/src/ui/file-menu.js
CHANGED
|
@@ -1,126 +1,126 @@
|
|
|
1
|
-
const { createElement: h, useEffect, useLayoutEffect, useRef, useState } = react;
|
|
2
|
-
|
|
3
|
-
export function FileMenu({ menu, items, close, invoke }) {
|
|
4
|
-
const root = useRef(null);
|
|
5
|
-
useLayoutEffect(() => {
|
|
6
|
-
const node = root.current;
|
|
7
|
-
const bounds = node.getBoundingClientRect();
|
|
8
|
-
node.style.left = Math.max(4, Math.min(menu.x, window.innerWidth - bounds.width - 4)) + 'px';
|
|
9
|
-
node.style.top = Math.max(4, Math.min(menu.y, window.innerHeight - bounds.height - 4)) + 'px';
|
|
10
|
-
node.querySelector('button:not(:disabled)')?.focus({ preventScroll: true });
|
|
11
|
-
}, [menu]);
|
|
12
|
-
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
const controller = new AbortController();
|
|
15
|
-
const options = { capture: true, signal: controller.signal };
|
|
16
|
-
function outside(event) {
|
|
17
|
-
if (!root.current?.contains(event.target)) close();
|
|
18
|
-
}
|
|
19
|
-
document.addEventListener('pointerdown', outside, options);
|
|
20
|
-
document.addEventListener('wheel', outside, options);
|
|
21
|
-
window.addEventListener('resize', close, options);
|
|
22
|
-
return () => controller.abort();
|
|
23
|
-
}, [close]);
|
|
24
|
-
|
|
25
|
-
return reactDom.createPortal(h('div', {
|
|
26
|
-
ref: root, className: 'dwe-file-menu', role: 'menu', 'aria-label': '文件操作',
|
|
27
|
-
onContextMenu: event => event.preventDefault(),
|
|
28
|
-
onKeyDown: event => {
|
|
29
|
-
if (event.key === 'Escape') {
|
|
30
|
-
event.preventDefault();
|
|
31
|
-
event.stopPropagation();
|
|
32
|
-
close(true);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
|
|
36
|
-
if (!keys.includes(event.key)) return;
|
|
37
|
-
const buttons = [...root.current.querySelectorAll('button:not(:disabled)')];
|
|
38
|
-
if (!buttons.length) return;
|
|
39
|
-
const current = buttons.indexOf(document.activeElement);
|
|
40
|
-
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
|
|
41
|
-
: (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length;
|
|
42
|
-
buttons[next].focus({ preventScroll: true });
|
|
43
|
-
event.preventDefault();
|
|
44
|
-
event.stopPropagation();
|
|
45
|
-
},
|
|
46
|
-
}, ...items.map(item => h('button', {
|
|
47
|
-
type: 'button', role: 'menuitem', key: item.id, disabled: item.disabled, 'data-dwe-action': item.id,
|
|
48
|
-
onClick: () => invoke(item.id),
|
|
49
|
-
}, item.icon, item.label))), document.body);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function DeleteDialog({ entries, close, remove }) {
|
|
53
|
-
const root = useRef(null);
|
|
54
|
-
const [working, setWorking] = useState(false);
|
|
55
|
-
const [error, setError] = useState('');
|
|
56
|
-
useLayoutEffect(() => {
|
|
57
|
-
const dialog = root.current;
|
|
58
|
-
dialog.showModal();
|
|
59
|
-
return () => dialog.close();
|
|
60
|
-
}, []);
|
|
61
|
-
|
|
62
|
-
return h('dialog', {
|
|
63
|
-
ref: root, className: 'dwe-file-dialog', 'aria-label': '删除项目',
|
|
64
|
-
onKeyDown: event => event.stopPropagation(),
|
|
65
|
-
onCancel: event => { event.preventDefault(); if (!working) close(); },
|
|
66
|
-
}, h('form', {
|
|
67
|
-
onSubmit: async event => {
|
|
68
|
-
event.preventDefault();
|
|
69
|
-
if (working) return;
|
|
70
|
-
setWorking(true);
|
|
71
|
-
setError('');
|
|
72
|
-
try { await remove(); }
|
|
73
|
-
catch (error) { setError(error.message); setWorking(false); }
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
h('h2', null, entries.length === 1 ? '移入回收站?' : '将这 ' + entries.length + ' 项移入回收站?'),
|
|
77
|
-
entries.length === 1
|
|
78
|
-
? h('div', { className: 'dwe-delete-path', title: entries[0].absolute }, entries[0].absolute)
|
|
79
|
-
: h('ul', { className: 'dwe-delete-path dwe-delete-paths' },
|
|
80
|
-
...entries.map(entry => h('li', { key: entry.absolute, title: entry.absolute }, entry.absolute))),
|
|
81
|
-
error && h('p', { role: 'alert' }, error),
|
|
82
|
-
h('div', { className: 'dwe-dialog-actions' },
|
|
83
|
-
h('button', { type: 'button', autoFocus: true, onClick: close, disabled: working }, '取消'),
|
|
84
|
-
h('button', {
|
|
85
|
-
type: 'submit', 'data-dwe-action': 'delete', disabled: working,
|
|
86
|
-
}, working ? '正在移入…' : '移入回收站'))));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function RenameDialog({ entry, close, rename }) {
|
|
90
|
-
const root = useRef(null);
|
|
91
|
-
const input = useRef(null);
|
|
92
|
-
const [name, setName] = useState(entry.name);
|
|
93
|
-
const [saving, setSaving] = useState(false);
|
|
94
|
-
const [error, setError] = useState('');
|
|
95
|
-
useLayoutEffect(() => {
|
|
96
|
-
const dialog = root.current;
|
|
97
|
-
dialog.showModal();
|
|
98
|
-
const dot = entry.type === 'file' ? entry.name.lastIndexOf('.') : -1;
|
|
99
|
-
input.current.setSelectionRange(0, dot > 0 ? dot : entry.name.length);
|
|
100
|
-
return () => dialog.close();
|
|
101
|
-
}, [entry]);
|
|
102
|
-
|
|
103
|
-
return h('dialog', {
|
|
104
|
-
ref: root, className: 'dwe-file-dialog', 'aria-label': '重命名',
|
|
105
|
-
onKeyDown: event => event.stopPropagation(),
|
|
106
|
-
onCancel: event => { event.preventDefault(); if (!saving) close(); },
|
|
107
|
-
}, h('form', {
|
|
108
|
-
onSubmit: async event => {
|
|
109
|
-
event.preventDefault();
|
|
110
|
-
if (saving || !name.trim()) return;
|
|
111
|
-
setSaving(true);
|
|
112
|
-
setError('');
|
|
113
|
-
try { await rename(name); }
|
|
114
|
-
catch (error) { setError(error.message); setSaving(false); }
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
h('h2', null, '重命名'),
|
|
118
|
-
h('label', null, '新名称', h('input', {
|
|
119
|
-
ref: input, value: name, autoFocus: true, spellCheck: false, readOnly: saving,
|
|
120
|
-
onChange: event => { setName(event.target.value); setError(''); },
|
|
121
|
-
})),
|
|
122
|
-
error && h('p', { role: 'alert' }, error),
|
|
123
|
-
h('div', { className: 'dwe-dialog-actions' },
|
|
124
|
-
h('button', { type: 'button', onClick: close, disabled: saving }, '取消'),
|
|
125
|
-
h('button', { type: 'submit', disabled: saving || !name.trim() }, saving ? '正在重命名…' : '确定'))));
|
|
1
|
+
const { createElement: h, useEffect, useLayoutEffect, useRef, useState } = react;
|
|
2
|
+
|
|
3
|
+
export function FileMenu({ menu, items, close, invoke }) {
|
|
4
|
+
const root = useRef(null);
|
|
5
|
+
useLayoutEffect(() => {
|
|
6
|
+
const node = root.current;
|
|
7
|
+
const bounds = node.getBoundingClientRect();
|
|
8
|
+
node.style.left = Math.max(4, Math.min(menu.x, window.innerWidth - bounds.width - 4)) + 'px';
|
|
9
|
+
node.style.top = Math.max(4, Math.min(menu.y, window.innerHeight - bounds.height - 4)) + 'px';
|
|
10
|
+
node.querySelector('button:not(:disabled)')?.focus({ preventScroll: true });
|
|
11
|
+
}, [menu]);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const options = { capture: true, signal: controller.signal };
|
|
16
|
+
function outside(event) {
|
|
17
|
+
if (!root.current?.contains(event.target)) close();
|
|
18
|
+
}
|
|
19
|
+
document.addEventListener('pointerdown', outside, options);
|
|
20
|
+
document.addEventListener('wheel', outside, options);
|
|
21
|
+
window.addEventListener('resize', close, options);
|
|
22
|
+
return () => controller.abort();
|
|
23
|
+
}, [close]);
|
|
24
|
+
|
|
25
|
+
return reactDom.createPortal(h('div', {
|
|
26
|
+
ref: root, className: 'dwe-file-menu', role: 'menu', 'aria-label': '文件操作',
|
|
27
|
+
onContextMenu: event => event.preventDefault(),
|
|
28
|
+
onKeyDown: event => {
|
|
29
|
+
if (event.key === 'Escape') {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
event.stopPropagation();
|
|
32
|
+
close(true);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
|
|
36
|
+
if (!keys.includes(event.key)) return;
|
|
37
|
+
const buttons = [...root.current.querySelectorAll('button:not(:disabled)')];
|
|
38
|
+
if (!buttons.length) return;
|
|
39
|
+
const current = buttons.indexOf(document.activeElement);
|
|
40
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
|
|
41
|
+
: (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length;
|
|
42
|
+
buttons[next].focus({ preventScroll: true });
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
event.stopPropagation();
|
|
45
|
+
},
|
|
46
|
+
}, ...items.map(item => h('button', {
|
|
47
|
+
type: 'button', role: 'menuitem', key: item.id, disabled: item.disabled, 'data-dwe-action': item.id,
|
|
48
|
+
onClick: () => invoke(item.id),
|
|
49
|
+
}, item.icon, item.label))), document.body);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function DeleteDialog({ entries, close, remove }) {
|
|
53
|
+
const root = useRef(null);
|
|
54
|
+
const [working, setWorking] = useState(false);
|
|
55
|
+
const [error, setError] = useState('');
|
|
56
|
+
useLayoutEffect(() => {
|
|
57
|
+
const dialog = root.current;
|
|
58
|
+
dialog.showModal();
|
|
59
|
+
return () => dialog.close();
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
return h('dialog', {
|
|
63
|
+
ref: root, className: 'dwe-file-dialog', 'aria-label': '删除项目',
|
|
64
|
+
onKeyDown: event => event.stopPropagation(),
|
|
65
|
+
onCancel: event => { event.preventDefault(); if (!working) close(); },
|
|
66
|
+
}, h('form', {
|
|
67
|
+
onSubmit: async event => {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
if (working) return;
|
|
70
|
+
setWorking(true);
|
|
71
|
+
setError('');
|
|
72
|
+
try { await remove(); }
|
|
73
|
+
catch (error) { setError(error.message); setWorking(false); }
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
h('h2', null, entries.length === 1 ? '移入回收站?' : '将这 ' + entries.length + ' 项移入回收站?'),
|
|
77
|
+
entries.length === 1
|
|
78
|
+
? h('div', { className: 'dwe-delete-path', title: entries[0].absolute }, entries[0].absolute)
|
|
79
|
+
: h('ul', { className: 'dwe-delete-path dwe-delete-paths' },
|
|
80
|
+
...entries.map(entry => h('li', { key: entry.absolute, title: entry.absolute }, entry.absolute))),
|
|
81
|
+
error && h('p', { role: 'alert' }, error),
|
|
82
|
+
h('div', { className: 'dwe-dialog-actions' },
|
|
83
|
+
h('button', { type: 'button', autoFocus: true, onClick: close, disabled: working }, '取消'),
|
|
84
|
+
h('button', {
|
|
85
|
+
type: 'submit', 'data-dwe-action': 'delete', disabled: working,
|
|
86
|
+
}, working ? '正在移入…' : '移入回收站'))));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function RenameDialog({ entry, close, rename }) {
|
|
90
|
+
const root = useRef(null);
|
|
91
|
+
const input = useRef(null);
|
|
92
|
+
const [name, setName] = useState(entry.name);
|
|
93
|
+
const [saving, setSaving] = useState(false);
|
|
94
|
+
const [error, setError] = useState('');
|
|
95
|
+
useLayoutEffect(() => {
|
|
96
|
+
const dialog = root.current;
|
|
97
|
+
dialog.showModal();
|
|
98
|
+
const dot = entry.type === 'file' ? entry.name.lastIndexOf('.') : -1;
|
|
99
|
+
input.current.setSelectionRange(0, dot > 0 ? dot : entry.name.length);
|
|
100
|
+
return () => dialog.close();
|
|
101
|
+
}, [entry]);
|
|
102
|
+
|
|
103
|
+
return h('dialog', {
|
|
104
|
+
ref: root, className: 'dwe-file-dialog', 'aria-label': '重命名',
|
|
105
|
+
onKeyDown: event => event.stopPropagation(),
|
|
106
|
+
onCancel: event => { event.preventDefault(); if (!saving) close(); },
|
|
107
|
+
}, h('form', {
|
|
108
|
+
onSubmit: async event => {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
if (saving || !name.trim()) return;
|
|
111
|
+
setSaving(true);
|
|
112
|
+
setError('');
|
|
113
|
+
try { await rename(name); }
|
|
114
|
+
catch (error) { setError(error.message); setSaving(false); }
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
h('h2', null, '重命名'),
|
|
118
|
+
h('label', null, '新名称', h('input', {
|
|
119
|
+
ref: input, value: name, autoFocus: true, spellCheck: false, readOnly: saving,
|
|
120
|
+
onChange: event => { setName(event.target.value); setError(''); },
|
|
121
|
+
})),
|
|
122
|
+
error && h('p', { role: 'alert' }, error),
|
|
123
|
+
h('div', { className: 'dwe-dialog-actions' },
|
|
124
|
+
h('button', { type: 'button', onClick: close, disabled: saving }, '取消'),
|
|
125
|
+
h('button', { type: 'submit', disabled: saving || !name.trim() }, saving ? '正在重命名…' : '确定'))));
|
|
126
126
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
export function scrollHorizontally(event, element) {
|
|
2
|
-
if (event.ctrlKey || !event.deltaY || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
|
|
3
|
-
const max = element.scrollWidth - element.clientWidth;
|
|
4
|
-
if (max <= 1) return;
|
|
5
|
-
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientWidth : 1;
|
|
6
|
-
const distance = Math.sign(event.deltaY) * Math.min(36, Math.abs(event.deltaY) * unit * 0.55);
|
|
7
|
-
const position = element.scrollLeft;
|
|
8
|
-
if (distance < 0 ? position <= 0 : position >= max - 1) return;
|
|
9
|
-
event.preventDefault();
|
|
10
|
-
event.stopPropagation();
|
|
11
|
-
element.scrollTo({ left: position + distance, behavior: 'instant' });
|
|
1
|
+
export function scrollHorizontally(event, element) {
|
|
2
|
+
if (event.ctrlKey || !event.deltaY || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
|
|
3
|
+
const max = element.scrollWidth - element.clientWidth;
|
|
4
|
+
if (max <= 1) return;
|
|
5
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientWidth : 1;
|
|
6
|
+
const distance = Math.sign(event.deltaY) * Math.min(36, Math.abs(event.deltaY) * unit * 0.55);
|
|
7
|
+
const position = element.scrollLeft;
|
|
8
|
+
if (distance < 0 ? position <= 0 : position >= max - 1) return;
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
event.stopPropagation();
|
|
11
|
+
element.scrollTo({ left: position + distance, behavior: 'instant' });
|
|
12
12
|
}
|
package/src/ui/pdf-preview.js
CHANGED
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
import { pdfAssetsPath } from '../document-types.js';
|
|
2
|
-
import { PreviewUnavailable } from './preview-unavailable.js';
|
|
3
|
-
import { usePreviewZoom } from './preview-zoom.js';
|
|
4
|
-
|
|
5
|
-
const { createElement: h, useEffect, useRef, useState } = react;
|
|
6
|
-
|
|
7
|
-
function asset(file) {
|
|
8
|
-
return new URL(pdfAssetsPath + '?file=' + file, location.href).href;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const pageBatch = 24;
|
|
12
|
-
|
|
13
|
-
const PdfPage = react.memo(function PdfPage({ pdf, number, initialSize, width, visible, renderZoom }) {
|
|
14
|
-
const [size, setSize] = useState(initialSize);
|
|
15
|
-
const [failed, setFailed] = useState(false);
|
|
16
|
-
const canvas = useRef(null);
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
if (!visible) return;
|
|
19
|
-
const surface = canvas.current;
|
|
20
|
-
let canceled = false;
|
|
21
|
-
let page;
|
|
22
|
-
let task;
|
|
23
|
-
async function render() {
|
|
24
|
-
try {
|
|
25
|
-
page = await pdf.getPage(number);
|
|
26
|
-
if (canceled) return;
|
|
27
|
-
const natural = page.getViewport({ scale: 1 });
|
|
28
|
-
setSize({ width: natural.width, height: natural.height });
|
|
29
|
-
const pageViewport = page.getViewport({ scale: Math.min(width / natural.width, 2) * renderZoom });
|
|
30
|
-
const pixels = Math.min(window.devicePixelRatio || 1, 4096 / Math.max(pageViewport.width, pageViewport.height));
|
|
31
|
-
surface.width = Math.max(1, Math.floor(pageViewport.width * pixels));
|
|
32
|
-
surface.height = Math.max(1, Math.floor(pageViewport.height * pixels));
|
|
33
|
-
task = page.render({
|
|
34
|
-
canvas: surface, viewport: pageViewport, transform: [pixels, 0, 0, pixels, 0, 0],
|
|
35
|
-
});
|
|
36
|
-
await task.promise;
|
|
37
|
-
if (!canceled) setFailed(false);
|
|
38
|
-
} catch {
|
|
39
|
-
if (!canceled) setFailed(true);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
void render();
|
|
43
|
-
return () => {
|
|
44
|
-
canceled = true;
|
|
45
|
-
task?.cancel();
|
|
46
|
-
page?.cleanup();
|
|
47
|
-
};
|
|
48
|
-
}, [pdf, number, visible, width, renderZoom]);
|
|
49
|
-
|
|
50
|
-
const displayWidth = Math.min(width, size.width * 2);
|
|
51
|
-
return h('div', {
|
|
52
|
-
className: 'dwe-pdf-page', 'data-pdf-page': number, 'data-dwe-zoom-surface': '',
|
|
53
|
-
style: { '--dwe-fit-width': displayWidth + 'px', '--dwe-fit-height': displayWidth * size.height / size.width + 'px' },
|
|
54
|
-
}, visible && h('canvas', { ref: canvas, role: 'img', 'aria-label': '文档第 ' + number + ' 页', hidden: failed }),
|
|
55
|
-
failed && h('p', { className: 'dwe-preview-hint' }, '第 ' + number + ' 页无法显示'));
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
function PdfPages({ pdf, initialSize }) {
|
|
59
|
-
const scroll = useRef(null);
|
|
60
|
-
const [width, setWidth] = useState(0);
|
|
61
|
-
const [visible, setVisible] = useState(() => new Set());
|
|
62
|
-
const [count, setCount] = useState(() => Math.min(pdf.numPages, pageBatch));
|
|
63
|
-
const [renderZoom, setRenderZoom] = useState(1);
|
|
64
|
-
usePreviewZoom(scroll, setRenderZoom);
|
|
65
|
-
const measured = width > 0;
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
const viewport = scroll.current;
|
|
68
|
-
const observer = new ResizeObserver(() => setWidth(Math.max(0, viewport.clientWidth - 16)));
|
|
69
|
-
observer.observe(viewport);
|
|
70
|
-
return () => observer.disconnect();
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (!measured) return;
|
|
75
|
-
const viewport = scroll.current;
|
|
76
|
-
const observer = new IntersectionObserver(entries => {
|
|
77
|
-
setVisible(current => {
|
|
78
|
-
const next = new Set(current);
|
|
79
|
-
for (const entry of entries) {
|
|
80
|
-
const number = Number(entry.target.dataset.pdfPage);
|
|
81
|
-
if (!number) continue;
|
|
82
|
-
if (entry.isIntersecting) next.add(number);
|
|
83
|
-
else next.delete(number);
|
|
84
|
-
}
|
|
85
|
-
return next;
|
|
86
|
-
});
|
|
87
|
-
if (entries.some(entry => !entry.target.dataset.pdfPage && entry.isIntersecting)) {
|
|
88
|
-
setCount(current => Math.min(pdf.numPages, current + pageBatch));
|
|
89
|
-
}
|
|
90
|
-
}, { root: viewport, rootMargin: '180px 0px' });
|
|
91
|
-
for (const node of viewport.children) observer.observe(node);
|
|
92
|
-
return () => observer.disconnect();
|
|
93
|
-
}, [count, measured, pdf]);
|
|
94
|
-
|
|
95
|
-
return h('div', { className: 'dwe-pdf-scroll', ref: scroll, tabIndex: 0, 'aria-label': '文档预览,共 ' + pdf.numPages + ' 页' },
|
|
96
|
-
measured && Array.from({ length: count }, (_, index) => h(PdfPage, {
|
|
97
|
-
key: index + 1, pdf, number: index + 1, initialSize, width, renderZoom, visible: visible.has(index + 1),
|
|
98
|
-
})),
|
|
99
|
-
measured && count < pdf.numPages && h('div', { key: 'more', className: 'dwe-pdf-more', 'aria-hidden': true }));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function PdfPreview({ data }) {
|
|
103
|
-
const [document, setDocument] = useState(null);
|
|
104
|
-
const [failed, setFailed] = useState(false);
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
let canceled = false;
|
|
107
|
-
let task;
|
|
108
|
-
async function load() {
|
|
109
|
-
try {
|
|
110
|
-
const { getDocument, GlobalWorkerOptions } = await import(asset('build/pdf.min.mjs'));
|
|
111
|
-
if (canceled) return;
|
|
112
|
-
GlobalWorkerOptions.workerSrc = asset('build/pdf.worker.min.mjs');
|
|
113
|
-
task = getDocument({
|
|
114
|
-
data: data.slice(), cMapPacked: true, useWorkerFetch: false,
|
|
115
|
-
cMapUrl: asset('cmaps/'), standardFontDataUrl: asset('standard_fonts/'), wasmUrl: asset('wasm/'),
|
|
116
|
-
enableXfa: false, isEvalSupported: false,
|
|
117
|
-
});
|
|
118
|
-
const pdf = await task.promise;
|
|
119
|
-
if (canceled) return;
|
|
120
|
-
const first = await pdf.getPage(1);
|
|
121
|
-
if (!canceled) setDocument({ pdf, initialSize: first.getViewport({ scale: 1 }) });
|
|
122
|
-
} catch {
|
|
123
|
-
if (!canceled) setFailed(true);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
void load();
|
|
127
|
-
return () => {
|
|
128
|
-
canceled = true;
|
|
129
|
-
void task?.destroy().catch(() => {});
|
|
130
|
-
};
|
|
131
|
-
}, [data]);
|
|
132
|
-
|
|
133
|
-
if (failed) return h(PreviewUnavailable);
|
|
134
|
-
return document ? h(PdfPages, document) : h('p', { className: 'dwe-preview-hint', role: 'status' }, '正在加载文档页面…');
|
|
135
|
-
}
|
|
1
|
+
import { pdfAssetsPath } from '../document-types.js';
|
|
2
|
+
import { PreviewUnavailable } from './preview-unavailable.js';
|
|
3
|
+
import { usePreviewZoom } from './preview-zoom.js';
|
|
4
|
+
|
|
5
|
+
const { createElement: h, useEffect, useRef, useState } = react;
|
|
6
|
+
|
|
7
|
+
function asset(file) {
|
|
8
|
+
return new URL(pdfAssetsPath + '?file=' + file, location.href).href;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const pageBatch = 24;
|
|
12
|
+
|
|
13
|
+
const PdfPage = react.memo(function PdfPage({ pdf, number, initialSize, width, visible, renderZoom }) {
|
|
14
|
+
const [size, setSize] = useState(initialSize);
|
|
15
|
+
const [failed, setFailed] = useState(false);
|
|
16
|
+
const canvas = useRef(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!visible) return;
|
|
19
|
+
const surface = canvas.current;
|
|
20
|
+
let canceled = false;
|
|
21
|
+
let page;
|
|
22
|
+
let task;
|
|
23
|
+
async function render() {
|
|
24
|
+
try {
|
|
25
|
+
page = await pdf.getPage(number);
|
|
26
|
+
if (canceled) return;
|
|
27
|
+
const natural = page.getViewport({ scale: 1 });
|
|
28
|
+
setSize({ width: natural.width, height: natural.height });
|
|
29
|
+
const pageViewport = page.getViewport({ scale: Math.min(width / natural.width, 2) * renderZoom });
|
|
30
|
+
const pixels = Math.min(window.devicePixelRatio || 1, 4096 / Math.max(pageViewport.width, pageViewport.height));
|
|
31
|
+
surface.width = Math.max(1, Math.floor(pageViewport.width * pixels));
|
|
32
|
+
surface.height = Math.max(1, Math.floor(pageViewport.height * pixels));
|
|
33
|
+
task = page.render({
|
|
34
|
+
canvas: surface, viewport: pageViewport, transform: [pixels, 0, 0, pixels, 0, 0],
|
|
35
|
+
});
|
|
36
|
+
await task.promise;
|
|
37
|
+
if (!canceled) setFailed(false);
|
|
38
|
+
} catch {
|
|
39
|
+
if (!canceled) setFailed(true);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
void render();
|
|
43
|
+
return () => {
|
|
44
|
+
canceled = true;
|
|
45
|
+
task?.cancel();
|
|
46
|
+
page?.cleanup();
|
|
47
|
+
};
|
|
48
|
+
}, [pdf, number, visible, width, renderZoom]);
|
|
49
|
+
|
|
50
|
+
const displayWidth = Math.min(width, size.width * 2);
|
|
51
|
+
return h('div', {
|
|
52
|
+
className: 'dwe-pdf-page', 'data-pdf-page': number, 'data-dwe-zoom-surface': '',
|
|
53
|
+
style: { '--dwe-fit-width': displayWidth + 'px', '--dwe-fit-height': displayWidth * size.height / size.width + 'px' },
|
|
54
|
+
}, visible && h('canvas', { ref: canvas, role: 'img', 'aria-label': '文档第 ' + number + ' 页', hidden: failed }),
|
|
55
|
+
failed && h('p', { className: 'dwe-preview-hint' }, '第 ' + number + ' 页无法显示'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function PdfPages({ pdf, initialSize }) {
|
|
59
|
+
const scroll = useRef(null);
|
|
60
|
+
const [width, setWidth] = useState(0);
|
|
61
|
+
const [visible, setVisible] = useState(() => new Set());
|
|
62
|
+
const [count, setCount] = useState(() => Math.min(pdf.numPages, pageBatch));
|
|
63
|
+
const [renderZoom, setRenderZoom] = useState(1);
|
|
64
|
+
usePreviewZoom(scroll, setRenderZoom);
|
|
65
|
+
const measured = width > 0;
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const viewport = scroll.current;
|
|
68
|
+
const observer = new ResizeObserver(() => setWidth(Math.max(0, viewport.clientWidth - 16)));
|
|
69
|
+
observer.observe(viewport);
|
|
70
|
+
return () => observer.disconnect();
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!measured) return;
|
|
75
|
+
const viewport = scroll.current;
|
|
76
|
+
const observer = new IntersectionObserver(entries => {
|
|
77
|
+
setVisible(current => {
|
|
78
|
+
const next = new Set(current);
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const number = Number(entry.target.dataset.pdfPage);
|
|
81
|
+
if (!number) continue;
|
|
82
|
+
if (entry.isIntersecting) next.add(number);
|
|
83
|
+
else next.delete(number);
|
|
84
|
+
}
|
|
85
|
+
return next;
|
|
86
|
+
});
|
|
87
|
+
if (entries.some(entry => !entry.target.dataset.pdfPage && entry.isIntersecting)) {
|
|
88
|
+
setCount(current => Math.min(pdf.numPages, current + pageBatch));
|
|
89
|
+
}
|
|
90
|
+
}, { root: viewport, rootMargin: '180px 0px' });
|
|
91
|
+
for (const node of viewport.children) observer.observe(node);
|
|
92
|
+
return () => observer.disconnect();
|
|
93
|
+
}, [count, measured, pdf]);
|
|
94
|
+
|
|
95
|
+
return h('div', { className: 'dwe-pdf-scroll', ref: scroll, tabIndex: 0, 'aria-label': '文档预览,共 ' + pdf.numPages + ' 页' },
|
|
96
|
+
measured && Array.from({ length: count }, (_, index) => h(PdfPage, {
|
|
97
|
+
key: index + 1, pdf, number: index + 1, initialSize, width, renderZoom, visible: visible.has(index + 1),
|
|
98
|
+
})),
|
|
99
|
+
measured && count < pdf.numPages && h('div', { key: 'more', className: 'dwe-pdf-more', 'aria-hidden': true }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function PdfPreview({ data }) {
|
|
103
|
+
const [document, setDocument] = useState(null);
|
|
104
|
+
const [failed, setFailed] = useState(false);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
let canceled = false;
|
|
107
|
+
let task;
|
|
108
|
+
async function load() {
|
|
109
|
+
try {
|
|
110
|
+
const { getDocument, GlobalWorkerOptions } = await import(asset('build/pdf.min.mjs'));
|
|
111
|
+
if (canceled) return;
|
|
112
|
+
GlobalWorkerOptions.workerSrc = asset('build/pdf.worker.min.mjs');
|
|
113
|
+
task = getDocument({
|
|
114
|
+
data: data.slice(), cMapPacked: true, useWorkerFetch: false,
|
|
115
|
+
cMapUrl: asset('cmaps/'), standardFontDataUrl: asset('standard_fonts/'), wasmUrl: asset('wasm/'),
|
|
116
|
+
enableXfa: false, isEvalSupported: false,
|
|
117
|
+
});
|
|
118
|
+
const pdf = await task.promise;
|
|
119
|
+
if (canceled) return;
|
|
120
|
+
const first = await pdf.getPage(1);
|
|
121
|
+
if (!canceled) setDocument({ pdf, initialSize: first.getViewport({ scale: 1 }) });
|
|
122
|
+
} catch {
|
|
123
|
+
if (!canceled) setFailed(true);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
void load();
|
|
127
|
+
return () => {
|
|
128
|
+
canceled = true;
|
|
129
|
+
void task?.destroy().catch(() => {});
|
|
130
|
+
};
|
|
131
|
+
}, [data]);
|
|
132
|
+
|
|
133
|
+
if (failed) return h(PreviewUnavailable);
|
|
134
|
+
return document ? h(PdfPages, document) : h('p', { className: 'dwe-preview-hint', role: 'status' }, '正在加载文档页面…');
|
|
135
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const h = react.createElement;
|
|
2
|
-
|
|
3
|
-
export function PreviewUnavailable() {
|
|
4
|
-
return h('p', { className: 'dwe-preview-hint dwe-preview-unavailable', role: 'status' },
|
|
5
|
-
'双击文件直接打开文件,或使用右上角的「在 DSH 文档预览中打开」预览此文档');
|
|
6
|
-
}
|
|
1
|
+
const h = react.createElement;
|
|
2
|
+
|
|
3
|
+
export function PreviewUnavailable() {
|
|
4
|
+
return h('p', { className: 'dwe-preview-hint dwe-preview-unavailable', role: 'status' },
|
|
5
|
+
'双击文件直接打开文件,或使用右上角的「在 DSH 文档预览中打开」预览此文档');
|
|
6
|
+
}
|