feat(workspace): Notion-style multi-select, select/unselect all, Library-style bulk action bar with move-to modal

This commit is contained in:
2026-09-11 16:42:46 -04:00
parent f1dd9d6181
commit da1fccb38f
2 changed files with 188 additions and 18 deletions
+5
View File
@@ -279,6 +279,10 @@
<!-- ── Multi-select bar ── -->
<div class="lib-select-bar" x-show="selectedCount > 0" x-transition>
<span class="sel-count" x-text="selectedCount + ' selected'"></span>
<button @click="toggleSelectAll()" :title="allSelected ? 'Clear selection' : 'Select all'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="!allSelected"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="allSelected" x-cloak><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="clearSelection()" title="Clear selection">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -329,6 +333,7 @@
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table-header">
<div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Page name
</div>
+183 -18
View File
@@ -59,6 +59,19 @@
.ws-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
.ws-select-bar button svg{width:14px;height:14px;}
/* ── Multi-select "…" dropdown (Library-style) ── */
.ws-select-menu{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:300;padding:6px;}
.ws-select-menu .menu-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.ws-select-menu .menu-item:hover{background:var(--bg-hover);}
.ws-select-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.ws-select-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);}
.ws-select-menu .menu-danger{color:var(--danger);}
.ws-select-menu .more-sep{height:1px;background:var(--border);margin:4px 8px;}
/* ── Move-to modal list items ── */
.move-modal-item{padding:7px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:6px;display:flex;align-items:center;gap:8px;}
.move-modal-item:hover{background:var(--bg-hover);}
/* ── Table (Notion-style rows) ── */
.ws-table{width:100%;}
.ws-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0 4px;}
@@ -534,6 +547,15 @@ window._wsInitData = (function() {
selectedIds: {},
selectedIdsCount: 0,
lastSelectedId: null,
allSelected: false,
moreOpen: false,
// Move-to modal
moveModalOpen: false,
moveIds: [],
moveSearchQuery: '',
moveCandidates: [],
_moveAllCandidates: [],
// Undo
undoVisible: false,
@@ -685,6 +707,7 @@ window._wsInitData = (function() {
this.flatTree = ft;
// filteredTableItems
this._computeFilteredTableItems();
this._syncSelection();
},
// ── Pin/Favorite items ──
@@ -702,7 +725,8 @@ window._wsInitData = (function() {
// ── Multi-select ──
toggleSelect(node, ev) {
if (ev.shiftKey && this.lastSelectedId) {
var e = ev || {};
if (e.shiftKey && this.lastSelectedId) {
// Range select
var allIds = this._collectVisibleIds(this.displayTree);
var start = allIds.indexOf(this.lastSelectedId);
@@ -711,18 +735,19 @@ window._wsInitData = (function() {
var from = Math.min(start, end), to = Math.max(start, end);
for (var i = from; i <= to; i++) this.selectedIds[allIds[i]] = true;
}
} else if (ev.ctrlKey || ev.metaKey) {
// Toggle single
} else if (e.ctrlKey || e.metaKey) {
// Toggle single, keep others
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
else this.selectedIds[node.id] = true;
} else {
// Replace selection
for (var k in this.selectedIds) delete this.selectedIds[k];
this.selectedIds[node.id] = true;
// Notion-style: toggle this item, keep the rest of the selection
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
else this.selectedIds[node.id] = true;
}
this.lastSelectedId = node.id;
// Force reactivity
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIdsCount = Object.keys(this.selectedIds).length;
this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
_collectVisibleIds(nodes) {
var ids = [];
@@ -755,16 +780,19 @@ window._wsInitData = (function() {
clearSelection() {
for (var k in this.selectedIds) delete this.selectedIds[k];
this.selectedIds = {};
this.selectedIdsCount = 0;
this.moreOpen = false;
this._syncSelection();
},
selectAll(ev) {
if (ev.target.checked) {
// Toggle: select / unselect all currently visible items
if (this.allSelected) {
for (var k in this.selectedIds) delete this.selectedIds[k];
} else {
var all = this._collectVisibleIds(this.displayTree);
for (var i=0; i<all.length; i++) this.selectedIds[all[i]] = true;
} else {
for (var k in this.selectedIds) delete this.selectedIds[k];
}
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIdsCount = Object.keys(this.selectedIds).length;
this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
// ── Table view data ──
@@ -1352,6 +1380,8 @@ window._wsInitData = (function() {
}
}
}
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
},
async navigateToFolder(id) {
@@ -1392,6 +1422,8 @@ window._wsInitData = (function() {
this._saveExpandedState();
this.previewItem = null;
this._recompute();
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
} catch(e) { console.error('Load folder error:', e); }
},
@@ -1466,6 +1498,8 @@ window._wsInitData = (function() {
// ── Keyboard shortcuts ──
onKeydown(ev) {
var tag = ev.target && ev.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return; // Don't hijack typing
if (this.renamingId) return; // Don't intercept during rename
if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
ev.preventDefault();
@@ -2304,7 +2338,86 @@ window._wsInitData = (function() {
for (var k in this.selectedIds) delete this.selectedIds[k];
}
this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
// ── Selection sync (keeps checkboxes & counters in sync) ──
_syncSelection() {
this.selectedIdsCount = Object.keys(this.selectedIds).length;
var total = this._collectVisibleIds(this.displayTree).length;
this.allSelected = total > 0 && this.selectedIdsCount >= total;
this._syncCheckboxes();
},
_syncCheckboxes() {
var boxes = document.querySelectorAll('input.item-checkbox[data-ws-id]');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = !!this.selectedIds[parseInt(boxes[i].getAttribute('data-ws-id'))];
}
},
// ── Multi-select actions (Library-style select bar) ──
_selectedIds() {
return Object.keys(this.selectedIds).map(Number);
},
copySelectedLinks() {
this.moreOpen = false;
var ids = Object.keys(this.selectedIds);
if (!ids.length) return;
var links = ids.map(function(id) { return window.location.origin + '/pages/' + id; });
navigator.clipboard.writeText(links.join('\n'));
if (window.showToast) window.showToast('Links copied', 'success');
},
async openMovePicker() {
var ids = this._selectedIds();
if (!ids.length) return;
this.moreOpen = false;
this.moveIds = ids;
this.moveSearchQuery = '';
this.moveModalOpen = true;
try {
// Fetch the full workspace tree so every folder is a valid destination
var r = await fetch('/api/local-workspace/tree');
var d = await r.json();
var folders = [];
this._collectFolders(d.tree || [], folders, 0);
this._moveAllCandidates = folders;
this.moveCandidates = folders.slice(0, 50);
} catch(e) {
this._moveAllCandidates = [];
this.moveCandidates = [];
}
},
_collectFolders(nodes, out, depth) {
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (n.is_folder) out.push({ id: n.id, name: n.name, depth: depth });
if (n.children) this._collectFolders(n.children, out, depth + 1);
}
},
doMoveSearch() {
var q = (this.moveSearchQuery || '').toLowerCase().trim();
if (!q) { this.moveCandidates = this._moveAllCandidates.slice(0, 50); return; }
this.moveCandidates = this._moveAllCandidates.filter(function(it) {
return (it.name || '').toLowerCase().indexOf(q) !== -1;
}).slice(0, 50);
},
async confirmBulkMove(parentId) {
this.moveModalOpen = false;
var ids = this.moveIds;
this.moveIds = [];
if (!ids || !ids.length) return;
for (var i = 0; i < ids.length; i++) {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch(e) {}
}
this.clearSelection();
this._reloadAfterAction();
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
}
};
@@ -2470,14 +2583,38 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<input x-ref="searchInput" type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter" autocomplete="off">
</div>
<!-- Bulk actions bar -->
<!-- Bulk actions bar (Library-style) -->
<div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition>
<span class="sel-count" x-text="selectedIdsCount + ' selected'"></span>
<button @click="clearSelection()" title="Clear"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button @click="bulkDelete()" style="margin-left:auto;color:var(--danger);" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Delete
<button @click="clearSelection()" title="Clear selection">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="copySelectedLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker()" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
<button @click.stop="moreOpen=!moreOpen" title="More actions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<div class="ws-select-menu" x-show="moreOpen" @click.outside="moreOpen=false" x-transition>
<div class="menu-item" @click="copySelectedLinks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="bulkDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash <span class="shortcut">Del</span>
</div>
</div>
</div>
</div>
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
@@ -2494,6 +2631,15 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Tree -->
<div x-show="viewMode==='tree'" style="display:contents;">
<!-- Select all toggle -->
<div x-show="viewMode==='tree' && displayTree.length>0"
style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin-bottom:2px;">
<input type="checkbox" class="item-checkbox" :checked="allSelected" @click="selectAll($event)" title="Select / unselect all">
<span style="font-size:12px;color:var(--text-secondary);cursor:pointer;user-select:none;" @click="selectAll($event)"
x-text="allSelected ? 'Clear selection' : 'Select all'"></span>
<span x-show="selectedIdsCount > 0" style="font-size:12px;color:var(--text-dim);" x-text="'(' + selectedIdsCount + ' selected)'"></span>
</div>
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
<!-- Parent folder ".." -->
<li x-show="currentFolder > 0" style="list-style:none;">
@@ -2615,7 +2761,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Filter table (shown when search/filter/tag is active) -->
<table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0">
<thead><tr>
<th style="width:30px"><input type="checkbox" @click="selectAllFiltered($event)"></th>
<th style="width:30px"><input type="checkbox" :checked="allSelected" @click="selectAllFiltered($event)"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
@@ -2820,6 +2966,25 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
</div>
<!-- Move-to modal -->
<div class="modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" @keydown.escape="moveModalOpen=false" style="display:none">
<div class="modal-box" style="width:440px;max-width:90vw;padding:0;overflow:hidden;display:flex;flex-direction:column;max-height:60vh;">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text-primary);">Move to…</div>
<div style="padding:8px 12px;border-bottom:1px solid var(--border);">
<input class="modal-input" type="text" placeholder="Search folders..." x-model="moveSearchQuery" @input="doMoveSearch()" style="padding:7px 10px;font-size:13px;">
</div>
<div style="flex:1;overflow-y:auto;padding:4px;">
<div class="move-modal-item" @click="confirmBulkMove(0)">{{ fd_icon("folder",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="'mv'+it.id">
<div class="move-modal-item" :style="{paddingLeft: (8 + (it.depth||0)*20) + 'px'}" @click="confirmBulkMove(it.id)">
{{ fd_icon("folder",14) }} <span x-text="it.name"></span>
</div>
</template>
<div x-show="moveCandidates.length === 0" style="padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;">No folders found</div>
</div>
</div>
</div>
<!-- Create Modal -->
<div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none">
<div class="modal-box">