diff --git a/ScreenShot_2026-01-26_101148_856.png b/ScreenShot_2026-01-26_101148_856.png new file mode 100644 index 0000000..16d1829 Binary files /dev/null and b/ScreenShot_2026-01-26_101148_856.png differ diff --git a/ScreenShot_2026-01-26_151019_180.png b/ScreenShot_2026-01-26_151019_180.png new file mode 100644 index 0000000..b65fb9a Binary files /dev/null and b/ScreenShot_2026-01-26_151019_180.png differ diff --git a/ScreenShot_2026-01-27_140219_283.png b/ScreenShot_2026-01-27_140219_283.png new file mode 100644 index 0000000..4d2be8a Binary files /dev/null and b/ScreenShot_2026-01-27_140219_283.png differ diff --git a/ScreenShot_2026-01-27_142115_699.png b/ScreenShot_2026-01-27_142115_699.png new file mode 100644 index 0000000..bfcab38 Binary files /dev/null and b/ScreenShot_2026-01-27_142115_699.png differ diff --git a/ScreenShot_2026-01-27_142125_810.png b/ScreenShot_2026-01-27_142125_810.png new file mode 100644 index 0000000..6d3c9ce Binary files /dev/null and b/ScreenShot_2026-01-27_142125_810.png differ diff --git a/ScreenShot_2026-01-27_142143_653.png b/ScreenShot_2026-01-27_142143_653.png new file mode 100644 index 0000000..9587ce9 Binary files /dev/null and b/ScreenShot_2026-01-27_142143_653.png differ diff --git a/ScreenShot_2026-01-27_161350_530.png b/ScreenShot_2026-01-27_161350_530.png new file mode 100644 index 0000000..6cd0379 Binary files /dev/null and b/ScreenShot_2026-01-27_161350_530.png differ diff --git a/ScreenShot_2026-01-27_164351_352.png b/ScreenShot_2026-01-27_164351_352.png new file mode 100644 index 0000000..6b1d5bc Binary files /dev/null and b/ScreenShot_2026-01-27_164351_352.png differ diff --git a/ScreenShot_2026-01-27_164407_879.png b/ScreenShot_2026-01-27_164407_879.png new file mode 100644 index 0000000..2db576a Binary files /dev/null and b/ScreenShot_2026-01-27_164407_879.png differ diff --git a/ScreenShot_2026-01-27_164417_272.png b/ScreenShot_2026-01-27_164417_272.png new file mode 100644 index 0000000..68b7fba Binary files /dev/null and b/ScreenShot_2026-01-27_164417_272.png differ diff --git a/ScreenShot_2026-01-27_164425_826.png b/ScreenShot_2026-01-27_164425_826.png new file mode 100644 index 0000000..b2dcae4 Binary files /dev/null and b/ScreenShot_2026-01-27_164425_826.png differ diff --git a/ScreenShot_2026-01-27_164433_919.png b/ScreenShot_2026-01-27_164433_919.png new file mode 100644 index 0000000..849ea7c Binary files /dev/null and b/ScreenShot_2026-01-27_164433_919.png differ diff --git a/ScreenShot_2026-01-27_164441_143.png b/ScreenShot_2026-01-27_164441_143.png new file mode 100644 index 0000000..20a601c Binary files /dev/null and b/ScreenShot_2026-01-27_164441_143.png differ diff --git a/ScreenShot_2026-01-27_164454_148.png b/ScreenShot_2026-01-27_164454_148.png new file mode 100644 index 0000000..a1f40e6 Binary files /dev/null and b/ScreenShot_2026-01-27_164454_148.png differ diff --git a/ScreenShot_2026-01-27_164508_593.png b/ScreenShot_2026-01-27_164508_593.png new file mode 100644 index 0000000..1b295c7 Binary files /dev/null and b/ScreenShot_2026-01-27_164508_593.png differ diff --git a/ScreenShot_2026-01-27_164516_311.png b/ScreenShot_2026-01-27_164516_311.png new file mode 100644 index 0000000..cde3d74 Binary files /dev/null and b/ScreenShot_2026-01-27_164516_311.png differ diff --git a/ScreenShot_2026-01-27_164535_751.png b/ScreenShot_2026-01-27_164535_751.png new file mode 100644 index 0000000..8ccdfcb Binary files /dev/null and b/ScreenShot_2026-01-27_164535_751.png differ diff --git a/ScreenShot_2026-01-28_161321_172.png b/ScreenShot_2026-01-28_161321_172.png new file mode 100644 index 0000000..1c82f6d Binary files /dev/null and b/ScreenShot_2026-01-28_161321_172.png differ diff --git a/app/package-lock.json b/app/package-lock.json index 6c3f50b..a0e5e63 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,6 +8,7 @@ "name": "itab-clone", "version": "0.0.0", "dependencies": { + "@iconify/vue": "^5.0.0", "muuri": "^0.9.5", "pinia": "^2.1.7", "vue": "^3.3.11" @@ -457,6 +458,27 @@ "node": ">=12" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/vue": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz", + "integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "vue": ">=3" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", diff --git a/app/package.json b/app/package.json index 651f184..b71282f 100644 --- a/app/package.json +++ b/app/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@iconify/vue": "^5.0.0", "muuri": "^0.9.5", "pinia": "^2.1.7", "vue": "^3.3.11" diff --git a/app/src/App.vue b/app/src/App.vue index 1ef6ee0..216a05a 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -1,15 +1,305 @@ diff --git a/app/src/components/AddIconDialog/index.vue b/app/src/components/AddIconDialog/index.vue new file mode 100644 index 0000000..5a1d78b --- /dev/null +++ b/app/src/components/AddIconDialog/index.vue @@ -0,0 +1,1112 @@ + + + + + diff --git a/app/src/components/EditIconDialog/index.vue b/app/src/components/EditIconDialog/index.vue new file mode 100644 index 0000000..31eee5c --- /dev/null +++ b/app/src/components/EditIconDialog/index.vue @@ -0,0 +1,541 @@ + + + + + diff --git a/app/src/components/GridCanvas/index.vue b/app/src/components/GridCanvas/index.vue index f7b85b2..553806d 100644 --- a/app/src/components/GridCanvas/index.vue +++ b/app/src/components/GridCanvas/index.vue @@ -4,6 +4,7 @@ import Muuri from 'muuri'; import { useLayoutStore } from '@/store/useLayoutStore'; import { useWidgetsStore } from '@/store/useWidgetsStore'; import { useUIStore } from '@/store/useUIStore'; +import { useSettingsStore } from '@/store/useSettingsStore'; import IconCard from '@/components/IconCard/index.vue'; import WidgetCard from '@/components/WidgetCard/index.vue'; @@ -15,6 +16,7 @@ interface Icon { name: string; url: string; img?: string; + text?: string; bgColor?: string; size?: GridItemSize; } @@ -41,6 +43,7 @@ const GRID_ORDER_STORAGE_KEY = 'itab_grid_order'; const layoutStore = useLayoutStore(); const widgetsStore = useWidgetsStore(); const uiStore = useUIStore(); +const settingsStore = useSettingsStore(); const gridRef = ref(null); const grid = ref(null); const isDragging = ref(false); @@ -51,6 +54,9 @@ const resizedKeys = ref(new Set()); const pendingResizedKeys = new Set(); const widgetSizeMap = new Map(); const iconSizeMap = new Map(); +const enteringKeys = ref(new Set()); +const previousIconIds = ref(new Set()); +const previousWidgetIds = ref(new Set()); const layoutAnimationMs = 240; const layoutEasing = 'cubic-bezier(0.4, 0, 0.2, 1)'; @@ -59,9 +65,10 @@ const buildDefaultOrder = (): GridOrderEntry[] => [ ...layoutStore.icons.map(icon => ({ id: icon.id, type: 'icon' as const })), ]; -const persistGridOrder = (order: GridOrderEntry[]) => { +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) { @@ -87,7 +94,7 @@ const loadGridOrder = () => { if (!gridOrder.value.length) { gridOrder.value = buildDefaultOrder(); } - persistGridOrder(gridOrder.value); + applyGridOrder(gridOrder.value, true); }; const ensureOrderConsistency = () => { @@ -124,7 +131,7 @@ const ensureOrderConsistency = () => { }); if (orderChanged) { - persistGridOrder(nextOrder); + applyGridOrder(nextOrder, false); } else { gridOrder.value = nextOrder; } @@ -176,7 +183,8 @@ const handleClick = (event: MouseEvent) => { 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 targetMode = settingsStore.openInNewTab ? '_blank' : '_self'; + window.open(link.href, targetMode); } }; @@ -190,7 +198,10 @@ const handleClickCapture = (event: MouseEvent) => { const handleContextMenu = (event: MouseEvent) => { const target = event.target as HTMLElement; const itemEl = target.closest('.grid-item') as HTMLElement | null; - if (!itemEl) return; + 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) { @@ -211,15 +222,31 @@ const persistOrderFromGrid = async () => { } } if (nextOrder.length) { - persistGridOrder(nextOrder); + applyGridOrder(nextOrder, 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) { + 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?.refreshItems(); if (instant) { grid.value?.layout(true); @@ -256,6 +283,8 @@ const syncSizeMap = ( onMounted(async () => { loadGridOrder(); + 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, { @@ -267,7 +296,7 @@ onMounted(async () => { }, dragSort: true, layout: { - fillGaps: true, + fillGaps: settingsStore.autoAlign, rounding: true, }, layoutDuration: layoutAnimationMs, @@ -297,19 +326,36 @@ onMounted(async () => { }); grid.value.on('layoutEnd', () => { - if (!pendingResizedKeys.size) return; - pendingResizedKeys.clear(); - resizedKeys.value = new Set(); + if (pendingResizedKeys.size) { + pendingResizedKeys.clear(); + resizedKeys.value = new Set(); + } + if (enteringKeys.value.size) { + enteringKeys.value = new Set(); + } }); grid.value.layout(true); }); watch( - () => [layoutStore.icons.length, widgetsStore.widgets.length], - () => { + () => [layoutStore.icons.map(icon => icon.id), widgetsStore.widgets.map(widget => widget.id)], + ([iconIds, widgetIds]) => { ensureOrderConsistency(); - refreshLayout(); + 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); } ); @@ -341,6 +387,28 @@ watch( } ); +watch( + () => settingsStore.autoAlign, + value => { + if (!grid.value) return; + // Muuri has no public setter for fillGaps; update internal setting and relayout. + grid.value._settings.layout.fillGaps = value; + grid.value.layout(true); + } +); + +watch( + () => [ + settingsStore.layoutDensity, + settingsStore.iconDensity, + settingsStore.compactSidebar, + ], + () => { + if (!grid.value) return; + refreshLayout(true); + } +); + onUnmounted(() => { pendingResizedKeys.clear(); grid.value?.destroy(); @@ -363,7 +431,10 @@ onUnmounted(() => { class="grid-item" :class="[ `size-${item.type === 'widget' ? item.widget.size : (item.icon.size ?? '1x1')}`, - { 'is-resized': resizedKeys.has(`${item.type}:${item.id}`) } + { + 'is-resized': resizedKeys.has(`${item.type}:${item.id}`), + 'is-entering': enteringKeys.has(`${item.type}:${item.id}`) + } ]" :data-id="item.id" :data-type="item.type" @@ -388,7 +459,8 @@ onUnmounted(() => { -ms-user-select: none; /* IE 10+/Edge 浏览器 */ --cell-size: var(--grid-cell-size); --cell-gap: var(--grid-gap); - padding: calc(var(--cell-gap) / 2); + --cell-gap-padding: max(var(--cell-gap), 0px); + padding: calc(var(--cell-gap-padding) / 2); box-sizing: border-box; } @@ -399,6 +471,7 @@ onUnmounted(() => { margin: calc(var(--cell-gap) / 2); cursor: grab; will-change: transform; + transition: opacity 160ms ease; } .grid-item.is-resized { @@ -406,6 +479,10 @@ onUnmounted(() => { border-radius: $border-radius-small; } +.grid-item.is-entering { + opacity: 0; +} + .grid-item.size-1x2 { height: calc(var(--cell-size) * 2 + var(--cell-gap)); } diff --git a/app/src/components/IconCard/index.vue b/app/src/components/IconCard/index.vue index 796a50e..640cd4a 100644 --- a/app/src/components/IconCard/index.vue +++ b/app/src/components/IconCard/index.vue @@ -8,8 +8,9 @@ >
- {{ props.icon.name.charAt(0) }} + {{ props.icon.text?.trim() || props.icon.name.charAt(0) }}
+
{{ props.icon.name }}
@@ -23,6 +24,7 @@ interface Icon { url: string; size?: IconSize; img?: string; + text?: string; bgColor?: string; } @@ -36,14 +38,13 @@ const props = defineProps<{ @import '@/styles/tokens.scss'; .icon-card-wrapper { - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: minmax(0, 1fr) var(--icon-label-margin-top) auto; align-items: stretch; - justify-content: flex-start; width: 100%; height: 100%; text-decoration: none; - border-radius: $border-radius-small; + border-radius: var(--icon-radius, #{$border-radius-small}); padding: var(--icon-card-padding); transition: transform $motion-duration-sm $motion-easing-standard; user-select: none; // 拖拽时避免选中文本 @@ -59,9 +60,9 @@ const props = defineProps<{ .icon-card { width: 100%; - flex: 1 1 auto; + height: 100%; min-height: 0; - border-radius: $border-radius-small; + border-radius: var(--icon-radius, #{$border-radius-small}); display: flex; align-items: center; justify-content: center; @@ -70,7 +71,6 @@ const props = defineProps<{ color: white; box-shadow: $shadow-md; transition: box-shadow $motion-duration-sm $motion-easing-standard; - margin-bottom: var(--icon-label-margin-top); background-size: cover; background-position: center; pointer-events: none; // 指针事件交给外层容器处理 @@ -78,20 +78,21 @@ const props = defineProps<{ img { width: 100%; height: 100%; - border-radius: $border-radius-small; + border-radius: var(--icon-radius, #{$border-radius-small}); object-fit: cover; } } -.icon-card-wrapper.size-1x1 { - align-items: center; - justify-content: center; +.card-spacer { + height: 100%; + pointer-events: none; } .icon-card-wrapper.size-1x1 .icon-card { - width: var(--icon-size); - height: var(--icon-size); - flex: 0 0 auto; + width: auto; + height: 100%; + aspect-ratio: 1 / 1; + justify-self: center; } .label { @@ -99,6 +100,7 @@ const props = defineProps<{ color: $color-text-primary; text-align: center; width: 100%; + display: var(--icon-label-display, block); white-space: normal; word-break: break-word; line-height: 1.2; diff --git a/app/src/components/MainContent/index.vue b/app/src/components/MainContent/index.vue index 9411977..66f0b0a 100644 --- a/app/src/components/MainContent/index.vue +++ b/app/src/components/MainContent/index.vue @@ -1,8 +1,13 @@