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/preview-zoom.js
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
const { useEffect } = react;
|
|
2
|
-
|
|
3
|
-
export function usePreviewZoom(viewport, renderAtZoom) {
|
|
4
|
-
useEffect(() => {
|
|
5
|
-
const element = viewport.current;
|
|
6
|
-
let zoom = 1;
|
|
7
|
-
let settle;
|
|
8
|
-
|
|
9
|
-
function wheel(event) {
|
|
10
|
-
if (!event.ctrlKey || !event.deltaY) return;
|
|
11
|
-
event.preventDefault();
|
|
12
|
-
event.stopPropagation();
|
|
13
|
-
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientHeight : 1;
|
|
14
|
-
const delta = Math.max(-100, Math.min(100, event.deltaY * unit));
|
|
15
|
-
const next = Math.max(0.25, Math.min(6, zoom * Math.exp(-delta * 0.002)));
|
|
16
|
-
if (next === zoom) return;
|
|
17
|
-
|
|
18
|
-
const surface = event.target instanceof Element && event.target.closest('[data-dwe-zoom-surface]');
|
|
19
|
-
const before = surface ? surface.getBoundingClientRect() : element.getBoundingClientRect();
|
|
20
|
-
const x = event.clientX - before.left;
|
|
21
|
-
const y = event.clientY - before.top;
|
|
22
|
-
const ratio = next / zoom;
|
|
23
|
-
const { scrollLeft, scrollTop } = element;
|
|
24
|
-
zoom = next;
|
|
25
|
-
element.style.setProperty('--dwe-preview-zoom', zoom);
|
|
26
|
-
if (surface) {
|
|
27
|
-
const after = surface.getBoundingClientRect();
|
|
28
|
-
element.scrollLeft += after.left + x * ratio - event.clientX;
|
|
29
|
-
element.scrollTop += after.top + y * ratio - event.clientY;
|
|
30
|
-
} else {
|
|
31
|
-
element.scrollLeft = (scrollLeft + x) * ratio - x;
|
|
32
|
-
element.scrollTop = (scrollTop + y) * ratio - y;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (renderAtZoom) {
|
|
36
|
-
clearTimeout(settle);
|
|
37
|
-
settle = setTimeout(() => renderAtZoom(zoom), 120);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
element.addEventListener('wheel', wheel, { passive: false });
|
|
42
|
-
return () => {
|
|
43
|
-
element.removeEventListener('wheel', wheel);
|
|
44
|
-
clearTimeout(settle);
|
|
45
|
-
};
|
|
46
|
-
}, [viewport, renderAtZoom]);
|
|
47
|
-
}
|
|
1
|
+
const { useEffect } = react;
|
|
2
|
+
|
|
3
|
+
export function usePreviewZoom(viewport, renderAtZoom) {
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const element = viewport.current;
|
|
6
|
+
let zoom = 1;
|
|
7
|
+
let settle;
|
|
8
|
+
|
|
9
|
+
function wheel(event) {
|
|
10
|
+
if (!event.ctrlKey || !event.deltaY) return;
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
event.stopPropagation();
|
|
13
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientHeight : 1;
|
|
14
|
+
const delta = Math.max(-100, Math.min(100, event.deltaY * unit));
|
|
15
|
+
const next = Math.max(0.25, Math.min(6, zoom * Math.exp(-delta * 0.002)));
|
|
16
|
+
if (next === zoom) return;
|
|
17
|
+
|
|
18
|
+
const surface = event.target instanceof Element && event.target.closest('[data-dwe-zoom-surface]');
|
|
19
|
+
const before = surface ? surface.getBoundingClientRect() : element.getBoundingClientRect();
|
|
20
|
+
const x = event.clientX - before.left;
|
|
21
|
+
const y = event.clientY - before.top;
|
|
22
|
+
const ratio = next / zoom;
|
|
23
|
+
const { scrollLeft, scrollTop } = element;
|
|
24
|
+
zoom = next;
|
|
25
|
+
element.style.setProperty('--dwe-preview-zoom', zoom);
|
|
26
|
+
if (surface) {
|
|
27
|
+
const after = surface.getBoundingClientRect();
|
|
28
|
+
element.scrollLeft += after.left + x * ratio - event.clientX;
|
|
29
|
+
element.scrollTop += after.top + y * ratio - event.clientY;
|
|
30
|
+
} else {
|
|
31
|
+
element.scrollLeft = (scrollLeft + x) * ratio - x;
|
|
32
|
+
element.scrollTop = (scrollTop + y) * ratio - y;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (renderAtZoom) {
|
|
36
|
+
clearTimeout(settle);
|
|
37
|
+
settle = setTimeout(() => renderAtZoom(zoom), 120);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
element.addEventListener('wheel', wheel, { passive: false });
|
|
42
|
+
return () => {
|
|
43
|
+
element.removeEventListener('wheel', wheel);
|
|
44
|
+
clearTimeout(settle);
|
|
45
|
+
};
|
|
46
|
+
}, [viewport, renderAtZoom]);
|
|
47
|
+
}
|
package/src/ui/thumbnail.js
CHANGED
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
import { thumbnailType } from '../thumbnail-types.js';
|
|
2
|
-
import { disks } from './workspace.js';
|
|
3
|
-
|
|
4
|
-
const { createElement: h, useEffect, useRef, useState } = react;
|
|
5
|
-
const pending = new Set();
|
|
6
|
-
let running = 0;
|
|
7
|
-
|
|
8
|
-
function startNext() {
|
|
9
|
-
while (running < 3 && pending.size) {
|
|
10
|
-
const next = pending.values().next().value;
|
|
11
|
-
pending.delete(next);
|
|
12
|
-
running++;
|
|
13
|
-
void next().finally(() => { running--; startNext(); });
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function waitForFrame(media, event, signal) {
|
|
18
|
-
return new Promise((resolve, reject) => {
|
|
19
|
-
const handlers = new AbortController();
|
|
20
|
-
function settle(error) {
|
|
21
|
-
handlers.abort();
|
|
22
|
-
signal.removeEventListener('abort', cancel);
|
|
23
|
-
if (error) reject(error);
|
|
24
|
-
else resolve();
|
|
25
|
-
}
|
|
26
|
-
function cancel() { settle(signal.reason); }
|
|
27
|
-
if (signal.aborted) { cancel(); return; }
|
|
28
|
-
const options = { once: true, signal: handlers.signal };
|
|
29
|
-
signal.addEventListener('abort', cancel, { once: true });
|
|
30
|
-
media.addEventListener(event, () => settle(), options);
|
|
31
|
-
media.addEventListener('error', () => settle(new Error('无法解码缩略图')), options);
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function makeThumbnail(path, type, signal, root, poster = false) {
|
|
36
|
-
const { url } = await disks.thumbnail(path, signal, root);
|
|
37
|
-
signal.throwIfAborted();
|
|
38
|
-
const video = type.startsWith('video/');
|
|
39
|
-
const media = document.createElement(video ? 'video' : 'img');
|
|
40
|
-
if (video) { media.preload = 'auto'; media.muted = true; }
|
|
41
|
-
try {
|
|
42
|
-
const firstFrame = waitForFrame(media, video ? 'loadeddata' : 'load', signal);
|
|
43
|
-
media.src = url;
|
|
44
|
-
await firstFrame;
|
|
45
|
-
if (video && !poster && Number.isFinite(media.duration) && media.duration > 0.2) {
|
|
46
|
-
const seeked = waitForFrame(media, 'seeked', signal);
|
|
47
|
-
media.currentTime = Math.min(0.7, media.duration * 0.12);
|
|
48
|
-
await seeked;
|
|
49
|
-
}
|
|
50
|
-
const width = video ? media.videoWidth : media.naturalWidth;
|
|
51
|
-
const height = video ? media.videoHeight : media.naturalHeight;
|
|
52
|
-
if (!width || !height) throw new Error('缩略图为空');
|
|
53
|
-
const [maxWidth, maxHeight] = poster ? [640, 360] : [192, 136];
|
|
54
|
-
const scale = Math.min(maxWidth / width, maxHeight / height, poster ? 1 : Infinity);
|
|
55
|
-
const canvas = document.createElement('canvas');
|
|
56
|
-
canvas.width = Math.max(1, Math.round(width * scale));
|
|
57
|
-
canvas.height = Math.max(1, Math.round(height * scale));
|
|
58
|
-
canvas.getContext('2d').drawImage(media, 0, 0, canvas.width, canvas.height);
|
|
59
|
-
return canvas.toDataURL('image/webp', 0.85);
|
|
60
|
-
} finally {
|
|
61
|
-
media.removeAttribute('src');
|
|
62
|
-
if (video) media.load();
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function Thumbnail({ path, active, fallback, root }) {
|
|
67
|
-
const slot = useRef(null);
|
|
68
|
-
const [result, setResult] = useState(null);
|
|
69
|
-
const type = thumbnailType(path);
|
|
70
|
-
const image = result?.path === path && result?.root === root ? result.image : null;
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
const element = slot.current;
|
|
73
|
-
if (!element || !active || !type || image) return;
|
|
74
|
-
let job;
|
|
75
|
-
let failed = false;
|
|
76
|
-
function cancel() {
|
|
77
|
-
if (!job) return;
|
|
78
|
-
pending.delete(job.run);
|
|
79
|
-
job.controller.abort();
|
|
80
|
-
job = undefined;
|
|
81
|
-
}
|
|
82
|
-
const observer = new IntersectionObserver(([entry]) => {
|
|
83
|
-
if (!entry.isIntersecting) { cancel(); return; }
|
|
84
|
-
if (job || failed) return;
|
|
85
|
-
const controller = new AbortController();
|
|
86
|
-
const run = async () => {
|
|
87
|
-
try {
|
|
88
|
-
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12000)]);
|
|
89
|
-
const image = await makeThumbnail(path, type, signal, root);
|
|
90
|
-
if (!signal.aborted) setResult({ path, root, image });
|
|
91
|
-
} catch {
|
|
92
|
-
if (!controller.signal.aborted) failed = true;
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
job = { controller, run };
|
|
96
|
-
pending.add(run);
|
|
97
|
-
startNext();
|
|
98
|
-
}, { root: element.closest('.dwe-list'), rootMargin: '72px 0px' });
|
|
99
|
-
observer.observe(element);
|
|
100
|
-
return () => { observer.disconnect(); cancel(); };
|
|
101
|
-
}, [active, path, type, image, root]);
|
|
102
|
-
|
|
103
|
-
return h('span', { ref: slot, className: 'dwe-thumbnail', 'aria-hidden': true },
|
|
104
|
-
image ? h('img', { src: image, alt: '', draggable: false }) : fallback,
|
|
105
|
-
image && type?.startsWith('video/') && h('span', { className: 'dwe-video-mark' }, '▶'));
|
|
1
|
+
import { thumbnailType } from '../thumbnail-types.js';
|
|
2
|
+
import { disks } from './workspace.js';
|
|
3
|
+
|
|
4
|
+
const { createElement: h, useEffect, useRef, useState } = react;
|
|
5
|
+
const pending = new Set();
|
|
6
|
+
let running = 0;
|
|
7
|
+
|
|
8
|
+
function startNext() {
|
|
9
|
+
while (running < 3 && pending.size) {
|
|
10
|
+
const next = pending.values().next().value;
|
|
11
|
+
pending.delete(next);
|
|
12
|
+
running++;
|
|
13
|
+
void next().finally(() => { running--; startNext(); });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function waitForFrame(media, event, signal) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const handlers = new AbortController();
|
|
20
|
+
function settle(error) {
|
|
21
|
+
handlers.abort();
|
|
22
|
+
signal.removeEventListener('abort', cancel);
|
|
23
|
+
if (error) reject(error);
|
|
24
|
+
else resolve();
|
|
25
|
+
}
|
|
26
|
+
function cancel() { settle(signal.reason); }
|
|
27
|
+
if (signal.aborted) { cancel(); return; }
|
|
28
|
+
const options = { once: true, signal: handlers.signal };
|
|
29
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
30
|
+
media.addEventListener(event, () => settle(), options);
|
|
31
|
+
media.addEventListener('error', () => settle(new Error('无法解码缩略图')), options);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function makeThumbnail(path, type, signal, root, poster = false) {
|
|
36
|
+
const { url } = await disks.thumbnail(path, signal, root);
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
const video = type.startsWith('video/');
|
|
39
|
+
const media = document.createElement(video ? 'video' : 'img');
|
|
40
|
+
if (video) { media.preload = 'auto'; media.muted = true; }
|
|
41
|
+
try {
|
|
42
|
+
const firstFrame = waitForFrame(media, video ? 'loadeddata' : 'load', signal);
|
|
43
|
+
media.src = url;
|
|
44
|
+
await firstFrame;
|
|
45
|
+
if (video && !poster && Number.isFinite(media.duration) && media.duration > 0.2) {
|
|
46
|
+
const seeked = waitForFrame(media, 'seeked', signal);
|
|
47
|
+
media.currentTime = Math.min(0.7, media.duration * 0.12);
|
|
48
|
+
await seeked;
|
|
49
|
+
}
|
|
50
|
+
const width = video ? media.videoWidth : media.naturalWidth;
|
|
51
|
+
const height = video ? media.videoHeight : media.naturalHeight;
|
|
52
|
+
if (!width || !height) throw new Error('缩略图为空');
|
|
53
|
+
const [maxWidth, maxHeight] = poster ? [640, 360] : [192, 136];
|
|
54
|
+
const scale = Math.min(maxWidth / width, maxHeight / height, poster ? 1 : Infinity);
|
|
55
|
+
const canvas = document.createElement('canvas');
|
|
56
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
57
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
58
|
+
canvas.getContext('2d').drawImage(media, 0, 0, canvas.width, canvas.height);
|
|
59
|
+
return canvas.toDataURL('image/webp', 0.85);
|
|
60
|
+
} finally {
|
|
61
|
+
media.removeAttribute('src');
|
|
62
|
+
if (video) media.load();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function Thumbnail({ path, active, fallback, root }) {
|
|
67
|
+
const slot = useRef(null);
|
|
68
|
+
const [result, setResult] = useState(null);
|
|
69
|
+
const type = thumbnailType(path);
|
|
70
|
+
const image = result?.path === path && result?.root === root ? result.image : null;
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const element = slot.current;
|
|
73
|
+
if (!element || !active || !type || image) return;
|
|
74
|
+
let job;
|
|
75
|
+
let failed = false;
|
|
76
|
+
function cancel() {
|
|
77
|
+
if (!job) return;
|
|
78
|
+
pending.delete(job.run);
|
|
79
|
+
job.controller.abort();
|
|
80
|
+
job = undefined;
|
|
81
|
+
}
|
|
82
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
83
|
+
if (!entry.isIntersecting) { cancel(); return; }
|
|
84
|
+
if (job || failed) return;
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const run = async () => {
|
|
87
|
+
try {
|
|
88
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12000)]);
|
|
89
|
+
const image = await makeThumbnail(path, type, signal, root);
|
|
90
|
+
if (!signal.aborted) setResult({ path, root, image });
|
|
91
|
+
} catch {
|
|
92
|
+
if (!controller.signal.aborted) failed = true;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
job = { controller, run };
|
|
96
|
+
pending.add(run);
|
|
97
|
+
startNext();
|
|
98
|
+
}, { root: element.closest('.dwe-list'), rootMargin: '72px 0px' });
|
|
99
|
+
observer.observe(element);
|
|
100
|
+
return () => { observer.disconnect(); cancel(); };
|
|
101
|
+
}, [active, path, type, image, root]);
|
|
102
|
+
|
|
103
|
+
return h('span', { ref: slot, className: 'dwe-thumbnail', 'aria-hidden': true },
|
|
104
|
+
image ? h('img', { src: image, alt: '', draggable: false }) : fallback,
|
|
105
|
+
image && type?.startsWith('video/') && h('span', { className: 'dwe-video-mark' }, '▶'));
|
|
106
106
|
}
|