+
@@ -524,5 +666,14 @@ function findDifference(layout: Layout, originalLayout: Layout) {
:h="state.placeholder.h"
:i="state.placeholder.i"
>
+
diff --git a/src/components/types.ts b/src/components/types.ts
index ee4ba27..8b1b0f6 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -1,4 +1,33 @@
-import type { Breakpoints, Layout, ResponsiveLayout } from '../helpers/types'
+import type {
+ Breakpoints,
+ Compactor,
+ Layout,
+ PositionStrategy,
+ ResponsiveLayout,
+} from '../helpers/types'
+
+export interface GridConfig {
+ colNum?: number,
+ rowHeight?: number,
+ maxRows?: number,
+ margin?: number[],
+ autoSize?: boolean,
+}
+
+export interface DragConfig {
+ isDraggable?: boolean,
+ dragThreshold?: number,
+ restoreOnDrag?: boolean,
+}
+
+export interface ResizeConfig {
+ isResizable?: boolean,
+}
+
+export interface DropConfig {
+ isDroppable?: boolean,
+ dropItem?: { w: number, h: number },
+}
export interface GridLayoutProps {
autoSize?: boolean,
@@ -10,17 +39,31 @@ export interface GridLayoutProps {
isResizable?: boolean,
isMirrored?: boolean,
isBounded?: boolean,
- useCssTransforms?: boolean,
- verticalCompact?: boolean,
restoreOnDrag?: boolean,
layout: Layout,
responsive?: boolean,
responsiveLayouts?: Partial
,
- transformScale?: number,
breakpoints?: Breakpoints,
cols?: Breakpoints,
preventCollision?: boolean,
- useStyleCursor?: boolean
+ useStyleCursor?: boolean,
+
+ /** 可插拔压缩器(默认 verticalCompactor) */
+ compactor?: Compactor,
+ /** 可插拔定位策略(默认 transformStrategy) */
+ positionStrategy?: PositionStrategy,
+ /** 是否允许外部拖入 */
+ isDroppable?: boolean,
+ /** 外部拖入元素的默认尺寸 */
+ dropItem?: { w: number, h: number },
+ /** 拖拽阈值(像素) */
+ dragThreshold?: number,
+
+ /** 分组配置对象 */
+ gridConfig?: GridConfig,
+ dragConfig?: DragConfig,
+ resizeConfig?: ResizeConfig,
+ dropConfig?: DropConfig,
}
export interface GridItemProps {
@@ -42,5 +85,8 @@ export interface GridItemProps {
resizeIgnoreFrom?: string,
preserveAspectRatio?: boolean,
dragOption?: Record,
- resizeOption?: Record
+ resizeOption?: Record,
+
+ /** 拖拽阈值(覆盖 GridLayout 的默认值) */
+ dragThreshold?: number,
}
diff --git a/src/composables/useContainerWidth.ts b/src/composables/useContainerWidth.ts
new file mode 100644
index 0000000..98aef26
--- /dev/null
+++ b/src/composables/useContainerWidth.ts
@@ -0,0 +1,45 @@
+import { onScopeDispose, ref, watch } from 'vue'
+
+import type { Ref } from 'vue'
+
+/**
+ * 监听容器元素宽度变化的 composable。
+ *
+ * @param el 容器元素的响应式引用,为 null 时返回 width = -1
+ * @returns 响应式的 width 值
+ */
+export function useContainerWidth(el: Ref): { width: Ref } {
+ const width = ref(-1)
+ let observer: ResizeObserver | null = null
+
+ function cleanup() {
+ if (observer) {
+ observer.disconnect()
+ observer = null
+ }
+ }
+
+ watch(
+ el,
+ (newEl) => {
+ cleanup()
+
+ if (!newEl) {
+ width.value = -1
+ return
+ }
+
+ observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ width.value = entry.contentRect.width
+ }
+ })
+ observer.observe(newEl)
+ },
+ { immediate: true },
+ )
+
+ onScopeDispose(cleanup)
+
+ return { width }
+}
diff --git a/src/composables/useGridLayout.ts b/src/composables/useGridLayout.ts
new file mode 100644
index 0000000..4c8af49
--- /dev/null
+++ b/src/composables/useGridLayout.ts
@@ -0,0 +1,92 @@
+import { computed, isRef, ref, watch } from 'vue'
+
+import { cloneLayout, correctBounds, getLayoutItem, moveElement } from '../helpers/common'
+import { verticalCompactor } from '../core/compactors'
+
+import type { Ref } from 'vue'
+import type { Compactor, Layout, LayoutItem } from '../helpers/types'
+
+export interface UseGridLayoutOptions {
+ layout: Ref | Layout,
+ cols?: number,
+ rowHeight?: number,
+ compactor?: Compactor,
+ preventCollision?: boolean,
+}
+
+export interface UseGridLayoutReturn {
+ currentLayout: Ref,
+ moveItem: (i: number | string, x: number, y: number) => void,
+ resizeItem: (i: number | string, w: number, h: number) => void,
+ addItem: (item: LayoutItem) => void,
+ removeItem: (i: number | string) => void,
+}
+
+/**
+ * 核心布局状态管理 composable。
+ * 不依赖浏览器 DOM API,可在 SSR 环境中使用。
+ *
+ * @param options 配置参数
+ * @returns 响应式布局状态和操作方法
+ */
+export function useGridLayout(options: UseGridLayoutOptions): UseGridLayoutReturn {
+ const {
+ cols = 12,
+ compactor: comp = verticalCompactor,
+ preventCollision = false,
+ } = options
+
+ const layoutSource = isRef(options.layout) ? options.layout : ref(options.layout)
+
+ /** 内部原始布局(操作直接修改此数组) */
+ const rawLayout = ref(cloneLayout(layoutSource.value))
+
+ /** 经过压缩后的当前布局 */
+ const currentLayout = computed(() => {
+ return comp.compact(correctBounds(cloneLayout(rawLayout.value), { cols }), cols)
+ })
+
+ // 当外部 layout 引用变化时,同步更新内部布局
+ watch(layoutSource, (newLayout) => {
+ rawLayout.value = cloneLayout(newLayout)
+ })
+
+ function moveItem(i: number | string, x: number, y: number): void {
+ const layout = cloneLayout(rawLayout.value)
+ const item = getLayoutItem(layout, i)
+ if (!item) return
+ moveElement(layout, item, x, y, true, preventCollision)
+ rawLayout.value = layout
+ }
+
+ function resizeItem(i: number | string, w: number, h: number): void {
+ const layout = cloneLayout(rawLayout.value)
+ const item = getLayoutItem(layout, i)
+ if (!item) return
+ item.w = w
+ item.h = h
+ rawLayout.value = layout
+ }
+
+ function addItem(item: LayoutItem): void {
+ const layout = cloneLayout(rawLayout.value)
+ layout.push({ ...item })
+ rawLayout.value = layout
+ }
+
+ function removeItem(i: number | string): void {
+ const layout = cloneLayout(rawLayout.value)
+ const idx = layout.findIndex(l => l.i === i)
+ if (idx === -1) return
+ layout.splice(idx, 1)
+ rawLayout.value = layout
+ }
+
+ return {
+ currentLayout,
+ moveItem,
+ resizeItem,
+ addItem,
+ removeItem,
+ }
+}
diff --git a/src/composables/useResponsiveLayout.ts b/src/composables/useResponsiveLayout.ts
new file mode 100644
index 0000000..b1f84b3
--- /dev/null
+++ b/src/composables/useResponsiveLayout.ts
@@ -0,0 +1,101 @@
+import { computed, ref, watch } from 'vue'
+
+import { verticalCompactor } from '../core/compactors'
+import {
+ findOrGenerateResponsiveLayout,
+ getBreakpointFromWidth,
+ getColsFromBreakpoint,
+} from '../helpers/responsive'
+
+import type { Ref } from 'vue'
+import type { Breakpoint, Breakpoints, Compactor, Layout, ResponsiveLayout } from '../helpers/types'
+
+export interface UseResponsiveLayoutOptions {
+ breakpoints: Breakpoints,
+ cols: Breakpoints,
+ width: Ref,
+ layouts: Ref>,
+ compactor?: Compactor,
+ originalLayout: Ref,
+}
+
+export interface UseResponsiveLayoutReturn {
+ currentBreakpoint: Ref,
+ currentCols: Ref,
+ currentLayout: Ref,
+}
+
+/**
+ * 响应式断点管理 composable。
+ * 不依赖浏览器 DOM API,可在 SSR 环境中使用。
+ *
+ * @param options 配置参数
+ * @returns 响应式断点、列数和布局
+ */
+export function useResponsiveLayout(options: UseResponsiveLayoutOptions): UseResponsiveLayoutReturn {
+ const {
+ breakpoints,
+ cols,
+ width,
+ layouts,
+ compactor: comp = verticalCompactor,
+ originalLayout,
+ } = options
+
+ const currentBreakpoint = ref(
+ getBreakpointFromWidth(breakpoints, width.value),
+ )
+
+ const currentCols = computed(() =>
+ getColsFromBreakpoint(currentBreakpoint.value, cols),
+ )
+
+ const currentLayout = ref(
+ findOrGenerateResponsiveLayout(
+ originalLayout.value,
+ layouts.value as ResponsiveLayout,
+ breakpoints,
+ currentBreakpoint.value,
+ currentBreakpoint.value,
+ currentCols.value,
+ true,
+ ),
+ )
+
+ watch(width, (newWidth) => {
+ const newBp = getBreakpointFromWidth(breakpoints, newWidth)
+
+ if (newBp !== currentBreakpoint.value) {
+ // 保存当前断点的布局到缓存
+ layouts.value = {
+ ...layouts.value,
+ [currentBreakpoint.value]: currentLayout.value,
+ }
+
+ const lastBp = currentBreakpoint.value
+ currentBreakpoint.value = newBp
+
+ const newCols = getColsFromBreakpoint(newBp, cols)
+
+ // 查找或生成新断点的布局,使用 compactor 进行压缩
+ const generated = findOrGenerateResponsiveLayout(
+ originalLayout.value,
+ layouts.value as ResponsiveLayout,
+ breakpoints,
+ newBp,
+ lastBp,
+ newCols,
+ true,
+ )
+
+ // 用 compactor 重新压缩(findOrGenerateResponsiveLayout 内部使用旧的 compact)
+ currentLayout.value = comp.compact(generated, newCols)
+ }
+ })
+
+ return {
+ currentBreakpoint,
+ currentCols,
+ currentLayout,
+ }
+}
diff --git a/src/core.ts b/src/core.ts
new file mode 100644
index 0000000..69e6be4
--- /dev/null
+++ b/src/core.ts
@@ -0,0 +1,49 @@
+/**
+ * 核心算法独立导出入口。
+ * 所有导出均为纯函数或类型,不依赖 Vue 运行时或浏览器 DOM API。
+ */
+
+// 从 helpers/common.ts 重新导出纯函数
+export {
+ bottom,
+ cloneLayout,
+ collides,
+ compact,
+ correctBounds,
+ getAllCollisions,
+ getFirstCollision,
+ moveElement,
+ sortLayoutItemsByRowCol,
+ validateLayout,
+} from './helpers/common'
+
+// 导出 Compactor 相关
+export {
+ fastHorizontalCompactor,
+ fastVerticalCompactor,
+ horizontalCompactor,
+ noCompactor,
+ verticalCompactor,
+ withOverlap,
+} from './core/compactors'
+
+// 导出 PositionStrategy 相关
+export {
+ absoluteStrategy,
+ transformStrategy,
+} from './core/position-strategies'
+
+// 导出工具函数
+export { calcGridCellDimensions } from './core/utils'
+
+// 导出类型
+export type {
+ Breakpoint,
+ Breakpoints,
+ Compactor,
+ GridCellDimensions,
+ Layout,
+ LayoutItem,
+ PositionStrategy,
+ ResponsiveLayout,
+} from './helpers/types'
diff --git a/src/core/compactors.ts b/src/core/compactors.ts
new file mode 100644
index 0000000..64522ac
--- /dev/null
+++ b/src/core/compactors.ts
@@ -0,0 +1,400 @@
+import {
+ cloneLayout,
+ compact,
+ getFirstCollision,
+ getStatics,
+ sortLayoutItemsByRowCol,
+} from '../helpers/common'
+
+import type { Compactor, Layout, LayoutItem } from '../helpers/types'
+
+/**
+ * 按列优先排序(先 x 后 y),用于水平压缩。
+ */
+function sortLayoutItemsByColRow(layout: Layout): Layout {
+ return Array.from(layout).sort((a, b) => {
+ if (a.x === b.x && a.y === b.y) return 0
+ if (a.x > b.x || (a.x === b.x && a.y > b.y)) return 1
+ return -1
+ })
+}
+
+/**
+ * 垂直压缩器 — 委托给现有 compact() 逻辑。
+ * 等价于 compact(layout, true)。
+ */
+export const verticalCompactor: Compactor = {
+ compact(layout: Layout, _cols: number): Layout {
+ return compact(cloneLayout(layout), true)
+ },
+}
+
+/**
+ * 水平压缩器 — 按列优先排序后向左压缩。
+ *
+ * 算法:
+ * 1. 按列优先排序(先 x 后 y)
+ * 2. 静态元素加入碰撞列表
+ * 3. 对每个非静态元素,保持 y 不变,将 x 向左移动至无碰撞的最小位置
+ * 4. 碰撞时放置在障碍物右侧
+ */
+export const horizontalCompactor: Compactor = {
+ compact(layout: Layout, cols: number): Layout {
+ const cloned = cloneLayout(layout)
+ const compareWith = getStatics(cloned)
+ const sorted = sortLayoutItemsByColRow(cloned)
+ const out: Layout = Array(cloned.length)
+
+ for (let i = 0, len = sorted.length; i < len; i++) {
+ let l = sorted[i]
+
+ if (!l.static) {
+ l = compactItemHorizontally(compareWith, l, cols)
+ compareWith.push(l)
+ }
+
+ out[cloned.findIndex(item => item.i === l.i)] = l
+ l.moved = false
+ }
+
+ return out
+ },
+}
+
+/**
+ * 水平压缩单个元素:保持 y 不变,将 x 向左移动至无碰撞的最小位置。
+ * 与 compactItem 的垂直逻辑对称:先尽量向左,再处理碰撞向右推移。
+ */
+function compactItemHorizontally(
+ compareWith: Layout,
+ l: LayoutItem,
+ _cols: number,
+): LayoutItem {
+ // 向左移动至无碰撞的最小 x
+ while (l.x > 0 && !getFirstCollision(compareWith, l)) {
+ l.x--
+ }
+
+ // 向右推移直到无碰撞(处理初始碰撞或移动过头的情况)
+ let collision: LayoutItem | undefined
+ while ((collision = getFirstCollision(compareWith, l))) {
+ l.x = collision.x + collision.w
+ }
+
+ return l
+}
+
+/**
+ * 无压缩器 — 返回浅拷贝,不移动任何元素。
+ */
+export const noCompactor: Compactor = {
+ compact(layout: Layout, _cols: number): Layout {
+ return cloneLayout(layout)
+ },
+}
+
+/**
+ * 创建带 allowOverlap 选项的压缩器包装。
+ * 当 allowOverlap=true 时跳过碰撞推移,仅返回浅拷贝。
+ */
+export function withOverlap(_compactor: Compactor): Compactor {
+ return {
+ compact(layout: Layout, _cols: number): Layout {
+ return cloneLayout(layout)
+ },
+ allowOverlap: true,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 区间树 — 用于 Fast Compactors 的 O(n log n) 碰撞检测加速
+// ---------------------------------------------------------------------------
+
+/** 区间树节点 */
+interface IntervalNode {
+ center: number
+ left: IntervalNode | null
+ right: IntervalNode | null
+ /** 按区间起点升序排列的条目 */
+ byStart: IntervalEntry[]
+ /** 按区间终点降序排列的条目 */
+ byEnd: IntervalEntry[]
+}
+
+interface IntervalEntry {
+ lo: number
+ hi: number
+ item: LayoutItem
+}
+
+/**
+ * 从一组区间条目构建静态区间树。
+ * 空输入返回 null。
+ */
+function buildIntervalTree(entries: IntervalEntry[]): IntervalNode | null {
+ if (entries.length === 0) return null
+
+ // 选取所有端点的中位数作为分割点
+ const pts: number[] = []
+ for (let i = 0; i < entries.length; i++) {
+ pts.push(entries[i].lo, entries[i].hi)
+ }
+ pts.sort((a, b) => a - b)
+ const center = pts[pts.length >> 1]
+
+ const leftEntries: IntervalEntry[] = []
+ const rightEntries: IntervalEntry[] = []
+ const centerByStart: IntervalEntry[] = []
+
+ for (let i = 0; i < entries.length; i++) {
+ const e = entries[i]
+ if (e.hi <= center) {
+ leftEntries.push(e)
+ } else if (e.lo > center) {
+ rightEntries.push(e)
+ } else {
+ centerByStart.push(e)
+ }
+ }
+
+ // 当分割无效(所有条目都落入同一侧)时,将全部条目放在当前节点,
+ // 避免无限递归。
+ if (centerByStart.length === 0 && (leftEntries.length === entries.length || rightEntries.length === entries.length)) {
+ const all = leftEntries.length === entries.length ? leftEntries : rightEntries
+ const byStart = Array.from(all)
+ const byEnd = Array.from(all)
+ byStart.sort((a, b) => a.lo - b.lo)
+ byEnd.sort((a, b) => b.hi - a.hi)
+ return {
+ center,
+ left: null,
+ right: null,
+ byStart,
+ byEnd,
+ }
+ }
+
+ const centerByEnd = Array.from(centerByStart)
+ centerByStart.sort((a, b) => a.lo - b.lo)
+ centerByEnd.sort((a, b) => b.hi - a.hi)
+
+ return {
+ center,
+ left: buildIntervalTree(leftEntries),
+ right: buildIntervalTree(rightEntries),
+ byStart: centerByStart,
+ byEnd: centerByEnd,
+ }
+}
+
+/**
+ * 查询区间树中与 [qLo, qHi) 重叠的所有条目。
+ * 重叠条件:entry.lo < qHi && entry.hi > qLo(开区间端点)。
+ */
+function queryIntervalTree(
+ node: IntervalNode | null,
+ qLo: number,
+ qHi: number,
+ result: LayoutItem[],
+): void {
+ if (!node) return
+
+ if (qLo >= node.center) {
+ // 查询区间在中心右侧,检查 byEnd(降序)中 hi > qLo 的条目
+ const arr = node.byEnd
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i].hi <= qLo) break
+ result.push(arr[i].item)
+ }
+ queryIntervalTree(node.right, qLo, qHi, result)
+ } else if (qHi <= node.center) {
+ // 查询区间在中心左侧,检查 byStart(升序)中 lo < qHi 的条目
+ const arr = node.byStart
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i].lo >= qHi) break
+ result.push(arr[i].item)
+ }
+ queryIntervalTree(node.left, qLo, qHi, result)
+ } else {
+ // 查询区间跨越中心,所有 center 条目都重叠
+ for (let i = 0; i < node.byStart.length; i++) {
+ result.push(node.byStart[i].item)
+ }
+ queryIntervalTree(node.left, qLo, qHi, result)
+ queryIntervalTree(node.right, qLo, qHi, result)
+ }
+}
+
+/**
+ * 在候选列表中查找与 item 碰撞的第一个元素(用于 fast compactors 的逐步压缩)。
+ * candidates 应来自区间树查询结果(已按一个轴过滤),此处再验证另一个轴。
+ */
+function firstCollisionAmong(
+ candidates: LayoutItem[],
+ item: LayoutItem,
+): LayoutItem | undefined {
+ for (let i = 0; i < candidates.length; i++) {
+ const c = candidates[i]
+ if (c === item) continue
+ // 完整 2D 碰撞检测
+ if (
+ item.x < c.x + c.w
+ && item.x + item.w > c.x
+ && item.y < c.y + c.h
+ && item.y + item.h > c.y
+ ) {
+ return c
+ }
+ }
+ return undefined
+}
+
+// ---------------------------------------------------------------------------
+// Fast Vertical Compactor
+// ---------------------------------------------------------------------------
+
+/**
+ * 快速垂直压缩器 — 使用区间树按 x 轴索引已放置元素,
+ * 将碰撞查询从 O(n) 降为 O(log n + k),整体 O(n log n)。
+ *
+ * 输出与 verticalCompactor 完全一致。
+ */
+export const fastVerticalCompactor: Compactor = {
+ compact(layout: Layout, _cols: number): Layout {
+ const cloned = cloneLayout(layout)
+ const sorted = sortLayoutItemsByRowCol(cloned)
+
+ // 已放置元素列表(用于增量重建区间树)
+ const placed: LayoutItem[] = []
+ // 收集静态元素
+ for (let i = 0; i < cloned.length; i++) {
+ if (cloned[i].static) placed.push(cloned[i])
+ }
+
+ const out: Layout = Array(cloned.length)
+
+ for (let i = 0, len = sorted.length; i < len; i++) {
+ let l = sorted[i]
+
+ if (!l.static) {
+ // 重建区间树(按 x 轴区间索引已放置元素)
+ const entries: IntervalEntry[] = []
+ for (let j = 0; j < placed.length; j++) {
+ const p = placed[j]
+ entries.push({ lo: p.x, hi: p.x + p.w, item: p })
+ }
+ const tree = buildIntervalTree(entries)
+
+ // 垂直压缩:向上移动至无碰撞的最小 y
+ l = fastCompactItemVertically(tree, l)
+ placed.push(l)
+ }
+
+ out[cloned.findIndex(item => item.i === l.i)] = l
+ l.moved = false
+ }
+
+ return out
+ },
+}
+
+/**
+ * 快速垂直压缩单个元素:使用区间树查询 x 轴重叠的候选元素,
+ * 然后在候选集中做 y 轴碰撞检测。
+ */
+function fastCompactItemVertically(
+ tree: IntervalNode | null,
+ l: LayoutItem,
+): LayoutItem {
+ // 查询 x 轴与当前元素重叠的所有已放置元素
+ const candidates: LayoutItem[] = []
+ queryIntervalTree(tree, l.x, l.x + l.w, candidates)
+
+ // 向上移动至无碰撞的最小 y
+ while (l.y > 0 && !firstCollisionAmong(candidates, l)) {
+ l.y--
+ }
+
+ // 向下推移直到无碰撞
+ let collision: LayoutItem | undefined
+ while ((collision = firstCollisionAmong(candidates, l))) {
+ l.y = collision.y + collision.h
+ }
+
+ return l
+}
+
+// ---------------------------------------------------------------------------
+// Fast Horizontal Compactor
+// ---------------------------------------------------------------------------
+
+/**
+ * 快速水平压缩器 — 使用区间树按 y 轴索引已放置元素,
+ * 将碰撞查询从 O(n) 降为 O(log n + k),整体 O(n log n)。
+ *
+ * 输出与 horizontalCompactor 完全一致。
+ */
+export const fastHorizontalCompactor: Compactor = {
+ compact(layout: Layout, cols: number): Layout {
+ const cloned = cloneLayout(layout)
+ const sorted = sortLayoutItemsByColRow(cloned)
+
+ // 已放置元素列表
+ const placed: LayoutItem[] = []
+ for (let i = 0; i < cloned.length; i++) {
+ if (cloned[i].static) placed.push(cloned[i])
+ }
+
+ const out: Layout = Array(cloned.length)
+
+ for (let i = 0, len = sorted.length; i < len; i++) {
+ let l = sorted[i]
+
+ if (!l.static) {
+ // 重建区间树(按 y 轴区间索引已放置元素)
+ const entries: IntervalEntry[] = []
+ for (let j = 0; j < placed.length; j++) {
+ const p = placed[j]
+ entries.push({ lo: p.y, hi: p.y + p.h, item: p })
+ }
+ const tree = buildIntervalTree(entries)
+
+ // 水平压缩:向左移动至无碰撞的最小 x
+ l = fastCompactItemHorizontally(tree, l, cols)
+ placed.push(l)
+ }
+
+ out[cloned.findIndex(item => item.i === l.i)] = l
+ l.moved = false
+ }
+
+ return out
+ },
+}
+
+/**
+ * 快速水平压缩单个元素:使用区间树查询 y 轴重叠的候选元素,
+ * 然后在候选集中做 x 轴碰撞检测。
+ */
+function fastCompactItemHorizontally(
+ tree: IntervalNode | null,
+ l: LayoutItem,
+ _cols: number,
+): LayoutItem {
+ // 查询 y 轴与当前元素重叠的所有已放置元素
+ const candidates: LayoutItem[] = []
+ queryIntervalTree(tree, l.y, l.y + l.h, candidates)
+
+ // 向左移动至无碰撞的最小 x
+ while (l.x > 0 && !firstCollisionAmong(candidates, l)) {
+ l.x--
+ }
+
+ // 向右推移直到无碰撞
+ let collision: LayoutItem | undefined
+ while ((collision = firstCollisionAmong(candidates, l))) {
+ l.x = collision.x + collision.w
+ }
+
+ return l
+}
diff --git a/src/core/position-strategies.ts b/src/core/position-strategies.ts
new file mode 100644
index 0000000..71e8f6c
--- /dev/null
+++ b/src/core/position-strategies.ts
@@ -0,0 +1,61 @@
+import type { PositionStrategy } from '../helpers/types'
+
+/**
+ * CSS transform translate3d 定位策略(默认)。
+ * 等价于现有 setTransform / setTransformRtl。
+ */
+export const transformStrategy: PositionStrategy = {
+ getStyle(top: number, left: number, width: number, height: number): Record {
+ const translate = `translate3d(${left}px,${top}px, 0)`
+ return {
+ transform: translate,
+ WebkitTransform: translate,
+ MozTransform: translate,
+ msTransform: translate,
+ OTransform: translate,
+ width: `${width}px`,
+ height: `${height}px`,
+ position: 'absolute',
+ }
+ },
+ getRtlStyle(top: number, right: number, width: number, height: number): Record {
+ const translate = `translate3d(${right * -1}px,${top}px, 0)`
+ return {
+ transform: translate,
+ WebkitTransform: translate,
+ MozTransform: translate,
+ msTransform: translate,
+ OTransform: translate,
+ width: `${width}px`,
+ height: `${height}px`,
+ position: 'absolute',
+ }
+ },
+}
+
+/**
+ * CSS top/left/right 绝对定位策略。
+ * 等价于现有 setTopLeft / setTopRight。
+ */
+export const absoluteStrategy: PositionStrategy = {
+ getStyle(top: number, left: number, width: number, height: number): Record {
+ return {
+ top: `${top}px`,
+ left: `${left}px`,
+ width: `${width}px`,
+ height: `${height}px`,
+ position: 'absolute',
+ }
+ },
+ getRtlStyle(top: number, right: number, width: number, height: number): Record {
+ return {
+ top: `${top}px`,
+ right: `${right}px`,
+ width: `${width}px`,
+ height: `${height}px`,
+ position: 'absolute',
+ }
+ },
+}
+
+
diff --git a/src/core/utils.ts b/src/core/utils.ts
new file mode 100644
index 0000000..590cfca
--- /dev/null
+++ b/src/core/utils.ts
@@ -0,0 +1,29 @@
+import type { GridCellDimensions } from '../helpers/types'
+
+/**
+ * 计算网格单元格的精确尺寸。
+ *
+ * cellWidth = (containerWidth - marginX * (cols + 1)) / cols
+ * cellHeight = rowHeight
+ */
+export function calcGridCellDimensions(params: {
+ containerWidth: number,
+ cols: number,
+ margin: [number, number],
+ rowHeight: number,
+}): GridCellDimensions {
+ const { containerWidth, cols, margin, rowHeight } = params
+ const marginX = margin[0]
+ const marginY = margin[1]
+
+ const cellWidth = cols <= 0
+ ? 0
+ : Math.max(0, (containerWidth - marginX * (cols + 1)) / cols)
+
+ return {
+ cellWidth,
+ cellHeight: rowHeight,
+ marginX,
+ marginY,
+ }
+}
diff --git a/src/helpers/responsive.ts b/src/helpers/responsive.ts
index 9524730..87929ca 100644
--- a/src/helpers/responsive.ts
+++ b/src/helpers/responsive.ts
@@ -58,7 +58,6 @@ export function findOrGenerateResponsiveLayout(
cols: number,
verticalCompact: boolean,
): Layout {
- debugger
// If it already exists, just return it.
if (layouts[breakpoint]) return cloneLayout(layouts[breakpoint])
// Find or generate the next layout
diff --git a/src/helpers/types.ts b/src/helpers/types.ts
index 3f8c34a..05242f1 100644
--- a/src/helpers/types.ts
+++ b/src/helpers/types.ts
@@ -1,3 +1,25 @@
+/** 压缩器接口 */
+export interface Compactor {
+ /** 对布局执行压缩,返回新布局(不修改输入) */
+ compact(layout: Layout, cols: number): Layout,
+ /** 是否允许元素重叠 */
+ allowOverlap?: boolean,
+}
+
+/** 定位策略接口 */
+export interface PositionStrategy {
+ getStyle(top: number, left: number, width: number, height: number): Record,
+ getRtlStyle(top: number, right: number, width: number, height: number): Record,
+}
+
+/** 网格单元格尺寸 */
+export interface GridCellDimensions {
+ cellWidth: number,
+ cellHeight: number,
+ marginX: number,
+ marginY: number,
+}
+
export interface LayoutItemRequired {
w: number,
h: number,
@@ -14,7 +36,7 @@ export interface LayoutItem extends LayoutItemRequired {
moved?: boolean,
static?: boolean,
isDraggable?: boolean,
- isResizable?: boolean
+ isResizable?: boolean,
}
export type Layout = Array
@@ -35,11 +57,14 @@ export interface LayoutInstance {
isDraggable: boolean,
isResizable: boolean,
isBounded: boolean,
- transformScale: number,
- useCssTransforms: boolean,
useStyleCursor: boolean,
maxRows: number,
isMirrored: boolean,
+ compactor: Compactor,
+ positionStrategy: PositionStrategy,
+ isDroppable: boolean,
+ dropItem: { w: number, h: number },
+ dragThreshold: number,
increaseItem: (item: any) => void,
- decreaseItem: (item: any) => void
+ decreaseItem: (item: any) => void,
}
diff --git a/src/index.ts b/src/index.ts
index d7a61ed..c578bd9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,41 @@ import './style.scss'
export { default as GridLayout } from './components/grid-layout.vue'
export { default as GridItem } from './components/grid-item.vue'
+export { default as GridBackground } from './components/grid-background.vue'
export type * from './components/types'
export type * from './helpers/types'
+
+// Composable API
+export { useContainerWidth } from './composables/useContainerWidth'
+export { useGridLayout } from './composables/useGridLayout'
+export { useResponsiveLayout } from './composables/useResponsiveLayout'
+
+export type { UseGridLayoutOptions, UseGridLayoutReturn } from './composables/useGridLayout'
+export type {
+ UseResponsiveLayoutOptions,
+ UseResponsiveLayoutReturn,
+} from './composables/useResponsiveLayout'
+
+// CoreAPI
+export {
+ bottom,
+ cloneLayout,
+ collides,
+ compact,
+ correctBounds,
+ getAllCollisions,
+ getFirstCollision,
+ moveElement,
+ sortLayoutItemsByRowCol,
+ validateLayout,
+ fastHorizontalCompactor,
+ fastVerticalCompactor,
+ horizontalCompactor,
+ noCompactor,
+ verticalCompactor,
+ withOverlap,
+ absoluteStrategy,
+ transformStrategy,
+ calcGridCellDimensions,
+} from './core'
diff --git a/src/style.scss b/src/style.scss
index 18cb223..5917c42 100644
--- a/src/style.scss
+++ b/src/style.scss
@@ -61,35 +61,156 @@
&__resizer {
position: absolute;
- right: 0;
- bottom: 0;
box-sizing: border-box;
width: var(--vgl-resizer-size);
height: var(--vgl-resizer-size);
- cursor: se-resize;
$border-width: var(--vgl-resizer-border-width);
&::before {
position: absolute;
- inset: 0 3px 3px 0;
content: '';
border: 0 solid var(--vgl-resizer-border-color);
- border-right-width: $border-width;
- border-bottom-width: $border-width;
}
- &--rtl {
- right: auto;
+ // 东南角(默认,右下)
+ &--se {
+ right: 0;
+ bottom: 0;
+ cursor: se-resize;
+
+ &::before {
+ inset: 0 3px 3px 0;
+ border-right-width: $border-width;
+ border-bottom-width: $border-width;
+ }
+ }
+
+ // 西南角(左下)
+ &--sw {
+ bottom: 0;
left: 0;
cursor: sw-resize;
&::before {
inset: 0 0 3px 3px;
- border-right-width: 0;
border-bottom-width: $border-width;
border-left-width: $border-width;
}
}
+
+ // 东北角(右上)
+ &--ne {
+ top: 0;
+ right: 0;
+ cursor: ne-resize;
+
+ &::before {
+ inset: 3px 3px 0 0;
+ border-top-width: $border-width;
+ border-right-width: $border-width;
+ }
+ }
+
+ // 西北角(左上)
+ &--nw {
+ top: 0;
+ left: 0;
+ cursor: nw-resize;
+
+ &::before {
+ inset: 3px 0 0 3px;
+ border-top-width: $border-width;
+ border-left-width: $border-width;
+ }
+ }
+
+ // 南(下边中间)
+ &--s {
+ right: 0;
+ bottom: 0;
+ left: 0;
+ width: auto;
+ cursor: s-resize;
+
+ &::before {
+ inset: auto 0 3px;
+ border-bottom-width: $border-width;
+ }
+ }
+
+ // 北(上边中间)
+ &--n {
+ top: 0;
+ right: 0;
+ left: 0;
+ width: auto;
+ cursor: n-resize;
+
+ &::before {
+ inset: 3px 0 auto;
+ border-top-width: $border-width;
+ }
+ }
+
+ // 东(右边中间)
+ &--e {
+ top: 0;
+ right: 0;
+ bottom: 0;
+ height: auto;
+ cursor: e-resize;
+
+ &::before {
+ inset: 0 3px 0 auto;
+ border-right-width: $border-width;
+ }
+ }
+
+ // 西(左边中间)
+ &--w {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ height: auto;
+ cursor: w-resize;
+
+ &::before {
+ inset: 0 auto 0 3px;
+ border-left-width: $border-width;
+ }
+ }
+
+ // RTL 模式下翻转水平方向的光标
+ &--rtl#{&}--se {
+ cursor: sw-resize;
+ }
+
+ &--rtl#{&}--sw {
+ cursor: se-resize;
+ }
+
+ &--rtl#{&}--ne {
+ cursor: nw-resize;
+ }
+
+ &--rtl#{&}--nw {
+ cursor: ne-resize;
+ }
+
+ &--rtl#{&}--e {
+ cursor: w-resize;
+ }
+
+ &--rtl#{&}--w {
+ cursor: e-resize;
+ }
}
}
+
+.vgl-background {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+}
diff --git a/tests/compactors.spec.ts b/tests/compactors.spec.ts
new file mode 100644
index 0000000..222dac1
--- /dev/null
+++ b/tests/compactors.spec.ts
@@ -0,0 +1,504 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ fastHorizontalCompactor,
+ fastVerticalCompactor,
+ horizontalCompactor,
+ noCompactor,
+ verticalCompactor,
+ withOverlap,
+} from '../src/core/compactors'
+
+import { cloneLayout, compact } from '../src/helpers/common'
+
+import type { Layout } from '../src/helpers/types'
+
+// ─── 辅助工具 ───────────────────────────────────────────────
+
+/** 提取布局中每个元素的核心位置信息,用于比较 */
+function positions(layout: Layout) {
+ return layout.map(l => ({ i: l.i, x: l.x, y: l.y, w: l.w, h: l.h }))
+}
+
+// ─── verticalCompactor ──────────────────────────────────────
+
+describe('verticalCompactor', () => {
+ it('与 compact(layout, true) 输出一致 — 空布局', () => {
+ const layout: Layout = []
+ expect(verticalCompactor.compact(layout, 12)).toEqual(compact(cloneLayout(layout), true))
+ })
+
+ it('与 compact(layout, true) 输出一致 — 单元素有空隙', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 5, w: 1, h: 1 }]
+ expect(positions(verticalCompactor.compact(layout, 12))).toEqual(
+ positions(compact(cloneLayout(layout), true)),
+ )
+ })
+
+ it('与 compact(layout, true) 输出一致 — 多元素碰撞', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 2, h: 5 },
+ { i: '2', x: 0, y: 0, w: 10, h: 1 },
+ { i: '3', x: 5, y: 1, w: 1, h: 1 },
+ { i: '4', x: 5, y: 2, w: 1, h: 1 },
+ { i: '5', x: 5, y: 3, w: 1, h: 1, static: true },
+ ]
+ expect(positions(verticalCompactor.compact(layout, 12))).toEqual(
+ positions(compact(cloneLayout(layout), true)),
+ )
+ })
+
+ it('与 compact(layout, true) 输出一致 — 含静态元素', () => {
+ const layout: Layout = [
+ { i: 'a', x: 0, y: 0, w: 1, h: 1, static: true },
+ { i: 'b', x: 0, y: 5, w: 1, h: 1 },
+ { i: 'c', x: 1, y: 3, w: 1, h: 2 },
+ ]
+ expect(positions(verticalCompactor.compact(layout, 12))).toEqual(
+ positions(compact(cloneLayout(layout), true)),
+ )
+ })
+
+ it('不修改输入布局', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 5, w: 1, h: 1 },
+ { i: '2', x: 1, y: 3, w: 1, h: 1 },
+ ]
+ const original = JSON.parse(JSON.stringify(layout))
+ verticalCompactor.compact(layout, 12)
+ expect(layout).toEqual(original)
+ })
+})
+
+// ─── horizontalCompactor ────────────────────────────────────
+
+describe('horizontalCompactor', () => {
+ it('将元素向左压缩消除水平空隙', () => {
+ const layout: Layout = [
+ { i: '1', x: 5, y: 0, w: 1, h: 1 },
+ { i: '2', x: 8, y: 1, w: 2, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result[0].x).toBe(0)
+ expect(result[1].x).toBe(0)
+ })
+
+ it('保持每个元素的 y 坐标不变', () => {
+ const layout: Layout = [
+ { i: '1', x: 3, y: 2, w: 1, h: 1 },
+ { i: '2', x: 6, y: 5, w: 1, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result[0].y).toBe(2)
+ expect(result[1].y).toBe(5)
+ })
+
+ it('静态元素不移动,作为碰撞障碍物', () => {
+ const layout: Layout = [
+ { i: 's', x: 2, y: 0, w: 2, h: 2, static: true },
+ { i: '1', x: 6, y: 0, w: 1, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ // 静态元素位置不变
+ const staticItem = result.find(l => l.i === 's')!
+ expect(staticItem.x).toBe(2)
+ expect(staticItem.y).toBe(0)
+ // 非静态元素向左压缩,遇到静态元素 (x=2,w=2) 碰撞后放在其右侧 x=4
+ const item1 = result.find(l => l.i === '1')!
+ expect(item1.x).toBe(4)
+ })
+
+ it('碰撞时放置在障碍物右侧', () => {
+ // 两个元素在同一行,压缩后应紧密排列
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 2, h: 1 },
+ { i: '2', x: 5, y: 0, w: 1, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result.find(l => l.i === '1')!.x).toBe(0)
+ expect(result.find(l => l.i === '2')!.x).toBe(2)
+ })
+
+ it('不同行的元素不受静态元素阻挡', () => {
+ // 静态元素在 y=0,非静态元素在 y=5,不碰撞
+ const layout: Layout = [
+ { i: 's', x: 2, y: 0, w: 2, h: 2, static: true },
+ { i: '1', x: 6, y: 5, w: 1, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result.find(l => l.i === '1')!.x).toBe(0)
+ })
+
+ it('静态元素阻挡时,非静态元素放在静态元素右侧', () => {
+ const layout: Layout = [
+ { i: 's', x: 0, y: 0, w: 3, h: 1, static: true },
+ { i: '1', x: 8, y: 0, w: 2, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result.find(l => l.i === '1')!.x).toBe(3)
+ })
+
+ it('按列优先排序(先 x 后 y)处理', () => {
+ // 元素 B 在 x=0 应先处理,元素 A 在 x=5 后处理
+ const layout: Layout = [
+ { i: 'A', x: 5, y: 0, w: 1, h: 1 },
+ { i: 'B', x: 0, y: 1, w: 1, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ // B 先处理,x 保持 0;A 后处理,向左压缩到 0(不同行不碰撞)
+ expect(result.find(l => l.i === 'B')!.x).toBe(0)
+ expect(result.find(l => l.i === 'A')!.x).toBe(0)
+ })
+
+ it('不修改输入布局', () => {
+ const layout: Layout = [
+ { i: '1', x: 5, y: 0, w: 1, h: 1 },
+ ]
+ const original = JSON.parse(JSON.stringify(layout))
+ horizontalCompactor.compact(layout, 12)
+ expect(layout).toEqual(original)
+ })
+})
+
+// ─── noCompactor ────────────────────────────────────────────
+
+describe('noCompactor', () => {
+ it('输出与输入位置完全相同', () => {
+ const layout: Layout = [
+ { i: '1', x: 3, y: 5, w: 2, h: 2 },
+ { i: '2', x: 0, y: 0, w: 1, h: 1 },
+ ]
+ const result = noCompactor.compact(layout, 12)
+ expect(positions(result)).toEqual(positions(layout))
+ })
+
+ it('返回新数组引用', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 0, w: 1, h: 1 }]
+ const result = noCompactor.compact(layout, 12)
+ expect(result).not.toBe(layout)
+ })
+
+ it('返回的元素是新对象引用(浅拷贝)', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 0, w: 1, h: 1 }]
+ const result = noCompactor.compact(layout, 12)
+ expect(result[0]).not.toBe(layout[0])
+ })
+
+ it('空布局返回空数组', () => {
+ expect(noCompactor.compact([], 12)).toEqual([])
+ })
+
+ it('含静态元素时位置也不变', () => {
+ const layout: Layout = [
+ { i: 's', x: 2, y: 3, w: 1, h: 1, static: true },
+ { i: '1', x: 5, y: 5, w: 2, h: 2 },
+ ]
+ const result = noCompactor.compact(layout, 12)
+ expect(positions(result)).toEqual(positions(layout))
+ })
+})
+
+// ─── withOverlap ────────────────────────────────────────────
+
+describe('withOverlap', () => {
+ it('allowOverlap 属性为 true', () => {
+ const wrapped = withOverlap(verticalCompactor)
+ expect(wrapped.allowOverlap).toBe(true)
+ })
+
+ it('跳过碰撞推移,元素位置与输入相同', () => {
+ // 两个重叠元素
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 2, h: 2 },
+ { i: '2', x: 0, y: 0, w: 1, h: 1 },
+ ]
+ const wrapped = withOverlap(verticalCompactor)
+ const result = wrapped.compact(layout, 12)
+ expect(positions(result)).toEqual(positions(layout))
+ })
+
+ it('包装 horizontalCompactor 时也跳过碰撞推移', () => {
+ const layout: Layout = [
+ { i: '1', x: 5, y: 0, w: 2, h: 1 },
+ { i: '2', x: 5, y: 0, w: 1, h: 1 },
+ ]
+ const wrapped = withOverlap(horizontalCompactor)
+ const result = wrapped.compact(layout, 12)
+ expect(positions(result)).toEqual(positions(layout))
+ })
+
+ it('返回新数组引用', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 0, w: 1, h: 1 }]
+ const wrapped = withOverlap(noCompactor)
+ const result = wrapped.compact(layout, 12)
+ expect(result).not.toBe(layout)
+ })
+})
+
+// ─── 边界用例 ───────────────────────────────────────────────
+
+describe('边界用例', () => {
+ it('空布局 — 所有压缩器返回空数组', () => {
+ const empty: Layout = []
+ expect(verticalCompactor.compact(empty, 12)).toEqual([])
+ expect(horizontalCompactor.compact(empty, 12)).toEqual([])
+ expect(noCompactor.compact(empty, 12)).toEqual([])
+ expect(withOverlap(verticalCompactor).compact(empty, 12)).toEqual([])
+ })
+
+ it('单元素 — verticalCompactor 压缩到顶部', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 10, w: 1, h: 1 }]
+ const result = verticalCompactor.compact(layout, 12)
+ expect(result[0].y).toBe(0)
+ expect(result[0].x).toBe(0)
+ })
+
+ it('单元素 — horizontalCompactor 压缩到左侧', () => {
+ const layout: Layout = [{ i: '1', x: 10, y: 3, w: 1, h: 1 }]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result[0].x).toBe(0)
+ expect(result[0].y).toBe(3)
+ })
+
+ it('全静态元素 — 所有压缩器不移动任何元素', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1, static: true },
+ { i: '2', x: 5, y: 5, w: 2, h: 2, static: true },
+ ]
+ const vResult = verticalCompactor.compact(layout, 12)
+ const hResult = horizontalCompactor.compact(layout, 12)
+
+ expect(positions(vResult)).toEqual(positions(layout))
+ expect(positions(hResult)).toEqual(positions(layout))
+ })
+
+ it('元素超出列数 — horizontalCompactor 仍正常处理', () => {
+ // cols=4,元素宽度 2 从 x=10 开始(超出范围)
+ const layout: Layout = [{ i: '1', x: 10, y: 0, w: 2, h: 1 }]
+ const result = horizontalCompactor.compact(layout, 4)
+ // 向左压缩到 x=0
+ expect(result[0].x).toBe(0)
+ })
+
+ it('多个元素在同一行紧密排列 — horizontalCompactor 保持顺序', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ { i: '2', x: 1, y: 0, w: 1, h: 1 },
+ { i: '3', x: 2, y: 0, w: 1, h: 1 },
+ ]
+ const result = horizontalCompactor.compact(layout, 12)
+ expect(result.find(l => l.i === '1')!.x).toBe(0)
+ expect(result.find(l => l.i === '2')!.x).toBe(1)
+ expect(result.find(l => l.i === '3')!.x).toBe(2)
+ })
+})
+
+// ─── fastVerticalCompactor ──────────────────────────────────
+
+describe('fastVerticalCompactor', () => {
+ it('空布局 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = []
+ expect(fastVerticalCompactor.compact(layout, 12)).toEqual(
+ verticalCompactor.compact(layout, 12),
+ )
+ })
+
+ it('单元素有空隙 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 5, w: 1, h: 1 }]
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('多元素碰撞 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 2, h: 5 },
+ { i: '2', x: 0, y: 0, w: 10, h: 1 },
+ { i: '3', x: 5, y: 1, w: 1, h: 1 },
+ { i: '4', x: 5, y: 2, w: 1, h: 1 },
+ { i: '5', x: 5, y: 3, w: 1, h: 1, static: true },
+ ]
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('含静态元素 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: 'a', x: 0, y: 0, w: 1, h: 1, static: true },
+ { i: 'b', x: 0, y: 5, w: 1, h: 1 },
+ { i: 'c', x: 1, y: 3, w: 1, h: 2 },
+ ]
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('全静态元素 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1, static: true },
+ { i: '2', x: 5, y: 5, w: 2, h: 2, static: true },
+ ]
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('密集布局 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 4, h: 2 },
+ { i: '2', x: 4, y: 0, w: 4, h: 3 },
+ { i: '3', x: 8, y: 0, w: 4, h: 1 },
+ { i: '4', x: 0, y: 5, w: 6, h: 2 },
+ { i: '5', x: 6, y: 5, w: 6, h: 1 },
+ { i: '6', x: 0, y: 10, w: 12, h: 1 },
+ ]
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('带间隙的稀疏布局 — 与 verticalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 10, w: 1, h: 1 },
+ { i: '2', x: 3, y: 20, w: 2, h: 3 },
+ { i: '3', x: 8, y: 50, w: 1, h: 1 },
+ ]
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('不修改输入布局', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 5, w: 1, h: 1 },
+ { i: '2', x: 1, y: 3, w: 1, h: 1 },
+ ]
+ const original = JSON.parse(JSON.stringify(layout))
+ fastVerticalCompactor.compact(layout, 12)
+ expect(layout).toEqual(original)
+ })
+
+ it('大布局(100+ 元素)不报错', () => {
+ const layout: Layout = []
+ for (let i = 0; i < 150; i++) {
+ layout.push({
+ i: String(i),
+ x: (i * 2) % 12,
+ y: Math.floor(i / 6) * 3,
+ w: 2,
+ h: 2,
+ })
+ }
+ expect(() => fastVerticalCompactor.compact(layout, 12)).not.toThrow()
+ // 同时验证与标准压缩器输出一致
+ expect(positions(fastVerticalCompactor.compact(layout, 12))).toEqual(
+ positions(verticalCompactor.compact(layout, 12)),
+ )
+ })
+})
+
+// ─── fastHorizontalCompactor ────────────────────────────────
+
+describe('fastHorizontalCompactor', () => {
+ it('空布局 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = []
+ expect(fastHorizontalCompactor.compact(layout, 12)).toEqual(
+ horizontalCompactor.compact(layout, 12),
+ )
+ })
+
+ it('单元素向左压缩 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = [{ i: '1', x: 10, y: 3, w: 1, h: 1 }]
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('多元素同行碰撞 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 2, h: 1 },
+ { i: '2', x: 5, y: 0, w: 1, h: 1 },
+ { i: '3', x: 8, y: 0, w: 3, h: 1 },
+ ]
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('含静态元素 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: 's', x: 2, y: 0, w: 2, h: 2, static: true },
+ { i: '1', x: 6, y: 0, w: 1, h: 1 },
+ { i: '2', x: 8, y: 1, w: 2, h: 1 },
+ ]
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('全静态元素 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1, static: true },
+ { i: '2', x: 5, y: 5, w: 2, h: 2, static: true },
+ ]
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('不同行元素互不影响 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 5, y: 0, w: 1, h: 1 },
+ { i: '2', x: 8, y: 5, w: 2, h: 1 },
+ ]
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('密集布局 — 与 horizontalCompactor 输出一致', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 4, h: 2 },
+ { i: '2', x: 4, y: 0, w: 4, h: 3 },
+ { i: '3', x: 8, y: 0, w: 4, h: 1 },
+ { i: '4', x: 0, y: 5, w: 6, h: 2 },
+ { i: '5', x: 6, y: 5, w: 6, h: 1 },
+ { i: '6', x: 0, y: 10, w: 12, h: 1 },
+ ]
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+
+ it('不修改输入布局', () => {
+ const layout: Layout = [
+ { i: '1', x: 5, y: 0, w: 1, h: 1 },
+ ]
+ const original = JSON.parse(JSON.stringify(layout))
+ fastHorizontalCompactor.compact(layout, 12)
+ expect(layout).toEqual(original)
+ })
+
+ it('大布局(100+ 元素)不报错', () => {
+ const layout: Layout = []
+ for (let i = 0; i < 150; i++) {
+ layout.push({
+ i: String(i),
+ x: (i * 2) % 12,
+ y: Math.floor(i / 6) * 3,
+ w: 2,
+ h: 2,
+ })
+ }
+ expect(() => fastHorizontalCompactor.compact(layout, 12)).not.toThrow()
+ // 同时验证与标准压缩器输出一致
+ expect(positions(fastHorizontalCompactor.compact(layout, 12))).toEqual(
+ positions(horizontalCompactor.compact(layout, 12)),
+ )
+ })
+})
diff --git a/tests/composables.spec.ts b/tests/composables.spec.ts
new file mode 100644
index 0000000..6230bb8
--- /dev/null
+++ b/tests/composables.spec.ts
@@ -0,0 +1,380 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+import { effectScope, nextTick, ref } from 'vue'
+
+import { useGridLayout } from '../src/composables/useGridLayout'
+import { useResponsiveLayout } from '../src/composables/useResponsiveLayout'
+import { horizontalCompactor, noCompactor, verticalCompactor } from '../src/core/compactors'
+import {
+ getBreakpointFromWidth,
+ getColsFromBreakpoint,
+} from '../src/helpers/responsive'
+
+import type { Breakpoints, Layout } from '../src/helpers/types'
+
+// ─── useGridLayout ──────────────────────────────────────────
+
+describe('useGridLayout', () => {
+ /** 在 effectScope 中运行以支持 onScopeDispose */
+ function withScope(fn: () => T): T {
+ let result!: T
+ const scope = effectScope()
+ scope.run(() => {
+ result = fn()
+ })
+ return result
+ }
+
+ it('初始化后 currentLayout 为压缩后的布局', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 2, w: 1, h: 1 },
+ { i: '2', x: 1, y: 0, w: 1, h: 1 },
+ ]
+
+ const { currentLayout } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ // 垂直压缩后,元素 '1' 应被向上压缩到 y=0
+ const item1 = currentLayout.value.find(l => l.i === '1')!
+ expect(item1.y).toBe(0)
+ })
+
+ it('使用 noCompactor 时布局位置不变', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 5, w: 1, h: 1 },
+ { i: '2', x: 1, y: 3, w: 1, h: 1 },
+ ]
+
+ const { currentLayout } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: noCompactor }),
+ )
+
+ expect(currentLayout.value.find(l => l.i === '1')!.y).toBe(5)
+ expect(currentLayout.value.find(l => l.i === '2')!.y).toBe(3)
+ })
+
+ it('moveItem 后布局正确更新并重新压缩', async () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 2, h: 1 },
+ { i: '2', x: 2, y: 0, w: 2, h: 1 },
+ ]
+
+ const { currentLayout, moveItem } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ moveItem('1', 0, 3)
+ await nextTick()
+
+ const item1 = currentLayout.value.find(l => l.i === '1')!
+ // 垂直压缩后,移动到 y=3 但上方无障碍物,应被压缩到 y=0
+ expect(item1.x).toBe(0)
+ expect(item1.y).toBe(0)
+ })
+
+ it('resizeItem 后布局正确更新并重新压缩', async () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ]
+
+ const { currentLayout, resizeItem } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ resizeItem('1', 4, 3)
+ await nextTick()
+
+ const item1 = currentLayout.value.find(l => l.i === '1')!
+ expect(item1.w).toBe(4)
+ expect(item1.h).toBe(3)
+ })
+
+
+ it('addItem 后元素数量增加 1', async () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ]
+
+ const { currentLayout, addItem } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ const before = currentLayout.value.length
+ addItem({ i: '2', x: 2, y: 0, w: 1, h: 1 })
+ await nextTick()
+
+ expect(currentLayout.value.length).toBe(before + 1)
+ expect(currentLayout.value.find(l => l.i === '2')).toBeTruthy()
+ })
+
+ it('removeItem 后元素数量减少 1', async () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ { i: '2', x: 1, y: 0, w: 1, h: 1 },
+ ]
+
+ const { currentLayout, removeItem } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ const before = currentLayout.value.length
+ removeItem('2')
+ await nextTick()
+
+ expect(currentLayout.value.length).toBe(before - 1)
+ expect(currentLayout.value.find(l => l.i === '2')).toBeUndefined()
+ })
+
+ it('moveItem — id 不存在时静默忽略', async () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ]
+
+ const { currentLayout, moveItem } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ const before = JSON.stringify(currentLayout.value)
+ moveItem('nonexistent', 5, 5)
+ await nextTick()
+
+ expect(JSON.stringify(currentLayout.value)).toBe(before)
+ })
+
+ it('removeItem — id 不存在时静默忽略', async () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ]
+
+ const { currentLayout, removeItem } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: verticalCompactor }),
+ )
+
+ const before = currentLayout.value.length
+ removeItem('nonexistent')
+ await nextTick()
+
+ expect(currentLayout.value.length).toBe(before)
+ })
+
+ it('外部 layout ref 变化时自动重新压缩', async () => {
+ const layoutRef = ref([
+ { i: '1', x: 0, y: 5, w: 1, h: 1 },
+ ])
+
+ const { currentLayout } = withScope(() =>
+ useGridLayout({ layout: layoutRef, cols: 12, compactor: verticalCompactor }),
+ )
+
+ // 初始压缩后 y 应为 0
+ expect(currentLayout.value.find(l => l.i === '1')!.y).toBe(0)
+
+ // 更新外部 ref
+ layoutRef.value = [
+ { i: '1', x: 0, y: 10, w: 1, h: 1 },
+ { i: '2', x: 1, y: 8, w: 1, h: 1 },
+ ]
+ await nextTick()
+
+ expect(currentLayout.value.length).toBe(2)
+ // 两个不重叠的元素,垂直压缩后都应在 y=0
+ expect(currentLayout.value.find(l => l.i === '1')!.y).toBe(0)
+ expect(currentLayout.value.find(l => l.i === '2')!.y).toBe(0)
+ })
+
+ it('使用 horizontalCompactor 时水平压缩生效', () => {
+ const layout: Layout = [
+ { i: '1', x: 5, y: 0, w: 1, h: 1 },
+ { i: '2', x: 3, y: 1, w: 1, h: 1 },
+ ]
+
+ const { currentLayout } = withScope(() =>
+ useGridLayout({ layout, cols: 12, compactor: horizontalCompactor }),
+ )
+
+ // 水平压缩后,元素应向左靠拢
+ expect(currentLayout.value.find(l => l.i === '1')!.x).toBe(0)
+ expect(currentLayout.value.find(l => l.i === '2')!.x).toBe(0)
+ })
+})
+
+
+// ─── useResponsiveLayout ────────────────────────────────────
+
+describe('useResponsiveLayout', () => {
+ const breakpoints: Breakpoints = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }
+ const cols: Breakpoints = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }
+
+ function withScope(fn: () => T): T {
+ let result!: T
+ const scope = effectScope()
+ scope.run(() => {
+ result = fn()
+ })
+ return result
+ }
+
+ it('不同 width 值对应正确的断点和列数', () => {
+ const width = ref(1400)
+ const layouts = ref({})
+ const originalLayout = ref([
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ])
+
+ const { currentBreakpoint, currentCols } = withScope(() =>
+ useResponsiveLayout({
+ breakpoints,
+ cols,
+ width,
+ layouts,
+ originalLayout,
+ }),
+ )
+
+ expect(currentBreakpoint.value).toBe(getBreakpointFromWidth(breakpoints, 1400))
+ expect(currentBreakpoint.value).toBe('lg')
+ expect(currentCols.value).toBe(getColsFromBreakpoint('lg', cols))
+ expect(currentCols.value).toBe(12)
+ })
+
+ it('width 变化导致断点切换时布局自动生成', async () => {
+ const width = ref(1400)
+ const layouts = ref({})
+ const originalLayout = ref([
+ { i: '1', x: 0, y: 0, w: 2, h: 1 },
+ { i: '2', x: 2, y: 0, w: 2, h: 1 },
+ ])
+
+ const { currentBreakpoint, currentCols, currentLayout } = withScope(() =>
+ useResponsiveLayout({
+ breakpoints,
+ cols,
+ width,
+ layouts,
+ originalLayout,
+ }),
+ )
+
+ expect(currentBreakpoint.value).toBe('lg')
+
+ // 切换到 sm 断点
+ width.value = 800
+ await nextTick()
+
+ expect(currentBreakpoint.value).toBe('sm')
+ expect(currentCols.value).toBe(6)
+ // 布局应该被生成且包含所有元素
+ expect(currentLayout.value.length).toBe(2)
+ })
+
+ it('切换回已缓存断点时恢复布局', async () => {
+ const width = ref(1400)
+ const layouts = ref({})
+ const originalLayout = ref([
+ { i: '1', x: 0, y: 0, w: 2, h: 1 },
+ { i: '2', x: 2, y: 0, w: 2, h: 1 },
+ ])
+
+ const { currentBreakpoint, currentLayout } = withScope(() =>
+ useResponsiveLayout({
+ breakpoints,
+ cols,
+ width,
+ layouts,
+ originalLayout,
+ }),
+ )
+
+ // 记录 lg 断点的布局
+ const lgLayout = JSON.parse(JSON.stringify(currentLayout.value))
+
+ // 切换到 sm
+ width.value = 800
+ await nextTick()
+ expect(currentBreakpoint.value).toBe('sm')
+
+ // lg 布局应被缓存
+ expect(layouts.value).toHaveProperty('lg')
+
+ // 切换回 lg
+ width.value = 1400
+ await nextTick()
+ expect(currentBreakpoint.value).toBe('lg')
+
+ // 恢复的布局应与之前的 lg 布局一致(元素 id 和位置)
+ for (const item of lgLayout) {
+ const restored = currentLayout.value.find((l: any) => l.i === item.i)
+ expect(restored).toBeTruthy()
+ expect(restored!.x).toBe(item.x)
+ expect(restored!.y).toBe(item.y)
+ expect(restored!.w).toBe(item.w)
+ expect(restored!.h).toBe(item.h)
+ }
+ })
+
+ it('width 变化但断点不变时不触发布局更新', async () => {
+ const width = ref(1400)
+ const layouts = ref({})
+ const originalLayout = ref([
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ])
+
+ const { currentBreakpoint, currentLayout } = withScope(() =>
+ useResponsiveLayout({
+ breakpoints,
+ cols,
+ width,
+ layouts,
+ originalLayout,
+ }),
+ )
+
+ const layoutBefore = currentLayout.value
+
+ // 同一断点内的 width 变化
+ width.value = 1300
+ await nextTick()
+
+ expect(currentBreakpoint.value).toBe('lg')
+ // 布局引用应不变(未触发更新)
+ expect(currentLayout.value).toBe(layoutBefore)
+ })
+
+ it('多次断点切换后缓存正确累积', async () => {
+ const width = ref(1400)
+ const layouts = ref({})
+ const originalLayout = ref([
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ ])
+
+ const { currentBreakpoint } = withScope(() =>
+ useResponsiveLayout({
+ breakpoints,
+ cols,
+ width,
+ layouts,
+ originalLayout,
+ }),
+ )
+
+ // lg → sm
+ width.value = 800
+ await nextTick()
+ expect(currentBreakpoint.value).toBe('sm')
+ expect(layouts.value).toHaveProperty('lg')
+
+ // sm → xs
+ width.value = 500
+ await nextTick()
+ expect(currentBreakpoint.value).toBe('xs')
+ expect(layouts.value).toHaveProperty('sm')
+
+ // 应同时有 lg 和 sm 的缓存
+ expect(layouts.value).toHaveProperty('lg')
+ expect(layouts.value).toHaveProperty('sm')
+ })
+})
diff --git a/tests/config-merge.spec.tsx b/tests/config-merge.spec.tsx
new file mode 100644
index 0000000..1eaac7f
--- /dev/null
+++ b/tests/config-merge.spec.tsx
@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import { GridLayout } from '../src'
+import { noCompactor, verticalCompactor } from '../src/core/compactors'
+import { absoluteStrategy, transformStrategy } from '../src/core/position-strategies'
+
+import type { Layout } from '../src/helpers/types'
+
+const baseLayout: Layout = [
+ { x: 0, y: 0, w: 2, h: 2, i: '0' },
+]
+
+describe('Config 合并逻辑(需求 8.5, 8.6)', () => {
+ it('扁平 props 优先级高于分组 gridConfig', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ colNum: 6,
+ gridConfig: { colNum: 10 },
+ },
+ })
+
+ // 扁平 prop colNum=6 应优先于 gridConfig.colNum=10
+ const vm = wrapper.vm as any
+ // GridLayout 通过 provide 传递 props,扁平 props 直接作为 props 传入
+ // withDefaults 确保扁平 props 始终有值
+ expect(vm.$props.colNum).toBe(6)
+ wrapper.unmount()
+ })
+
+ it('扁平 props 优先级高于分组 dragConfig', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDraggable: false,
+ dragConfig: { isDraggable: true },
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.isDraggable).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('扁平 props 优先级高于分组 resizeConfig', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isResizable: false,
+ resizeConfig: { isResizable: true },
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.isResizable).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('扁平 props 优先级高于分组 dropConfig', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ dropConfig: { isDroppable: false },
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.isDroppable).toBe(true)
+ wrapper.unmount()
+ })
+
+ it('仅传分组 config 时正确生效(gridConfig)', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ gridConfig: { rowHeight: 200 },
+ },
+ })
+
+ const vm = wrapper.vm as any
+ // 扁平 prop 未传入,分组 config 生效
+ expect(vm.effectiveConfig.rowHeight).toBe(200)
+ expect(vm.$props.gridConfig).toEqual({ rowHeight: 200 })
+ wrapper.unmount()
+ })
+
+ it('两者都不传时使用默认值', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.effectiveConfig.colNum).toBe(12)
+ expect(vm.effectiveConfig.rowHeight).toBe(150)
+ expect(vm.effectiveConfig.isDraggable).toBe(true)
+ expect(vm.effectiveConfig.isResizable).toBe(true)
+ expect(vm.effectiveConfig.isDroppable).toBe(false)
+ expect(vm.effectiveConfig.dragThreshold).toBe(0)
+ wrapper.unmount()
+ })
+
+ it('compactor 默认为 verticalCompactor', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.compactor).toBe(verticalCompactor)
+ wrapper.unmount()
+ })
+
+ it('positionStrategy 默认为 transformStrategy', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.positionStrategy).toBe(transformStrategy)
+ wrapper.unmount()
+ })
+
+ it('可以传入自定义 compactor', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ compactor: noCompactor,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ // Vue 的 reactive 系统可能包装对象,使用 toStrictEqual 验证值相等
+ expect(vm.$props.compactor.compact).toBeDefined()
+ expect(vm.$props.compactor).not.toBe(verticalCompactor)
+ wrapper.unmount()
+ })
+
+ it('可以传入自定义 positionStrategy', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ positionStrategy: absoluteStrategy,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.positionStrategy.getStyle).toBeDefined()
+ expect(vm.$props.positionStrategy.getRtlStyle).toBeDefined()
+ // 验证不是默认的 transformStrategy
+ // absoluteStrategy.getStyle 返回 top/left 而非 transform
+ const style = vm.$props.positionStrategy.getStyle(10, 20, 100, 50)
+ expect(style.top).toBe('10px')
+ expect(style.left).toBe('20px')
+ expect(style.transform).toBeUndefined()
+ wrapper.unmount()
+ })
+})
diff --git a/tests/core-utils.spec.ts b/tests/core-utils.spec.ts
new file mode 100644
index 0000000..9f33bfc
--- /dev/null
+++ b/tests/core-utils.spec.ts
@@ -0,0 +1,144 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+
+import { calcGridCellDimensions } from '../src/core/utils'
+import { bottom } from '../src/helpers/common'
+
+import type { Layout } from '../src/helpers/types'
+
+// ─── calcGridCellDimensions ─────────────────────────────────
+
+describe('calcGridCellDimensions', () => {
+ it('标准参数 — 公式 (containerWidth - marginX * (cols + 1)) / cols', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 1200,
+ cols: 12,
+ margin: [10, 10],
+ rowHeight: 30,
+ })
+
+ // cellWidth = (1200 - 10 * 13) / 12 = (1200 - 130) / 12 = 1070 / 12
+ expect(result.cellWidth).toBeCloseTo(1070 / 12)
+ expect(result.cellHeight).toBe(30)
+ expect(result.marginX).toBe(10)
+ expect(result.marginY).toBe(10)
+ })
+
+ it('不同 margin 值 — marginX 和 marginY 独立', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 800,
+ cols: 4,
+ margin: [5, 20],
+ rowHeight: 50,
+ })
+
+ // cellWidth = (800 - 5 * 5) / 4 = (800 - 25) / 4 = 193.75
+ expect(result.cellWidth).toBeCloseTo(193.75)
+ expect(result.cellHeight).toBe(50)
+ expect(result.marginX).toBe(5)
+ expect(result.marginY).toBe(20)
+ })
+
+ it('margin 为 0 — cellWidth = containerWidth / cols', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 600,
+ cols: 6,
+ margin: [0, 0],
+ rowHeight: 40,
+ })
+
+ expect(result.cellWidth).toBe(100)
+ expect(result.cellHeight).toBe(40)
+ expect(result.marginX).toBe(0)
+ expect(result.marginY).toBe(0)
+ })
+
+ it('cols <= 0 — cellWidth 返回 0', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 1200,
+ cols: 0,
+ margin: [10, 10],
+ rowHeight: 30,
+ })
+
+ expect(result.cellWidth).toBe(0)
+ })
+
+ it('cols = 1 — 单列场景', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 500,
+ cols: 1,
+ margin: [10, 10],
+ rowHeight: 100,
+ })
+
+ // cellWidth = (500 - 10 * 2) / 1 = 480
+ expect(result.cellWidth).toBe(480)
+ expect(result.cellHeight).toBe(100)
+ })
+
+ it('containerWidth 足够时 cellWidth 为正数', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 1000,
+ cols: 12,
+ margin: [10, 10],
+ rowHeight: 30,
+ })
+
+ expect(result.cellWidth).toBeGreaterThan(0)
+ })
+
+ it('containerWidth 不足时 cellWidth 可能为负数', () => {
+ const result = calcGridCellDimensions({
+ containerWidth: 10,
+ cols: 12,
+ margin: [10, 10],
+ rowHeight: 30,
+ })
+
+ // (10 - 10 * 13) / 12 = (10 - 130) / 12 = -10,但会被 clamp 到 0
+ expect(result.cellWidth).toBeCloseTo(0)
+ })
+})
+
+// ─── bottom ─────────────────────────────────────────────────
+
+describe('bottom', () => {
+ it('空布局返回 0', () => {
+ expect(bottom([])).toBe(0)
+ })
+
+ it('单元素布局', () => {
+ const layout: Layout = [{ i: '1', x: 0, y: 2, w: 1, h: 3 }]
+ expect(bottom(layout)).toBe(5) // 2 + 3
+ })
+
+ it('多元素返回 max(y + h)', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 2 },
+ { i: '2', x: 1, y: 3, w: 1, h: 4 },
+ { i: '3', x: 2, y: 1, w: 1, h: 1 },
+ ]
+ expect(bottom(layout)).toBe(7) // max(2, 7, 2) = 7
+ })
+
+ it('含静态元素 — 静态元素也参与计算', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 1 },
+ { i: '2', x: 0, y: 10, w: 1, h: 5, static: true },
+ ]
+ expect(bottom(layout)).toBe(15) // 10 + 5
+ })
+
+ it('所有元素在 y=0 — 返回最大 h', () => {
+ const layout: Layout = [
+ { i: '1', x: 0, y: 0, w: 1, h: 3 },
+ { i: '2', x: 1, y: 0, w: 1, h: 5 },
+ { i: '3', x: 2, y: 0, w: 1, h: 1 },
+ ]
+ expect(bottom(layout)).toBe(5)
+ })
+})
diff --git a/tests/drag-threshold.spec.tsx b/tests/drag-threshold.spec.tsx
new file mode 100644
index 0000000..ccd9972
--- /dev/null
+++ b/tests/drag-threshold.spec.tsx
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import { GridLayout } from '../src'
+
+import type { Layout } from '../src/helpers/types'
+
+/**
+ * 拖拽阈值功能测试(需求 7.2, 7.3)
+ *
+ * 由于 interactjs 的拖拽事件在 happy-dom 环境中难以完全模拟,
+ * 这里主要测试阈值配置的传递和默认值行为。
+ */
+describe('拖拽阈值配置(需求 7.2, 7.3)', () => {
+ const baseLayout: Layout = [
+ { x: 0, y: 0, w: 2, h: 2, i: '0' },
+ ]
+
+ it('dragThreshold 默认值为 0', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.effectiveConfig.dragThreshold).toBe(0)
+ wrapper.unmount()
+ })
+
+ it('可以设置自定义 dragThreshold', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ dragThreshold: 10,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.dragThreshold).toBe(10)
+ wrapper.unmount()
+ })
+
+ it('dragThreshold 通过 provide 传递给子组件', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ dragThreshold: 15,
+ isDraggable: true,
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ // 验证 GridLayout 的 dragThreshold prop 正确设置
+ expect(vm.$props.dragThreshold).toBe(15)
+
+ wrapper.unmount()
+ })
+
+ it('阈值为 0 时不阻止拖拽(默认行为)', () => {
+ // 阈值为 0 意味着任何移动都应立即触发拖拽
+ // 这是向后兼容的默认行为
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ dragThreshold: 0,
+ isDraggable: true,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.$props.dragThreshold).toBe(0)
+ // 阈值为 0 等同于无阈值限制
+ wrapper.unmount()
+ })
+
+ it('dragConfig.dragThreshold 不覆盖扁平 prop', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ dragThreshold: 5,
+ dragConfig: { dragThreshold: 20 },
+ },
+ })
+
+ const vm = wrapper.vm as any
+ // 扁平 prop 优先
+ expect(vm.$props.dragThreshold).toBe(5)
+ wrapper.unmount()
+ })
+})
diff --git a/tests/drop-zone.spec.tsx b/tests/drop-zone.spec.tsx
new file mode 100644
index 0000000..6243698
--- /dev/null
+++ b/tests/drop-zone.spec.tsx
@@ -0,0 +1,241 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import { GridLayout } from '../src'
+
+import type { Layout } from '../src/helpers/types'
+
+const baseLayout: Layout = [
+ { x: 0, y: 0, w: 2, h: 2, i: '0' },
+]
+
+/**
+ * 创建模拟的 DragEvent。
+ * happy-dom 对 DragEvent 支持有限,使用 MouseEvent 模拟基本属性。
+ */
+function createDragEvent(type: string, opts: { clientX?: number, clientY?: number } = {}) {
+ const event = new Event(type, { bubbles: true, cancelable: true }) as any
+ event.clientX = opts.clientX ?? 100
+ event.clientY = opts.clientY ?? 100
+ event.dataTransfer = { dropEffect: 'none', effectAllowed: 'all' }
+ event.preventDefault = () => {}
+ return event
+}
+
+describe('外部拖入功能(需求 6.1-6.6)', () => {
+ it('isDroppable=false 时不触发 drop-drag-over 事件', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: false,
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ const layoutEl = wrapper.find('.vgl-layout')
+ await layoutEl.trigger('dragover')
+
+ expect(wrapper.emitted('drop-drag-over')).toBeUndefined()
+
+ wrapper.unmount()
+ })
+
+ it('isDroppable=true 时 dragover 触发 drop-drag-over 事件', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ dropItem: { w: 2, h: 2 },
+ colNum: 12,
+ rowHeight: 150,
+ margin: [10, 10],
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ const layoutEl = wrapper.find('.vgl-layout')
+ const event = createDragEvent('dragover', { clientX: 100, clientY: 100 })
+ layoutEl.element.dispatchEvent(event)
+ await nextTick()
+
+ const emitted = wrapper.emitted('drop-drag-over')
+ expect(emitted).toBeDefined()
+ expect(emitted!.length).toBeGreaterThanOrEqual(1)
+
+ // 事件参数应包含网格坐标
+ const [coords] = emitted![0] as any[]
+ expect(typeof coords.x).toBe('number')
+ expect(typeof coords.y).toBe('number')
+ expect(coords.x).toBeGreaterThanOrEqual(0)
+ expect(coords.y).toBeGreaterThanOrEqual(0)
+
+ wrapper.unmount()
+ })
+
+ it('drop 事件触发 drop 自定义事件', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ dropItem: { w: 1, h: 1 },
+ colNum: 12,
+ rowHeight: 150,
+ margin: [10, 10],
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ const layoutEl = wrapper.find('.vgl-layout')
+
+ // 先触发 dragover 以设置 dropPlaceholder
+ const dragoverEvent = createDragEvent('dragover', { clientX: 100, clientY: 100 })
+ layoutEl.element.dispatchEvent(dragoverEvent)
+ await nextTick()
+
+ // 然后触发 drop
+ const dropEvent = createDragEvent('drop', { clientX: 100, clientY: 100 })
+ layoutEl.element.dispatchEvent(dropEvent)
+ await nextTick()
+
+ const emitted = wrapper.emitted('drop')
+ expect(emitted).toBeDefined()
+ expect(emitted!.length).toBeGreaterThanOrEqual(1)
+
+ const [coords] = emitted![0] as any[]
+ expect(typeof coords.x).toBe('number')
+ expect(typeof coords.y).toBe('number')
+ expect(typeof coords.w).toBe('number')
+ expect(typeof coords.h).toBe('number')
+
+ wrapper.unmount()
+ })
+
+ it('dragover 时显示占位符', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ dropItem: { w: 2, h: 2 },
+ colNum: 12,
+ rowHeight: 150,
+ margin: [10, 10],
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ // 初始状态无 drop 占位符
+ expect(vm.state.dropPlaceholder).toBeNull()
+
+ const layoutEl = wrapper.find('.vgl-layout')
+ const event = createDragEvent('dragover', { clientX: 200, clientY: 200 })
+ layoutEl.element.dispatchEvent(event)
+ await nextTick()
+
+ // dragover 后应有占位符
+ expect(vm.state.dropPlaceholder).not.toBeNull()
+ expect(vm.state.dropPlaceholder.w).toBe(2)
+ expect(vm.state.dropPlaceholder.h).toBe(2)
+
+ wrapper.unmount()
+ })
+
+ it('drop 后移除占位符', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ dropItem: { w: 1, h: 1 },
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ const layoutEl = wrapper.find('.vgl-layout')
+
+ // dragover 设置占位符
+ const dragoverEvent = createDragEvent('dragover', { clientX: 100, clientY: 100 })
+ layoutEl.element.dispatchEvent(dragoverEvent)
+ await nextTick()
+ expect(vm.state.dropPlaceholder).not.toBeNull()
+
+ // drop 移除占位符
+ const dropEvent = createDragEvent('drop', { clientX: 100, clientY: 100 })
+ layoutEl.element.dispatchEvent(dropEvent)
+ await nextTick()
+ expect(vm.state.dropPlaceholder).toBeNull()
+
+ wrapper.unmount()
+ })
+
+ it('坐标 clamp 到有效范围(x 不超过 cols - w)', async () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ dropItem: { w: 3, h: 1 },
+ colNum: 12,
+ rowHeight: 150,
+ margin: [10, 10],
+ },
+ attachTo: document.body,
+ })
+
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+
+ const layoutEl = wrapper.find('.vgl-layout')
+
+ // 使用非常大的 clientX 来模拟超出右边界
+ const event = createDragEvent('dragover', { clientX: 9999, clientY: 100 })
+ layoutEl.element.dispatchEvent(event)
+ await nextTick()
+
+ expect(vm.state.dropPlaceholder).not.toBeNull()
+ // x 应被 clamp 到 cols - w = 12 - 3 = 9
+ expect(vm.state.dropPlaceholder.x).toBeLessThanOrEqual(12 - 3)
+ expect(vm.state.dropPlaceholder.x).toBeGreaterThanOrEqual(0)
+
+ wrapper.unmount()
+ })
+
+ it('dropItem 默认尺寸为 { w: 1, h: 1 }', () => {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: baseLayout,
+ isDroppable: true,
+ },
+ })
+
+ const vm = wrapper.vm as any
+ expect(vm.effectiveConfig.dropItem).toEqual({ w: 1, h: 1 })
+
+ wrapper.unmount()
+ })
+})
diff --git a/tests/grid-background.spec.tsx b/tests/grid-background.spec.tsx
new file mode 100644
index 0000000..49c6f63
--- /dev/null
+++ b/tests/grid-background.spec.tsx
@@ -0,0 +1,160 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import GridBackground from '../src/components/grid-background.vue'
+
+describe('GridBackground 组件(需求 12.1-12.5)', () => {
+ it('渲染 div 并包含 vgl-background 类', () => {
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 12,
+ rowHeight: 30,
+ margin: [10, 10] as [number, number],
+ width: 1200,
+ },
+ })
+
+ const div = wrapper.find('div')
+ expect(div.exists()).toBe(true)
+ expect(div.classes()).toContain('vgl-background')
+
+ wrapper.unmount()
+ })
+
+ it('color 和 strokeWidth props 正确应用到 backgroundImage', () => {
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 4,
+ rowHeight: 50,
+ margin: [5, 5] as [number, number],
+ width: 400,
+ color: '#ff0000',
+ strokeWidth: 2,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('#ff0000')
+ expect(style).toContain('2px')
+
+ wrapper.unmount()
+ })
+
+ it('默认 color 和 strokeWidth', () => {
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 12,
+ rowHeight: 30,
+ margin: [10, 10] as [number, number],
+ width: 1200,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('rgba(0,0,0,0.1)')
+ expect(style).toContain('1px')
+
+ wrapper.unmount()
+ })
+
+ it('backgroundSize 与 calcGridCellDimensions 一致', () => {
+ // cols=4, margin=[10,10], width=400
+ // cellWidth = (400 - 10 * 5) / 4 = 350 / 4 = 87.5
+ // patternWidth = 87.5 + 10 = 97.5
+ // patternHeight = 50 + 10 = 60
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 4,
+ rowHeight: 50,
+ margin: [10, 10] as [number, number],
+ width: 400,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('background-size: 97.5px 60px')
+
+ wrapper.unmount()
+ })
+
+ it('不同 margin 配置下 backgroundSize 正确', () => {
+ // cols=6, margin=[20,15], width=800
+ // cellWidth = (800 - 20 * 7) / 6 = (800 - 140) / 6 = 110
+ // patternWidth = 110 + 20 = 130
+ // patternHeight = 40 + 15 = 55
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 6,
+ rowHeight: 40,
+ margin: [20, 15] as [number, number],
+ width: 800,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('background-size: 130px 55px')
+
+ wrapper.unmount()
+ })
+
+ it('rows prop 控制 div 高度', () => {
+ // patternHeight = 50 + 10 = 60, rows=3 → height = 60 * 3 + 10 = 190
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 4,
+ rowHeight: 50,
+ margin: [10, 10] as [number, number],
+ width: 400,
+ rows: 3,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('height: 190px')
+
+ wrapper.unmount()
+ })
+
+ it('未传 rows 时 div 高度为 100%', () => {
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 12,
+ rowHeight: 30,
+ margin: [10, 10] as [number, number],
+ width: 1200,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('height: 100%')
+
+ wrapper.unmount()
+ })
+
+ it('backgroundPosition 把网格线放在 margin 中间', () => {
+ // cols=12, margin=[10,10], width=1200
+ // cellWidth = (1200 - 10*13) / 12 = 89.166...
+ // bgPosX = cellWidth + 1.5*marginX = 89.166... + 15 = 104.166...
+ // bgPosY = cellHeight + 1.5*marginY = 30 + 15 = 45
+ const wrapper = mount(GridBackground, {
+ props: {
+ cols: 12,
+ rowHeight: 30,
+ margin: [10, 10] as [number, number],
+ width: 1200,
+ },
+ })
+
+ const div = wrapper.find('div')
+ const style = div.attributes('style') || ''
+ expect(style).toContain('background-position: 104.166667px 45px')
+
+ wrapper.unmount()
+ })
+})
diff --git a/tests/grid-item-handles.spec.tsx b/tests/grid-item-handles.spec.tsx
new file mode 100644
index 0000000..1c45d0b
--- /dev/null
+++ b/tests/grid-item-handles.spec.tsx
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import { GridLayout } from '../src'
+
+import type { Layout } from '../src/helpers/types'
+
+/**
+ * 辅助函数:挂载 GridLayout 并等待初始化完成,返回 wrapper。
+ */
+async function mountGrid(opts: {
+ layout: Layout,
+ isResizable?: boolean,
+}) {
+ const wrapper = mount(GridLayout, {
+ props: {
+ layout: opts.layout,
+ colNum: 12,
+ rowHeight: 150,
+ margin: [10, 10],
+ isDraggable: false,
+ isResizable: opts.isResizable ?? true,
+ },
+ attachTo: document.body,
+ })
+
+ // 设置宽度并等待渲染
+ const vm = wrapper.vm as any
+ vm.state.width = 1200
+ await nextTick()
+ await nextTick()
+ await nextTick()
+
+ return wrapper
+}
+
+describe('GridItem 缩放手柄渲染', () => {
+ const baseLayout: Layout = [
+ { x: 0, y: 0, w: 2, h: 2, i: '0' },
+ ]
+
+ it('默认可缩放时只渲染一个 se 手柄', async () => {
+ const wrapper = await mountGrid({ layout: baseLayout })
+
+ const items = wrapper.findAll('.vgl-item')
+ const gridItem = items.find(item => {
+ const style = item.attributes('style') || ''
+ return !style.includes('display: none') && !style.includes('display:none')
+ && !item.classes().includes('vgl-item--placeholder')
+ })
+
+ expect(gridItem).toBeDefined()
+ const handles = gridItem!.findAll('.vgl-item__resizer')
+ expect(handles.length).toBe(1)
+ expect(handles[0].classes()).toContain('vgl-item__resizer--se')
+
+ wrapper.unmount()
+ })
+
+ it('isResizable=false 时不渲染任何手柄', async () => {
+ const wrapper = await mountGrid({
+ layout: baseLayout,
+ isResizable: false,
+ })
+
+ const items = wrapper.findAll('.vgl-item')
+ const gridItem = items.find(item => {
+ return !item.classes().includes('vgl-item--placeholder')
+ })
+
+ expect(gridItem).toBeDefined()
+ const handles = gridItem!.findAll('.vgl-item__resizer')
+ expect(handles.length).toBe(0)
+
+ wrapper.unmount()
+ })
+
+ it('se 手柄有基础 CSS 类名', async () => {
+ const wrapper = await mountGrid({ layout: baseLayout })
+
+ const items = wrapper.findAll('.vgl-item')
+ const gridItem = items.find(item => {
+ return !item.classes().includes('vgl-item--placeholder')
+ })
+
+ expect(gridItem).toBeDefined()
+ const handle = gridItem!.find('.vgl-item__resizer')
+ expect(handle).toBeDefined()
+ expect(handle!.classes()).toContain('vgl-item__resizer')
+ expect(handle!.classes()).toContain('vgl-item__resizer--se')
+
+ wrapper.unmount()
+ })
+})
diff --git a/tests/position-strategies.spec.ts b/tests/position-strategies.spec.ts
new file mode 100644
index 0000000..8b102c4
--- /dev/null
+++ b/tests/position-strategies.spec.ts
@@ -0,0 +1,73 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ absoluteStrategy,
+ transformStrategy,
+} from '../src/core/position-strategies'
+
+import {
+ setTopLeft,
+ setTopRight,
+ setTransform,
+ setTransformRtl,
+} from '../src/helpers/common'
+
+describe('transformStrategy', () => {
+ const cases = [
+ { top: 0, left: 0, width: 100, height: 50 },
+ { top: 10, left: 20, width: 200, height: 100 },
+ { top: 0.5, left: 1.5, width: 99.9, height: 33.3 },
+ { top: 1000, left: 2000, width: 500, height: 300 },
+ ]
+
+ it.each(cases)(
+ 'getStyle($top, $left, $width, $height) matches setTransform',
+ ({ top, left, width, height }) => {
+ expect(transformStrategy.getStyle(top, left, width, height)).toEqual(
+ setTransform(top, left, width, height),
+ )
+ },
+ )
+
+ it.each(cases)(
+ 'getRtlStyle($top, $left, $width, $height) matches setTransformRtl',
+ ({ top, left: right, width, height }) => {
+ expect(transformStrategy.getRtlStyle(top, right, width, height)).toEqual(
+ setTransformRtl(top, right, width, height),
+ )
+ },
+ )
+})
+
+describe('absoluteStrategy', () => {
+ const cases = [
+ { top: 0, left: 0, width: 100, height: 50 },
+ { top: 10, left: 20, width: 200, height: 100 },
+ { top: 0.5, left: 1.5, width: 99.9, height: 33.3 },
+ { top: 1000, left: 2000, width: 500, height: 300 },
+ ]
+
+ it.each(cases)(
+ 'getStyle($top, $left, $width, $height) matches setTopLeft',
+ ({ top, left, width, height }) => {
+ expect(absoluteStrategy.getStyle(top, left, width, height)).toEqual(
+ setTopLeft(top, left, width, height),
+ )
+ },
+ )
+
+ it.each(cases)(
+ 'getRtlStyle($top, $left, $width, $height) matches setTopRight',
+ ({ top, left: right, width, height }) => {
+ expect(absoluteStrategy.getRtlStyle(top, right, width, height)).toEqual(
+ setTopRight(top, right, width, height),
+ )
+ },
+ )
+})
+
+
diff --git a/vite.config.ts b/vite.config.ts
index 0a5c3a8..7878063 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -38,7 +38,7 @@ export default defineConfig({
formats: ['es'],
},
rollupOptions: {
- input: [resolve(__dirname, 'src/index.ts')],
+ input: [resolve(__dirname, 'src/index.ts'), resolve(__dirname, 'src/core.ts')],
external,
output: [
{