1
This commit is contained in:
343
app/src/components/GridCanvas/index.vue
Normal file
343
app/src/components/GridCanvas/index.vue
Normal file
@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import Muuri from 'muuri';
|
||||
import { useLayoutStore } from '@/store/useLayoutStore';
|
||||
import { useWidgetsStore } from '@/store/useWidgetsStore';
|
||||
import { useUIStore } from '@/store/useUIStore';
|
||||
import IconCard from '@/components/IconCard/index.vue';
|
||||
import WidgetCard from '@/components/WidgetCard/index.vue';
|
||||
|
||||
type GridItemType = 'icon' | 'widget';
|
||||
|
||||
interface Icon {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
img?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
type WidgetSize = '1x1' | '1x2' | '2x1' | '2x2' | '2x4';
|
||||
interface Widget {
|
||||
id: string;
|
||||
component: string;
|
||||
size: WidgetSize;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface GridOrderEntry {
|
||||
id: string;
|
||||
type: GridItemType;
|
||||
}
|
||||
|
||||
type OrderedGridItem =
|
||||
| { type: 'icon'; id: string; icon: Icon }
|
||||
| { type: 'widget'; id: string; widget: Widget };
|
||||
|
||||
const GRID_ORDER_STORAGE_KEY = 'itab_grid_order';
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const widgetsStore = useWidgetsStore();
|
||||
const uiStore = useUIStore();
|
||||
const gridRef = ref<HTMLElement | null>(null);
|
||||
const grid = ref<any | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const suppressClick = ref(false);
|
||||
const gridOrder = ref<GridOrderEntry[]>([]);
|
||||
|
||||
const buildDefaultOrder = (): GridOrderEntry[] => [
|
||||
...widgetsStore.widgets.map(widget => ({ id: widget.id, type: 'widget' as const })),
|
||||
...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' as const })),
|
||||
];
|
||||
|
||||
const persistGridOrder = (order: GridOrderEntry[]) => {
|
||||
gridOrder.value = order;
|
||||
localStorage.setItem(GRID_ORDER_STORAGE_KEY, JSON.stringify(order));
|
||||
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 = parsed.filter(item => item?.id && (item.type === 'icon' || item.type === 'widget'));
|
||||
}
|
||||
} catch {
|
||||
gridOrder.value = [];
|
||||
}
|
||||
}
|
||||
if (!gridOrder.value.length) {
|
||||
gridOrder.value = buildDefaultOrder();
|
||||
}
|
||||
persistGridOrder(gridOrder.value);
|
||||
};
|
||||
|
||||
const ensureOrderConsistency = () => {
|
||||
const iconIds = new Set(layoutStore.icons.map(icon => icon.id));
|
||||
const widgetIds = new Set(widgetsStore.widgets.map(widget => widget.id));
|
||||
const nextOrder: GridOrderEntry[] = [];
|
||||
|
||||
for (const entry of gridOrder.value) {
|
||||
if (entry.type === 'icon' && iconIds.has(entry.id)) {
|
||||
nextOrder.push(entry);
|
||||
}
|
||||
if (entry.type === 'widget' && widgetIds.has(entry.id)) {
|
||||
nextOrder.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgetsStore.widgets) {
|
||||
if (!nextOrder.some(item => item.type === 'widget' && item.id === widget.id)) {
|
||||
nextOrder.push({ id: widget.id, type: 'widget' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of layoutStore.icons) {
|
||||
if (!nextOrder.some(item => item.type === 'icon' && item.id === icon.id)) {
|
||||
nextOrder.push({ id: icon.id, type: 'icon' });
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
persistGridOrder(nextOrder);
|
||||
} else {
|
||||
gridOrder.value = nextOrder;
|
||||
}
|
||||
};
|
||||
|
||||
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 used = new Set<string>();
|
||||
const items: OrderedGridItem[] = [];
|
||||
|
||||
for (const entry of gridOrder.value) {
|
||||
if (entry.type === 'icon') {
|
||||
const icon = iconsById.get(entry.id);
|
||||
if (icon) {
|
||||
items.push({ type: 'icon', id: icon.id, icon });
|
||||
used.add(`icon:${icon.id}`);
|
||||
}
|
||||
} else {
|
||||
const widget = widgetsById.get(entry.id);
|
||||
if (widget) {
|
||||
items.push({ type: 'widget', id: widget.id, widget });
|
||||
used.add(`widget:${widget.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgetsStore.widgets) {
|
||||
const key = `widget:${widget.id}`;
|
||||
if (!used.has(key)) {
|
||||
items.push({ type: 'widget', id: widget.id, widget });
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of layoutStore.icons) {
|
||||
const key = `icon:${icon.id}`;
|
||||
if (!used.has(key)) {
|
||||
items.push({ type: 'icon', id: icon.id, icon });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
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) {
|
||||
window.open(link.href, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const itemEl = target.closest('.grid-item') as HTMLElement | null;
|
||||
if (!itemEl) return;
|
||||
const id = itemEl.dataset.id;
|
||||
const type = itemEl.dataset.type as GridItemType | undefined;
|
||||
if (id && type) {
|
||||
uiStore.openContextMenu(event.clientX, event.clientY, id, type);
|
||||
}
|
||||
};
|
||||
|
||||
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')) {
|
||||
nextOrder.push({ id, type });
|
||||
}
|
||||
}
|
||||
if (nextOrder.length) {
|
||||
persistGridOrder(nextOrder);
|
||||
}
|
||||
await nextTick();
|
||||
grid.value?.synchronize();
|
||||
grid.value?.layout();
|
||||
};
|
||||
|
||||
const refreshLayout = async () => {
|
||||
await nextTick();
|
||||
grid.value?.refreshItems();
|
||||
grid.value?.layout();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
loadGridOrder();
|
||||
await nextTick();
|
||||
if (!gridRef.value) return;
|
||||
grid.value = new Muuri(gridRef.value, {
|
||||
items: '.grid-item',
|
||||
dragEnabled: true,
|
||||
dragStartPredicate: {
|
||||
distance: 5,
|
||||
delay: 150,
|
||||
},
|
||||
dragSort: true,
|
||||
layout: {
|
||||
fillGaps: true,
|
||||
rounding: true,
|
||||
},
|
||||
});
|
||||
|
||||
grid.value.on('dragStart', () => {
|
||||
isDragging.value = true;
|
||||
suppressClick.value = true;
|
||||
});
|
||||
|
||||
grid.value.on('dragEnd', () => {
|
||||
isDragging.value = false;
|
||||
window.setTimeout(() => {
|
||||
suppressClick.value = false;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
grid.value.on('dragReleaseEnd', () => {
|
||||
persistOrderFromGrid();
|
||||
});
|
||||
|
||||
grid.value.layout(true);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [layoutStore.icons.length, widgetsStore.widgets.length],
|
||||
() => {
|
||||
ensureOrderConsistency();
|
||||
refreshLayout();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => widgetsStore.widgets.map(widget => widget.size).join('|'),
|
||||
() => {
|
||||
refreshLayout();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
grid.value?.destroy();
|
||||
grid.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="gridRef"
|
||||
class="grid-canvas"
|
||||
@click="handleClick"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<div
|
||||
v-for="item in orderedItems"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
class="grid-item"
|
||||
:class="item.type === 'widget' ? `size-${item.widget.size}` : 'size-1x1'"
|
||||
: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 :icon="item.icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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);
|
||||
padding: calc(var(--cell-gap) / 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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
.grid-item.muuri-item-dragging {
|
||||
z-index: $z-index-menu;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user