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/thumbnail-types.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
const imageExtensions = new Map([
|
|
2
|
-
['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
|
|
3
|
-
['webp', 'image/webp'], ['gif', 'image/gif'], ['bmp', 'image/bmp'],
|
|
4
|
-
['ico', 'image/x-icon'], ['avif', 'image/avif'], ['apng', 'image/apng'],
|
|
5
|
-
['jfif', 'image/jpeg'], ['heic', 'image/heic'], ['heif', 'image/heif'],
|
|
6
|
-
['tif', 'image/tiff'], ['tiff', 'image/tiff'],
|
|
7
|
-
]);
|
|
8
|
-
|
|
9
|
-
const videoExtensions = new Map([
|
|
10
|
-
['mp4', 'video/mp4'], ['m4v', 'video/mp4'], ['webm', 'video/webm'],
|
|
11
|
-
['mov', 'video/quicktime'], ['mkv', 'video/x-matroska'], ['ogv', 'video/ogg'],
|
|
12
|
-
['avi', 'video/x-msvideo'], ['wmv', 'video/x-ms-wmv'], ['mpeg', 'video/mpeg'],
|
|
13
|
-
['mpg', 'video/mpeg'], ['3gp', 'video/3gpp'],
|
|
14
|
-
]);
|
|
15
|
-
|
|
16
|
-
export function thumbnailType(path) {
|
|
17
|
-
const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1].toLowerCase();
|
|
18
|
-
return imageExtensions.get(extension) ?? videoExtensions.get(extension);
|
|
1
|
+
const imageExtensions = new Map([
|
|
2
|
+
['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
|
|
3
|
+
['webp', 'image/webp'], ['gif', 'image/gif'], ['bmp', 'image/bmp'],
|
|
4
|
+
['ico', 'image/x-icon'], ['avif', 'image/avif'], ['apng', 'image/apng'],
|
|
5
|
+
['jfif', 'image/jpeg'], ['heic', 'image/heic'], ['heif', 'image/heif'],
|
|
6
|
+
['tif', 'image/tiff'], ['tiff', 'image/tiff'],
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const videoExtensions = new Map([
|
|
10
|
+
['mp4', 'video/mp4'], ['m4v', 'video/mp4'], ['webm', 'video/webm'],
|
|
11
|
+
['mov', 'video/quicktime'], ['mkv', 'video/x-matroska'], ['ogv', 'video/ogg'],
|
|
12
|
+
['avi', 'video/x-msvideo'], ['wmv', 'video/x-ms-wmv'], ['mpeg', 'video/mpeg'],
|
|
13
|
+
['mpg', 'video/mpeg'], ['3gp', 'video/3gpp'],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function thumbnailType(path) {
|
|
17
|
+
const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1].toLowerCase();
|
|
18
|
+
return imageExtensions.get(extension) ?? videoExtensions.get(extension);
|
|
19
19
|
}
|
package/src/ui/composer-drop.js
CHANGED
|
@@ -1,119 +1,119 @@
|
|
|
1
|
-
import { disks } from './workspace.js';
|
|
2
|
-
|
|
3
|
-
const { useEffect, useRef } = react;
|
|
4
|
-
const dragType = 'application/x-dsh-workspace-explorer-file';
|
|
5
|
-
const imageTypes = new Map([
|
|
6
|
-
['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
|
|
7
|
-
['gif', 'image/gif'], ['webp', 'image/webp'],
|
|
8
|
-
]);
|
|
9
|
-
|
|
10
|
-
export function useComposerDrop(sessionId, openFolderTab, reportError, root) {
|
|
11
|
-
const active = useRef(null);
|
|
12
|
-
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
if (!sessionId) return;
|
|
15
|
-
const lifetime = new AbortController();
|
|
16
|
-
const signal = lifetime.signal;
|
|
17
|
-
let highlighted;
|
|
18
|
-
let highlightClass;
|
|
19
|
-
|
|
20
|
-
function highlight(element, className) {
|
|
21
|
-
if (element === highlighted && className === highlightClass) return;
|
|
22
|
-
if (highlighted) highlighted.classList.remove(highlightClass);
|
|
23
|
-
highlighted = element;
|
|
24
|
-
highlightClass = className;
|
|
25
|
-
if (highlighted) highlighted.classList.add(highlightClass);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function targetCard(target) {
|
|
29
|
-
if (!(target instanceof Element)) return null;
|
|
30
|
-
const conversation = target.closest('[data-conversation-content]');
|
|
31
|
-
if (conversation?.dataset.conversationSession !== sessionId) return null;
|
|
32
|
-
const card = conversation.querySelector('[data-composer-card]');
|
|
33
|
-
return card?.querySelector('[data-composer-input][contenteditable="true"]') ? card : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function over(event) {
|
|
37
|
-
const drag = active.current;
|
|
38
|
-
if (!drag || !event.dataTransfer?.types.includes(dragType)) return;
|
|
39
|
-
const strip = drag.folder && tabStrip(event.target);
|
|
40
|
-
if (strip) {
|
|
41
|
-
highlight(strip, 'dwe-tab-drop-target');
|
|
42
|
-
event.preventDefault();
|
|
43
|
-
event.dataTransfer.dropEffect = 'copy';
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
const card = targetCard(event.target);
|
|
47
|
-
highlight(card, 'dwe-drop-target');
|
|
48
|
-
if (!card) return;
|
|
49
|
-
event.preventDefault();
|
|
50
|
-
event.dataTransfer.dropEffect = 'copy';
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function reset() {
|
|
54
|
-
active.current = null;
|
|
55
|
-
highlight(null);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function drop(event) {
|
|
59
|
-
const file = active.current;
|
|
60
|
-
if (!file || event.dataTransfer?.getData(dragType) !== file.token) return;
|
|
61
|
-
const strip = file.folder && tabStrip(event.target);
|
|
62
|
-
const card = targetCard(event.target);
|
|
63
|
-
reset();
|
|
64
|
-
if (!strip && !card) return;
|
|
65
|
-
event.preventDefault();
|
|
66
|
-
event.stopPropagation();
|
|
67
|
-
reportError('');
|
|
68
|
-
try {
|
|
69
|
-
if (strip) {
|
|
70
|
-
openFolderTab(file.path, strip.dataset.dockkitStrip);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (file.folder) {
|
|
74
|
-
const editor = card.querySelector('[data-composer-input][contenteditable="true"]');
|
|
75
|
-
const data = new DataTransfer();
|
|
76
|
-
data.setData('text/plain', file.path);
|
|
77
|
-
editor.focus({ preventScroll: true });
|
|
78
|
-
editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const bytes = await disks.readBytes(file.path, signal, root);
|
|
82
|
-
// The composer may have switched sessions or become locked during the read.
|
|
83
|
-
if (signal.aborted || !card.isConnected || targetCard(card) !== card) return;
|
|
84
|
-
const extension = file.name.split('.').at(-1)?.toLowerCase();
|
|
85
|
-
const dataTransfer = new DataTransfer();
|
|
86
|
-
dataTransfer.items.add(new File([bytes], file.name, {
|
|
87
|
-
type: imageTypes.get(extension) ?? 'application/octet-stream',
|
|
88
|
-
}));
|
|
89
|
-
// Use the composer's existing attachment intake, including its file checks.
|
|
90
|
-
card.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }));
|
|
91
|
-
} catch (error) {
|
|
92
|
-
if (!signal.aborted) reportError('无法拖入会话 · ' + (error?.message || String(error)));
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
document.addEventListener('dragover', over, { signal, capture: true });
|
|
97
|
-
document.addEventListener('drop', drop, { signal, capture: true });
|
|
98
|
-
window.addEventListener('dragend', reset, { signal });
|
|
99
|
-
return () => {
|
|
100
|
-
lifetime.abort();
|
|
101
|
-
reset();
|
|
102
|
-
};
|
|
103
|
-
}, [sessionId, openFolderTab, reportError, root]);
|
|
104
|
-
|
|
105
|
-
return (event, path, name, folder = false) => {
|
|
106
|
-
if (!sessionId) {
|
|
107
|
-
event.preventDefault();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const token = crypto.randomUUID();
|
|
111
|
-
active.current = { token, path, name, folder };
|
|
112
|
-
event.dataTransfer.effectAllowed = 'copy';
|
|
113
|
-
event.dataTransfer.setData(dragType, token);
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function tabStrip(target) {
|
|
118
|
-
return target instanceof Element ? target.closest('[data-dockkit-strip]') : null;
|
|
119
|
-
}
|
|
1
|
+
import { disks } from './workspace.js';
|
|
2
|
+
|
|
3
|
+
const { useEffect, useRef } = react;
|
|
4
|
+
const dragType = 'application/x-dsh-workspace-explorer-file';
|
|
5
|
+
const imageTypes = new Map([
|
|
6
|
+
['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
|
|
7
|
+
['gif', 'image/gif'], ['webp', 'image/webp'],
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function useComposerDrop(sessionId, openFolderTab, reportError, root) {
|
|
11
|
+
const active = useRef(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!sessionId) return;
|
|
15
|
+
const lifetime = new AbortController();
|
|
16
|
+
const signal = lifetime.signal;
|
|
17
|
+
let highlighted;
|
|
18
|
+
let highlightClass;
|
|
19
|
+
|
|
20
|
+
function highlight(element, className) {
|
|
21
|
+
if (element === highlighted && className === highlightClass) return;
|
|
22
|
+
if (highlighted) highlighted.classList.remove(highlightClass);
|
|
23
|
+
highlighted = element;
|
|
24
|
+
highlightClass = className;
|
|
25
|
+
if (highlighted) highlighted.classList.add(highlightClass);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function targetCard(target) {
|
|
29
|
+
if (!(target instanceof Element)) return null;
|
|
30
|
+
const conversation = target.closest('[data-conversation-content]');
|
|
31
|
+
if (conversation?.dataset.conversationSession !== sessionId) return null;
|
|
32
|
+
const card = conversation.querySelector('[data-composer-card]');
|
|
33
|
+
return card?.querySelector('[data-composer-input][contenteditable="true"]') ? card : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function over(event) {
|
|
37
|
+
const drag = active.current;
|
|
38
|
+
if (!drag || !event.dataTransfer?.types.includes(dragType)) return;
|
|
39
|
+
const strip = drag.folder && tabStrip(event.target);
|
|
40
|
+
if (strip) {
|
|
41
|
+
highlight(strip, 'dwe-tab-drop-target');
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const card = targetCard(event.target);
|
|
47
|
+
highlight(card, 'dwe-drop-target');
|
|
48
|
+
if (!card) return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reset() {
|
|
54
|
+
active.current = null;
|
|
55
|
+
highlight(null);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function drop(event) {
|
|
59
|
+
const file = active.current;
|
|
60
|
+
if (!file || event.dataTransfer?.getData(dragType) !== file.token) return;
|
|
61
|
+
const strip = file.folder && tabStrip(event.target);
|
|
62
|
+
const card = targetCard(event.target);
|
|
63
|
+
reset();
|
|
64
|
+
if (!strip && !card) return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
reportError('');
|
|
68
|
+
try {
|
|
69
|
+
if (strip) {
|
|
70
|
+
openFolderTab(file.path, strip.dataset.dockkitStrip);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (file.folder) {
|
|
74
|
+
const editor = card.querySelector('[data-composer-input][contenteditable="true"]');
|
|
75
|
+
const data = new DataTransfer();
|
|
76
|
+
data.setData('text/plain', file.path);
|
|
77
|
+
editor.focus({ preventScroll: true });
|
|
78
|
+
editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const bytes = await disks.readBytes(file.path, signal, root);
|
|
82
|
+
// The composer may have switched sessions or become locked during the read.
|
|
83
|
+
if (signal.aborted || !card.isConnected || targetCard(card) !== card) return;
|
|
84
|
+
const extension = file.name.split('.').at(-1)?.toLowerCase();
|
|
85
|
+
const dataTransfer = new DataTransfer();
|
|
86
|
+
dataTransfer.items.add(new File([bytes], file.name, {
|
|
87
|
+
type: imageTypes.get(extension) ?? 'application/octet-stream',
|
|
88
|
+
}));
|
|
89
|
+
// Use the composer's existing attachment intake, including its file checks.
|
|
90
|
+
card.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!signal.aborted) reportError('无法拖入会话 · ' + (error?.message || String(error)));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
document.addEventListener('dragover', over, { signal, capture: true });
|
|
97
|
+
document.addEventListener('drop', drop, { signal, capture: true });
|
|
98
|
+
window.addEventListener('dragend', reset, { signal });
|
|
99
|
+
return () => {
|
|
100
|
+
lifetime.abort();
|
|
101
|
+
reset();
|
|
102
|
+
};
|
|
103
|
+
}, [sessionId, openFolderTab, reportError, root]);
|
|
104
|
+
|
|
105
|
+
return (event, path, name, folder = false) => {
|
|
106
|
+
if (!sessionId) {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const token = crypto.randomUUID();
|
|
111
|
+
active.current = { token, path, name, folder };
|
|
112
|
+
event.dataTransfer.effectAllowed = 'copy';
|
|
113
|
+
event.dataTransfer.setData(dragType, token);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tabStrip(target) {
|
|
118
|
+
return target instanceof Element ? target.closest('[data-dockkit-strip]') : null;
|
|
119
|
+
}
|
package/src/ui/favorites.js
CHANGED
|
@@ -1,177 +1,177 @@
|
|
|
1
|
-
import { renamePath, underPath, windowsPath } from './navigation.js';
|
|
2
|
-
|
|
3
|
-
const key = 'dsh-workspace-explorer.favorites';
|
|
4
|
-
const dragType = 'application/x-dwe-favorite';
|
|
5
|
-
const { createElement: h, useLayoutEffect, useRef, useSyncExternalStore } = react;
|
|
6
|
-
const listeners = new Set();
|
|
7
|
-
|
|
8
|
-
function unique(paths) {
|
|
9
|
-
const seen = new Set();
|
|
10
|
-
return paths.filter(path => {
|
|
11
|
-
const id = path.toLowerCase();
|
|
12
|
-
if (seen.has(id)) return false;
|
|
13
|
-
seen.add(id);
|
|
14
|
-
return true;
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function read() {
|
|
19
|
-
try {
|
|
20
|
-
const paths = JSON.parse(localStorage.getItem(key));
|
|
21
|
-
return Array.isArray(paths) ? unique(paths.map(windowsPath).filter(Boolean)) : [];
|
|
22
|
-
} catch { return []; }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let folders = read();
|
|
26
|
-
|
|
27
|
-
function update(next, persist = true) {
|
|
28
|
-
if (next.length === folders.length && next.every((path, index) => path === folders[index])) return;
|
|
29
|
-
folders = next;
|
|
30
|
-
if (persist) {
|
|
31
|
-
try { localStorage.setItem(key, JSON.stringify(folders)); } catch { /* Keep the current window's favorites. */ }
|
|
32
|
-
}
|
|
33
|
-
for (const listener of listeners) listener();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function storageChanged(event) {
|
|
37
|
-
if (event.key === key || event.key === null) update(read(), false);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function subscribe(listener) {
|
|
41
|
-
if (!listeners.size) window.addEventListener('storage', storageChanged);
|
|
42
|
-
listeners.add(listener);
|
|
43
|
-
return () => {
|
|
44
|
-
listeners.delete(listener);
|
|
45
|
-
if (!listeners.size) window.removeEventListener('storage', storageChanged);
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function useFavorites() {
|
|
50
|
-
return useSyncExternalStore(subscribe, () => folders);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function removeFavorite(path) {
|
|
54
|
-
update(folders.filter(folder => folder.toLowerCase() !== path.toLowerCase()));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function toggleFavorite(path) {
|
|
58
|
-
if (folders.some(folder => folder.toLowerCase() === path.toLowerCase())) removeFavorite(path);
|
|
59
|
-
else update([...folders, path]);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function clearFavorites() {
|
|
63
|
-
update([]);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function renameFavorites(from, to) {
|
|
67
|
-
update(unique(folders.map(path => renamePath(path, from, to))));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function moveFavorite(from, to, after) {
|
|
71
|
-
if (from === to || !folders.includes(from) || !folders.includes(to)) return;
|
|
72
|
-
const next = folders.filter(path => path !== from);
|
|
73
|
-
next.splice(next.indexOf(to) + Number(after), 0, from);
|
|
74
|
-
update(next);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function Favorites({ anchor, close, folders, navigate, currentPath, removeIcon, workspaceMode, root }) {
|
|
78
|
-
const panel = useRef(null);
|
|
79
|
-
const dragged = useRef(null);
|
|
80
|
-
const dropRow = useRef(null);
|
|
81
|
-
|
|
82
|
-
function clearDropRow() {
|
|
83
|
-
if (dropRow.current) delete dropRow.current.dataset.dweFavoriteDrop;
|
|
84
|
-
dropRow.current = null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function resetDrag() {
|
|
88
|
-
dragged.current = null;
|
|
89
|
-
clearDropRow();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function isAfter(event) {
|
|
93
|
-
const bounds = event.currentTarget.getBoundingClientRect();
|
|
94
|
-
return event.clientY >= bounds.top + bounds.height / 2;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
useLayoutEffect(() => {
|
|
98
|
-
const popup = panel.current;
|
|
99
|
-
const button = anchor.current;
|
|
100
|
-
const parent = popup.offsetParent;
|
|
101
|
-
if (!button || !parent) return;
|
|
102
|
-
|
|
103
|
-
function position() {
|
|
104
|
-
const bounds = parent.getBoundingClientRect();
|
|
105
|
-
const trigger = button.getBoundingClientRect();
|
|
106
|
-
popup.style.left = Math.max(4, Math.min(trigger.right - bounds.left - popup.offsetWidth,
|
|
107
|
-
bounds.width - popup.offsetWidth - 4)) + 'px';
|
|
108
|
-
popup.style.top = trigger.bottom - bounds.top + 4 + 'px';
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const observer = new ResizeObserver(position);
|
|
112
|
-
observer.observe(parent);
|
|
113
|
-
observer.observe(popup);
|
|
114
|
-
position();
|
|
115
|
-
const lifetime = new AbortController();
|
|
116
|
-
const options = { capture: true, signal: lifetime.signal };
|
|
117
|
-
function outside(event) {
|
|
118
|
-
if (popup.contains(event.target) || button.contains(event.target)) return;
|
|
119
|
-
close();
|
|
120
|
-
}
|
|
121
|
-
for (const event of ['pointerdown', 'click', 'wheel', 'focusin']) {
|
|
122
|
-
document.addEventListener(event, outside, options);
|
|
123
|
-
}
|
|
124
|
-
window.addEventListener('blur', close, { signal: lifetime.signal });
|
|
125
|
-
document.addEventListener('keydown', event => {
|
|
126
|
-
if (event.key === 'Escape') {
|
|
127
|
-
if (popup.contains(document.activeElement)) button.focus({ preventScroll: true });
|
|
128
|
-
close();
|
|
129
|
-
} else outside(event);
|
|
130
|
-
}, options);
|
|
131
|
-
return () => { observer.disconnect(); lifetime.abort(); };
|
|
132
|
-
}, [anchor, close]);
|
|
133
|
-
|
|
134
|
-
return h('section', { ref: panel, className: 'dwe-favorites', 'aria-label': '收藏夹' },
|
|
135
|
-
h('header', null, h('strong', null, '收藏夹'),
|
|
136
|
-
h('button', { type: 'button', className: 'dwe-clear-favorites', disabled: !folders.length, onClick: clearFavorites }, '清空收藏')),
|
|
137
|
-
folders.length ? h('ul', { onDragLeave: event => {
|
|
138
|
-
if (!event.currentTarget.contains(event.relatedTarget)) clearDropRow();
|
|
139
|
-
} }, ...folders.map(path => {
|
|
140
|
-
const name = path.length === 3 ? path.slice(0, 2) : path.split('\\').at(-1);
|
|
141
|
-
return h('li', {
|
|
142
|
-
key: path, draggable: true,
|
|
143
|
-
onDragStart: event => {
|
|
144
|
-
dragged.current = path;
|
|
145
|
-
event.dataTransfer.effectAllowed = 'move';
|
|
146
|
-
event.dataTransfer.setData(dragType, path);
|
|
147
|
-
},
|
|
148
|
-
onDragOver: event => {
|
|
149
|
-
if (!dragged.current || !event.dataTransfer.types.includes(dragType)) return;
|
|
150
|
-
if (dropRow.current !== event.currentTarget) clearDropRow();
|
|
151
|
-
if (dragged.current === path) return;
|
|
152
|
-
event.preventDefault();
|
|
153
|
-
event.dataTransfer.dropEffect = 'move';
|
|
154
|
-
dropRow.current = event.currentTarget;
|
|
155
|
-
dropRow.current.dataset.dweFavoriteDrop = isAfter(event) ? 'after' : 'before';
|
|
156
|
-
},
|
|
157
|
-
onDrop: event => {
|
|
158
|
-
const from = dragged.current;
|
|
159
|
-
if (!from || event.dataTransfer.getData(dragType) !== from) return;
|
|
160
|
-
event.preventDefault();
|
|
161
|
-
resetDrag();
|
|
162
|
-
moveFavorite(from, path, isAfter(event));
|
|
163
|
-
},
|
|
164
|
-
onDragEnd: resetDrag,
|
|
165
|
-
},
|
|
166
|
-
h('button', {
|
|
167
|
-
type: 'button', className: 'dwe-favorite-location', title: path, onClick: () => navigate(path),
|
|
168
|
-
disabled: workspaceMode && !underPath(path, root),
|
|
169
|
-
'aria-current': currentPath?.toLowerCase() === path.toLowerCase() ? 'location' : undefined,
|
|
170
|
-
}, h(primitives.FileTypeIcon, { kind: 'folder', size: 16 }),
|
|
171
|
-
h('span', { className: 'dwe-favorite-text' }, name)),
|
|
172
|
-
h('button', {
|
|
173
|
-
type: 'button', className: 'dwe-tool', title: '移除收藏', 'aria-label': '移除收藏 ' + name,
|
|
174
|
-
onClick: () => removeFavorite(path),
|
|
175
|
-
}, removeIcon));
|
|
176
|
-
})) : h('p', null, '点击地址栏右侧的星标收藏当前文件夹。'));
|
|
1
|
+
import { renamePath, underPath, windowsPath } from './navigation.js';
|
|
2
|
+
|
|
3
|
+
const key = 'dsh-workspace-explorer.favorites';
|
|
4
|
+
const dragType = 'application/x-dwe-favorite';
|
|
5
|
+
const { createElement: h, useLayoutEffect, useRef, useSyncExternalStore } = react;
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
|
|
8
|
+
function unique(paths) {
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
return paths.filter(path => {
|
|
11
|
+
const id = path.toLowerCase();
|
|
12
|
+
if (seen.has(id)) return false;
|
|
13
|
+
seen.add(id);
|
|
14
|
+
return true;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function read() {
|
|
19
|
+
try {
|
|
20
|
+
const paths = JSON.parse(localStorage.getItem(key));
|
|
21
|
+
return Array.isArray(paths) ? unique(paths.map(windowsPath).filter(Boolean)) : [];
|
|
22
|
+
} catch { return []; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let folders = read();
|
|
26
|
+
|
|
27
|
+
function update(next, persist = true) {
|
|
28
|
+
if (next.length === folders.length && next.every((path, index) => path === folders[index])) return;
|
|
29
|
+
folders = next;
|
|
30
|
+
if (persist) {
|
|
31
|
+
try { localStorage.setItem(key, JSON.stringify(folders)); } catch { /* Keep the current window's favorites. */ }
|
|
32
|
+
}
|
|
33
|
+
for (const listener of listeners) listener();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function storageChanged(event) {
|
|
37
|
+
if (event.key === key || event.key === null) update(read(), false);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function subscribe(listener) {
|
|
41
|
+
if (!listeners.size) window.addEventListener('storage', storageChanged);
|
|
42
|
+
listeners.add(listener);
|
|
43
|
+
return () => {
|
|
44
|
+
listeners.delete(listener);
|
|
45
|
+
if (!listeners.size) window.removeEventListener('storage', storageChanged);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useFavorites() {
|
|
50
|
+
return useSyncExternalStore(subscribe, () => folders);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function removeFavorite(path) {
|
|
54
|
+
update(folders.filter(folder => folder.toLowerCase() !== path.toLowerCase()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toggleFavorite(path) {
|
|
58
|
+
if (folders.some(folder => folder.toLowerCase() === path.toLowerCase())) removeFavorite(path);
|
|
59
|
+
else update([...folders, path]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function clearFavorites() {
|
|
63
|
+
update([]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renameFavorites(from, to) {
|
|
67
|
+
update(unique(folders.map(path => renamePath(path, from, to))));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function moveFavorite(from, to, after) {
|
|
71
|
+
if (from === to || !folders.includes(from) || !folders.includes(to)) return;
|
|
72
|
+
const next = folders.filter(path => path !== from);
|
|
73
|
+
next.splice(next.indexOf(to) + Number(after), 0, from);
|
|
74
|
+
update(next);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Favorites({ anchor, close, folders, navigate, currentPath, removeIcon, workspaceMode, root }) {
|
|
78
|
+
const panel = useRef(null);
|
|
79
|
+
const dragged = useRef(null);
|
|
80
|
+
const dropRow = useRef(null);
|
|
81
|
+
|
|
82
|
+
function clearDropRow() {
|
|
83
|
+
if (dropRow.current) delete dropRow.current.dataset.dweFavoriteDrop;
|
|
84
|
+
dropRow.current = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resetDrag() {
|
|
88
|
+
dragged.current = null;
|
|
89
|
+
clearDropRow();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isAfter(event) {
|
|
93
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
94
|
+
return event.clientY >= bounds.top + bounds.height / 2;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
const popup = panel.current;
|
|
99
|
+
const button = anchor.current;
|
|
100
|
+
const parent = popup.offsetParent;
|
|
101
|
+
if (!button || !parent) return;
|
|
102
|
+
|
|
103
|
+
function position() {
|
|
104
|
+
const bounds = parent.getBoundingClientRect();
|
|
105
|
+
const trigger = button.getBoundingClientRect();
|
|
106
|
+
popup.style.left = Math.max(4, Math.min(trigger.right - bounds.left - popup.offsetWidth,
|
|
107
|
+
bounds.width - popup.offsetWidth - 4)) + 'px';
|
|
108
|
+
popup.style.top = trigger.bottom - bounds.top + 4 + 'px';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const observer = new ResizeObserver(position);
|
|
112
|
+
observer.observe(parent);
|
|
113
|
+
observer.observe(popup);
|
|
114
|
+
position();
|
|
115
|
+
const lifetime = new AbortController();
|
|
116
|
+
const options = { capture: true, signal: lifetime.signal };
|
|
117
|
+
function outside(event) {
|
|
118
|
+
if (popup.contains(event.target) || button.contains(event.target)) return;
|
|
119
|
+
close();
|
|
120
|
+
}
|
|
121
|
+
for (const event of ['pointerdown', 'click', 'wheel', 'focusin']) {
|
|
122
|
+
document.addEventListener(event, outside, options);
|
|
123
|
+
}
|
|
124
|
+
window.addEventListener('blur', close, { signal: lifetime.signal });
|
|
125
|
+
document.addEventListener('keydown', event => {
|
|
126
|
+
if (event.key === 'Escape') {
|
|
127
|
+
if (popup.contains(document.activeElement)) button.focus({ preventScroll: true });
|
|
128
|
+
close();
|
|
129
|
+
} else outside(event);
|
|
130
|
+
}, options);
|
|
131
|
+
return () => { observer.disconnect(); lifetime.abort(); };
|
|
132
|
+
}, [anchor, close]);
|
|
133
|
+
|
|
134
|
+
return h('section', { ref: panel, className: 'dwe-favorites', 'aria-label': '收藏夹' },
|
|
135
|
+
h('header', null, h('strong', null, '收藏夹'),
|
|
136
|
+
h('button', { type: 'button', className: 'dwe-clear-favorites', disabled: !folders.length, onClick: clearFavorites }, '清空收藏')),
|
|
137
|
+
folders.length ? h('ul', { onDragLeave: event => {
|
|
138
|
+
if (!event.currentTarget.contains(event.relatedTarget)) clearDropRow();
|
|
139
|
+
} }, ...folders.map(path => {
|
|
140
|
+
const name = path.length === 3 ? path.slice(0, 2) : path.split('\\').at(-1);
|
|
141
|
+
return h('li', {
|
|
142
|
+
key: path, draggable: true,
|
|
143
|
+
onDragStart: event => {
|
|
144
|
+
dragged.current = path;
|
|
145
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
146
|
+
event.dataTransfer.setData(dragType, path);
|
|
147
|
+
},
|
|
148
|
+
onDragOver: event => {
|
|
149
|
+
if (!dragged.current || !event.dataTransfer.types.includes(dragType)) return;
|
|
150
|
+
if (dropRow.current !== event.currentTarget) clearDropRow();
|
|
151
|
+
if (dragged.current === path) return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
event.dataTransfer.dropEffect = 'move';
|
|
154
|
+
dropRow.current = event.currentTarget;
|
|
155
|
+
dropRow.current.dataset.dweFavoriteDrop = isAfter(event) ? 'after' : 'before';
|
|
156
|
+
},
|
|
157
|
+
onDrop: event => {
|
|
158
|
+
const from = dragged.current;
|
|
159
|
+
if (!from || event.dataTransfer.getData(dragType) !== from) return;
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
resetDrag();
|
|
162
|
+
moveFavorite(from, path, isAfter(event));
|
|
163
|
+
},
|
|
164
|
+
onDragEnd: resetDrag,
|
|
165
|
+
},
|
|
166
|
+
h('button', {
|
|
167
|
+
type: 'button', className: 'dwe-favorite-location', title: path, onClick: () => navigate(path),
|
|
168
|
+
disabled: workspaceMode && !underPath(path, root),
|
|
169
|
+
'aria-current': currentPath?.toLowerCase() === path.toLowerCase() ? 'location' : undefined,
|
|
170
|
+
}, h(primitives.FileTypeIcon, { kind: 'folder', size: 16 }),
|
|
171
|
+
h('span', { className: 'dwe-favorite-text' }, name)),
|
|
172
|
+
h('button', {
|
|
173
|
+
type: 'button', className: 'dwe-tool', title: '移除收藏', 'aria-label': '移除收藏 ' + name,
|
|
174
|
+
onClick: () => removeFavorite(path),
|
|
175
|
+
}, removeIcon));
|
|
176
|
+
})) : h('p', null, '点击地址栏右侧的星标收藏当前文件夹。'));
|
|
177
177
|
}
|