feat: drag and drop move in File Manager

This commit is contained in:
NGPixel committed 2026-10-26 04:41:53 -04:00
1 parent 5adf0ce795
commit 4229781039
10 files changed
+583 -86

No files matched your search

+6
View File
@@ -3261,6 +3261,12 @@
"fileman.detailsTitle": "Title",
"fileman.dmgFileType": "Apple Disk Image File",
"fileman.docxFileType": "Microsoft Word Document",
"fileman.dropMove.confirm": "Move {name} into {destination}?",
"fileman.dropMove.failed": "Failed to move the item.",
"fileman.dropMove.folderHint": "Every page and file under it moves with it.",
"fileman.dropMove.pageSuccess": "Page moved successfully.",
"fileman.dropMove.root": "the root folder",
"fileman.dropMove.title": "Move Item",
"fileman.epsFileType": "EPS Image",
"fileman.excalidrawPageType": "Drawing",
"fileman.exeFileType": "Windows Executable",
+22 -22
View File
@@ -1,16 +1,16 @@
{
"name": "wiki-backend",
"version": "3.0.0",
"version": "3.0.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wiki-backend",
"version": "3.0.0",
"version": "3.0.0-dev",
"license": "AGPL-3.0",
"dependencies": {
"@aws-sdk/client-s3": "3.1155.0",
"@aws-sdk/s3-request-presigner": "3.1155.0",
"@aws-sdk/client-s3": "3.1156.0",
"@aws-sdk/s3-request-presigner": "3.1156.0",
"@azure/identity": "4.14.0",
"@azure/storage-blob": "12.34.0",
"@fastify/compress": "9.2.2",
@@ -40,10 +40,10 @@
"diff": "9.0.0",
"drizzle-orm": "1.0.0-rc.4",
"emittery": "2.2.0",
"es-toolkit": "1.52.0",
"es-toolkit": "1.53.0",
"fastify": "5.12.7",
"fastify-favicon": "5.0.0",
"filesize": "11.0.28",
"filesize": "11.0.29",
"fs-extra": "11.4.1",
"js-yaml": "5.5.0",
"ldapts": "9.2.0",
@@ -51,7 +51,7 @@
"mime": "4.1.1",
"nanoid": "6.0.2",
"node-cache": "5.1.2",
"nodemailer": "10.2.0",
"nodemailer": "10.2.1",
"openid-client": "6.8.8",
"pg": "8.23.1",
"poolifier": "6.0.2",
@@ -120,9 +120,9 @@
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1155.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1155.0.tgz",
"integrity": "sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==",
"version": "3.1156.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1156.0.tgz",
"integrity": "sha512-Z2PqQqKMDVX56tNZ6ggmjoI5o/8+4+8ArmjLwE5ktYsEzs1BrvklBT1ZzI+s0Vcf3wfPguoEMD7EkMswmEta/A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.5",
@@ -345,9 +345,9 @@
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1155.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1155.0.tgz",
"integrity": "sha512-t0AlTvpL6bqCGlN2avesGtNNZ/qssSJgIZgIMEoQan0dH8k64k8iK5jDWOGFAjTs4G+ALvXjjkNl5ITxhYGpxw==",
"version": "3.1156.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1156.0.tgz",
"integrity": "sha512-M4yj4HmAPydhIMxKK90eTcnHr5FHBl000d2oFMAWpt+XPX86c2kmNJpiYbKrPLIe8jyK9sweRt5CDdWrhq/QTg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.5",
@@ -5346,9 +5346,9 @@
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.0.tgz",
"integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==",
"license": "MIT",
"workspaces": [
"docs",
@@ -5675,9 +5675,9 @@
}
},
"node_modules/filesize": {
"version": "11.0.28",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.28.tgz",
"integrity": "sha512-tsZcwUmhL0WQpsxe9Oua6FBjb0aBTRkXobZBhQdkoII0pYgy9h+cXm5tp13Fsr8dSwp1+UVDxNwAFULDpxkXaw==",
"version": "11.0.29",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.29.tgz",
"integrity": "sha512-+Mkmp8keoklLzAnvP/RQGDUEWEpjGmhvm4d4U739C1ZnkqxqoSqdLHnjv9/4QX6lltKSRk6qzQI7+94JVDtuCA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 10.8.0"
@@ -6651,9 +6651,9 @@
}
},
"node_modules/nodemailer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz",
"integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
"integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
+5 -5
View File
@@ -35,8 +35,8 @@
"db-up": "drizzle-kit up --dialect=postgresql --out=./db/migrations"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1155.0",
"@aws-sdk/s3-request-presigner": "3.1155.0",
"@aws-sdk/client-s3": "3.1156.0",
"@aws-sdk/s3-request-presigner": "3.1156.0",
"@azure/identity": "4.14.0",
"@azure/storage-blob": "12.34.0",
"@fastify/compress": "9.2.2",
@@ -66,10 +66,10 @@
"diff": "9.0.0",
"drizzle-orm": "1.0.0-rc.4",
"emittery": "2.2.0",
"es-toolkit": "1.52.0",
"es-toolkit": "1.53.0",
"fastify": "5.12.7",
"fastify-favicon": "5.0.0",
"filesize": "11.0.28",
"filesize": "11.0.29",
"fs-extra": "11.4.1",
"js-yaml": "5.5.0",
"ldapts": "9.2.0",
@@ -77,7 +77,7 @@
"mime": "4.1.1",
"nanoid": "6.0.2",
"node-cache": "5.1.2",
"nodemailer": "10.2.0",
"nodemailer": "10.2.1",
"openid-client": "6.8.8",
"pg": "8.23.1",
"poolifier": "6.0.2",
+20 -20
View File
@@ -24,8 +24,8 @@
"d3-shape": "3.2.0",
"d3-transition": "3.0.1",
"d3-zoom": "3.0.0",
"es-toolkit": "1.52.0",
"filesize": "11.0.28",
"es-toolkit": "1.53.0",
"filesize": "11.0.29",
"filesize-parser": "1.5.2",
"fuse.js": "7.5.0",
"highlight.js": "11.12.0",
@@ -53,13 +53,13 @@
"prosemirror-history": "1.5.1",
"prosemirror-inputrules": "1.5.1",
"prosemirror-keymap": "1.2.3",
"prosemirror-markdown": "1.13.8",
"prosemirror-markdown": "1.13.9",
"prosemirror-model": "1.25.12",
"prosemirror-schema-list": "1.5.1",
"prosemirror-state": "1.4.4",
"prosemirror-tables": "1.8.5",
"prosemirror-transform": "1.12.2",
"prosemirror-view": "1.42.6",
"prosemirror-transform": "1.12.4",
"prosemirror-view": "1.42.8",
"react": "19.3.1",
"react-dom": "19.3.1",
"semver": "7.8.5",
@@ -5099,9 +5099,9 @@
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.0.tgz",
"integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==",
"license": "MIT",
"workspaces": [
"docs",
@@ -5160,9 +5160,9 @@
}
},
"node_modules/filesize": {
"version": "11.0.28",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.28.tgz",
"integrity": "sha512-tsZcwUmhL0WQpsxe9Oua6FBjb0aBTRkXobZBhQdkoII0pYgy9h+cXm5tp13Fsr8dSwp1+UVDxNwAFULDpxkXaw==",
"version": "11.0.29",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.29.tgz",
"integrity": "sha512-+Mkmp8keoklLzAnvP/RQGDUEWEpjGmhvm4d4U739C1ZnkqxqoSqdLHnjv9/4QX6lltKSRk6qzQI7+94JVDtuCA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 10.8.0"
@@ -6743,9 +6743,9 @@
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.8.tgz",
"integrity": "sha512-Zi43BXX28jmxDJKvk8TDS2gMkBY/mgP8ynd6l/Vxrv8rgNFs5hsV1G0VscxTID1UCzVPcidADP9fUOBY3xxUeA==",
"version": "1.13.9",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.9.tgz",
"integrity": "sha512-qAI3I9YdtjWHjTCBErc08ivr24KSrUjcTh5dYncCxFM+JS02kT0iHeWYjO35xeTPwt5t8rhFrelewXhXz16WZQ==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
@@ -6792,18 +6792,18 @@
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
"version": "1.42.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",
+5 -5
View File
@@ -33,8 +33,8 @@
"d3-shape": "3.2.0",
"d3-transition": "3.0.1",
"d3-zoom": "3.0.0",
"es-toolkit": "1.52.0",
"filesize": "11.0.28",
"es-toolkit": "1.53.0",
"filesize": "11.0.29",
"filesize-parser": "1.5.2",
"fuse.js": "7.5.0",
"highlight.js": "11.12.0",
@@ -62,13 +62,13 @@
"prosemirror-history": "1.5.1",
"prosemirror-inputrules": "1.5.1",
"prosemirror-keymap": "1.2.3",
"prosemirror-markdown": "1.13.8",
"prosemirror-markdown": "1.13.9",
"prosemirror-model": "1.25.12",
"prosemirror-schema-list": "1.5.1",
"prosemirror-state": "1.4.4",
"prosemirror-tables": "1.8.5",
"prosemirror-transform": "1.12.2",
"prosemirror-view": "1.42.6",
"prosemirror-transform": "1.12.4",
"prosemirror-view": "1.42.8",
"react": "19.3.1",
"react-dom": "19.3.1",
"semver": "7.8.5",
+277 -28
View File
@@ -130,7 +130,10 @@
@lazy-load="treeLazyLoad"
:use-lazy-load="true"
@context-action="treeContextAction"
:display-mode="state.displayMode" />
:display-mode="state.displayMode"
:drag-active="state.dragItem !== null"
:can-drop="canDropOn"
@drop-item="dropOnFolder" />
</div>
</w-scroll-area>
<!--
@@ -550,6 +553,10 @@
<img src="/_assets/icons/carbon-copy-empty-box.svg" />
<span>This folder is empty.</span>
</div>
<!--
Every row can be dragged onto a folder -- a folder row here, or any folder in the tree,
root included -- to move it there. Only folder rows take a drop.
-->
<w-list class="fileman-filelist" v-else :class="state.isCompact && `is-compact`">
<w-item
v-for="item of files"
@@ -557,8 +564,16 @@
clickable
active-class="active"
:active="item.id === state.currentFileId"
:class="{ 'is-drop-target': item.id === state.dropFolderId }"
draggable="true"
@click="selectItem(item)"
@dblclick="doubleClickItem(item)">
@dblclick="doubleClickItem(item)"
@dragstart="onItemDragStart($event, item)"
@dragend="onItemDragEnd"
@dragenter="onListDragOver($event, item)"
@dragover="onListDragOver($event, item)"
@dragleave="onListDragLeave($event, item)"
@drop="onListDrop($event, item)">
<w-item-section class="fileman-filelist-icon" avatar>
<w-icon
:name="item.icon"
@@ -945,7 +960,11 @@ const state = reactive({
* Whether the graph view covers the manager. Independent of `isRecycleBin`: it goes over whichever
* of the two was showing, and closing it comes back to that.
*/
isGraph: false
isGraph: false,
/** The listing entry being dragged, from its `dragstart` to its `dragend`. */
dragItem: null,
/** The folder row in the listing a drag would drop into, which is what lights it up. */
dropFolderId: null
})
// -> Over the defaults just above, which is what the view falls back to on a first visit
@@ -1638,18 +1657,10 @@ function moveFolder(folderId) {
caption: t('fileman.folderMovingHint')
})
try {
const resp = await API_CLIENT.put(`sites/${siteStore.id}/tree/folders/${folderId}/path`, {
json: {
folderPath: opts.folderPath,
locale: opts.locale
},
// -> Nothing on this side gives up on it, since the server does not either
timeout: false
}).json()
// -> The API client does not throw on 400, so a refused destination comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
const resp = await requestFolderMove(folderId, {
folderPath: opts.folderPath,
locale: opts.locale
})
notify({
type: 'positive',
message: resp.message
@@ -1669,6 +1680,25 @@ function moveFolder(folderId) {
})
}
/**
* The request behind every folder move, answering with the folder where it now is.
*
* @param folderPath The new parent's path, empty for the root.
* @param locale The locale to move it to; it stays in its own when absent.
*/
async function requestFolderMove(folderId, { folderPath, locale }) {
const resp = await API_CLIENT.put(`sites/${siteStore.id}/tree/folders/${folderId}/path`, {
json: { folderPath, locale },
// -> Nothing on this side gives up on it, since the server does not either
timeout: false
}).json()
// -> The API client does not throw on 400, so a refused destination comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
return resp
}
/**
* Colour a folder's icon.
*
@@ -1986,19 +2016,13 @@ function renameMoveAsset(item) {
}
}).onOk(async (opts) => {
try {
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${item.id}`, {
json: {
fileName: opts.fileName,
folderPath: opts.folderPath,
// -> The destination is a locale as well as a folder, as it is for a page: the same folder
// in another locale is somewhere else
locale: opts.locale
}
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
const resp = await requestAssetMove(item.id, {
fileName: opts.fileName,
folderPath: opts.folderPath,
// -> The destination is a locale as well as a folder, as it is for a page: the same folder
// in another locale is somewhere else
locale: opts.locale
})
notify({
type: 'positive',
message: resp.message
@@ -2015,6 +2039,21 @@ function renameMoveAsset(item) {
})
}
/**
* The request behind every file rename and move. Each of the three is optional, and one left out
* stays as it is.
*/
async function requestAssetMove(assetId, { fileName, folderPath, locale }) {
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${assetId}`, {
json: { fileName, folderPath, locale }
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
return resp
}
/**
* Resize an image, over itself or into a new file beside it. Either way the folder is listed again:
* a new file has to appear in it, and a replaced one has a new size and thumbnail to show.
@@ -2363,6 +2402,211 @@ function delItem(item) {
}
}
// --------------------------------------
// DRAG AND DROP
// --------------------------------------
/**
* The type a listing row is dragged as. Its own rather than `text/plain`, so that nothing outside the
* manager -- an editor's text area, another tab -- takes a dragged row for something to paste.
*/
const DRAG_TYPE = 'application/x-wiki-fileman-item'
/** A loaded folder's own path, as the move endpoints address a destination. */
function treeNodePath(node) {
return node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName
}
/**
* Whether the row being dragged may be dropped into a folder (`null` for the root). Asked by the
* listing's folder rows and by every row of the tree.
*
* Every row in the listing sits in the folder being listed, so that one is never a destination. Nor,
* for a folder, is the folder itself or anything under it.
*/
function canDropOn(folderId) {
const item = state.dragItem
if (!item || folderId === state.currentFolderId) {
return false
}
const target = folderId ? state.treeNodes[folderId] : null
if (folderId && !target) {
return false
}
if (item.type === 'folder') {
if (folderId === item.id) {
return false
}
const source = state.treeNodes[item.id]
if (!source) {
return false
}
if (target && treeNodePath(target).startsWith(`${treeNodePath(source)}/`)) {
return false
}
}
return true
}
function onItemDragStart(ev, item) {
ev.dataTransfer.effectAllowed = 'move'
// -> Firefox starts no drag without some data, though nothing here reads it back
ev.dataTransfer.setData(DRAG_TYPE, item.id)
state.dragItem = item
}
/** The end of every drag, whether it was dropped, dropped somewhere that refused it, or cancelled. */
function onItemDragEnd() {
state.dragItem = null
state.dropFolderId = null
}
/*
The listing's half of the drop target, which the tree does for itself (see `TreeNav`). Driven from
`dragover` for the same reason: it keeps firing while the pointer is over the row, so the highlight
cannot be left behind by a `dragleave` fired on one of the row's children.
*/
function onListDragOver(ev, item) {
if (item.type !== 'folder' || !canDropOn(item.id)) {
return
}
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
state.dropFolderId = item.id
}
function onListDragLeave(ev, item) {
if (ev.currentTarget.contains(ev.relatedTarget)) {
return
}
if (state.dropFolderId === item.id) {
state.dropFolderId = null
}
}
function onListDrop(ev, item) {
state.dropFolderId = null
if (item.type !== 'folder' || !canDropOn(item.id)) {
return
}
ev.preventDefault()
dropOnFolder(item.id)
}
/**
* A row dropped onto a folder: asked about first, then moved.
*
* The item is captured here, because `dragend` clears it as soon as this returns -- long before the
* dialog is answered.
*/
function dropOnFolder(folderId) {
const item = state.dragItem
if (!item || !canDropOn(folderId)) {
return
}
const target = folderId ? state.treeNodes[folderId] : null
const folderPath = target ? treeNodePath(target) : ''
dialog({
component: defineAsyncComponent(() => import('@/components/FileManagerMoveDialog.vue')),
componentProps: {
itemType: item.type,
itemTitle: item.title,
folderTitle: target?.title ?? null,
folderPath
}
}).onOk((opts) => {
moveItemInto(item, folderId, folderPath, opts)
})
}
/**
* Move a listing entry into another folder of the same locale, keeping its name.
*
* The view stays where it is, unlike the Move To actions: a drop is a quick gesture, often one of
* several in a row, and following each item away would mean finding the way back every time.
*/
async function moveItemInto(item, folderId, folderPath, { updateLinks = false } = {}) {
try {
switch (item.type) {
case 'page': {
const relinked = await pageStore.pageMove({
id: item.id,
path: folderPath ? `${folderPath}/${item.fileName}` : item.fileName,
locale: state.locale,
updateLinks
})
notify({
type: 'positive',
message: t('fileman.dropMove.pageSuccess')
})
notifyRelinked(relinked, t)
break
}
case 'asset': {
const resp = await requestAssetMove(item.id, { folderPath })
notify({
type: 'positive',
message: resp.message
})
break
}
case 'folder': {
// -> Blocking, as Move To is, and for its reason: see `moveFolder`
loading.show({
message: t('fileman.folderMoving'),
caption: t('fileman.folderMovingHint')
})
try {
const resp = await requestFolderMove(item.id, { folderPath })
notify({
type: 'positive',
message: resp.message
})
await relocateTreeBranch(item.id, folderId)
} finally {
loading.hide()
}
break
}
}
} catch (err) {
notify({
type: 'negative',
message: t('fileman.dropMove.failed'),
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
// -> Whatever happened, the listing is fetched again: the row has gone, or a folder beside it now
// holds one more thing -- and a failure may have been a conflict the listing is out of date about
await loadTree({ parentId: state.currentFolderId })
}
/**
* Put a moved folder where it now is in the tree, without starting the tree over.
*
* The folder and everything cached under it is forgotten -- all of it has a new path -- and the
* destination's folders are fetched again, which brings the folder back under its new parent. What
* was under it is fetched again when it is next opened.
*/
async function relocateTreeBranch(folderId, destinationId) {
const node = state.treeNodes[folderId]
if (node) {
const branchPath = treeNodePath(node)
for (const [id, other] of Object.entries(state.treeNodes)) {
if (id === folderId || treeNodePath(other).startsWith(`${branchPath}/`)) {
delete state.treeNodes[id]
treeComp.value?.setLoaded(id, false)
}
}
}
for (const other of Object.values(state.treeNodes)) {
other.children = other.children.filter((c) => c !== folderId)
}
state.treeRoots = state.treeRoots.filter((c) => c !== folderId)
// -> Never the folder being listed, which cannot be a destination: this fetch touches the tree alone
await loadTree({ parentId: destinationId, types: ['folder'] })
}
/**
* Ctrl+K reaches THIS search field while the overlay is up.
*
@@ -2771,6 +3015,11 @@ $fileman-bottom-row-height: 45px;
color: rgba(255, 255, 255, 0.7);
}
}
// -> The folder a dragged item would land in, outlined the way the tree outlines one
&.is-drop-target {
box-shadow: inset 0 0 0 2px var(--color-primary);
}
}
&.is-compact {
@@ -0,0 +1,114 @@
<template>
<w-dialog v-model="dialogVisible" max-width="850px" @hide="onDialogHide">
<w-card style="min-width: 550px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-document-in-folder.svg" size="sm" class="mr-2" />
<span>{{ t(`fileman.dropMove.title`) }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2">
<i18n-t keypath="fileman.dropMove.confirm">
<template #name>
<strong>{{ itemTitle }}</strong>
</template>
<template #destination>
<strong>{{ folderTitle ?? t(`fileman.dropMove.root`) }}</strong>
</template>
</i18n-t>
</div>
<div class="text-caption text-grey mt-2">/{{ folderPath ? `${folderPath}/` : `` }}</div>
<div v-if="itemType === `folder`" class="text-caption text-grey mt-2">
{{ t(`fileman.dropMove.folderHint`) }}
</div>
</w-card-section>
<!-- -> The same choice, and the same default, the Rename / Move Page dialog offers -->
<w-list v-if="itemType === `page`">
<w-item tag="label">
<blueprint-icon icon="link" />
<w-item-section>
<w-item-label>{{ t(`pageRenameDialog.updateLinks`) }}</w-item-label>
<w-item-label caption>{{ t(`pageRenameDialog.updateLinksHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle v-model="state.updateLinks" :aria-label="t(`pageRenameDialog.updateLinks`)" />
</w-item-section>
</w-item>
</w-list>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="cancel"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.move`)"
color="primary"
padding="xs md"
@click="confirm" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
/*
Asks before a drop in the File Manager moves anything. It only asks: the move itself is the File
Manager's to make, since what it refreshes afterwards depends on what moved.
*/
import { useI18n } from 'vue-i18n'
import { reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
// PROPS
const props = defineProps({
/** `page`, `asset` or `folder` -- which decides what else there is to say or ask. */
itemType: {
type: String,
required: true
},
itemTitle: {
type: String,
required: true
},
/** The destination's title, or null for the root. */
folderTitle: {
type: String,
default: null
},
/** The destination's path, empty for the root. */
folderPath: {
type: String,
default: ''
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
updateLinks: true
})
// METHODS
function confirm() {
onDialogOK(props.itemType === 'page' ? { updateLinks: state.updateLinks } : {})
}
</script>
+11 -1
View File
@@ -2,7 +2,14 @@
<ul class="treeview-level">
<!-- ROOT NODE -->
<li class="treeview-node" v-if="!props.parentId">
<div class="treeview-label" @click="setRoot" :class="{ active: !selection }">
<div
class="treeview-label"
@click="setRoot"
:class="{ active: !selection, 'is-drop-target': isDropTarget }"
@dragenter="dropTarget.over($event, null)"
@dragover="dropTarget.over($event, null)"
@dragleave="dropTarget.leave($event, null)"
@drop="dropTarget.drop($event, null)">
<w-icon name="img:/_assets/icons/fluent-ftp.svg" size="sm" />
<div class="treeview-label-text" :class="dark.isActive ? `text-purple-4` : `text-purple`">
root
@@ -74,6 +81,7 @@ const nodes = inject('nodes')
const selection = inject('selection')
// -> A computed, not a plain array: see the note where `TreeNav` provides it
const contextActionList = inject('contextActionList')
const dropTarget = inject('dropTarget')
// COMPOSABLES
@@ -88,6 +96,8 @@ const rootContextActionList = computed(() => {
return contextActionList.value.filter((c) => c.key === 'newFolder')
})
const isDropTarget = computed(() => dropTarget.isOver(null) && dropTarget.accepts(null))
const level = computed(() => {
const items = []
if (!props.parentId) {
+86 -3
View File
@@ -6,7 +6,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, provide, reactive, toRef } from 'vue'
import { computed, onMounted, provide, reactive, toRef, watch } from 'vue'
import { findKey } from 'es-toolkit/object'
import TreeLevel from './TreeLevel.vue'
@@ -37,12 +37,30 @@ const props = defineProps({
displayMode: {
type: String,
default: 'title'
},
/**
* Whether something the parent cares about is being dragged right now. The tree cannot tell on its
* own: a drag is started elsewhere (the File Manager's list), and one from outside the page -- a
* file off the desktop -- must not open folders or light rows up.
*/
dragActive: {
type: Boolean,
default: false
},
/**
* Whether the item being dragged may be dropped on a node (`null` for the root). Leaving it unset
* is what keeps the tree a plain picker: nothing is ever a drop target, which is how every dialog
* that browses folders uses it.
*/
canDrop: {
type: Function,
default: null
}
})
// EMITS
const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction', 'dropItem'])
// I18N
@@ -107,7 +125,12 @@ provide(
const state = reactive({
loaded: {},
opened: {}
opened: {},
/**
* The node a drag is over, droppable or not -- the folder the item already sits in still opens on a
* hover, so that the folders under it can be reached. `null` is the root, so nothing is `undefined`.
*/
dragOverId: undefined
})
// COMPUTED
@@ -131,6 +154,59 @@ function emitLazyLoad(nodeId, isCurrent, clb) {
}
}
/*
One set of handlers for every row, the root included, keyed by node. The drag is tracked from
`dragover` rather than from `dragenter` / `dragleave` pairs: those fire for every child element a
pointer crosses, and a row that missed one would stay lit. `dragover` keeps firing while the pointer
is anywhere over the row, so a wrong guess is corrected a moment later.
*/
function acceptsDrop(nodeId) {
return props.dragActive && Boolean(props.canDrop?.(nodeId))
}
const dropTarget = {
isOver: (nodeId) => props.dragActive && state.dragOverId === nodeId,
accepts: acceptsDrop,
over(ev, nodeId) {
if (!props.dragActive || !props.canDrop) {
return
}
state.dragOverId = nodeId
// -> Cancelling the event is what makes the row a drop target at all
if (acceptsDrop(nodeId)) {
ev.preventDefault()
ev.dataTransfer.dropEffect = 'move'
}
},
leave(ev, nodeId) {
// -> Moving onto the row's own icon or label is not leaving it
if (ev.currentTarget.contains(ev.relatedTarget)) {
return
}
if (state.dragOverId === nodeId) {
state.dragOverId = undefined
}
},
drop(ev, nodeId) {
state.dragOverId = undefined
if (!acceptsDrop(nodeId)) {
return
}
ev.preventDefault()
emit('dropItem', nodeId)
}
}
// -> A drag cancelled with Escape, or dropped anywhere else, ends here too
watch(
() => props.dragActive,
(active) => {
if (!active) {
state.dragOverId = undefined
}
}
)
function setOpened(nodeId) {
state.opened[nodeId] = true
}
@@ -159,6 +235,7 @@ provide('opened', state.opened)
provide('displayMode', toRef(props, 'displayMode'))
provide('selection', selection)
provide('emitLazyLoad', emitLazyLoad)
provide('dropTarget', dropTarget)
// EXPOSE
@@ -239,6 +316,12 @@ onMounted(() => {
}
}
// -> Where a dragged item would land. Outlined rather than filled, so it reads over the selected
// row's fill as well as over a plain one
&.is-drop-target {
box-shadow: inset 0 0 0 2px var(--color-primary);
}
> .w-icon {
margin-right: 5px;
}
+37 -2
View File
@@ -1,7 +1,14 @@
<template>
<li class="treeview-node">
<!-- NODE -->
<div class="treeview-label" @click="openNode" :class="{ active: isActive }">
<div
class="treeview-label"
@click="openNode"
:class="{ active: isActive, 'is-drop-target': isDropTarget }"
@dragenter="dropTarget.over($event, node.id)"
@dragover="dropTarget.over($event, node.id)"
@dragleave="dropTarget.leave($event, node.id)"
@drop="dropTarget.drop($event, node.id)">
<w-icon :name="icon" size="sm" :style="hueFilter" @click.stop="toggleNode()" />
<div class="treeview-label-text">
{{ displayMode === 'path' ? node.fileName : node.title }}
@@ -50,7 +57,7 @@
</template>
<script setup>
import { computed, inject, reactive } from 'vue'
import { computed, inject, onBeforeUnmount, reactive, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { folderIconStyle } from '@/helpers/folderColors'
@@ -84,6 +91,10 @@ const emitLazyLoad = inject('emitLazyLoad')
// -> A computed, not a plain array: see the note where `TreeNav` provides it. Unwrapped by the
// template, which is the only place it is read here.
const contextActionList = inject('contextActionList')
const dropTarget = inject('dropTarget')
/** How long a drag has to rest on a closed folder before it opens, to reach the folders inside. */
const DRAG_EXPAND_DELAY_MS = 700
// DATA
@@ -122,6 +133,30 @@ const isOpened = computed(() => {
const isActive = computed(() => {
return state.isContextMenuShown || selection.value === props.node.id
})
const isDragOver = computed(() => dropTarget.isOver(props.node.id))
const isDropTarget = computed(() => isDragOver.value && dropTarget.accepts(props.node.id))
// WATCHERS
/*
Opens a closed folder a drag is resting on, droppable or not: the folder an item already sits in
cannot take it, but a folder inside it can. Never closes one -- a drag passing over an open folder
is on its way somewhere in it.
*/
let dragExpandTimer = null
watch(isDragOver, (over) => {
clearTimeout(dragExpandTimer)
if (over && !isOpened.value) {
dragExpandTimer = setTimeout(() => {
if (isDragOver.value && !isOpened.value) {
toggleNode()
}
}, DRAG_EXPAND_DELAY_MS)
}
})
onBeforeUnmount(() => {
clearTimeout(dragExpandTimer)
})
// METHODS