393 lines
12 KiB
Svelte
393 lines
12 KiB
Svelte
<script>
|
|
import { createEventDispatcher } from 'svelte'
|
|
import FileIcon from './lib/FileIcon.svelte'
|
|
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
|
|
|
export let item
|
|
export let selected = false
|
|
export let onDragStart
|
|
export let onDragOver
|
|
export let onDrop
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
const kind = getFileKind(item)
|
|
const isFolder = item.type === 'folder'
|
|
const fileType = formatFileType(item)
|
|
let menuOpen = false
|
|
let clickTimer = null
|
|
|
|
function handleClick(e) {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
dispatch('toggleSelect', item.id)
|
|
} else if (e.shiftKey) {
|
|
dispatch('rangeSelect', item.id)
|
|
} else {
|
|
if (clickTimer) {
|
|
clearTimeout(clickTimer)
|
|
clickTimer = null
|
|
// Double click → открыть
|
|
if (isFolder) {
|
|
dispatch('navigate', item.id)
|
|
} else {
|
|
dispatch('preview', item)
|
|
}
|
|
} else {
|
|
clickTimer = setTimeout(() => {
|
|
clickTimer = null
|
|
// Single click → выделить
|
|
dispatch('selectOne', item.id)
|
|
}, 250)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleOpenExternal() {
|
|
dispatch('openExternal', item.fileId)
|
|
}
|
|
|
|
function handleShowInFolder() {
|
|
dispatch('showInFolder', item.id)
|
|
}
|
|
|
|
function handleDelete() {
|
|
menuOpen = false
|
|
dispatch('delete', { id: item.id, type: item.type })
|
|
}
|
|
|
|
function handleRename() {
|
|
menuOpen = false
|
|
dispatch('rename', { id: item.id, name: item.name })
|
|
}
|
|
|
|
function handleDuplicate() {
|
|
menuOpen = false
|
|
dispatch('duplicate', item.id)
|
|
}
|
|
|
|
function handleCut() {
|
|
menuOpen = false
|
|
dispatch('cut', item.id)
|
|
}
|
|
|
|
function handleCopy() {
|
|
menuOpen = false
|
|
dispatch('copy', item.id)
|
|
}
|
|
|
|
function handleOpen() {
|
|
menuOpen = false
|
|
if (isFolder) {
|
|
dispatch('navigate', item.id)
|
|
} else {
|
|
dispatch('preview', item)
|
|
}
|
|
}
|
|
|
|
function toggleMenu() {
|
|
menuOpen = !menuOpen
|
|
}
|
|
|
|
function closeMenu() {
|
|
menuOpen = false
|
|
}
|
|
|
|
function handleDragStart(e) {
|
|
if (onDragStart) onDragStart(e, item.id)
|
|
}
|
|
|
|
function handleDragOver(e) {
|
|
if (onDragOver && isFolder) onDragOver(e, item.id)
|
|
}
|
|
|
|
function handleDrop(e) {
|
|
if (onDrop && isFolder) onDrop(e, item.id)
|
|
}
|
|
|
|
function oncontextmenu(e) {
|
|
e.preventDefault()
|
|
menuOpen = true
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:click={closeMenu}/>
|
|
|
|
<div class="file-row"
|
|
class:file-row--selected={selected}
|
|
role="button"
|
|
tabindex="0"
|
|
draggable="true"
|
|
on:click={handleClick}
|
|
on:contextmenu={oncontextmenu}
|
|
on:dragstart={handleDragStart}
|
|
on:dragover={handleDragOver}
|
|
on:drop={handleDrop}
|
|
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
|
<div class="file-row-icon">
|
|
<FileIcon {kind} size={22}/>
|
|
</div>
|
|
<div class="file-row-body">
|
|
<div class="file-row-name" title={item.name}>{item.name}</div>
|
|
<div class="file-row-meta">
|
|
<span>{fileType}</span>
|
|
{#if !isFolder}
|
|
<span class="meta-sep">·</span>
|
|
<span>{formatFileSize(item.size)}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="file-row-actions">
|
|
{#if !isFolder}
|
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
<circle cx="12" cy="12" r="3"/>
|
|
</svg>
|
|
</button>
|
|
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
<polyline points="15 3 21 3 21 9"/>
|
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
</svg>
|
|
</button>
|
|
{:else}
|
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<circle cx="12" cy="5" r="2"/>
|
|
<circle cx="12" cy="12" r="2"/>
|
|
<circle cx="12" cy="19" r="2"/>
|
|
</svg>
|
|
</button>
|
|
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="3 6 5 6 21 6"/>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if menuOpen}
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
|
<div class="menu" on:click|stopPropagation role="menu">
|
|
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
Открыть
|
|
</button>
|
|
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
Открыть во внешней программе
|
|
</button>
|
|
{#if isFolder}
|
|
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
Показать в проводнике
|
|
</button>
|
|
{/if}
|
|
<div class="menu-sep"></div>
|
|
<button class="menu-item" on:click={handleRename} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
|
Переименовать
|
|
</button>
|
|
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
Дублировать
|
|
</button>
|
|
<button class="menu-item" on:click={handleCut} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
|
Вырезать
|
|
</button>
|
|
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
Копировать
|
|
</button>
|
|
<div class="menu-sep"></div>
|
|
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
Удалить
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.file-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: default;
|
|
transition: background 0.12s;
|
|
min-height: 52px;
|
|
user-select: none;
|
|
position: relative;
|
|
}
|
|
|
|
.file-row:hover {
|
|
background: #1e1e30;
|
|
}
|
|
|
|
.file-row--selected {
|
|
background: #1e1e3a;
|
|
outline: 1px solid #3a3a6c;
|
|
}
|
|
|
|
.file-row--selected:hover {
|
|
background: #252545;
|
|
}
|
|
|
|
.file-row:focus-visible {
|
|
outline: 2px solid #5588ff;
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.file-row-icon {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
color: #888;
|
|
}
|
|
|
|
.file-row-body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.file-row-name {
|
|
font-size: 13px;
|
|
color: #ddd;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.file-row-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
color: #666;
|
|
}
|
|
|
|
.meta-sep {
|
|
color: #444;
|
|
}
|
|
|
|
.file-row-actions {
|
|
display: flex;
|
|
gap: 2px;
|
|
align-items: center;
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-row:hover .file-row-actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
.action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: #666;
|
|
cursor: pointer;
|
|
transition: background 0.12s, color 0.12s;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: #2a2a3c;
|
|
color: #ccc;
|
|
}
|
|
|
|
.action-btn-danger:hover {
|
|
background: #3a2222;
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.action-btn:focus-visible {
|
|
outline: 2px solid #5588ff;
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
.menu-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 99;
|
|
}
|
|
|
|
.menu {
|
|
position: absolute;
|
|
right: 12px;
|
|
margin-top: 4px;
|
|
background: #1a1a28;
|
|
border: 1px solid #2a2a3c;
|
|
border-radius: 8px;
|
|
padding: 4px;
|
|
z-index: 100;
|
|
min-width: 220px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
.menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 7px 10px;
|
|
border: none;
|
|
background: transparent;
|
|
color: #ccc;
|
|
font-size: 12px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.menu-item:hover {
|
|
background: #2a2a3c;
|
|
color: #fff;
|
|
}
|
|
|
|
.menu-item-danger {
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.menu-item-danger:hover {
|
|
background: #3a2222;
|
|
}
|
|
|
|
.menu-item:focus-visible {
|
|
outline: 2px solid #5588ff;
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
.menu-sep {
|
|
height: 1px;
|
|
background: #2a2a3c;
|
|
margin: 4px 8px;
|
|
}
|
|
</style>
|