@cloudron/pankow 4.0.0 → 4.0.1
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/components/DirectoryView.vue +6 -1
- package/components/TreeView.vue +68 -0
- package/components/TreeViewNode.vue +222 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/types/index.d.ts +1 -1
|
@@ -474,7 +474,12 @@ export default {
|
|
|
474
474
|
onDragExit(event) {
|
|
475
475
|
this.dropTargetActive = false;
|
|
476
476
|
},
|
|
477
|
-
onItemDragStart(event) {
|
|
477
|
+
onItemDragStart(event, item) {
|
|
478
|
+
// auto-select the dragged item if it is not already part of the selection
|
|
479
|
+
if (item && !item.selected) {
|
|
480
|
+
this.onSelectAndFocus(item, {}, false);
|
|
481
|
+
}
|
|
482
|
+
|
|
478
483
|
const dragHandle = document.getElementsByClassName('drag-handle')[0];
|
|
479
484
|
|
|
480
485
|
// clear element
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="tree-view">
|
|
3
|
+
<TreeViewNode
|
|
4
|
+
:entry="rootEntry"
|
|
5
|
+
:list-files="listFiles"
|
|
6
|
+
:active-path="activePath"
|
|
7
|
+
:depth="0"
|
|
8
|
+
:fallback-icon="fallbackIcon"
|
|
9
|
+
:expand-on-mount="expandRoot"
|
|
10
|
+
:drop-handler="dropHandler"
|
|
11
|
+
@navigate="$emit('navigate', $event)"
|
|
12
|
+
/>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup>
|
|
17
|
+
|
|
18
|
+
import { computed } from 'vue';
|
|
19
|
+
import TreeViewNode from './TreeViewNode.vue';
|
|
20
|
+
|
|
21
|
+
const props = defineProps({
|
|
22
|
+
listFiles: {
|
|
23
|
+
type: Function,
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
activePath: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: '/',
|
|
29
|
+
},
|
|
30
|
+
fallbackIcon: {
|
|
31
|
+
type: String,
|
|
32
|
+
default: '',
|
|
33
|
+
},
|
|
34
|
+
rootLabel: {
|
|
35
|
+
type: String,
|
|
36
|
+
default: '/',
|
|
37
|
+
},
|
|
38
|
+
expandRoot: {
|
|
39
|
+
type: Boolean,
|
|
40
|
+
default: true,
|
|
41
|
+
},
|
|
42
|
+
dropHandler: {
|
|
43
|
+
type: Function,
|
|
44
|
+
default: null,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
defineEmits(['navigate']);
|
|
49
|
+
|
|
50
|
+
const rootEntry = computed(() => ({
|
|
51
|
+
name: props.rootLabel,
|
|
52
|
+
path: '/',
|
|
53
|
+
isDirectory: true,
|
|
54
|
+
icon: props.fallbackIcon,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
|
|
61
|
+
.tree-view {
|
|
62
|
+
display: flex;
|
|
63
|
+
overflow: auto;
|
|
64
|
+
height: 100%;
|
|
65
|
+
padding: 4px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="tree-view-node">
|
|
3
|
+
<div
|
|
4
|
+
class="tree-view-node-row"
|
|
5
|
+
:class="{ 'active': isActive, 'drop-target': dropTargetActive }"
|
|
6
|
+
:style="{ paddingLeft: (props.depth * 16 + 4) + 'px' }"
|
|
7
|
+
@click="onRowClick"
|
|
8
|
+
@dragover.stop.prevent="onDragOver"
|
|
9
|
+
@dragenter.stop.prevent="onDragEnter"
|
|
10
|
+
@dragleave="onDragLeave"
|
|
11
|
+
@drop.stop.prevent="onDrop"
|
|
12
|
+
>
|
|
13
|
+
<span class="tree-view-node-chevron">
|
|
14
|
+
<i v-if="loading" class="fa-solid fa-spinner fa-spin" />
|
|
15
|
+
<i v-else-if="expanded" class="fa-solid fa-chevron-down" />
|
|
16
|
+
<i v-else class="fa-solid fa-chevron-right" />
|
|
17
|
+
</span>
|
|
18
|
+
<i class="fa-solid tree-view-node-folder-icon" :class="expanded ? 'fa-folder-open' : 'fa-folder'" />
|
|
19
|
+
<span :title="props.entry.name">{{ props.entry.name }}</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div v-if="expanded && children.length > 0" class="tree-view-node-children">
|
|
22
|
+
<TreeViewNode
|
|
23
|
+
v-for="child in children"
|
|
24
|
+
:key="child.name"
|
|
25
|
+
:entry="child"
|
|
26
|
+
:list-files="props.listFiles"
|
|
27
|
+
:active-path="props.activePath"
|
|
28
|
+
:depth="props.depth + 1"
|
|
29
|
+
:fallback-icon="props.fallbackIcon"
|
|
30
|
+
:drop-handler="props.dropHandler"
|
|
31
|
+
@navigate="$emit('navigate', $event)"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup>
|
|
38
|
+
|
|
39
|
+
import { ref, computed, watch, onMounted } from 'vue';
|
|
40
|
+
|
|
41
|
+
const props = defineProps({
|
|
42
|
+
entry: {
|
|
43
|
+
type: Object,
|
|
44
|
+
required: true,
|
|
45
|
+
},
|
|
46
|
+
listFiles: {
|
|
47
|
+
type: Function,
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
50
|
+
activePath: {
|
|
51
|
+
type: String,
|
|
52
|
+
default: '/',
|
|
53
|
+
},
|
|
54
|
+
depth: {
|
|
55
|
+
type: Number,
|
|
56
|
+
default: 0,
|
|
57
|
+
},
|
|
58
|
+
fallbackIcon: {
|
|
59
|
+
type: String,
|
|
60
|
+
default: '',
|
|
61
|
+
},
|
|
62
|
+
expandOnMount: {
|
|
63
|
+
type: Boolean,
|
|
64
|
+
default: false,
|
|
65
|
+
},
|
|
66
|
+
dropHandler: {
|
|
67
|
+
type: Function,
|
|
68
|
+
default: null,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const emit = defineEmits(['navigate']);
|
|
73
|
+
|
|
74
|
+
const expanded = ref(false);
|
|
75
|
+
const children = ref([]);
|
|
76
|
+
const loading = ref(false);
|
|
77
|
+
const loaded = ref(false);
|
|
78
|
+
const dropTargetActive = ref(false);
|
|
79
|
+
let dragExpandTimer = null;
|
|
80
|
+
|
|
81
|
+
const isActive = computed(() => props.activePath === props.entry.path);
|
|
82
|
+
|
|
83
|
+
async function expand() {
|
|
84
|
+
if (loading.value) return;
|
|
85
|
+
|
|
86
|
+
loading.value = true;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const entries = await props.listFiles(props.entry.path);
|
|
90
|
+
children.value = entries
|
|
91
|
+
.filter(item => item.isDirectory)
|
|
92
|
+
.map(item => ({
|
|
93
|
+
name: item.name || item.fileName,
|
|
94
|
+
path: props.entry.path === '/'
|
|
95
|
+
? '/' + (item.name || item.fileName)
|
|
96
|
+
: props.entry.path + '/' + (item.name || item.fileName),
|
|
97
|
+
isDirectory: true,
|
|
98
|
+
icon: item.icon || props.fallbackIcon,
|
|
99
|
+
}));
|
|
100
|
+
loaded.value = true;
|
|
101
|
+
expanded.value = true;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error('TreeViewNode: failed to load children', e);
|
|
104
|
+
} finally {
|
|
105
|
+
loading.value = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function onToggle() {
|
|
110
|
+
if (expanded.value) {
|
|
111
|
+
expanded.value = false;
|
|
112
|
+
} else if (loaded.value) {
|
|
113
|
+
expanded.value = true;
|
|
114
|
+
} else {
|
|
115
|
+
await expand();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onRowClick() {
|
|
120
|
+
onToggle();
|
|
121
|
+
emit('navigate', { path: props.entry.path });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onDragOver(event) {
|
|
125
|
+
if (!props.dropHandler) return;
|
|
126
|
+
event.dataTransfer.dropEffect = 'move';
|
|
127
|
+
dropTargetActive.value = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onDragEnter(event) {
|
|
131
|
+
if (!props.dropHandler) return;
|
|
132
|
+
event.dataTransfer.dropEffect = 'move';
|
|
133
|
+
if (!expanded.value) dragExpandTimer = setTimeout(expand, 500);
|
|
134
|
+
dropTargetActive.value = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onDragLeave() {
|
|
138
|
+
clearTimeout(dragExpandTimer);
|
|
139
|
+
dropTargetActive.value = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onDrop(event) {
|
|
143
|
+
dropTargetActive.value = false;
|
|
144
|
+
if (!props.dropHandler) return;
|
|
145
|
+
clearTimeout(dragExpandTimer);
|
|
146
|
+
props.dropHandler(props.entry.path, event);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onMounted(() => {
|
|
150
|
+
if (props.expandOnMount) expand();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Auto-expand if the active path is a descendant of this node
|
|
154
|
+
watch(() => props.activePath, (newPath) => {
|
|
155
|
+
const prefix = props.entry.path === '/' ? '/' : props.entry.path + '/';
|
|
156
|
+
if (newPath && newPath !== props.entry.path && newPath.startsWith(prefix)) {
|
|
157
|
+
if (!expanded.value) {
|
|
158
|
+
expand();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}, { immediate: true });
|
|
162
|
+
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<style scoped>
|
|
166
|
+
|
|
167
|
+
.tree-view-node {
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
flex: 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.tree-view-node-row {
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
border-radius: var(--pankow-border-radius);
|
|
179
|
+
padding: 6px 8px 6px 0;
|
|
180
|
+
margin: 2px 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.tree-view-node-row:hover {
|
|
184
|
+
background-color: var(--pankow-color-background-hover);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.tree-view-node-row.active {
|
|
188
|
+
background-color: #dbedfb;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@media (prefers-color-scheme: dark) {
|
|
192
|
+
.tree-view-node-row.active {
|
|
193
|
+
background-color: rgba(255, 255, 255, 0.2);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.tree-view-node-row.drop-target {
|
|
198
|
+
background-color: #dbedfb;
|
|
199
|
+
outline: 2px solid var(--pankow-color-primary, #3b82f6);
|
|
200
|
+
outline-offset: -2px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@media (prefers-color-scheme: dark) {
|
|
204
|
+
.tree-view-node-row.drop-target {
|
|
205
|
+
background-color: rgba(255, 255, 255, 0.2);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.tree-view-node-chevron {
|
|
210
|
+
display: inline-flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
margin-right: 4px;
|
|
214
|
+
font-size: 10px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.tree-view-node-folder-icon {
|
|
218
|
+
margin-right: 6px;
|
|
219
|
+
color: var(--pankow-color-secondary);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
</style>
|
package/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import TagInput from './components/TagInput.vue';
|
|
|
43
43
|
import TextInput from './components/TextInput.vue';
|
|
44
44
|
import TextInputRaw from './components/TextInputRaw.vue';
|
|
45
45
|
import TopBar from './components/TopBar.vue';
|
|
46
|
+
import TreeView from './components/TreeView.vue';
|
|
46
47
|
import InputGroup from './components/InputGroup.vue'; // must be at the end for border-radius handling
|
|
47
48
|
|
|
48
49
|
import fetcher from './fetcher.js';
|
|
@@ -90,6 +91,7 @@ export {
|
|
|
90
91
|
TextInput,
|
|
91
92
|
TextInputRaw,
|
|
92
93
|
TopBar,
|
|
94
|
+
TreeView,
|
|
93
95
|
|
|
94
96
|
fetcher,
|
|
95
97
|
gestures,
|
package/package.json
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ import gestures from './gestures.js';
|
|
|
3
3
|
import tooltip from './tooltip.js';
|
|
4
4
|
import fallbackImage from './fallbackImage.js';
|
|
5
5
|
import utils from './utils.js';
|
|
6
|
-
export { BottomBar, Breadcrumb, Button, ButtonGroup, ClipboardAction, ClipboardButton, Checkbox, DateTimeInput, Dialog, DirectoryView, EmailInput, FileUploader, FormGroup, InputGroup, Icon, InputDialog, MainLayout, MaskedInput, Menu, MenuItem, SingleSelect, MultiSelect, Notification, NumberInput, OfflineBanner, PasswordInput, Popover, ProgressBar, Radiobutton, SideBar, Spinner, Switch, TableView, TabView, TagInput, TextInput, TextInputRaw, TopBar, fetcher, gestures, tooltip, fallbackImage, utils };
|
|
6
|
+
export { BottomBar, Breadcrumb, Button, ButtonGroup, ClipboardAction, ClipboardButton, Checkbox, DateTimeInput, Dialog, DirectoryView, EmailInput, FileUploader, FormGroup, InputGroup, Icon, InputDialog, MainLayout, MaskedInput, Menu, MenuItem, SingleSelect, MultiSelect, Notification, NumberInput, OfflineBanner, PasswordInput, Popover, ProgressBar, Radiobutton, SideBar, Spinner, Switch, TableView, TabView, TagInput, TextInput, TextInputRaw, TopBar, TreeView, fetcher, gestures, tooltip, fallbackImage, utils };
|