Files
ztab/app/src/components/GridCanvas/index.vue
yinsx bf5a3bc343 1
2026-02-02 09:07:30 +08:00

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>