1678 lines
53 KiB
Vue
1678 lines
53 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
|
import Muuri from '@/vendor/muuri-src';
|
|
import { useLayoutStore } from '@/store/useLayoutStore';
|
|
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
|
import { useFoldersStore, type Folder, type FolderItem } from '@/store/useFoldersStore';
|
|
import { useUIStore } from '@/store/useUIStore';
|
|
import { useSettingsStore } from '@/store/useSettingsStore';
|
|
import IconCard from '@/components/IconCard/index.vue';
|
|
import WidgetCard from '@/components/WidgetCard/index.vue';
|
|
import FolderCard from '@/components/FolderCard/index.vue';
|
|
import FolderDialog from '@/components/FolderDialog/index.vue';
|
|
|
|
type GridItemType = 'icon' | 'widget' | 'folder';
|
|
type GridItemSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
|
|
|
interface Icon {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
img?: string;
|
|
text?: string;
|
|
bgColor?: string;
|
|
size?: GridItemSize;
|
|
groupId?: string;
|
|
folderId?: string;
|
|
}
|
|
|
|
type WidgetSize = GridItemSize;
|
|
interface Widget {
|
|
id: string;
|
|
component: string;
|
|
size: WidgetSize;
|
|
data?: any;
|
|
groupId?: string;
|
|
folderId?: string;
|
|
}
|
|
|
|
type FolderChild =
|
|
| { type: 'icon'; id: string; icon: Icon }
|
|
| { type: 'widget'; id: string; widget: Widget };
|
|
|
|
interface GridOrderEntry {
|
|
id: string;
|
|
type: GridItemType;
|
|
}
|
|
|
|
type OrderedGridItem =
|
|
| { type: 'icon'; id: string; icon: Icon }
|
|
| { type: 'widget'; id: string; widget: Widget }
|
|
| { type: 'folder'; id: string; folder: Folder; children: FolderChild[] };
|
|
|
|
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
|
|
const DEFAULT_GROUP_ID = 'home';
|
|
const DEFAULT_FOLDER_SIZE: GridItemSize = '1x1';
|
|
|
|
const props = defineProps<{
|
|
activeGroupId?: string | null;
|
|
}>();
|
|
|
|
const layoutStore = useLayoutStore();
|
|
const widgetsStore = useWidgetsStore();
|
|
const foldersStore = useFoldersStore();
|
|
const uiStore = useUIStore();
|
|
const settingsStore = useSettingsStore();
|
|
const gridRef = ref<HTMLElement | null>(null);
|
|
const grid = ref<any | null>(null);
|
|
const isDragging = ref(false);
|
|
const suppressClick = ref(false);
|
|
const clickBlockUntil = ref(0);
|
|
const gridOrder = ref<GridOrderEntry[]>([]);
|
|
const resizedKeys = ref(new Set<string>());
|
|
const pendingResizedKeys = new Set<string>();
|
|
const widgetSizeMap = new Map<string, GridItemSize>();
|
|
const iconSizeMap = new Map<string, GridItemSize>();
|
|
const folderSizeMap = new Map<string, GridItemSize>();
|
|
const enteringKeys = ref(new Set<string>());
|
|
const previousIconIds = ref(new Set<string>());
|
|
const previousWidgetIds = ref(new Set<string>());
|
|
const openFolderId = ref<string | null>(null);
|
|
const hoverTargetEl = ref<HTMLElement | null>(null);
|
|
const draggingMeta = ref<{ id: string | null; type: GridItemType | null }>({
|
|
id: null,
|
|
type: null,
|
|
});
|
|
const draggingPointerBackup = ref<string | null>(null);
|
|
const draggingEl = ref<HTMLElement | null>(null);
|
|
const draggingItemRef = ref<any | null>(null);
|
|
const overlapRafId = ref<number | null>(null);
|
|
const overlapPendingEl = ref<HTMLElement | null>(null);
|
|
const disableSort = ref(false);
|
|
const mergeTargetEl = ref<HTMLElement | null>(null);
|
|
const mergeCandidateEl = ref<HTMLElement | null>(null);
|
|
const mergeCandidateAt = ref<number>(0);
|
|
const mergeActive = ref(false);
|
|
const mergeCommitted = ref(false);
|
|
const mergeOrderSnapshot = ref<GridOrderEntry[] | null>(null);
|
|
const mergePendingFolderId = ref<string | null>(null);
|
|
const mergePendingInsertIndex = ref<number | null>(null);
|
|
const OVERLAP_MERGE_THRESHOLD = 0.6;
|
|
const OVERLAP_HOLD_MS = 100;
|
|
const OVERLAP_DROP_THRESHOLD = 0.35;
|
|
const MERGE_HIT_INSET_RATIO = 0.18;
|
|
const MERGE_HIT_INSET_PX = 6;
|
|
const POINTER_ROW_TOLERANCE_RATIO = 0.6;
|
|
const POINTER_EDGE_PADDING = 6;
|
|
const lastPointer = ref<{ x: number; y: number } | null>(null);
|
|
const pointerTargetEl = ref<HTMLElement | null>(null);
|
|
const layoutAnimationMs = 240;
|
|
const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
|
const DELETE_ANIM_MS = 240;
|
|
const DELETE_ANIM_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
|
const normalizeGroupId = (groupId?: string | null) => groupId ?? DEFAULT_GROUP_ID;
|
|
const activeGroupId = computed(() => normalizeGroupId(props.activeGroupId));
|
|
|
|
const normalizeGridOrder = (order: GridOrderEntry[]) => {
|
|
const seen = new Set<string>();
|
|
const normalized: GridOrderEntry[] = [];
|
|
for (const entry of order) {
|
|
if (!entry || typeof entry.id !== 'string') continue;
|
|
if (entry.type !== 'icon' && entry.type !== 'widget' && entry.type !== 'folder') continue;
|
|
const key = `${entry.type}:${entry.id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
normalized.push(entry);
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
const buildDefaultOrder = (): GridOrderEntry[] => [
|
|
...widgetsStore.widgets.filter(widget => !widget.folderId).map(widget => ({ id: widget.id, type: 'widget' as const })),
|
|
...layoutStore.icons.filter(icon => !icon.folderId).map(icon => ({ id: icon.id, type: 'icon' as const })),
|
|
...foldersStore.folders.map(folder => ({ id: folder.id, type: 'folder' as const })),
|
|
];
|
|
|
|
const applyGridOrder = (order: GridOrderEntry[], syncStoreOrder = true) => {
|
|
gridOrder.value = order;
|
|
localStorage.setItem(GRID_ORDER_STORAGE_KEY, JSON.stringify(order));
|
|
if (!syncStoreOrder) return;
|
|
const iconOrder = order.filter(item => item.type === 'icon').map(item => item.id);
|
|
const widgetOrder = order.filter(item => item.type === 'widget').map(item => item.id);
|
|
if (iconOrder.length) {
|
|
layoutStore.setIconOrder(iconOrder);
|
|
}
|
|
if (widgetOrder.length) {
|
|
widgetsStore.setWidgetOrder(widgetOrder);
|
|
}
|
|
};
|
|
|
|
const loadGridOrder = () => {
|
|
const saved = localStorage.getItem(GRID_ORDER_STORAGE_KEY);
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved) as GridOrderEntry[];
|
|
if (Array.isArray(parsed)) {
|
|
gridOrder.value = normalizeGridOrder(parsed);
|
|
}
|
|
} catch {
|
|
gridOrder.value = [];
|
|
}
|
|
}
|
|
if (!gridOrder.value.length) {
|
|
gridOrder.value = buildDefaultOrder();
|
|
}
|
|
applyGridOrder(gridOrder.value, true);
|
|
};
|
|
|
|
const ensureOrderConsistency = () => {
|
|
const iconIds = new Set(layoutStore.icons.filter(icon => !icon.folderId).map(icon => icon.id));
|
|
const widgetIds = new Set(widgetsStore.widgets.filter(widget => !widget.folderId).map(widget => widget.id));
|
|
const folderIds = new Set(foldersStore.folders.map(folder => folder.id));
|
|
const seen = new Set<string>();
|
|
const nextOrder: GridOrderEntry[] = [];
|
|
|
|
const pushEntry = (entry: GridOrderEntry) => {
|
|
const key = `${entry.type}:${entry.id}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
nextOrder.push(entry);
|
|
};
|
|
|
|
for (const entry of gridOrder.value) {
|
|
if (entry.type === 'icon' && iconIds.has(entry.id)) {
|
|
pushEntry(entry);
|
|
}
|
|
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
|
|
pushEntry(entry);
|
|
}
|
|
if (entry.type === 'folder' && folderIds.has(entry.id)) {
|
|
pushEntry(entry);
|
|
}
|
|
}
|
|
|
|
for (const widget of widgetsStore.widgets) {
|
|
const key = `widget:${widget.id}`;
|
|
if (!widget.folderId && !seen.has(key)) {
|
|
pushEntry({ id: widget.id, type: 'widget' });
|
|
}
|
|
}
|
|
|
|
for (const icon of layoutStore.icons) {
|
|
const key = `icon:${icon.id}`;
|
|
if (!icon.folderId && !seen.has(key)) {
|
|
pushEntry({ id: icon.id, type: 'icon' });
|
|
}
|
|
}
|
|
|
|
for (const folder of foldersStore.folders) {
|
|
const key = `folder:${folder.id}`;
|
|
if (!seen.has(key)) {
|
|
pushEntry({ id: folder.id, type: 'folder' });
|
|
}
|
|
}
|
|
|
|
const orderChanged =
|
|
nextOrder.length !== gridOrder.value.length ||
|
|
nextOrder.some((entry, index) => {
|
|
const current = gridOrder.value[index];
|
|
return current?.id !== entry.id || current?.type !== entry.type;
|
|
});
|
|
|
|
if (orderChanged) {
|
|
applyGridOrder(nextOrder, false);
|
|
} else {
|
|
gridOrder.value = nextOrder;
|
|
}
|
|
};
|
|
|
|
const isInActiveGroup = (groupId?: string | null) =>
|
|
normalizeGroupId(groupId) === activeGroupId.value;
|
|
|
|
const orderedItems = computed<OrderedGridItem[]>(() => {
|
|
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
|
|
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
|
|
const foldersById = new Map(foldersStore.folders.map(folder => [folder.id, folder]));
|
|
const folderedKeys = new Set<string>();
|
|
const folderedById = new Map<string, FolderChild[]>();
|
|
for (const icon of layoutStore.icons) {
|
|
if (icon.folderId) {
|
|
folderedKeys.add(`icon:${icon.id}`);
|
|
const list = folderedById.get(icon.folderId) ?? [];
|
|
list.push({ type: 'icon', id: icon.id, icon });
|
|
folderedById.set(icon.folderId, list);
|
|
}
|
|
}
|
|
for (const widget of widgetsStore.widgets) {
|
|
if (widget.folderId) {
|
|
folderedKeys.add(`widget:${widget.id}`);
|
|
const list = folderedById.get(widget.folderId) ?? [];
|
|
list.push({ type: 'widget', id: widget.id, widget });
|
|
folderedById.set(widget.folderId, list);
|
|
}
|
|
}
|
|
const used = new Set<string>();
|
|
const items: OrderedGridItem[] = [];
|
|
|
|
const buildChildren = (folder: Folder): FolderChild[] => {
|
|
const children: FolderChild[] = [];
|
|
const existing = new Set<string>();
|
|
for (const child of folder.items) {
|
|
if (child.type === 'icon') {
|
|
const icon = iconsById.get(child.id);
|
|
if (icon) {
|
|
children.push({ type: 'icon', id: icon.id, icon });
|
|
existing.add(`icon:${icon.id}`);
|
|
}
|
|
} else if (child.type === 'widget') {
|
|
const widget = widgetsById.get(child.id);
|
|
if (widget) {
|
|
children.push({ type: 'widget', id: widget.id, widget });
|
|
existing.add(`widget:${widget.id}`);
|
|
}
|
|
}
|
|
}
|
|
const extras = folderedById.get(folder.id);
|
|
if (extras?.length) {
|
|
for (const child of extras) {
|
|
const key = `${child.type}:${child.id}`;
|
|
if (!existing.has(key)) {
|
|
children.push(child);
|
|
existing.add(key);
|
|
}
|
|
}
|
|
}
|
|
return children;
|
|
};
|
|
|
|
for (const entry of gridOrder.value) {
|
|
if (entry.type === 'icon') {
|
|
const icon = iconsById.get(entry.id);
|
|
if (
|
|
icon &&
|
|
!icon.folderId &&
|
|
!folderedKeys.has(`icon:${icon.id}`) &&
|
|
isInActiveGroup(icon.groupId)
|
|
) {
|
|
items.push({ type: 'icon', id: icon.id, icon });
|
|
used.add(`icon:${icon.id}`);
|
|
}
|
|
} else if (entry.type === 'widget') {
|
|
const widget = widgetsById.get(entry.id);
|
|
if (
|
|
widget &&
|
|
!widget.folderId &&
|
|
!folderedKeys.has(`widget:${widget.id}`) &&
|
|
isInActiveGroup(widget.groupId)
|
|
) {
|
|
items.push({ type: 'widget', id: widget.id, widget });
|
|
used.add(`widget:${widget.id}`);
|
|
}
|
|
} else if (entry.type === 'folder') {
|
|
const folder = foldersById.get(entry.id);
|
|
if (folder && isInActiveGroup(folder.groupId)) {
|
|
items.push({ type: 'folder', id: folder.id, folder, children: buildChildren(folder) });
|
|
used.add(`folder:${folder.id}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const widget of widgetsStore.widgets) {
|
|
const key = `widget:${widget.id}`;
|
|
if (
|
|
!widget.folderId &&
|
|
!folderedKeys.has(key) &&
|
|
!used.has(key) &&
|
|
isInActiveGroup(widget.groupId)
|
|
) {
|
|
items.push({ type: 'widget', id: widget.id, widget });
|
|
}
|
|
}
|
|
|
|
for (const icon of layoutStore.icons) {
|
|
const key = `icon:${icon.id}`;
|
|
if (
|
|
!icon.folderId &&
|
|
!folderedKeys.has(key) &&
|
|
!used.has(key) &&
|
|
isInActiveGroup(icon.groupId)
|
|
) {
|
|
items.push({ type: 'icon', id: icon.id, icon });
|
|
}
|
|
}
|
|
|
|
for (const folder of foldersStore.folders) {
|
|
const key = `folder:${folder.id}`;
|
|
if (!used.has(key) && isInActiveGroup(folder.groupId)) {
|
|
items.push({ type: 'folder', id: folder.id, folder, children: buildChildren(folder) });
|
|
}
|
|
}
|
|
|
|
return items;
|
|
});
|
|
|
|
const resolveFolderChildren = (folderId: string): FolderChild[] => {
|
|
const folder = foldersStore.folders.find(f => f.id === folderId);
|
|
if (!folder) return [];
|
|
const iconsById = new Map(layoutStore.icons.map(icon => [icon.id, icon]));
|
|
const widgetsById = new Map(widgetsStore.widgets.map(widget => [widget.id, widget]));
|
|
const children: FolderChild[] = [];
|
|
const existing = new Set<string>();
|
|
for (const child of folder.items) {
|
|
if (child.type === 'icon') {
|
|
const icon = iconsById.get(child.id);
|
|
if (icon) {
|
|
children.push({ type: 'icon', id: icon.id, icon });
|
|
existing.add(`icon:${icon.id}`);
|
|
}
|
|
} else if (child.type === 'widget') {
|
|
const widget = widgetsById.get(child.id);
|
|
if (widget) {
|
|
children.push({ type: 'widget', id: widget.id, widget });
|
|
existing.add(`widget:${widget.id}`);
|
|
}
|
|
}
|
|
}
|
|
for (const icon of layoutStore.icons) {
|
|
if (icon.folderId === folderId && !existing.has(`icon:${icon.id}`)) {
|
|
children.push({ type: 'icon', id: icon.id, icon });
|
|
existing.add(`icon:${icon.id}`);
|
|
}
|
|
}
|
|
for (const widget of widgetsStore.widgets) {
|
|
if (widget.folderId === folderId && !existing.has(`widget:${widget.id}`)) {
|
|
children.push({ type: 'widget', id: widget.id, widget });
|
|
existing.add(`widget:${widget.id}`);
|
|
}
|
|
}
|
|
return children;
|
|
};
|
|
|
|
const sizeClassFor = (item: OrderedGridItem) => {
|
|
if (item.type === 'widget') return `size-${item.widget.size}`;
|
|
if (item.type === 'folder') return `size-${item.folder.size ?? DEFAULT_FOLDER_SIZE}`;
|
|
return `size-${item.icon.size ?? '1x1'}`;
|
|
};
|
|
|
|
const currentFolder = computed(() => {
|
|
if (!openFolderId.value) return null;
|
|
return foldersStore.folders.find(folder => folder.id === openFolderId.value) ?? null;
|
|
});
|
|
|
|
const currentFolderChildren = computed(() =>
|
|
currentFolder.value ? resolveFolderChildren(currentFolder.value.id) : []
|
|
);
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (suppressClick.value || isDragging.value) return;
|
|
const target = event.target as HTMLElement;
|
|
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
|
if (!itemEl || itemEl.dataset.type !== 'icon') return;
|
|
const link = target.closest('a') as HTMLAnchorElement | null;
|
|
if (link && link.href) {
|
|
const targetMode = settingsStore.openInNewTab ? '_blank' : '_self';
|
|
window.open(link.href, targetMode);
|
|
}
|
|
};
|
|
|
|
const handleClickCapture = (event: MouseEvent) => {
|
|
if (isDragging.value || suppressClick.value || Date.now() < clickBlockUntil.value) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
const handleContextMenu = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
|
if (!itemEl) {
|
|
uiStore.openContextMenu(event.clientX, event.clientY, null, 'desktop');
|
|
return;
|
|
}
|
|
const id = itemEl.dataset.id;
|
|
const type = itemEl.dataset.type as GridItemType | undefined;
|
|
if (id && (type === 'icon' || type === 'widget' || type === 'folder')) {
|
|
uiStore.openContextMenu(event.clientX, event.clientY, id, type);
|
|
} else {
|
|
uiStore.openContextMenu(event.clientX, event.clientY, null, 'desktop');
|
|
}
|
|
};
|
|
|
|
const setHoverTarget = (el: HTMLElement | null) => {
|
|
if (hoverTargetEl.value === el) return;
|
|
if (hoverTargetEl.value) {
|
|
hoverTargetEl.value.classList.remove('is-drop-target');
|
|
}
|
|
hoverTargetEl.value = el;
|
|
if (el) {
|
|
el.classList.add('is-drop-target');
|
|
}
|
|
};
|
|
|
|
const clearHoverTarget = () => setHoverTarget(null);
|
|
|
|
const activateMergeTarget = (el: HTMLElement) => {
|
|
mergeActive.value = true;
|
|
mergeTargetEl.value = el;
|
|
mergeCandidateEl.value = null;
|
|
mergeCandidateAt.value = 0;
|
|
setHoverTarget(el);
|
|
};
|
|
|
|
const deactivateMergeTarget = (forceSort = true) => {
|
|
if (!mergeActive.value) return;
|
|
mergeActive.value = false;
|
|
mergeTargetEl.value = null;
|
|
clearHoverTarget();
|
|
if (forceSort) {
|
|
draggingItemRef.value?._drag?.sort?.(true);
|
|
}
|
|
};
|
|
|
|
const applySortLock = (locked: boolean) => {
|
|
const prev = disableSort.value;
|
|
disableSort.value = locked;
|
|
if (prev && !locked) {
|
|
draggingItemRef.value?._drag?.sort?.(true);
|
|
}
|
|
};
|
|
|
|
const calcOverlapArea = (a: DOMRect, b: DOMRect) => {
|
|
const xOverlap = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
|
|
const yOverlap = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
|
|
return xOverlap * yOverlap;
|
|
};
|
|
|
|
const getPointerTargetFromRects = (pointer: { x: number; y: number }, dragEl: HTMLElement) => {
|
|
if (!gridRef.value) return null;
|
|
const elements = Array.from(gridRef.value.querySelectorAll('.grid-item')) as HTMLElement[];
|
|
let hit: HTMLElement | null = null;
|
|
let bestArea = Infinity;
|
|
for (const el of elements) {
|
|
if (el === dragEl) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
const inset = Math.max(
|
|
MERGE_HIT_INSET_PX,
|
|
Math.min(rect.width, rect.height) * MERGE_HIT_INSET_RATIO
|
|
);
|
|
const left = rect.left + inset;
|
|
const right = rect.right - inset;
|
|
const top = rect.top + inset;
|
|
const bottom = rect.bottom - inset;
|
|
if (
|
|
pointer.x >= left &&
|
|
pointer.x <= right &&
|
|
pointer.y >= top &&
|
|
pointer.y <= bottom
|
|
) {
|
|
const area = rect.width * rect.height;
|
|
if (area < bestArea) {
|
|
bestArea = area;
|
|
hit = el;
|
|
}
|
|
}
|
|
}
|
|
return hit;
|
|
};
|
|
|
|
const resolvePointerFromEvent = (event: any) => {
|
|
if (!event) return null;
|
|
const x =
|
|
typeof event.clientX === 'number'
|
|
? event.clientX
|
|
: typeof event.pageX === 'number'
|
|
? event.pageX
|
|
: typeof event.x === 'number'
|
|
? event.x
|
|
: typeof event?.srcEvent?.clientX === 'number'
|
|
? event.srcEvent.clientX
|
|
: typeof event?.srcEvent?.pageX === 'number'
|
|
? event.srcEvent.pageX
|
|
: undefined;
|
|
const y =
|
|
typeof event.clientY === 'number'
|
|
? event.clientY
|
|
: typeof event.pageY === 'number'
|
|
? event.pageY
|
|
: typeof event.y === 'number'
|
|
? event.y
|
|
: typeof event?.srcEvent?.clientY === 'number'
|
|
? event.srcEvent.clientY
|
|
: typeof event?.srcEvent?.pageY === 'number'
|
|
? event.srcEvent.pageY
|
|
: undefined;
|
|
if (x === undefined || y === undefined) return null;
|
|
return { x, y };
|
|
};
|
|
|
|
const buildPointerSortRows = (
|
|
items: { item: any; rect: DOMRect }[]
|
|
) => {
|
|
if (!items.length) return [];
|
|
const sorted = [...items].sort((a, b) => {
|
|
const topDiff = a.rect.top - b.rect.top;
|
|
if (Math.abs(topDiff) > 1) return topDiff;
|
|
return a.rect.left - b.rect.left;
|
|
});
|
|
const baseSize = Math.min(sorted[0].rect.width, sorted[0].rect.height);
|
|
const rowTolerance = baseSize * POINTER_ROW_TOLERANCE_RATIO;
|
|
const rows: { items: { item: any; rect: DOMRect }[]; top: number; bottom: number; mid: number }[] = [];
|
|
let current = {
|
|
items: [sorted[0]],
|
|
top: sorted[0].rect.top,
|
|
bottom: sorted[0].rect.bottom,
|
|
mid: (sorted[0].rect.top + sorted[0].rect.bottom) / 2,
|
|
};
|
|
|
|
for (const entry of sorted.slice(1)) {
|
|
if (Math.abs(entry.rect.top - current.top) <= rowTolerance) {
|
|
current.items.push(entry);
|
|
current.top = Math.min(current.top, entry.rect.top);
|
|
current.bottom = Math.max(current.bottom, entry.rect.bottom);
|
|
current.mid = (current.top + current.bottom) / 2;
|
|
} else {
|
|
current.items.sort((a, b) => a.rect.left - b.rect.left);
|
|
rows.push(current);
|
|
current = {
|
|
items: [entry],
|
|
top: entry.rect.top,
|
|
bottom: entry.rect.bottom,
|
|
mid: (entry.rect.top + entry.rect.bottom) / 2,
|
|
};
|
|
}
|
|
}
|
|
|
|
current.items.sort((a, b) => a.rect.left - b.rect.left);
|
|
rows.push(current);
|
|
return rows;
|
|
};
|
|
|
|
const getPointerInsertIndex = (
|
|
pointer: { x: number; y: number },
|
|
rows: { items: { item: any; rect: DOMRect }[]; top: number; bottom: number; mid: number }[]
|
|
) => {
|
|
if (!rows.length) return 0;
|
|
const total = rows.reduce((sum, row) => sum + row.items.length, 0);
|
|
const first = rows[0];
|
|
const last = rows[rows.length - 1];
|
|
|
|
if (pointer.y < first.top - POINTER_EDGE_PADDING) {
|
|
return 0;
|
|
}
|
|
if (pointer.y > last.bottom + POINTER_EDGE_PADDING) {
|
|
return total;
|
|
}
|
|
|
|
let rowIndex = rows.length - 1;
|
|
for (let i = 0; i < rows.length; i += 1) {
|
|
if (pointer.y < rows[i].mid) {
|
|
rowIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const row = rows[rowIndex];
|
|
let colIndex = row.items.length;
|
|
for (let i = 0; i < row.items.length; i += 1) {
|
|
const rect = row.items[i].rect;
|
|
const midX = rect.left + rect.width / 2;
|
|
if (pointer.x < midX) {
|
|
colIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const baseIndex = rows.slice(0, rowIndex).reduce((sum, r) => sum + r.items.length, 0);
|
|
return baseIndex + colIndex;
|
|
};
|
|
|
|
const getOverlapRatio = (dragEl: HTMLElement, targetEl: HTMLElement) => {
|
|
const dragRect = dragEl.getBoundingClientRect();
|
|
const targetRect = targetEl.getBoundingClientRect();
|
|
const dragArea = Math.max(1, dragRect.width * dragRect.height);
|
|
const targetArea = Math.max(1, targetRect.width * targetRect.height);
|
|
const baseArea = Math.max(1, Math.min(dragArea, targetArea));
|
|
return calcOverlapArea(dragRect, targetRect) / baseArea;
|
|
};
|
|
|
|
const findTargetStats = (dragEl: HTMLElement) => {
|
|
if (!gridRef.value) return null;
|
|
const dragRect = dragEl.getBoundingClientRect();
|
|
const dragArea = Math.max(1, dragRect.width * dragRect.height);
|
|
let best: { el: HTMLElement; ratio: number } | null = null;
|
|
const elements = Array.from(gridRef.value.querySelectorAll('.grid-item')) as HTMLElement[];
|
|
for (const el of elements) {
|
|
if (el === dragEl) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
const targetArea = Math.max(1, rect.width * rect.height);
|
|
const baseArea = Math.max(1, Math.min(dragArea, targetArea));
|
|
const ratio = calcOverlapArea(dragRect, rect) / baseArea;
|
|
if (!best || ratio > best.ratio) {
|
|
best = { el, ratio };
|
|
}
|
|
}
|
|
return { best };
|
|
};
|
|
|
|
const resolveGroupIdByItem = (item: { id: string; type: GridItemType }) => {
|
|
if (item.type === 'icon') {
|
|
return layoutStore.icons.find(icon => icon.id === item.id)?.groupId ?? activeGroupId.value;
|
|
}
|
|
if (item.type === 'widget') {
|
|
return widgetsStore.widgets.find(widget => widget.id === item.id)?.groupId ?? activeGroupId.value;
|
|
}
|
|
if (item.type === 'folder') {
|
|
return foldersStore.folders.find(folder => folder.id === item.id)?.groupId ?? activeGroupId.value;
|
|
}
|
|
return activeGroupId.value;
|
|
};
|
|
|
|
const addItemToFolderState = (folderId: string, item: { id: string; type: 'icon' | 'widget' }) => {
|
|
const folderGroup = foldersStore.folders.find(f => f.id === folderId)?.groupId ?? activeGroupId.value;
|
|
if (item.type === 'icon') {
|
|
layoutStore.updateIcon(item.id, { folderId, groupId: folderGroup });
|
|
} else {
|
|
widgetsStore.updateWidgetFolder(item.id, folderId, folderGroup);
|
|
}
|
|
foldersStore.addItem(folderId, { id: item.id, type: item.type });
|
|
};
|
|
|
|
const removeItemFromFolderState = (folderId: string, item: { id: string; type: 'icon' | 'widget' }) => {
|
|
if (item.type === 'icon') {
|
|
layoutStore.updateIcon(item.id, { folderId: undefined });
|
|
} else {
|
|
widgetsStore.updateWidgetFolder(item.id, undefined);
|
|
}
|
|
foldersStore.removeItem(folderId, item.id, item.type);
|
|
};
|
|
|
|
const removeEntryFromOrder = (order: GridOrderEntry[], item: { id: string; type: GridItemType }) =>
|
|
order.filter(entry => !(entry.id === item.id && entry.type === item.type));
|
|
|
|
const entryKey = (entry: GridOrderEntry) => `${entry.type}:${entry.id}`;
|
|
|
|
const getVisibleOrderEntries = (): GridOrderEntry[] => {
|
|
if (!grid.value) return [];
|
|
const items = grid.value.getItems();
|
|
const nextOrder: GridOrderEntry[] = [];
|
|
for (const item of items) {
|
|
const element = item.getElement() as HTMLElement;
|
|
const id = element?.dataset?.id;
|
|
const type = element?.dataset?.type as GridItemType | undefined;
|
|
if (id && (type === 'icon' || type === 'widget' || type === 'folder')) {
|
|
nextOrder.push({ id, type });
|
|
}
|
|
}
|
|
return nextOrder;
|
|
};
|
|
|
|
const mergeVisibleOrder = (visibleOrder: GridOrderEntry[], baseOrder: GridOrderEntry[]) => {
|
|
const visibleKeys = new Set(visibleOrder.map(entryKey));
|
|
const visibleQueue = [...visibleOrder];
|
|
const merged: GridOrderEntry[] = [];
|
|
|
|
for (const entry of baseOrder) {
|
|
if (visibleKeys.has(entryKey(entry))) {
|
|
const nextEntry = visibleQueue.shift();
|
|
if (nextEntry) {
|
|
merged.push(nextEntry);
|
|
}
|
|
} else {
|
|
merged.push(entry);
|
|
}
|
|
}
|
|
|
|
const mergedKeys = new Set(merged.map(entryKey));
|
|
for (const entry of visibleQueue) {
|
|
const key = entryKey(entry);
|
|
if (!mergedKeys.has(key)) {
|
|
merged.push(entry);
|
|
mergedKeys.add(key);
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
};
|
|
|
|
const createFolderFromItems = (
|
|
source: { id: string; type: 'icon' | 'widget' },
|
|
target: { id: string; type: 'icon' | 'widget' }
|
|
) => {
|
|
const groupId = resolveGroupIdByItem(target);
|
|
const folder = foldersStore.addFolder({
|
|
name: '文件夹',
|
|
groupId,
|
|
size: DEFAULT_FOLDER_SIZE,
|
|
items: [
|
|
{ id: target.id, type: target.type },
|
|
{ id: source.id, type: source.type },
|
|
],
|
|
});
|
|
addItemToFolderState(folder.id, target);
|
|
addItemToFolderState(folder.id, source);
|
|
markGridEntriesHidden([
|
|
{ id: target.id, type: target.type },
|
|
{ id: source.id, type: source.type },
|
|
]);
|
|
|
|
const visibleOrder = getVisibleOrderEntries();
|
|
const sourceIndex = visibleOrder.findIndex(entry => entry.id === source.id && entry.type === source.type);
|
|
const targetIndex = visibleOrder.findIndex(entry => entry.id === target.id && entry.type === target.type);
|
|
let insertIndex = targetIndex >= 0 ? targetIndex : visibleOrder.length;
|
|
if (sourceIndex !== -1 && sourceIndex < insertIndex) {
|
|
insertIndex -= 1;
|
|
}
|
|
|
|
const nextVisible = visibleOrder.filter(
|
|
entry =>
|
|
!(entry.id === source.id && entry.type === source.type) &&
|
|
!(entry.id === target.id && entry.type === target.type)
|
|
);
|
|
if (insertIndex < 0) insertIndex = 0;
|
|
if (insertIndex > nextVisible.length) insertIndex = nextVisible.length;
|
|
nextVisible.splice(insertIndex, 0, { id: folder.id, type: 'folder' });
|
|
|
|
const finalOrder = mergeVisibleOrder(nextVisible, gridOrder.value);
|
|
applyGridOrder(finalOrder, true);
|
|
mergeOrderSnapshot.value = finalOrder.slice();
|
|
mergePendingFolderId.value = folder.id;
|
|
mergePendingInsertIndex.value = insertIndex;
|
|
removeGridEntries([
|
|
{ id: target.id, type: target.type },
|
|
{ id: source.id, type: source.type },
|
|
]);
|
|
};
|
|
|
|
const moveItemIntoFolder = (folderId: string, item: { id: string; type: 'icon' | 'widget' }) => {
|
|
const folder = foldersStore.folders.find(f => f.id === folderId);
|
|
if (!folder) return;
|
|
addItemToFolderState(folderId, item);
|
|
markGridEntriesHidden([{ id: item.id, type: item.type }]);
|
|
removeGridEntries([{ id: item.id, type: item.type }]);
|
|
const nextOrder = removeEntryFromOrder(gridOrder.value, item);
|
|
applyGridOrder(nextOrder, true);
|
|
refreshLayout(true);
|
|
};
|
|
|
|
const handleMergeDrop = () => {
|
|
const dragId = draggingMeta.value.id;
|
|
const dragType = draggingMeta.value.type;
|
|
if (!dragId || !dragType) return;
|
|
let targetEl = mergeTargetEl.value;
|
|
if (!mergeActive.value || !targetEl) {
|
|
targetEl = hoverTargetEl.value;
|
|
const dragEl = draggingEl.value;
|
|
if (!targetEl && dragEl) {
|
|
const stats = findTargetStats(dragEl);
|
|
const best = stats?.best ?? null;
|
|
if (best && best.ratio >= OVERLAP_MERGE_THRESHOLD) {
|
|
targetEl = best.el;
|
|
}
|
|
}
|
|
}
|
|
if (!targetEl) {
|
|
clearHoverTarget();
|
|
return;
|
|
}
|
|
if (!mergeActive.value && draggingEl.value) {
|
|
const ratio = getOverlapRatio(draggingEl.value, targetEl);
|
|
if (ratio < OVERLAP_DROP_THRESHOLD) {
|
|
clearHoverTarget();
|
|
return;
|
|
}
|
|
}
|
|
const targetId = targetEl.dataset.id;
|
|
const targetType = targetEl.dataset.type as GridItemType | undefined;
|
|
if (!targetId || !targetType) return;
|
|
if (targetId === dragId && targetType === dragType) return;
|
|
if (dragType === 'folder') return; // no nesting folders for now
|
|
|
|
if (targetType === 'folder') {
|
|
moveItemIntoFolder(targetId, { id: dragId, type: dragType });
|
|
mergeCommitted.value = true;
|
|
return;
|
|
}
|
|
if (targetType === 'icon' || targetType === 'widget') {
|
|
if (dragType === 'icon' || dragType === 'widget') {
|
|
createFolderFromItems({ id: dragId, type: dragType }, { id: targetId, type: targetType });
|
|
mergeCommitted.value = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
const openFolder = (folderId: string) => {
|
|
openFolderId.value = folderId;
|
|
};
|
|
|
|
const handleRenameFolder = (payload: { id: string; name: string }) => {
|
|
foldersStore.renameFolder(payload.id, payload.name);
|
|
};
|
|
|
|
const handleOpenChild = (child: FolderChild) => {
|
|
if (child.type === 'icon') {
|
|
const targetMode = settingsStore.openInNewTab ? '_blank' : '_self';
|
|
window.open(child.icon.url, targetMode);
|
|
}
|
|
};
|
|
|
|
const handleRemoveFromFolder = (payload: { folderId: string; child: FolderChild }) => {
|
|
const { folderId, child } = payload;
|
|
const baseOrder = gridOrder.value.slice();
|
|
removeItemFromFolderState(folderId, { id: child.id, type: child.type });
|
|
let nextOrder = removeEntryFromOrder(baseOrder, { id: child.id, type: child.type });
|
|
const folderAfter = foldersStore.folders.find(f => f.id === folderId);
|
|
const folderIndex = baseOrder.findIndex(entry => entry.id === folderId && entry.type === 'folder');
|
|
const safeFolderIndex = folderIndex >= 0 ? folderIndex : nextOrder.length;
|
|
if (!folderAfter) {
|
|
nextOrder = removeEntryFromOrder(nextOrder, { id: folderId, type: 'folder' });
|
|
nextOrder.splice(safeFolderIndex, 0, { id: child.id, type: child.type });
|
|
applyGridOrder(nextOrder, true);
|
|
refreshLayout(true);
|
|
return;
|
|
}
|
|
if (folderAfter.items.length === 1) {
|
|
const remaining = folderAfter.items[0];
|
|
removeItemFromFolderState(folderId, { id: remaining.id, type: remaining.type });
|
|
nextOrder = removeEntryFromOrder(nextOrder, { id: folderId, type: 'folder' });
|
|
nextOrder = removeEntryFromOrder(nextOrder, { id: remaining.id, type: remaining.type });
|
|
nextOrder.splice(safeFolderIndex, 0, { id: remaining.id, type: remaining.type });
|
|
nextOrder.splice(safeFolderIndex + 1, 0, { id: child.id, type: child.type });
|
|
const nextEntering = new Set(enteringKeys.value);
|
|
nextEntering.add(`${remaining.type}:${remaining.id}`);
|
|
nextEntering.add(`${child.type}:${child.id}`);
|
|
enteringKeys.value = nextEntering;
|
|
applyGridOrder(nextOrder, true);
|
|
refreshLayout(true);
|
|
return;
|
|
}
|
|
const insertIndex = safeFolderIndex + 1;
|
|
nextOrder.splice(insertIndex, 0, { id: child.id, type: child.type });
|
|
applyGridOrder(nextOrder, true);
|
|
refreshLayout(true);
|
|
};
|
|
|
|
const clearFolderChildren = (folderId: string) => {
|
|
const released: { id: string; type: 'icon' | 'widget' }[] = [];
|
|
for (const icon of layoutStore.icons) {
|
|
if (icon.folderId === folderId) {
|
|
layoutStore.updateIcon(icon.id, { folderId: undefined });
|
|
released.push({ id: icon.id, type: 'icon' });
|
|
}
|
|
}
|
|
for (const widget of widgetsStore.widgets) {
|
|
if (widget.folderId === folderId) {
|
|
widgetsStore.updateWidgetFolder(widget.id, undefined);
|
|
released.push({ id: widget.id, type: 'widget' });
|
|
}
|
|
}
|
|
const folder = foldersStore.folders.find(item => item.id === folderId);
|
|
if (folder) {
|
|
for (const child of folder.items) {
|
|
if (child.type === 'icon') {
|
|
layoutStore.updateIcon(child.id, { folderId: undefined });
|
|
if (!released.some(entry => entry.id === child.id && entry.type === 'icon')) {
|
|
released.push({ id: child.id, type: 'icon' });
|
|
}
|
|
} else {
|
|
widgetsStore.updateWidgetFolder(child.id, undefined);
|
|
if (!released.some(entry => entry.id === child.id && entry.type === 'widget')) {
|
|
released.push({ id: child.id, type: 'widget' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return released;
|
|
};
|
|
|
|
const animateDeleteEntry = (id: string, type: GridItemType) => {
|
|
const el = gridRef.value?.querySelector(
|
|
`.grid-item[data-type="${type}"][data-id="${id}"]`
|
|
) as HTMLElement | null;
|
|
if (!el) return false;
|
|
const content = el.querySelector('.grid-item-content') as HTMLElement | null;
|
|
const target = content ?? el;
|
|
el.classList.add('is-deleting');
|
|
el.style.pointerEvents = 'none';
|
|
target.style.transition = `transform ${DELETE_ANIM_MS}ms ${DELETE_ANIM_EASING}, opacity ${DELETE_ANIM_MS}ms ${DELETE_ANIM_EASING}`;
|
|
target.style.transform = 'scale(1)';
|
|
target.style.opacity = '1';
|
|
// Force reflow so the transition kicks in.
|
|
void target.offsetWidth;
|
|
requestAnimationFrame(() => {
|
|
target.style.transform = 'scale(0)';
|
|
target.style.opacity = '0';
|
|
});
|
|
return true;
|
|
};
|
|
|
|
watch(
|
|
() => uiStore.pendingDelete,
|
|
payload => {
|
|
if (!payload) return;
|
|
const { id, type } = payload;
|
|
const hasAnimation = animateDeleteEntry(id, type);
|
|
const finalize = () => {
|
|
if (type === 'icon') {
|
|
layoutStore.deleteIcon(id);
|
|
} else if (type === 'widget') {
|
|
widgetsStore.deleteWidget(id);
|
|
} else if (type === 'folder') {
|
|
const released = clearFolderChildren(id);
|
|
foldersStore.deleteFolder(id);
|
|
if (released.length) {
|
|
const nextEntering = new Set(enteringKeys.value);
|
|
for (const entry of released) {
|
|
nextEntering.add(`${entry.type}:${entry.id}`);
|
|
}
|
|
enteringKeys.value = nextEntering;
|
|
}
|
|
}
|
|
uiStore.clearPendingDelete();
|
|
refreshLayout(true);
|
|
};
|
|
if (hasAnimation) {
|
|
window.setTimeout(finalize, DELETE_ANIM_MS);
|
|
} else {
|
|
finalize();
|
|
}
|
|
}
|
|
);
|
|
|
|
const persistOrderFromGrid = async () => {
|
|
if (!grid.value) return;
|
|
const items = grid.value.getItems();
|
|
const nextOrder: GridOrderEntry[] = [];
|
|
for (const item of items) {
|
|
const element = item.getElement() as HTMLElement;
|
|
const id = element.dataset.id;
|
|
const type = element.dataset.type as GridItemType | undefined;
|
|
if (id && (type === 'icon' || type === 'widget' || type === 'folder')) {
|
|
nextOrder.push({ id, type });
|
|
}
|
|
}
|
|
if (nextOrder.length) {
|
|
if (!gridOrder.value.length) {
|
|
applyGridOrder(nextOrder, true);
|
|
} else {
|
|
const entryKey = (entry: GridOrderEntry) => `${entry.type}:${entry.id}`;
|
|
const visibleKeys = new Set(nextOrder.map(entryKey));
|
|
const visibleQueue = [...nextOrder];
|
|
const merged: GridOrderEntry[] = [];
|
|
|
|
for (const entry of gridOrder.value) {
|
|
if (visibleKeys.has(entryKey(entry))) {
|
|
const nextEntry = visibleQueue.shift();
|
|
if (nextEntry) {
|
|
merged.push(nextEntry);
|
|
}
|
|
} else {
|
|
merged.push(entry);
|
|
}
|
|
}
|
|
|
|
const mergedKeys = new Set(merged.map(entryKey));
|
|
for (const entry of visibleQueue) {
|
|
const key = entryKey(entry);
|
|
if (!mergedKeys.has(key)) {
|
|
merged.push(entry);
|
|
mergedKeys.add(key);
|
|
}
|
|
}
|
|
|
|
applyGridOrder(merged, true);
|
|
}
|
|
}
|
|
await nextTick();
|
|
grid.value?.synchronize();
|
|
grid.value?.layout();
|
|
};
|
|
|
|
const syncGridItems = () => {
|
|
if (!grid.value || !gridRef.value) return;
|
|
const existingItems = grid.value.getItems() ?? [];
|
|
const existingElements = new Set(existingItems.map((item: any) => item.getElement()));
|
|
const domElements = Array.from(gridRef.value.querySelectorAll('.grid-item'));
|
|
const toAdd = domElements.filter(element => !existingElements.has(element));
|
|
if (toAdd.length) {
|
|
let pendingFolderEl: HTMLElement | null = null;
|
|
const pendingFolderId = mergePendingFolderId.value;
|
|
const insertIndex = mergePendingInsertIndex.value;
|
|
if (pendingFolderId) {
|
|
pendingFolderEl =
|
|
toAdd.find(
|
|
element =>
|
|
element.dataset?.type === 'folder' &&
|
|
element.dataset?.id === pendingFolderId
|
|
) ?? null;
|
|
}
|
|
if (pendingFolderEl && insertIndex !== null && insertIndex !== undefined) {
|
|
grid.value.add([pendingFolderEl], { layout: false, index: insertIndex });
|
|
mergePendingFolderId.value = null;
|
|
mergePendingInsertIndex.value = null;
|
|
const remaining = toAdd.filter(element => element !== pendingFolderEl);
|
|
if (remaining.length) {
|
|
grid.value.add(remaining, { layout: false });
|
|
}
|
|
} else {
|
|
grid.value.add(toAdd, { layout: false });
|
|
}
|
|
}
|
|
const toRemove = existingItems.filter((item: any) => !gridRef.value?.contains(item.getElement()));
|
|
if (toRemove.length) {
|
|
grid.value.remove(toRemove, { removeElements: false });
|
|
}
|
|
};
|
|
|
|
const refreshLayout = async (instant = false) => {
|
|
await nextTick();
|
|
syncGridItems();
|
|
grid.value?.synchronize();
|
|
grid.value?.refreshItems();
|
|
if (grid.value?._settings?.layout) {
|
|
grid.value._settings.layout.fillGaps = true;
|
|
}
|
|
if (instant) {
|
|
grid.value?.layout(true);
|
|
} else {
|
|
grid.value?.layout();
|
|
}
|
|
};
|
|
|
|
const sortGridToOrder = (order: GridOrderEntry[], instant = false) => {
|
|
if (!grid.value) return;
|
|
syncGridItems();
|
|
grid.value.synchronize();
|
|
grid.value.refreshItems();
|
|
const items = grid.value.getItems() ?? [];
|
|
if (!items.length) return;
|
|
const itemMap = new Map<string, any>();
|
|
for (const item of items) {
|
|
const el = item.getElement() as HTMLElement | null;
|
|
const id = el?.dataset?.id;
|
|
const type = el?.dataset?.type;
|
|
if (id && type) {
|
|
itemMap.set(`${type}:${id}`, item);
|
|
}
|
|
}
|
|
const ordered: any[] = [];
|
|
for (const entry of order) {
|
|
const item = itemMap.get(`${entry.type}:${entry.id}`);
|
|
if (item) ordered.push(item);
|
|
}
|
|
const orderedSet = new Set(ordered);
|
|
for (const item of items) {
|
|
if (!orderedSet.has(item)) ordered.push(item);
|
|
}
|
|
grid.value.sort(ordered, { layout: instant ? 'instant' : true });
|
|
};
|
|
|
|
const getGridItemsByEntries = (entries: { id: string; type: GridItemType }[]) => {
|
|
if (!grid.value) return [];
|
|
const items = grid.value.getItems() ?? [];
|
|
const targetKeys = new Set(entries.map(entry => `${entry.type}:${entry.id}`));
|
|
const matches: any[] = [];
|
|
for (const item of items) {
|
|
const el = item.getElement() as HTMLElement | null;
|
|
const id = el?.dataset?.id;
|
|
const type = el?.dataset?.type;
|
|
if (id && type && targetKeys.has(`${type}:${id}`)) {
|
|
matches.push(item);
|
|
}
|
|
}
|
|
return matches;
|
|
};
|
|
|
|
const hideGridEntries = (entries: { id: string; type: GridItemType }[]) => {
|
|
if (!grid.value) return;
|
|
const items = getGridItemsByEntries(entries);
|
|
if (items.length) {
|
|
grid.value.hide(items, { instant: true, layout: false, syncWithLayout: false });
|
|
}
|
|
};
|
|
|
|
const markGridEntriesHidden = (entries: { id: string; type: GridItemType }[]) => {
|
|
const keySet = new Set(entries.map(entry => `${entry.type}:${entry.id}`));
|
|
for (const entry of entries) {
|
|
const el = gridRef.value?.querySelector(
|
|
`.grid-item[data-type="${entry.type}"][data-id="${entry.id}"]`
|
|
) as HTMLElement | null;
|
|
if (el) {
|
|
el.dataset.mergeHidden = '1';
|
|
el.style.setProperty('display', 'none', 'important');
|
|
}
|
|
}
|
|
const dragEl = draggingEl.value;
|
|
if (dragEl) {
|
|
const dragKey = `${dragEl.dataset?.type}:${dragEl.dataset?.id}`;
|
|
if (keySet.has(dragKey)) {
|
|
dragEl.dataset.mergeHidden = '1';
|
|
dragEl.style.setProperty('display', 'none', 'important');
|
|
}
|
|
}
|
|
hideGridEntries(entries);
|
|
};
|
|
|
|
const removeGridEntries = (entries: { id: string; type: GridItemType }[]) => {
|
|
if (!grid.value) return;
|
|
const items = getGridItemsByEntries(entries);
|
|
if (items.length) {
|
|
grid.value.remove(items, { removeElements: true, layout: false });
|
|
}
|
|
};
|
|
|
|
const waitForNextFrame = () =>
|
|
new Promise<void>(resolve => {
|
|
requestAnimationFrame(() => resolve());
|
|
});
|
|
|
|
const waitForFolderElement = async (folderId?: string | null, attempts = 8) => {
|
|
if (!folderId) return;
|
|
for (let i = 0; i < attempts; i += 1) {
|
|
const el = gridRef.value?.querySelector(
|
|
`.grid-item[data-type="folder"][data-id="${folderId}"]`
|
|
);
|
|
if (el) return;
|
|
await waitForNextFrame();
|
|
}
|
|
};
|
|
|
|
const pruneGridToOrder = (order: GridOrderEntry[]) => {
|
|
if (!grid.value) return;
|
|
const allowed = new Set(order.map(entry => `${entry.type}:${entry.id}`));
|
|
const items = grid.value.getItems() ?? [];
|
|
const toRemove: any[] = [];
|
|
for (const item of items) {
|
|
const el = item.getElement() as HTMLElement | null;
|
|
const id = el?.dataset?.id;
|
|
const type = el?.dataset?.type;
|
|
if (id && type && !allowed.has(`${type}:${id}`)) {
|
|
toRemove.push(item);
|
|
}
|
|
}
|
|
if (toRemove.length) {
|
|
grid.value.remove(toRemove, { removeElements: true });
|
|
}
|
|
};
|
|
|
|
const reconcileGridAfterMerge = async (order: GridOrderEntry[], folderId?: string | null) => {
|
|
await nextTick();
|
|
await waitForFolderElement(folderId);
|
|
pruneGridToOrder(order);
|
|
sortGridToOrder(order, true);
|
|
};
|
|
|
|
const markResized = (type: GridItemType, id: string) => {
|
|
pendingResizedKeys.add(`${type}:${id}`);
|
|
};
|
|
|
|
const syncSizeMap = (
|
|
items: { id: string; size: GridItemSize }[],
|
|
map: Map<string, GridItemSize>
|
|
) => {
|
|
const changed: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const item of items) {
|
|
const prev = map.get(item.id);
|
|
if (prev && prev !== item.size) {
|
|
changed.push(item.id);
|
|
}
|
|
map.set(item.id, item.size);
|
|
seen.add(item.id);
|
|
}
|
|
for (const id of Array.from(map.keys())) {
|
|
if (!seen.has(id)) {
|
|
map.delete(id);
|
|
}
|
|
}
|
|
return changed;
|
|
};
|
|
|
|
onMounted(async () => {
|
|
loadGridOrder();
|
|
ensureOrderConsistency();
|
|
previousIconIds.value = new Set(layoutStore.icons.map(icon => icon.id));
|
|
previousWidgetIds.value = new Set(widgetsStore.widgets.map(widget => widget.id));
|
|
await nextTick();
|
|
if (!gridRef.value) return;
|
|
grid.value = new Muuri(gridRef.value, {
|
|
items: '.grid-item',
|
|
dragEnabled: true,
|
|
dragStartPredicate: {
|
|
distance: 5,
|
|
delay: 0,
|
|
},
|
|
dragSort: true,
|
|
dragSortLock: () => disableSort.value,
|
|
dragSortPredicate: (item: any, event: any) => {
|
|
if (disableSort.value) return null;
|
|
const predicate = (Muuri as any).ItemDrag?.defaultSortPredicate;
|
|
return predicate
|
|
? predicate(item, { threshold: 30, action: 'move', migrateAction: 'move' }, event)
|
|
: null;
|
|
},
|
|
dragSortHeuristics: {
|
|
sortInterval: 60,
|
|
minDragDistance: 2,
|
|
minBounceBackAngle: 0.5,
|
|
},
|
|
layout: {
|
|
fillGaps: true,
|
|
rounding: true,
|
|
},
|
|
layoutDuration: layoutAnimationMs,
|
|
layoutEasing,
|
|
});
|
|
|
|
grid.value.on('dragStart', (item: any) => {
|
|
isDragging.value = true;
|
|
suppressClick.value = true;
|
|
const el = item.getElement() as HTMLElement;
|
|
draggingEl.value = el;
|
|
draggingItemRef.value = item;
|
|
draggingMeta.value = {
|
|
id: el?.dataset?.id ?? null,
|
|
type: (el?.dataset?.type as GridItemType) ?? null,
|
|
};
|
|
draggingPointerBackup.value = el.style.pointerEvents || null;
|
|
el.style.pointerEvents = 'none';
|
|
disableSort.value = false;
|
|
mergeCommitted.value = false;
|
|
mergeOrderSnapshot.value = null;
|
|
mergePendingInsertIndex.value = null;
|
|
mergePendingFolderId.value = null;
|
|
});
|
|
|
|
grid.value.on('dragMove', (item: any, event: any) => {
|
|
const el = item.getElement() as HTMLElement;
|
|
const nextPointer = resolvePointerFromEvent(event);
|
|
if (nextPointer) {
|
|
lastPointer.value = nextPointer;
|
|
}
|
|
const pointer = lastPointer.value;
|
|
if (pointer) {
|
|
pointerTargetEl.value = getPointerTargetFromRects(pointer, el);
|
|
} else {
|
|
pointerTargetEl.value = null;
|
|
}
|
|
|
|
overlapPendingEl.value = el;
|
|
if (overlapRafId.value === null) {
|
|
overlapRafId.value = window.requestAnimationFrame(() => {
|
|
overlapRafId.value = null;
|
|
const pending = overlapPendingEl.value;
|
|
overlapPendingEl.value = null;
|
|
if (!pending) return;
|
|
const now = performance.now();
|
|
const pointerTarget = pointerTargetEl.value;
|
|
|
|
if (mergeActive.value) {
|
|
const locked = mergeTargetEl.value;
|
|
if (!locked || !locked.isConnected) {
|
|
deactivateMergeTarget(true);
|
|
} else if (pointerTarget && pointerTarget !== locked) {
|
|
deactivateMergeTarget(true);
|
|
mergeCandidateEl.value = pointerTarget;
|
|
mergeCandidateAt.value = now;
|
|
setHoverTarget(pointerTarget);
|
|
} else if (!pointerTarget) {
|
|
deactivateMergeTarget(true);
|
|
} else {
|
|
setHoverTarget(locked);
|
|
}
|
|
} else {
|
|
if (pointerTarget && pointerTarget.isConnected) {
|
|
setHoverTarget(pointerTarget);
|
|
if (mergeCandidateEl.value === pointerTarget) {
|
|
if (now - mergeCandidateAt.value >= OVERLAP_HOLD_MS) {
|
|
activateMergeTarget(pointerTarget);
|
|
}
|
|
} else {
|
|
mergeCandidateEl.value = pointerTarget;
|
|
mergeCandidateAt.value = now;
|
|
}
|
|
} else {
|
|
mergeCandidateEl.value = null;
|
|
mergeCandidateAt.value = 0;
|
|
clearHoverTarget();
|
|
}
|
|
}
|
|
|
|
applySortLock(mergeActive.value);
|
|
});
|
|
}
|
|
});
|
|
|
|
grid.value.on('dragEnd', () => {
|
|
isDragging.value = false;
|
|
handleMergeDrop();
|
|
deactivateMergeTarget(false);
|
|
clickBlockUntil.value = Date.now() + 180;
|
|
window.setTimeout(() => {
|
|
suppressClick.value = false;
|
|
}, 0);
|
|
draggingMeta.value = { id: null, type: null };
|
|
const el = draggingEl.value;
|
|
if (el) {
|
|
el.style.pointerEvents = draggingPointerBackup.value ?? '';
|
|
}
|
|
draggingPointerBackup.value = null;
|
|
draggingEl.value = null;
|
|
draggingItemRef.value = null;
|
|
if (overlapRafId.value !== null) {
|
|
cancelAnimationFrame(overlapRafId.value);
|
|
overlapRafId.value = null;
|
|
overlapPendingEl.value = null;
|
|
}
|
|
applySortLock(false);
|
|
mergeCandidateEl.value = null;
|
|
mergeCandidateAt.value = 0;
|
|
lastPointer.value = null;
|
|
pointerTargetEl.value = null;
|
|
});
|
|
|
|
grid.value.on('dragReleaseEnd', () => {
|
|
if (mergeCommitted.value) {
|
|
mergeCommitted.value = false;
|
|
const order = mergeOrderSnapshot.value ?? gridOrder.value;
|
|
const folderId = mergePendingFolderId.value;
|
|
mergeOrderSnapshot.value = null;
|
|
mergePendingFolderId.value = null;
|
|
mergePendingInsertIndex.value = null;
|
|
void reconcileGridAfterMerge(order, folderId);
|
|
} else {
|
|
persistOrderFromGrid();
|
|
}
|
|
clearHoverTarget();
|
|
});
|
|
|
|
grid.value.on('layoutStart', () => {
|
|
if (!pendingResizedKeys.size) return;
|
|
resizedKeys.value = new Set(pendingResizedKeys);
|
|
});
|
|
|
|
grid.value.on('layoutEnd', () => {
|
|
if (pendingResizedKeys.size) {
|
|
pendingResizedKeys.clear();
|
|
resizedKeys.value = new Set<string>();
|
|
}
|
|
if (enteringKeys.value.size) {
|
|
enteringKeys.value = new Set<string>();
|
|
}
|
|
});
|
|
|
|
grid.value.layout(true);
|
|
});
|
|
|
|
watch(
|
|
() => [layoutStore.icons.map(icon => icon.id), widgetsStore.widgets.map(widget => widget.id)],
|
|
([iconIds, widgetIds]) => {
|
|
ensureOrderConsistency();
|
|
const nextIconIds = new Set(iconIds);
|
|
const nextWidgetIds = new Set(widgetIds);
|
|
const addedIcons = iconIds.filter(id => !previousIconIds.value.has(id));
|
|
const addedWidgets = widgetIds.filter(id => !previousWidgetIds.value.has(id));
|
|
if (addedIcons.length || addedWidgets.length) {
|
|
const nextEntering = new Set(enteringKeys.value);
|
|
for (const id of addedIcons) nextEntering.add(`icon:${id}`);
|
|
for (const id of addedWidgets) nextEntering.add(`widget:${id}`);
|
|
enteringKeys.value = nextEntering;
|
|
}
|
|
previousIconIds.value = nextIconIds;
|
|
previousWidgetIds.value = nextWidgetIds;
|
|
const instant = addedIcons.length > 0 || addedWidgets.length > 0;
|
|
refreshLayout(instant);
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => widgetsStore.widgets.map(widget => ({ id: widget.id, size: widget.size })),
|
|
async () => {
|
|
const changed = syncSizeMap(
|
|
widgetsStore.widgets.map(widget => ({ id: widget.id, size: widget.size })),
|
|
widgetSizeMap
|
|
);
|
|
for (const id of changed) {
|
|
markResized('widget', id);
|
|
}
|
|
await refreshLayout();
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => layoutStore.icons.map(icon => ({ id: icon.id, size: icon.size ?? '1x1' })),
|
|
async () => {
|
|
const changed = syncSizeMap(
|
|
layoutStore.icons.map(icon => ({ id: icon.id, size: icon.size ?? '1x1' })),
|
|
iconSizeMap
|
|
);
|
|
for (const id of changed) {
|
|
markResized('icon', id);
|
|
}
|
|
await refreshLayout();
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => foldersStore.folders.map(folder => ({ id: folder.id, size: folder.size ?? DEFAULT_FOLDER_SIZE })),
|
|
async () => {
|
|
const changed = syncSizeMap(
|
|
foldersStore.folders.map(folder => ({ id: folder.id, size: folder.size ?? DEFAULT_FOLDER_SIZE })),
|
|
folderSizeMap
|
|
);
|
|
for (const id of changed) {
|
|
markResized('folder', id);
|
|
}
|
|
await refreshLayout();
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => foldersStore.folders.map(folder => ({ id: folder.id, items: folder.items.length, groupId: folder.groupId })),
|
|
() => {
|
|
ensureOrderConsistency();
|
|
refreshLayout(true);
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
watch(
|
|
() => props.activeGroupId,
|
|
() => {
|
|
if (!grid.value) return;
|
|
refreshLayout(true);
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => [
|
|
settingsStore.layoutDensity,
|
|
settingsStore.iconDensity,
|
|
settingsStore.compactSidebar,
|
|
],
|
|
() => {
|
|
if (!grid.value) return;
|
|
refreshLayout(true);
|
|
}
|
|
);
|
|
|
|
onUnmounted(() => {
|
|
pendingResizedKeys.clear();
|
|
grid.value?.destroy();
|
|
grid.value = null;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="gridRef"
|
|
class="grid-canvas"
|
|
:style="{ '--layout-anim-ms': `${layoutAnimationMs}ms` }"
|
|
@click.capture="handleClickCapture"
|
|
@click="handleClick"
|
|
@contextmenu.prevent="handleContextMenu"
|
|
>
|
|
<div
|
|
v-for="item in orderedItems"
|
|
:key="`${item.type}-${item.id}`"
|
|
class="grid-item"
|
|
:class="[
|
|
sizeClassFor(item),
|
|
{
|
|
'is-resized': resizedKeys.has(`${item.type}:${item.id}`),
|
|
'is-entering': enteringKeys.has(`${item.type}:${item.id}`),
|
|
'is-drop-target': hoverTargetEl && hoverTargetEl.dataset?.id === item.id
|
|
}
|
|
]"
|
|
:data-id="item.id"
|
|
:data-type="item.type"
|
|
>
|
|
<div class="grid-item-content">
|
|
<WidgetCard v-if="item.type === 'widget'" :widget="item.widget" />
|
|
<IconCard v-else-if="item.type === 'icon'" :icon="item.icon" />
|
|
<FolderCard
|
|
v-else
|
|
:folder="item.folder"
|
|
:children="item.children"
|
|
@open="openFolder(item.folder.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<FolderDialog
|
|
:open="!!currentFolder"
|
|
:folder="currentFolder"
|
|
:children="currentFolderChildren"
|
|
@close="openFolderId = null"
|
|
@rename="handleRenameFolder"
|
|
@remove="handleRemoveFromFolder"
|
|
@open-item="handleOpenChild"
|
|
/>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@import '@/styles/tokens.scss';
|
|
|
|
.grid-canvas {
|
|
width: 100%;
|
|
position: relative;
|
|
user-select: none; /* 鏍囧噯 */
|
|
-webkit-user-select: none; /* Safari 娴忚鍣?*/
|
|
-moz-user-select: none; /* Firefox 娴忚鍣?*/
|
|
-ms-user-select: none; /* IE 10+/Edge 娴忚鍣?*/
|
|
--cell-size: var(--grid-cell-size);
|
|
--cell-gap: var(--grid-gap);
|
|
--cell-gap-padding: max(var(--cell-gap), 0px);
|
|
padding: calc(var(--cell-gap-padding) / 2);
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.grid-item {
|
|
position: absolute;
|
|
width: var(--cell-size);
|
|
height: var(--cell-size);
|
|
margin: calc(var(--cell-gap) / 2);
|
|
cursor: grab;
|
|
will-change: transform;
|
|
transition: opacity 160ms ease;
|
|
}
|
|
|
|
.grid-item.is-resized {
|
|
overflow: visible;
|
|
}
|
|
|
|
.grid-item.is-entering {
|
|
opacity: 0;
|
|
}
|
|
|
|
.grid-item.is-entering .grid-item-content {
|
|
animation: grid-item-enter 220ms $motion-easing-standard;
|
|
}
|
|
|
|
@keyframes grid-item-enter {
|
|
0% {
|
|
transform: scale(0.7);
|
|
opacity: 0;
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.grid-item.size-1x2 {
|
|
height: calc(var(--cell-size) * 2 + var(--cell-gap));
|
|
}
|
|
|
|
.grid-item.size-2x1 {
|
|
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
|
}
|
|
|
|
.grid-item.size-2x2 {
|
|
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
|
height: calc(var(--cell-size) * 2 + var(--cell-gap));
|
|
}
|
|
|
|
.grid-item.size-2x4 {
|
|
width: calc(var(--cell-size) * 2 + var(--cell-gap));
|
|
height: calc(var(--cell-size) * 4 + var(--cell-gap) * 3);
|
|
}
|
|
|
|
.grid-item-content {
|
|
width: 100%;
|
|
height: 100%;
|
|
transform-origin: center;
|
|
border-radius: var(--icon-radius, #{$border-radius-small});
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
transition: transform 240ms $motion-easing-standard, opacity 240ms $motion-easing-standard;
|
|
position: relative;
|
|
}
|
|
|
|
.grid-item.is-deleting .grid-item-content {
|
|
transform: scale(0);
|
|
opacity: 0;
|
|
}
|
|
|
|
.grid-item.is-deleting {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.grid-item.is-resized .grid-item-content {
|
|
animation: grid-item-resize-zoom var(--layout-anim-ms) $motion-easing-standard;
|
|
}
|
|
|
|
@keyframes grid-item-resize-zoom {
|
|
0% {
|
|
transform: scale(0.97);
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.grid-item.muuri-item-dragging {
|
|
z-index: $z-index-menu;
|
|
cursor: grabbing;
|
|
transition: none;
|
|
}
|
|
|
|
.grid-item-content::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: -6px;
|
|
border-radius: var(--icon-radius, #{$border-radius-small});
|
|
background: rgba(255, 255, 255, 0.32);
|
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
|
opacity: 0;
|
|
transform: scale(0.96);
|
|
transition: opacity 160ms $motion-easing-standard, transform 160ms $motion-easing-standard;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.grid-item.is-drop-target .grid-item-content::before {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
</style>
|
|
|