mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
feat(ui): enhance file browser drag-and-drop and move functionality
Docker Images / Build and push (push) Failing after 15s
Docker Images / Build and push (push) Failing after 15s
Refactor the file browser to improve the user experience and reliability of folder management and item movement. - Implement a dedicated drag handle wrapper to improve drag-and-drop precision and visual feedback. - Improve drag-and-drop event handling to prevent accidental triggers and ensure correct visual states during drag operations. - Refactor the move modal logic and styling for better clarity and usability. - Fix folder menu closing logic to correctly handle outside clicks. - Update CI workflow to ensure Docker is installed before building images.
This commit is contained in:
@@ -28,6 +28,15 @@ jobs:
|
|||||||
id: image
|
id: image
|
||||||
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker
|
||||||
|
run: |
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
rm get-docker.sh
|
||||||
|
fi
|
||||||
|
docker --version
|
||||||
|
|
||||||
- name: Use GitHub token for GHCR
|
- name: Use GitHub token for GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -132,6 +132,10 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.folderItem {
|
.folderItem {
|
||||||
@@ -173,11 +177,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dragHandle {
|
.dragHandleWrapper {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--duration-fast) var(--ease-out);
|
transition: opacity var(--duration-fast) var(--ease-out);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
.folderItem:hover & {
|
.folderItem:hover & {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -186,7 +206,7 @@
|
|||||||
|
|
||||||
.dragOver {
|
.dragOver {
|
||||||
background: var(--color-surface-primary-container);
|
background: var(--color-surface-primary-container);
|
||||||
border-color: var(--color-primary);
|
border: 2px dashed var(--color-primary);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,18 +445,6 @@
|
|||||||
margin: var(--space-1) 0;
|
margin: var(--space-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownSubmenu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownSubheader {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newProjectForm {
|
.newProjectForm {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -630,3 +638,58 @@
|
|||||||
&:hover { background: var(--color-primary-darker); }
|
&:hover { background: var(--color-primary-darker); }
|
||||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.moveHint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-low);
|
||||||
|
border-color: var(--default-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moveItemActive {
|
||||||
|
background: var(--color-surface-primary-container);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary-darkest);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveCurrent {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-muted);
|
||||||
|
background: var(--color-surface-low);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
// Move state
|
// Move state
|
||||||
const [movingId, setMovingId] = useState<string | null>(null);
|
const [moveModalDrawing, setMoveModalDrawing] = useState<Drawing | null>(null);
|
||||||
|
|
||||||
// Folder menu state
|
// Folder menu state
|
||||||
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
|
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
|
||||||
@@ -227,8 +227,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.drawings.update(drawing.id, { folder_id: folderId });
|
await api.drawings.update(drawing.id, { folder_id: folderId });
|
||||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
||||||
setMovingId(null);
|
setMoveModalDrawing(null);
|
||||||
setActiveMenu(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to move drawing:', err);
|
console.error('Failed to move drawing:', err);
|
||||||
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
||||||
@@ -284,23 +283,36 @@ export const FileBrowser: React.FC = () => {
|
|||||||
|
|
||||||
// Drag and drop handlers for folders
|
// Drag and drop handlers for folders
|
||||||
const handleDragStart = (e: React.DragEvent, folderId: string) => {
|
const handleDragStart = (e: React.DragEvent, folderId: string) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isHandle = target.closest(`.${styles.dragHandleWrapper}`) !== null;
|
||||||
|
if (!isHandle) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDraggedFolderId(folderId);
|
setDraggedFolderId(folderId);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, folderId: string) => {
|
const handleDragOver = (e: React.DragEvent, folderId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
if (draggedFolderId && draggedFolderId !== folderId) {
|
if (draggedFolderId && draggedFolderId !== folderId) {
|
||||||
setDragOverFolderId(folderId);
|
setDragOverFolderId(folderId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
const related = e.relatedTarget as HTMLElement;
|
||||||
|
const current = e.currentTarget as HTMLElement;
|
||||||
|
if (related && current.contains(related)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDragOverFolderId(null);
|
setDragOverFolderId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => {
|
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (!draggedFolderId || draggedFolderId === targetFolderId) {
|
if (!draggedFolderId || draggedFolderId === targetFolderId) {
|
||||||
setDraggedFolderId(null);
|
setDraggedFolderId(null);
|
||||||
setDragOverFolderId(null);
|
setDragOverFolderId(null);
|
||||||
@@ -342,9 +354,16 @@ export const FileBrowser: React.FC = () => {
|
|||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
const target = e.target as HTMLElement;
|
||||||
|
if (menuRef.current && !menuRef.current.contains(target)) {
|
||||||
setActiveMenu(null);
|
setActiveMenu(null);
|
||||||
}
|
}
|
||||||
|
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
|
||||||
|
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
|
||||||
|
if (!isMenuBtn) {
|
||||||
|
setFolderMenuId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', onClick);
|
document.addEventListener('mousedown', onClick);
|
||||||
return () => document.removeEventListener('mousedown', onClick);
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
@@ -503,7 +522,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, folder.id)}
|
onDragStart={(e) => handleDragStart(e, folder.id)}
|
||||||
onDragOver={(e) => handleDragOver(e, folder.id)}
|
onDragOver={(e) => handleDragOver(e, folder.id)}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={(e) => handleDragLeave(e)}
|
||||||
onDrop={(e) => handleDrop(e, folder.id)}
|
onDrop={(e) => handleDrop(e, folder.id)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
|
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
|
||||||
@@ -532,7 +551,13 @@ export const FileBrowser: React.FC = () => {
|
|||||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className={styles.dragHandle} aria-hidden="true" />
|
<span
|
||||||
|
className={styles.dragHandleWrapper}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className={styles.dragHandle} />
|
||||||
|
</span>
|
||||||
<Folder size={18} aria-hidden="true" />
|
<Folder size={18} aria-hidden="true" />
|
||||||
<span>{folder.name}</span>
|
<span>{folder.name}</span>
|
||||||
<button
|
<button
|
||||||
@@ -655,18 +680,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
||||||
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
||||||
{movingId === drawing.id ? (
|
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
|
||||||
<div className={styles.dropdownSubmenu}>
|
|
||||||
<button className={styles.dropdownSubheader}>Move to:</button>
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
|
|
||||||
{folders.map(f => (
|
|
||||||
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
|
|
||||||
))}
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
|
|
||||||
)}
|
|
||||||
<div className={styles.dropdownDivider} />
|
<div className={styles.dropdownDivider} />
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -707,6 +721,44 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{moveModalDrawing && (
|
||||||
|
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="move-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setMoveModalDrawing(null); }}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h3 id="move-drawing-title">Move "{moveModalDrawing.title}"</h3>
|
||||||
|
<button className={styles.modalClose} onClick={() => setMoveModalDrawing(null)} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalBody}>
|
||||||
|
<p className={styles.moveHint}>Select a destination:</p>
|
||||||
|
<div className={styles.moveList}>
|
||||||
|
<button
|
||||||
|
className={`${styles.moveItem} ${moveModalDrawing.folder_id === null ? styles.moveItemActive : ''}`}
|
||||||
|
onClick={() => handleMoveDrawing(moveModalDrawing, null)}
|
||||||
|
>
|
||||||
|
<Folder size={18} />
|
||||||
|
<span>All Projects</span>
|
||||||
|
{moveModalDrawing.folder_id === null && <span className={styles.moveCurrent}>Current</span>}
|
||||||
|
</button>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
className={`${styles.moveItem} ${moveModalDrawing.folder_id === f.id ? styles.moveItemActive : ''}`}
|
||||||
|
onClick={() => handleMoveDrawing(moveModalDrawing, f.id)}
|
||||||
|
>
|
||||||
|
<Folder size={18} />
|
||||||
|
<span>{f.name}</span>
|
||||||
|
{moveModalDrawing.folder_id === f.id && <span className={styles.moveCurrent}>Current</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<button className={styles.modalBtnSecondary} onClick={() => setMoveModalDrawing(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user