diff --git a/.kiro/specs/rgl-v2-feature-parity/.config.kiro b/.kiro/specs/rgl-v2-feature-parity/.config.kiro new file mode 100644 index 0000000..de562b6 --- /dev/null +++ b/.kiro/specs/rgl-v2-feature-parity/.config.kiro @@ -0,0 +1 @@ +{"specId": "b5d9ad5c-db9e-4580-bb40-f2cd26d7f3bb", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/rgl-v2-feature-parity/design.md b/.kiro/specs/rgl-v2-feature-parity/design.md new file mode 100644 index 0000000..11bbf0d --- /dev/null +++ b/.kiro/specs/rgl-v2-feature-parity/design.md @@ -0,0 +1,620 @@ +# 设计文档 + +## 概述 + +本设计文档描述 grid-layout-plus 补齐 react-grid-layout v2 缺失功能的技术方案。变更覆盖四个层面: + +1. **核心算法层**(需求 1-4, 13-15):水平压缩、可插拔 Compactor/PositionStrategy 接口、允许重叠、核心独立导出、快速压缩器、单元格尺寸计算 +2. **组件功能增强**(需求 5-7):多方向缩放手柄、外部拖入、拖拽阈值 +3. **Composable API**(需求 9-11):useContainerWidth、useGridLayout、useResponsiveLayout +4. **Extras**(需求 8, 12):Composable Config 分组接口、GridBackground 组件 + +本次为 Major 版本升级(v2.0.0),允许 breaking changes。废弃的 props 将被移除,新 API 采用更干净的设计。 + +## 架构 + +### 模块依赖关系 + +```mermaid +graph TD + subgraph 核心层["核心层 (无 Vue 依赖)"] + CORE["src/core.ts
纯函数 + 接口导出"] + COMPACT["src/core/compactors.ts
Compactor 接口 & 实现"] + POSITION["src/core/position-strategies.ts
PositionStrategy 接口 & 实现"] + COMMON["src/helpers/common.ts
现有布局算法 (重构)"] + RESPONSIVE["src/helpers/responsive.ts"] + end + + subgraph Composable层["Composable 层"] + UCW["src/composables/useContainerWidth.ts"] + UGL["src/composables/useGridLayout.ts"] + URL["src/composables/useResponsiveLayout.ts"] + end + + subgraph 组件层["组件层"] + GL["src/components/grid-layout.vue"] + GI["src/components/grid-item.vue"] + GB["src/components/grid-background.vue"] + TYPES["src/components/types.ts"] + end + + CORE --> COMPACT + CORE --> POSITION + CORE --> COMMON + GL --> UGL + GL --> UCW + GL --> URL + GL --> CORE + GI --> POSITION + GI --> CORE + GB --> CORE + UGL --> COMPACT + UGL --> COMMON + URL --> RESPONSIVE + UCW -.-> |"ResizeObserver"| DOM + + INDEX["src/index.ts"] --> GL + INDEX --> GI + INDEX --> GB + INDEX --> CORE + INDEX --> UCW + INDEX --> UGL + INDEX --> URL +``` + +### 设计原则 + +- **核心无依赖**:`src/core.ts` 及其子模块不导入 `vue`、不使用 DOM API,可在 Node.js 中直接使用 +- **接口优先**:Compactor 和 PositionStrategy 通过 TypeScript 接口定义,内置实现和用户自定义实现地位平等 +- **干净 API**:Major 版本升级,移除废弃 props(`verticalCompact`、`useCssTransforms`、`transformScale`),统一使用 `compactor` 和 `positionStrategy` 替代 +- **渐进采用**:Composable API 独立于组件,用户可按需使用 + +## 组件与接口 + +### 1. Compactor 接口(需求 1, 2, 3, 13) + +```typescript +// src/core/compactors.ts + +/** 压缩器接口 */ +export interface Compactor { + /** 对布局执行压缩,返回新布局(不修改输入) */ + compact(layout: Layout, cols: number): Layout + /** 是否允许元素重叠 */ + allowOverlap?: boolean +} + +/** 垂直压缩器 — 等价于现有 compact(layout, true) */ +export const verticalCompactor: Compactor + +/** 水平压缩器 — 按列优先排序后向左压缩 */ +export const horizontalCompactor: Compactor + +/** 无压缩器 — 返回浅拷贝,不移动元素 */ +export const noCompactor: Compactor + +/** 快速垂直压缩器 — O(n log n) 实现 */ +export const fastVerticalCompactor: Compactor + +/** 快速水平压缩器 — O(n log n) 实现 */ +export const fastHorizontalCompactor: Compactor + +/** + * 创建带 allowOverlap 选项的压缩器包装 + * 当 allowOverlap=true 时跳过碰撞检测 + */ +export function withOverlap(compactor: Compactor): Compactor +``` + +**水平压缩算法**: +1. 按列优先排序(先 x 后 y) +2. 静态元素加入碰撞列表 +3. 对每个非静态元素,保持 y 不变,将 x 向左移动至无碰撞的最小位置 +4. 碰撞时放置在障碍物右侧 + +**快速压缩器算法**: +- 使用区间树(interval tree)加速碰撞检测,将 O(n) 碰撞查询降为 O(log n) +- 整体复杂度从 O(n²) 降为 O(n log n) + +### 2. PositionStrategy 接口(需求 14) + +```typescript +// src/core/position-strategies.ts + +/** 定位策略接口 */ +export interface PositionStrategy { + getStyle(top: number, left: number, width: number, height: number): Record + getRtlStyle(top: number, right: number, width: number, height: number): Record +} + +/** CSS transform translate3d 定位(默认) */ +export const transformStrategy: PositionStrategy + +/** CSS top/left/right 绝对定位 */ +export const absoluteStrategy: PositionStrategy + +/** 缩放定位工厂函数 */ +export function scaledStrategy(scale: number): PositionStrategy +``` + +### 3. calcGridCellDimensions(需求 15) + +```typescript +// src/core.ts 导出 + +export interface GridCellDimensions { + cellWidth: number + cellHeight: number + marginX: number + marginY: number +} + +export function calcGridCellDimensions(params: { + containerWidth: number + cols: number + margin: [number, number] + rowHeight: number +}): GridCellDimensions +``` + +### 4. GridLayout 组件增强(需求 2, 5, 6, 7, 8, 14) + +```typescript +// src/components/types.ts — v2 props(移除 verticalCompact、useCssTransforms、transformScale) + +export interface GridLayoutProps { + autoSize?: boolean + colNum?: number + rowHeight?: number + maxRows?: number + margin?: number[] + isDraggable?: boolean + isResizable?: boolean + isMirrored?: boolean + isBounded?: boolean + restoreOnDrag?: boolean + layout: Layout + responsive?: boolean + responsiveLayouts?: Partial + breakpoints?: Breakpoints + cols?: Breakpoints + preventCollision?: boolean + useStyleCursor?: boolean + + /** 可插拔压缩器(默认 verticalCompactor) */ + compactor?: Compactor + /** 可插拔定位策略(默认 transformStrategy) */ + positionStrategy?: PositionStrategy + /** 所有子项的默认缩放手柄方向 */ + resizeHandles?: ResizeHandle[] + /** 是否允许外部拖入 */ + isDroppable?: boolean + /** 外部拖入元素的默认尺寸 */ + dropItem?: { w: number, h: number } + /** 拖拽阈值(像素) */ + dragThreshold?: number + + /** 分组配置对象(需求 8) */ + gridConfig?: GridConfig + dragConfig?: DragConfig + resizeConfig?: ResizeConfig + dropConfig?: DropConfig +} + +export type ResizeHandle = 's' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne' + +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 + resizeHandles?: ResizeHandle[] +} + +export interface DropConfig { + isDroppable?: boolean + dropItem?: { w: number, h: number } +} +``` + +**新增事件**(需求 6): +- `drop-drag-over: (gridCoords: { x: number, y: number }, event: DragEvent) => void` +- `drop: (gridCoords: { x: number, y: number, w: number, h: number }, event: DragEvent) => void` +- `drop-drag-leave: (event: DragEvent) => void` + +### 5. GridItem 组件增强(需求 5, 7) + +```typescript +// src/components/types.ts — GridItemProps 新增 + +export interface GridItemProps { + // ... 现有 props 保持不变 ... + + /** 缩放手柄方向(覆盖 GridLayout 的默认值) */ + resizeHandles?: ResizeHandle[] + /** 拖拽阈值(覆盖 GridLayout 的默认值) */ + dragThreshold?: number +} +``` + +### 6. GridBackground 组件(需求 12) + +```typescript +// src/components/grid-background.vue + +export interface GridBackgroundProps { + cols: number + rowHeight: number + margin: [number, number] + width: number + rows?: number + color?: string // 默认 'rgba(0,0,0,0.1)' + strokeWidth?: number // 默认 1 +} +``` + +使用 SVG `` 元素渲染网格线,通过 inject 从父 GridLayout 获取配置或通过 props 独立使用。 + +### 7. Composable API(需求 9, 10, 11) + +```typescript +// src/composables/useContainerWidth.ts +export function useContainerWidth( + el: Ref +): { width: Ref } + +// src/composables/useGridLayout.ts +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 +} + +export function useGridLayout(options: UseGridLayoutOptions): UseGridLayoutReturn + +// src/composables/useResponsiveLayout.ts +export interface UseResponsiveLayoutOptions { + breakpoints: Breakpoints + cols: Breakpoints + width: Ref + layouts: Ref> + compactor?: Compactor + originalLayout: Ref +} + +export interface UseResponsiveLayoutReturn { + currentBreakpoint: Ref + currentCols: Ref + currentLayout: Ref +} + +export function useResponsiveLayout(options: UseResponsiveLayoutOptions): UseResponsiveLayoutReturn +``` + +### 8. 核心独立导出(需求 4) + +```typescript +// src/core.ts — 聚合导出 + +// 从 helpers/common.ts 重新导出纯函数 +export { + compact, moveElement, correctBounds, getAllCollisions, + getFirstCollision, collides, validateLayout, bottom, + cloneLayout, sortLayoutItemsByRowCol +} from './helpers/common' + +// 导出 Compactor 相关 +export { + type Compactor, verticalCompactor, horizontalCompactor, + noCompactor, fastVerticalCompactor, fastHorizontalCompactor, + withOverlap +} from './core/compactors' + +// 导出 PositionStrategy 相关 +export { + type PositionStrategy, transformStrategy, absoluteStrategy, + scaledStrategy +} from './core/position-strategies' + +// 导出工具函数 +export { calcGridCellDimensions, type GridCellDimensions } from './core/utils' + +// 导出类型 +export type { Layout, LayoutItem, Breakpoint, Breakpoints, ResponsiveLayout } from './helpers/types' +``` + +构建配置需在 `vite.config.ts` 的 `rollupOptions.input` 中添加 `src/core.ts` 入口,并在 `package.json` 的 `exports` 中添加 `./core` 路径映射。 + +## 数据模型 + +### 类型变更汇总 + +```typescript +// src/helpers/types.ts — 新增/修改类型 + +export type ResizeHandle = 's' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne' + +export interface LayoutItem extends LayoutItemRequired { + // ... 现有字段保持不变 ... + resizeHandles?: ResizeHandle[] // 新增:单项缩放手柄方向 +} + +/** Compactor 接口 */ +export interface Compactor { + compact(layout: Layout, cols: number): Layout + allowOverlap?: boolean +} + +/** PositionStrategy 接口 */ +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 +} + +/** @internal — 更新 LayoutInstance 以包含新字段 */ +export interface LayoutInstance { + // ... 现有字段保持不变 ... + compactor: Compactor + positionStrategy: PositionStrategy + resizeHandles: ResizeHandle[] + isDroppable: boolean + dropItem: { w: number, h: number } + dragThreshold: number +} +``` + +### Breaking Changes(v1 → v2 迁移) + +| 移除的 prop | 替代方案 | 说明 | +|---|---|---| +| `verticalCompact` | `compactor` | `verticalCompact: true` → `compactor={verticalCompactor}`(默认值),`false` → `compactor={noCompactor}` | +| `useCssTransforms` | `positionStrategy` | `useCssTransforms: true` → `positionStrategy={transformStrategy}`(默认值),`false` → `positionStrategy={absoluteStrategy}` | +| `transformScale` | `positionStrategy` | `transformScale: 0.5` → `positionStrategy={scaledStrategy(0.5)}` | +| 扁平 props(可选保留) | 分组 config | 扁平 props 和分组 config 均可使用,扁平 props 优先级更高 | +| 单个 `resizer` span | `resizeHandles` | 默认 `['se']`,渲染单个手柄,与现有行为一致 | + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|---|---|---| +| `src/core/compactors.ts` | 新增 | Compactor 接口 + 5 个内置实现 + withOverlap | +| `src/core/position-strategies.ts` | 新增 | PositionStrategy 接口 + 3 个内置实现 | +| `src/core/utils.ts` | 新增 | calcGridCellDimensions | +| `src/core.ts` | 新增 | 核心聚合导出入口 | +| `src/composables/useContainerWidth.ts` | 新增 | 容器宽度 composable | +| `src/composables/useGridLayout.ts` | 新增 | 布局状态管理 composable | +| `src/composables/useResponsiveLayout.ts` | 新增 | 响应式断点 composable | +| `src/components/grid-background.vue` | 新增 | SVG 网格背景组件 | +| `src/helpers/types.ts` | 修改 | 新增 ResizeHandle、Compactor、PositionStrategy 等类型 | +| `src/helpers/common.ts` | 修改 | 重构 compact() 以委托给 Compactor;提取 setTransform 等到 PositionStrategy | +| `src/components/types.ts` | 修改 | GridLayoutProps/GridItemProps 新增 props | +| `src/components/grid-layout.vue` | 修改 | 集成 Compactor、PositionStrategy、外部拖入、拖拽阈值、Config 分组 | +| `src/components/grid-item.vue` | 修改 | 多方向缩放手柄、PositionStrategy 集成、拖拽阈值 | +| `src/index.ts` | 修改 | 重新导出 core、composables、GridBackground | +| `src/style.scss` | 修改 | 8 方向缩放手柄样式 + GridBackground 样式 | +| `vite.config.ts` | 修改 | 添加 core.ts 入口 | +| `package.json` | 修改 | exports 添加 ./core 路径 | +| `tests/compactors.spec.ts` | 新增 | Compactor 单元测试 + 属性测试 | +| `tests/position-strategies.spec.ts` | 新增 | PositionStrategy 测试 | +| `tests/core-utils.spec.ts` | 新增 | calcGridCellDimensions 测试 | +| `tests/composables.spec.ts` | 新增 | Composable 测试 | +| `tests/grid-background.spec.tsx` | 新增 | GridBackground 组件测试 | + + +## 正确性属性 + +*属性(Property)是一种在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。* + +### Property 1: 水平压缩不变量 + +*对于任意* 有效的 Layout 和列数 cols,执行水平压缩后: +- 每个非静态元素的 x 坐标应为无碰撞的最小值(不能再向左移动) +- 每个元素的 y 坐标应与压缩前相同 +- 静态元素的 x、y 坐标应与压缩前相同 +- 压缩后的布局中不存在任何两个元素的矩形区域重叠 + +**Validates: Requirements 1.1, 1.2, 1.3, 1.4** + +### Property 2: verticalCompactor 等价性 + +*对于任意* 有效的 Layout 和列数 cols,`verticalCompactor.compact(layout, cols)` 的输出应与现有 `compact(layout, true)` 函数的输出完全一致(元素顺序和所有字段值相同)。 + +**Validates: Requirements 2.2** + +### Property 3: noCompactor 恒等性 + +*对于任意* 有效的 Layout 和列数 cols,`noCompactor.compact(layout, cols)` 返回的布局中每个元素的 `{ x, y, w, h }` 应与输入完全相同,且返回的数组引用不同于输入数组。 + +**Validates: Requirements 2.4** + +### Property 4: allowOverlap 跳过碰撞 + +*对于任意* 有效的 Layout 和任意 Compactor,当 `allowOverlap` 设置为 `true` 时,`withOverlap(compactor).compact(layout, cols)` 返回的布局中每个元素的 `{ x, y }` 应与输入相同(不执行碰撞推移)。 + +**Validates: Requirements 3.1** + +### Property 5: 容器高度计算 + +*对于任意* 有效的 Layout(包括允许重叠的布局),`bottom(layout)` 的返回值应等于所有元素中 `max(y + h)` 的值(空布局返回 0)。 + +**Validates: Requirements 3.3** + +### Property 6: 缩放手柄渲染数量与方向 + +*对于任意* ResizeHandle 数组子集(从 `['s','w','e','n','sw','nw','se','ne']` 中选取),当 GridItem 的 `resizeHandles` 设置为该子集时,渲染的手柄 DOM 元素数量应等于数组长度,且每个手柄元素具有对应方向的 CSS 类名。 + +**Validates: Requirements 5.2, 5.5** + +### Property 7: 拖拽阈值行为 + +*对于任意* 正数阈值 `t` 和任意移动距离 `d`,当 `dragThreshold = t` 时:若 `d < t` 则不触发 dragstart 事件;若 `d >= t` 则触发 dragstart 事件。 + +**Validates: Requirements 7.2, 7.3** + +### Property 8: 扁平 props 优先级 + +*对于任意* GridLayout 配置项(如 `colNum`、`isDraggable` 等),当同一配置项同时通过扁平 prop 和分组 config 对象传入不同值时,GridLayout 实际使用的值应等于扁平 prop 的值。 + +**Validates: Requirements 8.5** + +### Property 9: useGridLayout 压缩结果一致性 + +*对于任意* 有效的 Layout、列数和 Compactor,`useGridLayout` 返回的 `currentLayout` 应等于 `compactor.compact(layout, cols)` 的结果。 + +**Validates: Requirements 10.2** + +### Property 10: useGridLayout 操作后重新压缩 + +*对于任意* 有效的 Layout 和合法的 moveItem/resizeItem 操作,操作执行后 `currentLayout` 应等于对操作后的原始布局重新执行压缩的结果。 + +**Validates: Requirements 10.3, 10.4** + +### Property 11: useGridLayout 增删元素 + +*对于任意* 有效的 Layout 和新 LayoutItem,`addItem` 后 `currentLayout` 的长度应比操作前多 1 且包含新元素;`removeItem` 后长度应比操作前少 1 且不包含被删元素。 + +**Validates: Requirements 10.5** + +### Property 12: 断点映射正确性 + +*对于任意* 有效的 breakpoints 配置、cols 配置和容器宽度 width,`useResponsiveLayout` 返回的 `currentBreakpoint` 应等于 `getBreakpointFromWidth(breakpoints, width)` 的结果,`currentCols` 应等于 `getColsFromBreakpoint(currentBreakpoint, cols)` 的结果。 + +**Validates: Requirements 11.2, 11.3** + +### Property 13: 断点切换布局生成 + +*对于任意* 有效的 breakpoints、cols 和 width 变化序列,当 width 变化导致断点切换时,`useResponsiveLayout` 返回的 `currentLayout` 应等于 `findOrGenerateResponsiveLayout` 对新断点生成的布局。 + +**Validates: Requirements 11.4, 11.5** + +### Property 14: 断点切换布局缓存 + +*对于任意* 断点切换序列,当从断点 A 切换到断点 B 时,切换前断点 A 的布局应被保存到 layouts 缓存中;再次切换回断点 A 时应恢复缓存的布局。 + +**Validates: Requirements 11.6** + +### Property 15: GridBackground 网格线数量 + +*对于任意* 有效的 cols、rowHeight、margin、width 和 rows 参数,GridBackground 渲染的 SVG pattern 的宽度应等于 `calcGridCellDimensions` 计算的 `cellWidth + marginX`,高度应等于 `cellHeight + marginY`。 + +**Validates: Requirements 12.1** + +### Property 16: fastVerticalCompactor 等价性 + +*对于任意* 有效的 Layout 和列数 cols,`fastVerticalCompactor.compact(layout, cols)` 的输出应与 `verticalCompactor.compact(layout, cols)` 的输出完全一致。 + +**Validates: Requirements 13.3** + +### Property 17: fastHorizontalCompactor 等价性 + +*对于任意* 有效的 Layout 和列数 cols,`fastHorizontalCompactor.compact(layout, cols)` 的输出应与 `horizontalCompactor.compact(layout, cols)` 的输出完全一致。 + +**Validates: Requirements 13.4** + +### Property 18: 内置 PositionStrategy 等价性 + +*对于任意* top、left/right、width、height 数值,`transformStrategy.getStyle(top, left, width, height)` 的输出应与现有 `setTransform(top, left, width, height)` 一致;`absoluteStrategy.getStyle` 应与 `setTopLeft` 一致;对应的 RTL 方法同理。 + +**Validates: Requirements 14.2, 14.3** + +### Property 19: scaledStrategy 缩放正确性 + +*对于任意* 正数 scale 和任意 top、left、width、height,`scaledStrategy(scale).getStyle(top, left, width, height)` 生成的 transform 中的坐标值应等于 `top * scale` 和 `left * scale`,尺寸值应等于 `width * scale` 和 `height * scale`。 + +**Validates: Requirements 14.4** + +### Property 20: calcGridCellDimensions 计算正确性 + +*对于任意* 有效的 containerWidth、cols、margin 和 rowHeight(其中 containerWidth 足够容纳至少一列加两侧 margin),`calcGridCellDimensions` 返回的 `cellWidth` 应等于 `(containerWidth - margin[0] * (cols + 1)) / cols`,`cellHeight` 应等于 `rowHeight`,`marginX` 应等于 `margin[0]`,`marginY` 应等于 `margin[1]`,且 `cellWidth` 为正数。 + +**Validates: Requirements 15.3, 15.4, 15.6** + +## 错误处理 + +### 输入验证 + +| 场景 | 处理方式 | +|---|---| +| `compactor` 属性不符合 Compactor 接口 | TypeScript 编译期报错;运行时不做额外检查(信任类型系统) | +| `resizeHandles` 包含无效方向字符串 | TypeScript 编译期报错;运行时忽略无效值 | +| `calcGridCellDimensions` 的 cols ≤ 0 | 返回 `cellWidth: 0`,不抛异常 | +| `calcGridCellDimensions` 的 containerWidth 不足 | 返回计算值(可能为负数),由调用方判断 | +| `useContainerWidth` 传入 null 元素 | 返回 `width: -1`,不抛异常 | +| `useGridLayout.moveItem` 的 id 不存在 | 静默忽略,不修改布局 | +| `useGridLayout.removeItem` 的 id 不存在 | 静默忽略,不修改布局 | +| `validateLayout` 检测到无效布局 | 抛出 Error(保持现有行为) | + +### 外部拖入错误处理 + +| 场景 | 处理方式 | +|---|---| +| `isDroppable=false` 时收到 drag 事件 | 忽略,不触发任何自定义事件 | +| `dropItem` 未设置但 `isDroppable=true` | 使用默认尺寸 `{ w: 1, h: 1 }` | +| dragover 事件的坐标超出网格范围 | 将坐标 clamp 到有效范围 `[0, cols-w]` 和 `[0, maxRows-h]` | + +### 向后兼容保护 + +- 本次为 Major 版本升级(v2.0.0),以下 props 被移除:`verticalCompact`、`useCssTransforms`、`transformScale` +- 移除的 props 由 `compactor` 和 `positionStrategy` 替代,默认值保持相同行为(`verticalCompactor` + `transformStrategy`) +- 扁平 props(如 `colNum`、`isDraggable`)继续保留,同时新增分组 config 对象作为替代方案 + +## 测试策略 + +### 测试框架 + +- **单元测试**:Vitest(`vitest run`,非 watch 模式) +- **组件测试**:@vue/test-utils + happy-dom +- **测试环境**:核心算法测试使用 `@vitest-environment node`,组件测试使用 `happy-dom` + +### 测试方法 + +使用固定示例和边界条件的单元测试验证正确性属性: + +- 具体示例和边界条件(空布局、单元素布局、全静态元素等) +- 多组固定布局验证压缩器等价性(fast vs standard) +- 组件集成点(GridLayout 传递 compactor/positionStrategy 到子项) +- 错误条件(无效输入、id 不存在) +- 外部拖入的 DOM 事件模拟 + +### 测试文件规划 + +| 文件 | 内容 | +|---|---| +| `tests/compactors.spec.ts` | Compactor 接口实现测试(垂直/水平/无压缩/重叠/快速) | +| `tests/position-strategies.spec.ts` | PositionStrategy 接口实现测试 | +| `tests/core-utils.spec.ts` | calcGridCellDimensions 和 bottom 测试 | +| `tests/composables.spec.ts` | useGridLayout / useResponsiveLayout 测试 | +| `tests/grid-item-handles.spec.tsx` | 多方向缩放手柄渲染测试 | +| `tests/drag-threshold.spec.tsx` | 拖拽阈值行为测试 | +| `tests/config-merge.spec.tsx` | Config 分组合并优先级测试 | +| `tests/grid-background.spec.tsx` | GridBackground 组件渲染测试 | +| `tests/drop-zone.spec.tsx` | 外部拖入集成测试 | diff --git a/.kiro/specs/rgl-v2-feature-parity/requirements.md b/.kiro/specs/rgl-v2-feature-parity/requirements.md new file mode 100644 index 0000000..57033c8 --- /dev/null +++ b/.kiro/specs/rgl-v2-feature-parity/requirements.md @@ -0,0 +1,218 @@ +# 需求文档 + +## 简介 + +本文档定义 grid-layout-plus v2.0.0 补齐 react-grid-layout v2 所有缺失功能的需求。变更分为四个阶段:核心算法层、组件功能增强、Composable API(headless 模式)、Extras。本次为 Major 版本升级,允许 breaking changes:废弃的 props(`verticalCompact`、`useCssTransforms`、`transformScale`)将被移除,由新的可插拔接口替代。 + +## 术语表 + +- **GridLayout**:网格布局容器组件(``),负责管理布局状态、压缩、碰撞检测 +- **GridItem**:网格布局子项组件(``),负责拖拽、缩放、定位 +- **Layout**:布局数组,类型为 `LayoutItem[]`,描述所有子项的位置与尺寸 +- **LayoutItem**:单个布局项,包含 `{ i, x, y, w, h, static?, ... }` 属性 +- **Compactor**:压缩器接口,定义 `compact(layout): Layout` 方法,负责消除布局中的空隙 +- **VerticalCompactor**:垂直压缩器,将元素向上移动以消除垂直空隙(现有行为) +- **HorizontalCompactor**:水平压缩器,将元素向左移动以消除水平空隙(新增) +- **NoCompactor**:无压缩器,不执行任何压缩操作(新增) +- **AllowOverlap**:允许重叠模式,Compactor 的属性,启用后元素可以重叠放置 +- **ResizeHandle**:缩放手柄方向,取值为 `'s' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne'` +- **DropZone**:拖放区域,GridLayout 作为外部拖入目标时的行为区域 +- **DragThreshold**:拖拽阈值,鼠标移动超过该像素距离后才触发拖拽 +- **Composable**:Vue 3 组合式函数(`use*`),提供可复用的响应式逻辑 +- **GridBackground**:SVG 网格背景组件,可视化显示网格线 +- **PositionStrategy**:定位策略接口,定义元素在 DOM 中的定位方式(transform/absolute/scaled/自定义) +- **FastCompactor**:高性能压缩器,使用 O(n log n) 算法替代默认 O(n²) 实现 +- **CoreAPI**:核心算法独立导出模块(`src/core.ts`),不依赖 Vue 的纯函数集合 +- **GridCellDimensions**:网格单元格尺寸,包含单元格宽度和高度的计算结果 + +## 需求 + +### 需求 1:水平压缩算法 + +**用户故事:** 作为开发者,我希望布局支持水平方向的压缩,以便元素能自动向左靠拢消除水平空隙。 + +#### 验收标准 + +1. WHEN GridLayout 的 `compactType` 属性设置为 `'horizontal'` 时,THE Compactor SHALL 将所有非静态 LayoutItem 向左移动至无碰撞的最小 x 坐标位置 +2. WHILE 水平压缩执行期间,THE Compactor SHALL 保持每个 LayoutItem 的 y 坐标不变 +3. WHILE 水平压缩执行期间,THE Compactor SHALL 跳过 `static` 属性为 `true` 的 LayoutItem,并将静态元素作为碰撞障碍物处理 +4. WHEN 两个非静态 LayoutItem 在水平压缩后占据重叠区域时,THE Compactor SHALL 将后处理的元素放置在先处理元素的右侧(`x = 先处理元素.x + 先处理元素.w`) +5. THE Compactor SHALL 按列优先顺序(先 x 后 y)排序 LayoutItem 后再执行水平压缩 + +### 需求 2:可插拔 Compactor 接口 + +**用户故事:** 作为开发者,我希望压缩逻辑可插拔,以便我能选择不同的压缩策略或实现自定义压缩器。 + +#### 验收标准 + +1. THE CoreAPI SHALL 导出 `Compactor` 接口,该接口定义 `compact(layout: Layout, cols: number): Layout` 方法签名 +2. THE CoreAPI SHALL 导出 `verticalCompactor` 实现,其行为与当前 `compact()` 函数在 `verticalCompact=true` 时的行为一致 +3. THE CoreAPI SHALL 导出 `horizontalCompactor` 实现,其行为符合需求 1 的水平压缩规则 +4. THE CoreAPI SHALL 导出 `noCompactor` 实现,该实现返回输入布局的浅拷贝且不移动任何元素 +5. WHEN GridLayout 的 `compactor` 属性被设置为自定义 Compactor 实例时,THE GridLayout SHALL 使用该自定义实例替代默认压缩器 +6. WHEN GridLayout 未设置 `compactor` 属性时,THE GridLayout SHALL 使用 `verticalCompactor` 作为默认压缩器(旧 `verticalCompact` prop 已移除) + +### 需求 3:允许重叠模式 + +**用户故事:** 作为开发者,我希望布局支持元素重叠放置,以便实现自由定位的仪表盘场景。 + +#### 验收标准 + +1. WHEN Compactor 的 `allowOverlap` 选项设置为 `true` 时,THE Compactor SHALL 跳过碰撞检测,允许 LayoutItem 之间存在重叠 +2. WHEN `allowOverlap` 为 `true` 且用户拖拽 LayoutItem 时,THE GridLayout SHALL 将该元素放置在用户释放的精确网格坐标,不执行碰撞推移 +3. WHEN `allowOverlap` 为 `true` 时,THE GridLayout SHALL 仍然正确计算容器高度(基于所有 LayoutItem 的最大 `y + h` 值) +4. WHEN `allowOverlap` 未设置或设置为 `false` 时,THE Compactor SHALL 保持现有碰撞检测与推移行为不变 + +### 需求 4:核心算法独立导出 + +**用户故事:** 作为开发者,我希望核心布局算法可以独立于 Vue 使用,以便在 Node.js 脚本或其他框架中复用。 + +#### 验收标准 + +1. THE CoreAPI SHALL 通过 `src/core.ts` 入口文件导出所有纯函数,包括:`compact`、`moveElement`、`correctBounds`、`getAllCollisions`、`getFirstCollision`、`collides`、`validateLayout`、`bottom`、`cloneLayout`、`sortLayoutItemsByRowCol` +2. THE CoreAPI SHALL 导出所有 Compactor 相关类型和实现(`Compactor` 接口、`verticalCompactor`、`horizontalCompactor`、`noCompactor`) +3. THE CoreAPI 中的所有函数 SHALL 不依赖 Vue 运行时(不导入 `vue` 包) +4. THE CoreAPI 中的所有函数 SHALL 不依赖浏览器 DOM API +5. WHEN 用户通过 `import { compact } from 'grid-layout-plus/core'` 导入时,THE 构建系统 SHALL 正确解析到 `src/core.ts` 的编译产物 +6. THE 公共入口 `src/index.ts` SHALL 重新导出 CoreAPI 的所有公共符号,以保持向后兼容 + +### 需求 5:多方向缩放手柄 + +**用户故事:** 作为开发者,我希望 GridItem 支持 8 个方向的缩放手柄,以便用户可以从任意边或角缩放元素。 + +#### 验收标准 + +1. THE GridItem SHALL 支持 `resizeHandles` 属性,类型为 `ResizeHandle[]`,默认值为 `['se']`(保持向后兼容) +2. WHEN `resizeHandles` 包含 `'s'`、`'w'`、`'e'`、`'n'`、`'sw'`、`'nw'`、`'se'`、`'ne'` 中的任意值时,THE GridItem SHALL 在对应方向渲染缩放手柄 DOM 元素 +3. WHEN 用户通过非默认方向(如 `'n'`、`'w'`、`'nw'`)的手柄缩放时,THE GridItem SHALL 同时更新元素的位置(x 和/或 y)和尺寸(w 和/或 h),使缩放视觉效果正确 +4. THE GridLayout SHALL 支持 `resizeHandles` 属性作为所有子项的默认值,单个 GridItem 的 `resizeHandles` 属性优先级高于 GridLayout 的设置 +5. WHEN `resizeHandles` 包含多个方向时,THE GridItem SHALL 为每个方向渲染独立的手柄元素,每个手柄具有方向特定的 CSS 类名和光标样式 +6. THE `src/style.scss` SHALL 为所有 8 个方向的缩放手柄定义正确的定位(position)、光标(cursor)和视觉样式 + +### 需求 6:从外部拖入 + +**用户故事:** 作为开发者,我希望支持从 GridLayout 外部拖入元素,以便实现工具栏拖拽添加组件的交互。 + +#### 验收标准 + +1. WHEN 外部可拖拽元素被拖入 GridLayout 区域时,THE GridLayout SHALL 触发 `drop-drag-over` 事件,事件参数包含拖拽位置对应的网格坐标 `{ x, y }` 和原生 DragEvent +2. WHEN 外部可拖拽元素在 GridLayout 区域内移动时,THE GridLayout SHALL 显示占位符(placeholder)预览元素将要放置的位置 +3. WHEN 外部可拖拽元素在 GridLayout 区域内释放时,THE GridLayout SHALL 触发 `drop` 事件,事件参数包含最终网格坐标 `{ x, y, w, h }` 和原生 DragEvent +4. WHEN 外部可拖拽元素离开 GridLayout 区域时,THE GridLayout SHALL 触发 `drop-drag-leave` 事件并移除占位符 +5. THE GridLayout SHALL 支持 `isDroppable` 属性(默认 `false`),仅当该属性为 `true` 时启用外部拖入功能 +6. WHEN `isDroppable` 为 `true` 时,THE GridLayout SHALL 支持 `dropItem` 属性,用于指定拖入元素的默认尺寸 `{ w, h }` + +### 需求 7:拖拽阈值 + +**用户故事:** 作为开发者,我希望能配置拖拽触发的最小移动距离,以便区分点击和拖拽操作,避免误触发。 + +#### 验收标准 + +1. THE GridLayout SHALL 支持 `dragThreshold` 属性,类型为 `number`,单位为像素,默认值为 `0`(保持向后兼容) +2. WHEN `dragThreshold` 大于 0 时,THE GridItem SHALL 在鼠标/触摸移动距离超过 `dragThreshold` 像素后才触发 dragstart 事件 +3. WHEN 鼠标/触摸移动距离未超过 `dragThreshold` 时,THE GridItem SHALL 不触发任何拖拽相关事件,mouseup/touchend 后视为普通点击 +4. THE GridItem SHALL 支持独立的 `dragThreshold` 属性,其优先级高于 GridLayout 的全局设置 + +### 需求 8:Composable Config 接口 + +**用户故事:** 作为开发者,我希望将扁平的 props 分组为语义化的配置对象,以便代码更清晰且易于维护。 + +#### 验收标准 + +1. THE GridLayout SHALL 支持 `gridConfig` 属性,包含 `colNum`、`rowHeight`、`maxRows`、`margin`、`autoSize` 等网格配置 +2. THE GridLayout SHALL 支持 `dragConfig` 属性,包含 `isDraggable`、`dragThreshold`、`restoreOnDrag` 等拖拽配置 +3. THE GridLayout SHALL 支持 `resizeConfig` 属性,包含 `isResizable`、`resizeHandles` 等缩放配置 +4. THE GridLayout SHALL 支持 `dropConfig` 属性,包含 `isDroppable`、`dropItem` 等拖放配置 +5. WHEN 同一配置项同时通过分组属性和扁平属性传入时,THE GridLayout SHALL 以扁平属性的值为准(扁平属性优先级更高) +6. WHEN 仅通过扁平属性传入配置时,THE GridLayout SHALL 保持与 v1.1.1 完全一致的行为(向后兼容) + +### 需求 9:useContainerWidth Composable + +**用户故事:** 作为开发者,我希望有一个独立的容器宽度测量 composable,以便在 headless 模式下复用响应式宽度逻辑。 + +#### 验收标准 + +1. THE `useContainerWidth` Composable SHALL 接受一个 `Ref` 参数,返回响应式的 `width: Ref` +2. WHEN 传入的 DOM 元素尺寸发生变化时,THE `useContainerWidth` SHALL 在 16ms 内更新 `width` 值(使用 ResizeObserver) +3. WHEN 传入的 DOM 元素为 `null` 时,THE `useContainerWidth` SHALL 返回 `width` 值为 `-1` +4. WHEN 组件卸载时,THE `useContainerWidth` SHALL 自动清理 ResizeObserver 监听,不产生内存泄漏 +5. THE `useContainerWidth` SHALL 不依赖 GridLayout 或 GridItem 组件,可独立使用 + +### 需求 10:useGridLayout Composable + +**用户故事:** 作为开发者,我希望有一个核心布局状态管理 composable,以便在不使用 GridLayout 组件的情况下管理布局逻辑。 + +#### 验收标准 + +1. THE `useGridLayout` Composable SHALL 接受配置参数(`layout`、`cols`、`rowHeight`、`compactor` 等),返回响应式的布局状态和操作方法 +2. THE `useGridLayout` SHALL 返回 `currentLayout: Ref`,表示经过压缩后的当前布局 +3. THE `useGridLayout` SHALL 返回 `moveItem(i, x, y): void` 方法,用于移动指定元素并触发重新压缩 +4. THE `useGridLayout` SHALL 返回 `resizeItem(i, w, h): void` 方法,用于缩放指定元素并触发重新压缩 +5. THE `useGridLayout` SHALL 返回 `addItem(item): void` 和 `removeItem(i): void` 方法,用于动态增删元素 +6. WHEN 输入的 `layout` 引用发生变化时,THE `useGridLayout` SHALL 自动重新执行压缩并更新 `currentLayout` +7. THE `useGridLayout` SHALL 不依赖浏览器 DOM API,可在 SSR 环境中使用 + +### 需求 11:useResponsiveLayout Composable + +**用户故事:** 作为开发者,我希望有一个响应式断点管理 composable,以便在 headless 模式下复用断点切换逻辑。 + +#### 验收标准 + +1. THE `useResponsiveLayout` Composable SHALL 接受 `breakpoints`、`cols`、`width` 和 `layouts` 参数 +2. THE `useResponsiveLayout` SHALL 返回 `currentBreakpoint: Ref`,表示当前激活的断点 +3. THE `useResponsiveLayout` SHALL 返回 `currentCols: Ref`,表示当前断点对应的列数 +4. THE `useResponsiveLayout` SHALL 返回 `currentLayout: Ref`,表示当前断点对应的布局 +5. WHEN `width` 值变化导致断点切换时,THE `useResponsiveLayout` SHALL 自动查找或生成新断点的布局 +6. WHEN 断点切换发生时,THE `useResponsiveLayout` SHALL 自动保存切换前的布局到 `layouts` 缓存中 +7. THE `useResponsiveLayout` SHALL 不依赖浏览器 DOM API,可在 SSR 环境中使用 + +### 需求 12:GridBackground 组件 + +**用户故事:** 作为开发者,我希望有一个网格背景组件,以便在设计模式下可视化显示网格线辅助布局。 + +#### 验收标准 + +1. THE GridBackground 组件 SHALL 根据 `cols`、`rowHeight`、`margin` 和容器宽度渲染 SVG 网格线 +2. WHEN GridLayout 的尺寸或配置发生变化时,THE GridBackground SHALL 自动重新渲染以匹配新的网格参数 +3. THE GridBackground SHALL 支持 `color` 属性(默认 `'rgba(0,0,0,0.1)'`)用于自定义网格线颜色 +4. THE GridBackground SHALL 支持 `strokeWidth` 属性(默认 `1`)用于自定义网格线宽度 +5. THE GridBackground SHALL 使用 SVG `` 元素实现,确保大量网格线时的渲染性能 +6. THE GridBackground 的所有样式 SHALL 定义在 `src/style.scss` 中 + +### 需求 13:Fast Compactors + +**用户故事:** 作为开发者,我希望有高性能的压缩算法实现,以便在大量元素(100+)场景下保持流畅。 + +#### 验收标准 + +1. THE CoreAPI SHALL 导出 `fastVerticalCompactor` 实现,使用 O(n log n) 时间复杂度的算法 +2. THE CoreAPI SHALL 导出 `fastHorizontalCompactor` 实现,使用 O(n log n) 时间复杂度的算法 +3. FOR ALL 有效的 Layout 输入,THE `fastVerticalCompactor` SHALL 产生与 `verticalCompactor` 完全一致的输出结果 +4. FOR ALL 有效的 Layout 输入,THE `fastHorizontalCompactor` SHALL 产生与 `horizontalCompactor` 完全一致的输出结果 +5. WHEN Layout 包含 100 个以上 LayoutItem 时,THE FastCompactor SHALL 比标准 Compactor 具有可测量的性能优势 + +### 需求 14:可插拔 PositionStrategy + +**用户故事:** 作为开发者,我希望元素的 DOM 定位方式可插拔,以便在不同场景下选择最优的定位策略或实现自定义定位。 + +#### 验收标准 + +1. THE CoreAPI SHALL 导出 `PositionStrategy` 接口,该接口定义 `getStyle(top: number, left: number, width: number, height: number): Record` 方法签名和 `getRtlStyle(top: number, right: number, width: number, height: number): Record` 方法签名 +2. THE CoreAPI SHALL 导出 `transformStrategy` 实现,其行为与当前 `setTransform` / `setTransformRtl` 函数一致(使用 CSS transform translate3d 定位) +3. THE CoreAPI SHALL 导出 `absoluteStrategy` 实现,其行为与当前 `setTopLeft` / `setTopRight` 函数一致(使用 CSS top/left/right 定位) +4. THE CoreAPI SHALL 导出 `scaledStrategy` 工厂函数,接受 `scale: number` 参数,返回一个将坐标按比例缩放后应用 transform 定位的 PositionStrategy 实例 +5. WHEN GridLayout 的 `positionStrategy` 属性被设置为自定义 PositionStrategy 实例时,THE GridItem SHALL 使用该策略生成定位样式 +6. WHEN GridLayout 未设置 `positionStrategy` 属性时,THE GridItem SHALL 使用 `transformStrategy` 作为默认定位策略(旧 `useCssTransforms` 和 `transformScale` props 已移除) + +### 需求 15:calcGridCellDimensions 工具函数 + +**用户故事:** 作为开发者,我希望有一个工具函数来计算网格单元格的精确尺寸,以便在自定义渲染或外部集成时获取网格几何信息。 + +#### 验收标准 + +1. THE CoreAPI SHALL 导出 `calcGridCellDimensions` 函数,接受参数 `{ containerWidth: number, cols: number, margin: [number, number], rowHeight: number }` +2. THE `calcGridCellDimensions` SHALL 返回 `{ cellWidth: number, cellHeight: number, marginX: number, marginY: number }` 对象 +3. THE `cellWidth` 的计算公式 SHALL 为 `(containerWidth - margin[0] * (cols + 1)) / cols`,与 GridItem 内部的 `calcColWidth` 逻辑一致 +4. THE `cellHeight` SHALL 等于传入的 `rowHeight` 值 +5. THE `calcGridCellDimensions` SHALL 不依赖 Vue 运行时或浏览器 DOM API +6. FOR ALL 有效的输入参数,THE `calcGridCellDimensions` 返回的 `cellWidth` SHALL 为正数(当 `containerWidth` 足够容纳至少一列加两侧 margin 时) diff --git a/.kiro/specs/rgl-v2-feature-parity/tasks.md b/.kiro/specs/rgl-v2-feature-parity/tasks.md new file mode 100644 index 0000000..39aaf31 --- /dev/null +++ b/.kiro/specs/rgl-v2-feature-parity/tasks.md @@ -0,0 +1,243 @@ +# 实施计划:grid-layout-plus v2.0.0 功能补齐 + +## 概述 + +将设计文档中的四个阶段转化为可执行的编码任务。每个任务构建在前一个任务之上,最终将所有模块连接集成。使用 TypeScript strict mode,测试使用 Vitest。 + +## 任务 + +- [ ] 1. 阶段 1:核心算法层(基础设施) + - [x] 1.1 创建核心目录结构 + - 创建 `src/core/` 目录 + - 创建 `src/composables/` 目录 + - _需求: 4.1, 4.2_ + + - [x] 1.2 扩展类型定义 + - 在 `src/helpers/types.ts` 中新增 `ResizeHandle`、`Compactor`、`PositionStrategy`、`GridCellDimensions` 类型 + - 更新 `LayoutItem` 接口添加 `resizeHandles?: ResizeHandle[]` 字段 + - 更新 `LayoutInstance` 接口添加 `compactor`、`positionStrategy`、`resizeHandles`、`isDroppable`、`dropItem`、`dragThreshold` 字段 + - _需求: 2.1, 5.1, 14.1_ + + - [x] 1.3 实现 PositionStrategy 接口及内置策略 + - 创建 `src/core/position-strategies.ts` + - 实现 `transformStrategy`(等价于现有 `setTransform`/`setTransformRtl`) + - 实现 `absoluteStrategy`(等价于现有 `setTopLeft`/`setTopRight`) + - 实现 `scaledStrategy(scale)` 工厂函数 + - _需求: 14.1, 14.2, 14.3, 14.4_ + + - [x] 1.4 编写 PositionStrategy 单元测试 + - 创建 `tests/position-strategies.spec.ts` + - 测试 `transformStrategy` 输出与现有 `setTransform`/`setTransformRtl` 一致 + - 测试 `absoluteStrategy` 输出与现有 `setTopLeft`/`setTopRight` 一致 + - 测试 `scaledStrategy` 坐标和尺寸按比例缩放 + - _验证: 需求 14.2, 14.3, 14.4_ + + - [x] 1.5 实现 Compactor 接口及内置压缩器 + - 创建 `src/core/compactors.ts` + - 实现 `verticalCompactor`(委托给现有 `compact()` 逻辑) + - 实现 `horizontalCompactor`(按列优先排序后向左压缩) + - 实现 `noCompactor`(返回浅拷贝,不移动元素) + - 实现 `withOverlap(compactor)` 包装函数 + - _需求: 1.1-1.5, 2.1-2.4, 3.1, 3.4_ + + - [x] 1.6 编写 Compactor 单元测试 + - 创建 `tests/compactors.spec.ts` + - 测试 `verticalCompactor` 与现有 `compact(layout, true)` 输出一致 + - 测试 `horizontalCompactor`:元素向左压缩、y 不变、静态元素不动、碰撞处理 + - 测试 `noCompactor`:输出与输入位置相同、返回新数组引用 + - 测试 `withOverlap`:allowOverlap 时跳过碰撞推移 + - 边界用例:空布局、单元素、全静态元素、元素超出列数 + - _验证: 需求 1.1-1.4, 2.2, 2.4, 3.1_ + + - [x] 1.7 实现 Fast Compactors + - 在 `src/core/compactors.ts` 中实现 `fastVerticalCompactor`(O(n log n) 区间树加速) + - 实现 `fastHorizontalCompactor`(O(n log n) 区间树加速) + - _需求: 13.1, 13.2_ + + - [x] 1.8 编写 Fast Compactor 单元测试 + - 在 `tests/compactors.spec.ts` 中追加测试 + - 使用多组固定布局验证 `fastVerticalCompactor` 与 `verticalCompactor` 输出一致 + - 使用多组固定布局验证 `fastHorizontalCompactor` 与 `horizontalCompactor` 输出一致 + - 测试大布局(100+ 元素)场景下不报错 + - _验证: 需求 13.3, 13.4_ + + - [x] 1.9 实现 calcGridCellDimensions 工具函数 + - 创建 `src/core/utils.ts` + - 实现 `calcGridCellDimensions` 函数 + - _需求: 15.1-15.5_ + + - [x] 1.10 编写 calcGridCellDimensions 和 bottom 单元测试 + - 创建 `tests/core-utils.spec.ts` + - 测试 `calcGridCellDimensions` 计算公式正确性 + - 测试 `bottom()` 返回所有元素中 `max(y + h)` 的值,空布局返回 0 + - _验证: 需求 3.3, 15.3, 15.4, 15.6_ + + - [x] 1.11 创建核心聚合导出入口 + - 创建 `src/core.ts`,聚合导出 compactors、position-strategies、utils、helpers/common 中的纯函数和类型 + - 更新 `src/index.ts` 重新导出 CoreAPI 的所有公共符号 + - _需求: 4.1-4.6_ + + - [x] 1.12 配置构建系统支持 core 入口 + - 在 `vite.config.ts` 的 `rollupOptions.input` 中添加 `src/core.ts` 入口 + - 在 `package.json` 的 `exports` 中添加 `./core` 路径映射 + - _需求: 4.5_ + +- [x] 2. 阶段 1 检查点 + - 确保所有测试通过(`pnpm test`),如有问题请向用户确认。 + +- [x] 3. 阶段 2:组件功能增强 + - [x] 3.1 更新组件类型定义 + - 更新 `src/components/types.ts` 中的 `GridLayoutProps`:移除 `verticalCompact`、`useCssTransforms`、`transformScale`,新增 `compactor`、`positionStrategy`、`resizeHandles`、`isDroppable`、`dropItem`、`dragThreshold`、`gridConfig`、`dragConfig`、`resizeConfig`、`dropConfig` + - 更新 `GridItemProps`:新增 `resizeHandles`、`dragThreshold` + - 新增 `GridConfig`、`DragConfig`、`ResizeConfig`、`DropConfig` 接口 + - _需求: 2.5, 2.6, 5.1, 5.4, 6.5, 6.6, 7.1, 7.4, 8.1-8.4, 14.5, 14.6_ + + - [x] 3.2 重构 GridLayout 组件集成新接口 + - 修改 `src/components/grid-layout.vue`: + - 移除 `verticalCompact`、`useCssTransforms`、`transformScale` props + - 新增 `compactor`(默认 `verticalCompactor`)、`positionStrategy`(默认 `transformStrategy`)props + - 实现 Config 分组合并逻辑(扁平 props 优先) + - 将 `compact()` 调用替换为 `compactor.compact()` 委托 + - 通过 provide 向子组件传递 `positionStrategy`、`resizeHandles`、`dragThreshold` + - _需求: 2.5, 2.6, 8.1-8.6, 14.5, 14.6_ + + - [x] 3.3 编写 Config 合并单元测试 + - 创建 `tests/config-merge.spec.tsx` + - 测试扁平 props 优先级高于分组 config + - 测试仅传分组 config 时正确生效 + - 测试两者都不传时使用默认值 + - _验证: 需求 8.5, 8.6_ + + - [x] 3.4 实现多方向缩放手柄 + - 修改 `src/components/grid-item.vue`: + - 新增 `resizeHandles` prop(默认从 GridLayout inject,回退 `['se']`) + - 为每个方向渲染独立的手柄 `` 元素,带方向特定 CSS 类名 + - 更新 `tryMakeResizable` 以根据手柄方向配置 interactjs 的 `edges` + - 处理 n/w/nw 等方向缩放时同时更新位置和尺寸 + - 在 `src/style.scss` 中添加 8 个方向缩放手柄的定位、光标和视觉样式 + - _需求: 5.1-5.6_ + + - [x] 3.5 编写缩放手柄渲染测试 + - 创建 `tests/grid-item-handles.spec.tsx` + - 测试不同 `resizeHandles` 配置下渲染的手柄 DOM 数量和 CSS 类名 + - 测试默认 `['se']` 只渲染一个手柄 + - 测试空数组不渲染手柄 + - _验证: 需求 5.2, 5.5_ + + - [x] 3.6 集成 PositionStrategy 到 GridItem + - 修改 `src/components/grid-item.vue`: + - 从 GridLayout inject `positionStrategy` + - 将 `createStyle` 中的 `setTransform`/`setTopLeft` 等调用替换为 `positionStrategy.getStyle`/`positionStrategy.getRtlStyle` + - 移除 `state.useCssTransforms` 和 `state.transformScale` 相关逻辑 + - _需求: 14.5, 14.6_ + + - [x] 3.7 实现拖拽阈值功能 + - 修改 `src/components/grid-item.vue`: + - 新增 `dragThreshold` prop(从 GridLayout inject 默认值) + - 在 `tryMakeDraggable` 中记录 dragstart 位置 + - 在 dragmove 中计算移动距离,未超过阈值时抑制拖拽事件 + - _需求: 7.1-7.4_ + + - [x] 3.8 编写拖拽阈值测试 + - 创建 `tests/drag-threshold.spec.tsx` + - 测试阈值为 0 时立即触发拖拽 + - 测试阈值大于 0 时,移动距离不足不触发拖拽 + - 测试超过阈值后正常触发拖拽 + - _验证: 需求 7.2, 7.3_ + + - [x] 3.9 实现外部拖入功能 + - 修改 `src/components/grid-layout.vue`: + - 新增 `isDroppable`、`dropItem` props + - 在 template 中绑定 `dragover`、`drop`、`dragleave` 原生事件 + - 实现 `handleDragOver`:计算网格坐标、显示占位符、触发 `drop-drag-over` 事件 + - 实现 `handleDrop`:触发 `drop` 事件、移除占位符 + - 实现 `handleDragLeave`:触发 `drop-drag-leave` 事件、移除占位符 + - 坐标超出范围时 clamp 到有效范围 + - _需求: 6.1-6.6_ + + - [x] 3.10 编写外部拖入集成测试 + - 创建 `tests/drop-zone.spec.tsx` + - 测试 dragover/drop/dragleave 事件触发和占位符显示 + - 测试 `isDroppable=false` 时忽略事件 + - 测试坐标 clamp 到有效范围 + - _验证: 需求 6.1-6.6_ + +- [x] 4. 阶段 2 检查点 + - 确保所有测试通过(`pnpm test`),如有问题请向用户确认。 + +- [x] 5. 阶段 3:Composable API + - [x] 5.1 实现 useContainerWidth composable + - 创建 `src/composables/useContainerWidth.ts` + - 使用 ResizeObserver 监听容器宽度变化 + - 元素为 null 时返回 width: -1 + - 组件卸载时自动清理 + - _需求: 9.1-9.5_ + + - [x] 5.2 实现 useGridLayout composable + - 创建 `src/composables/useGridLayout.ts` + - 接受 layout、cols、rowHeight、compactor、preventCollision 参数 + - 返回 currentLayout、moveItem、resizeItem、addItem、removeItem + - 操作后自动重新压缩 + - _需求: 10.1-10.7_ + + - [x] 5.3 编写 useGridLayout 单元测试 + - 创建 `tests/composables.spec.ts` + - 测试初始化后 currentLayout 为压缩后的布局 + - 测试 moveItem 后布局正确更新并重新压缩 + - 测试 resizeItem 后布局正确更新并重新压缩 + - 测试 addItem/removeItem 后元素数量正确 + - 测试 id 不存在时静默忽略 + - _验证: 需求 10.2-10.5_ + + - [x] 5.4 实现 useResponsiveLayout composable + - 创建 `src/composables/useResponsiveLayout.ts` + - 接受 breakpoints、cols、width、layouts、compactor、originalLayout 参数 + - 返回 currentBreakpoint、currentCols、currentLayout + - 断点切换时自动保存/恢复布局缓存 + - _需求: 11.1-11.7_ + + - [x] 5.5 编写 useResponsiveLayout 单元测试 + - 在 `tests/composables.spec.ts` 中追加测试 + - 测试不同 width 值对应正确的断点和列数 + - 测试断点切换时布局自动生成 + - 测试切换回已缓存断点时恢复布局 + - _验证: 需求 11.2-11.6_ + + - [x] 5.6 更新公共导出 + - 在 `src/index.ts` 中导出 `useContainerWidth`、`useGridLayout`、`useResponsiveLayout` + - _需求: 4.6_ + +- [x] 6. 阶段 3 检查点 + - 确保所有测试通过(`pnpm test`),如有问题请向用户确认。 + +- [x] 7. 阶段 4:Extras + - [x] 7.1 实现 GridBackground 组件 + - 创建 `src/components/grid-background.vue` + - 使用 SVG `` 元素渲染网格线 + - 支持 `cols`、`rowHeight`、`margin`、`width`、`rows`、`color`、`strokeWidth` props + - 通过 inject 从父 GridLayout 获取配置或通过 props 独立使用 + - 在 `src/style.scss` 中添加 GridBackground 样式 + - _需求: 12.1-12.6_ + + - [x] 7.2 编写 GridBackground 单元测试 + - 创建 `tests/grid-background.spec.tsx` + - 测试渲染的 SVG 包含 `` 元素 + - 测试 color 和 strokeWidth props 正确应用 + - 测试不同 cols/rowHeight/margin 配置下 pattern 尺寸正确 + - _验证: 需求 12.1-12.5_ + + - [x] 7.3 导出 GridBackground 并更新入口 + - 在 `src/index.ts` 中导出 `GridBackground` 组件 + - _需求: 12.1_ + +- [x] 8. 最终检查点 + - 确保所有测试通过(`pnpm test`),如有问题请向用户确认。 + - 运行 `pnpm lint` 确保代码规范。 + - 运行 `pnpm build` 确保构建通过。 + +## 备注 + +- 每个任务引用了具体的需求编号以确保可追溯性 +- 检查点确保增量验证 +- 所有代码使用 TypeScript strict mode + `" + + // Step 2: 生成 template + template ← "" + + // Step 3: 生成 scoped style(复用标准样式模式) + style ← standardDemoStyles() + + RETURN script + template + style +END +``` + +## Example Usage + +### 水平压缩 Demo 示例 + +```vue + + + +``` + +### GridBackground Demo 示例 + +```vue + + + +``` + +### 原生拖放 Demo 示例 + +```vue + + + +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: New props documentation completeness + +*For any* new v2 GridLayout prop (`compactor`, `positionStrategy`, `resizeHandles`, `isDroppable`, `dropItem`, `dragThreshold`, `gridConfig`, `dragConfig`, `resizeConfig`, `dropConfig`), the `properties.md` file SHALL contain a dedicated section documenting that prop with its type, default value, and description. + +**Validates: Requirement 11.1** + +### Property 2: New events documentation completeness + +*For any* new v2 GridLayout event (`drop-drag-over`, `drop`, `drop-drag-leave`), the `events.md` file SHALL contain a dedicated section documenting that event with its TypeScript signature. + +**Validates: Requirements 12.1, 12.2** + +### Property 3: Deprecated props removed from usage examples + +*For any* code example block in `usage.md`, the block SHALL NOT contain the deprecated prop names `vertical-compact` or `use-css-transforms`, and SHALL use their v2 equivalents instead. + +**Validates: Requirement 14.1** + +### Property 4: English sidebar completeness + +*For any* new demo page, the English sidebar "Example" group in `config.ts` SHALL contain a link entry pointing to the corresponding `/example/{demo-name}` path. + +**Validates: Requirement 15.1** + +### Property 5: Chinese sidebar completeness + +*For any* new demo page, the Chinese sidebar "示例" group in `config.ts` SHALL contain a link entry pointing to the corresponding `/zh/example/{demo-name}` path. + +**Validates: Requirement 15.2** + +### Property 6: Bilingual example page correspondence + +*For any* new English Example_Page at `docs/example/{name}.md`, there SHALL exist a corresponding Chinese Example_Page at `docs/zh/example/{name}.md` that references the same `Demo{PascalName}` component and the same source code path. + +**Validates: Requirements 16.1, 16.3** + +### Property 7: Bilingual guide page correspondence + +*For any* new or updated section in an English Guide_Page, the corresponding Chinese Guide_Page SHALL contain a structurally equivalent section covering the same props, events, or API items. + +**Validates: Requirement 16.2** + +### Property 8: Demo naming validity and uniqueness + +*For any* new Demo_Component file in `docs/demos/`, the filename SHALL be kebab-case ending in `.vue`, and converting it to `Demo{PascalName}` SHALL produce a name that is unique among all registered demo components. + +**Validates: Requirements 17.1, 17.3** + +## Error Handling + +### Demo 运行时错误 + +**Condition**: 用户在 demo 中切换压缩器/定位策略时可能触发布局重排 +**Response**: 使用 `computed` 或 `ref` 确保响应式更新,避免直接替换 prop 引用 +**Recovery**: 布局数据使用 `reactive` 或 `ref` 包裹,确保 Vue 响应式系统正确追踪 + +### GridBackground 未注册错误 + +**Condition**: 如果 demo 中使用 `` 但未 import,VitePress 构建会报错 +**Response**: 在所有使用 GridBackground 的 demo 中显式 import +**Recovery**: VitePress 构建失败时检查 demo 中的 import 语句 + +## Testing Strategy + +### 手动验证 + +1. `pnpm dev` — 在 dev-server 中逐个访问新 demo,验证交互功能 +2. VitePress 本地预览 — 验证所有新页面渲染正确、源码展示正确 +3. 双语切换 — 验证中英文页面内容一致 + +### 构建验证 + +1. `pnpm build` — 确保库构建不受文档变更影响 +2. VitePress build — 确保文档站构建成功,无未解析的组件引用 + +## Dependencies + +- 无新增依赖。所有新功能的 API 已在 `grid-layout-plus` 包中导出。 +- `GridBackground` 组件已在 `src/index.ts` 中导出,VitePress 通过 alias 解析到 `src/`。 +- dev-server 通过 `import.meta.glob('../docs/demos/*.vue')` 自动发现新 demo 文件。 diff --git a/.kiro/specs/v2-docs-update/requirements.md b/.kiro/specs/v2-docs-update/requirements.md new file mode 100644 index 0000000..1a73805 --- /dev/null +++ b/.kiro/specs/v2-docs-update/requirements.md @@ -0,0 +1,194 @@ +# Requirements Document + +## Introduction + +grid-layout-plus v2.0.0 引入了多项新功能(可插拔压缩器、定位策略、多方向缩放手柄、原生拖放、GridBackground 组件、Composable API、配置分组等),需要全面更新 VitePress 文档站,包括新增 10 个 demo、更新属性/事件参考文档、更新安装/用法指南、更新侧边栏配置,并保持英文和中文双语一致。 + +## Glossary + +- **Documentation_Site**: 基于 VitePress 构建的 grid-layout-plus 文档站,位于 `docs/` 目录 +- **Demo_Component**: 位于 `docs/demos/` 下的 Vue SFC 文件,通过 `import.meta.glob` 自动注册为 `Demo{PascalName}` 全局组件 +- **Example_Page**: 位于 `docs/example/` (英文) 和 `docs/zh/example/` (中文) 下的 Markdown 页面,引用 Demo_Component 展示效果和源码 +- **Guide_Page**: 位于 `docs/guide/` (英文) 和 `docs/zh/guide/` (中文) 下的 Markdown 参考文档页面 +- **Sidebar_Config**: `docs/.vitepress/config.ts` 中定义的侧边栏导航配置 +- **GridLayout**: 栅格布局容器组件,已全局注册 +- **GridItem**: 栅格子元素组件,已全局注册 +- **GridBackground**: SVG 网格背景组件,未全局注册,需手动 import +- **Compactor**: 可插拔的布局压缩算法接口 +- **PositionStrategy**: 可插拔的元素定位策略接口 +- **ResizeHandle**: 缩放手柄方向类型,值为 `'s' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne'` + +## Requirements + +### Requirement 1: 水平压缩 Demo + +**User Story:** As a developer, I want to see a demo of horizontal compaction, so that I can understand how to use `horizontalCompactor` to compact items leftward. + +#### Acceptance Criteria + +1. WHEN the Documentation_Site builds, THE Demo_Component `horizontal-compact.vue` SHALL render a GridLayout with a toggle to switch between `verticalCompactor` and `horizontalCompactor` +2. WHEN a user selects `horizontalCompactor` in the demo, THE GridLayout SHALL compact layout items toward the left +3. THE Demo_Component `horizontal-compact.vue` SHALL import `horizontalCompactor` and `verticalCompactor` from `grid-layout-plus` in its ` + + + + diff --git a/docs/demos/composable-api.vue b/docs/demos/composable-api.vue new file mode 100644 index 0000000..cbc0f9e --- /dev/null +++ b/docs/demos/composable-api.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/docs/demos/config-grouping.vue b/docs/demos/config-grouping.vue new file mode 100644 index 0000000..84d4188 --- /dev/null +++ b/docs/demos/config-grouping.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/docs/demos/drag-threshold.vue b/docs/demos/drag-threshold.vue new file mode 100644 index 0000000..ed1a0e8 --- /dev/null +++ b/docs/demos/drag-threshold.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/docs/demos/grid-background.vue b/docs/demos/grid-background.vue new file mode 100644 index 0000000..1205aa2 --- /dev/null +++ b/docs/demos/grid-background.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/docs/demos/horizontal-compact.vue b/docs/demos/horizontal-compact.vue new file mode 100644 index 0000000..25a31ba --- /dev/null +++ b/docs/demos/horizontal-compact.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/docs/demos/native-drop.vue b/docs/demos/native-drop.vue new file mode 100644 index 0000000..ca4f994 --- /dev/null +++ b/docs/demos/native-drop.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/docs/demos/no-compact.vue b/docs/demos/no-compact.vue new file mode 100644 index 0000000..269919a --- /dev/null +++ b/docs/demos/no-compact.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/docs/demos/position-strategy.vue b/docs/demos/position-strategy.vue new file mode 100644 index 0000000..39f5040 --- /dev/null +++ b/docs/demos/position-strategy.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/docs/example/allow-overlap.md b/docs/example/allow-overlap.md new file mode 100644 index 0000000..efe66b9 --- /dev/null +++ b/docs/example/allow-overlap.md @@ -0,0 +1,11 @@ +# Allow Overlap + +## Effect + + + + + +## Source + +<<< @/demos/allow-overlap.vue diff --git a/docs/example/composable-api.md b/docs/example/composable-api.md new file mode 100644 index 0000000..e049b1f --- /dev/null +++ b/docs/example/composable-api.md @@ -0,0 +1,11 @@ +# Composable API + +## Effect + + + + + +## Source + +<<< @/demos/composable-api.vue diff --git a/docs/example/config-grouping.md b/docs/example/config-grouping.md new file mode 100644 index 0000000..e06b4d6 --- /dev/null +++ b/docs/example/config-grouping.md @@ -0,0 +1,11 @@ +# Config Grouping + +## Effect + + + + + +## Source + +<<< @/demos/config-grouping.vue diff --git a/docs/example/drag-threshold.md b/docs/example/drag-threshold.md new file mode 100644 index 0000000..2b8db15 --- /dev/null +++ b/docs/example/drag-threshold.md @@ -0,0 +1,11 @@ +# Drag Threshold + +## Effect + + + + + +## Source + +<<< @/demos/drag-threshold.vue diff --git a/docs/example/grid-background.md b/docs/example/grid-background.md new file mode 100644 index 0000000..3d16007 --- /dev/null +++ b/docs/example/grid-background.md @@ -0,0 +1,11 @@ +# Grid Background + +## Effect + + + + + +## Source + +<<< @/demos/grid-background.vue diff --git a/docs/example/horizontal-compact.md b/docs/example/horizontal-compact.md new file mode 100644 index 0000000..669bb28 --- /dev/null +++ b/docs/example/horizontal-compact.md @@ -0,0 +1,11 @@ +# Horizontal Compaction + +## Effect + + + + + +## Source + +<<< @/demos/horizontal-compact.vue diff --git a/docs/example/native-drop.md b/docs/example/native-drop.md new file mode 100644 index 0000000..888f1c7 --- /dev/null +++ b/docs/example/native-drop.md @@ -0,0 +1,11 @@ +# Native Drag & Drop + +## Effect + + + + + +## Source + +<<< @/demos/native-drop.vue diff --git a/docs/example/no-compact.md b/docs/example/no-compact.md new file mode 100644 index 0000000..bb523ce --- /dev/null +++ b/docs/example/no-compact.md @@ -0,0 +1,11 @@ +# No Compaction + +## Effect + + + + + +## Source + +<<< @/demos/no-compact.vue diff --git a/docs/example/position-strategy.md b/docs/example/position-strategy.md new file mode 100644 index 0000000..d2bdf67 --- /dev/null +++ b/docs/example/position-strategy.md @@ -0,0 +1,11 @@ +# Position Strategy + +## Effect + + + + + +## Source + +<<< @/demos/position-strategy.vue diff --git a/docs/guide/events.md b/docs/guide/events.md index 410b1ea..9c0b1a5 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -14,6 +14,9 @@ Working example [here](../example/events). @layout-ready="layoutReady" @layout-updated="layoutUpdated" @breakpoint-changed="breakpointChanged" + @drop-drag-over="dropDragOver" + @drop="handleDrop" + @drop-drag-leave="dropDragLeave" > ``` + +## Additional Imports + +`GridLayout` and `GridItem` are the main components. Other features need to be imported explicitly. + +> **Note:** `GridBackground` is NOT included in the default registration. You must import it manually. + +```ts +import { + // Components + GridBackground, + + // Compactors + verticalCompactor, + horizontalCompactor, + noCompactor, + withOverlap, + + // Position strategies + transformStrategy, + absoluteStrategy, + scaledStrategy, + + // Composables + useGridLayout, + useContainerWidth, + useResponsiveLayout, +} from 'grid-layout-plus' +``` diff --git a/docs/guide/properties.md b/docs/guide/properties.md index e6e074d..16266ef 100644 --- a/docs/guide/properties.md +++ b/docs/guide/properties.md @@ -53,6 +53,86 @@ type Breakpoints = Record type ResponsiveLayout = Record ``` +### Compactor + +The pluggable compaction algorithm interface. A compactor receives a layout and column count, and returns a new compacted layout. + +```ts +interface Compactor { + compact(layout: Layout, cols: number): Layout + allowOverlap?: boolean +} +``` + +Built-in compactors: + +| Compactor | Description | +| --- | --- | +| `verticalCompactor` | Compacts items upward (default, equivalent to v1 `verticalCompact: true`) | +| `horizontalCompactor` | Compacts items to the left | +| `noCompactor` | No compaction, free-form positioning | +| `fastVerticalCompactor` | Interval-tree accelerated vertical compaction, O(n log n) | +| `fastHorizontalCompactor` | Interval-tree accelerated horizontal compaction, O(n log n) | +| `withOverlap(compactor)` | Wraps any compactor to allow items to overlap | + +### PositionStrategy + +The pluggable positioning strategy interface. Controls how grid items are positioned in the DOM. + +```ts +interface PositionStrategy { + getStyle(top: number, left: number, width: number, height: number): Record + getRtlStyle(top: number, right: number, width: number, height: number): Record +} +``` + +Built-in strategies: + +| Strategy | Description | +| --- | --- | +| `transformStrategy` | Uses CSS `translate3d` for positioning (default) | +| `absoluteStrategy` | Uses CSS `top`/`left` for positioning | +| `scaledStrategy(scale)` | Applies a scaling factor to positions and sizes | + +### GridConfig + +```ts +interface GridConfig { + colNum?: number + rowHeight?: number + maxRows?: number + margin?: number[] + autoSize?: boolean +} +``` + +### DragConfig + +```ts +interface DragConfig { + isDraggable?: boolean + dragThreshold?: number + restoreOnDrag?: boolean +} +``` + +### ResizeConfig + +```ts +interface ResizeConfig { + isResizable?: boolean +} +``` + +### DropConfig + +```ts +interface DropConfig { + isDroppable?: boolean + dropItem?: { w: number; h: number } +} +``` + ## GridLayout ### layout @@ -144,6 +224,8 @@ Says if the container height should swells and contracts to fit contents. ### vertical-compact +> ⚠️ **Deprecated** — Use [`compactor`](#compactor) instead. Pass `verticalCompactor` (default) or `noCompactor` to control compaction behavior. + - type: `boolean` - default: `true` @@ -165,6 +247,8 @@ Says whether to prevent items collision. When `true`, the items can only be drop ### use-css-transforms +> ⚠️ **Deprecated** — Use [`positionStrategy`](#position-strategy) instead. Pass `transformStrategy` (default) or `absoluteStrategy`. + - type: `boolean` - default: `true` @@ -206,11 +290,120 @@ Says if set the cursor style dynamically. When dragging freezes, setting this va ### transform-scale +> ⚠️ **Deprecated** — Use [`positionStrategy`](#position-strategy) with `scaledStrategy(scale)` instead. + - type: `number` - default: `1` Sets a scaling factor to the size of the grid items, `1` means 100%. +### compactor + +- type: `Compactor` +- default: `verticalCompactor` + +Sets the compaction algorithm for the layout. Import a built-in compactor from `grid-layout-plus`: + +```ts +import { horizontalCompactor, noCompactor, verticalCompactor, withOverlap } from 'grid-layout-plus' +``` + +Use `withOverlap(compactor)` to allow items to overlap while still applying compaction. + +### position-strategy + +- type: `PositionStrategy` +- default: `transformStrategy` + +Sets the positioning strategy for grid items. Import a built-in strategy from `grid-layout-plus`: + +```ts +import { absoluteStrategy, scaledStrategy, transformStrategy } from 'grid-layout-plus' +``` + +Use `scaledStrategy(scale)` when the grid is rendered inside a scaled container. + +### is-droppable + +- type: `boolean` +- default: `false` + +Enables native HTML5 drag-and-drop into the grid from external elements. Use together with [`drop-item`](#drop-item) and the [drop events](./events#drop-drag-over). + +### drop-item + +- type: `{ w: number, h: number }` +- default: `{ w: 1, h: 1 }` + +Sets the default size (in grid units) for items dropped from outside the grid. Only effective when [`is-droppable`](#is-droppable) is `true`. + +### drag-threshold + +- type: `number` +- default: `0` + +Sets the minimum distance in pixels that the pointer must move before a drag operation starts. Useful for preventing accidental drags. Each item can override this via its own `drag-threshold` prop. + +### grid-config + +- type: `GridConfig` +- default: `undefined` + +A grouped configuration object for grid-related props. When provided, its values override the corresponding individual props (`col-num`, `row-height`, `max-rows`, `margin`, `auto-size`). + +```ts +interface GridConfig { + colNum?: number + rowHeight?: number + maxRows?: number + margin?: number[] + autoSize?: boolean +} +``` + +### drag-config + +- type: `DragConfig` +- default: `undefined` + +A grouped configuration object for drag-related props. When provided, its values override the corresponding individual props (`is-draggable`, `drag-threshold`, `restore-on-drag`). + +```ts +interface DragConfig { + isDraggable?: boolean + dragThreshold?: number + restoreOnDrag?: boolean +} +``` + +### resize-config + +- type: `ResizeConfig` +- default: `undefined` + +A grouped configuration object for resize-related props. When provided, its values override the corresponding individual props (`is-resizable`, `resize-handles`). + +```ts +interface ResizeConfig { + isResizable?: boolean + resizeHandles?: ResizeHandle[] +} +``` + +### drop-config + +- type: `DropConfig` +- default: `undefined` + +A grouped configuration object for drop-related props. When provided, its values override the corresponding individual props (`is-droppable`, `drop-item`). + +```ts +interface DropConfig { + isDroppable?: boolean + dropItem?: { w: number; h: number } +} +``` + ## GridItem ### i @@ -353,3 +546,12 @@ Passthrough object for the grid item [interact.js draggable configuration](https - default: `{}` Passthrough object for the grid item [interact.js resizable configuration](https://interactjs.io/docs/resizable/). + + + +### drag-threshold + +- type: `number` +- default: `null` + +Sets the minimum drag distance in pixels for this item. If `null`, inherits from the parent GridLayout's [`drag-threshold`](#drag-threshold) prop. diff --git a/docs/guide/usage.md b/docs/guide/usage.md index b0d9dbe..b3a1409 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -30,8 +30,6 @@ Using `item` slot is an easier way to define elements of each item, the properti :row-height="30" is-draggable is-resizable - vertical-compact - use-css-transforms > ``` + +## Compaction and Positioning + +In v2, the `vertical-compact` and `use-css-transforms` boolean props have been replaced by pluggable `compactor` and `positionStrategy` props. The defaults remain the same — vertical compaction with CSS transforms — so existing code works without changes. + +```vue + +``` + +See [Properties](./properties#compactor) for all available compactors and strategies. diff --git a/docs/zh/example/allow-overlap.md b/docs/zh/example/allow-overlap.md new file mode 100644 index 0000000..d9938f8 --- /dev/null +++ b/docs/zh/example/allow-overlap.md @@ -0,0 +1,11 @@ +# 允许重叠 + +## 效果 + + + + + +## 源码 + +<<< @/demos/allow-overlap.vue diff --git a/docs/zh/example/composable-api.md b/docs/zh/example/composable-api.md new file mode 100644 index 0000000..5fbafc6 --- /dev/null +++ b/docs/zh/example/composable-api.md @@ -0,0 +1,11 @@ +# 组合式 API + +## 效果 + + + + + +## 源码 + +<<< @/demos/composable-api.vue diff --git a/docs/zh/example/config-grouping.md b/docs/zh/example/config-grouping.md new file mode 100644 index 0000000..b64236a --- /dev/null +++ b/docs/zh/example/config-grouping.md @@ -0,0 +1,11 @@ +# 配置分组 + +## 效果 + + + + + +## 源码 + +<<< @/demos/config-grouping.vue diff --git a/docs/zh/example/drag-threshold.md b/docs/zh/example/drag-threshold.md new file mode 100644 index 0000000..dd50081 --- /dev/null +++ b/docs/zh/example/drag-threshold.md @@ -0,0 +1,11 @@ +# 拖拽阈值 + +## 效果 + + + + + +## 源码 + +<<< @/demos/drag-threshold.vue diff --git a/docs/zh/example/grid-background.md b/docs/zh/example/grid-background.md new file mode 100644 index 0000000..5eef994 --- /dev/null +++ b/docs/zh/example/grid-background.md @@ -0,0 +1,11 @@ +# 网格背景 + +## 效果 + + + + + +## 源码 + +<<< @/demos/grid-background.vue diff --git a/docs/zh/example/horizontal-compact.md b/docs/zh/example/horizontal-compact.md new file mode 100644 index 0000000..ac3fa5a --- /dev/null +++ b/docs/zh/example/horizontal-compact.md @@ -0,0 +1,11 @@ +# 水平压缩 + +## 效果 + + + + + +## 源码 + +<<< @/demos/horizontal-compact.vue diff --git a/docs/zh/example/native-drop.md b/docs/zh/example/native-drop.md new file mode 100644 index 0000000..295b0fb --- /dev/null +++ b/docs/zh/example/native-drop.md @@ -0,0 +1,11 @@ +# 原生拖放 + +## 效果 + + + + + +## 源码 + +<<< @/demos/native-drop.vue diff --git a/docs/zh/example/no-compact.md b/docs/zh/example/no-compact.md new file mode 100644 index 0000000..9f6fd5d --- /dev/null +++ b/docs/zh/example/no-compact.md @@ -0,0 +1,11 @@ +# 无压缩 + +## 效果 + + + + + +## 源码 + +<<< @/demos/no-compact.vue diff --git a/docs/zh/example/position-strategy.md b/docs/zh/example/position-strategy.md new file mode 100644 index 0000000..cd4c91c --- /dev/null +++ b/docs/zh/example/position-strategy.md @@ -0,0 +1,11 @@ +# 定位策略 + +## 效果 + + + + + +## 源码 + +<<< @/demos/position-strategy.vue diff --git a/docs/zh/guide/events.md b/docs/zh/guide/events.md index 79ab0eb..9a3e00f 100644 --- a/docs/zh/guide/events.md +++ b/docs/zh/guide/events.md @@ -14,6 +14,9 @@ @layout-ready="layoutReady" @layout-updated="layoutUpdated" @breakpoint-changed="breakpointChanged" + @drop-drag-over="dropDragOver" + @drop="handleDrop" + @drop-drag-leave="dropDragLeave" > ``` + +## 额外引入 + +`GridLayout` 和 `GridItem` 是主要组件。其他功能需要显式引入。 + +> **注意:** `GridBackground` 不包含在默认注册中,必须手动引入。 + +```ts +import { + // 组件 + GridBackground, + + // 压缩器 + verticalCompactor, + horizontalCompactor, + noCompactor, + withOverlap, + + // 定位策略 + transformStrategy, + absoluteStrategy, + scaledStrategy, + + // 组合式 API + useGridLayout, + useContainerWidth, + useResponsiveLayout, +} from 'grid-layout-plus' +``` diff --git a/docs/zh/guide/properties.md b/docs/zh/guide/properties.md index 728af85..d95e516 100644 --- a/docs/zh/guide/properties.md +++ b/docs/zh/guide/properties.md @@ -53,6 +53,86 @@ type Breakpoints = Record type ResponsiveLayout = Record ``` +### Compactor + +可插拔的布局压缩算法接口。压缩器接收布局和列数,返回压缩后的新布局。 + +```ts +interface Compactor { + compact(layout: Layout, cols: number): Layout + allowOverlap?: boolean +} +``` + +内置压缩器: + +| 压缩器 | 说明 | +| --- | --- | +| `verticalCompactor` | 向上压缩元素(默认,等价于 v1 的 `verticalCompact: true`) | +| `horizontalCompactor` | 向左压缩元素 | +| `noCompactor` | 无压缩,自由定位 | +| `fastVerticalCompactor` | 区间树加速的垂直压缩,O(n log n) | +| `fastHorizontalCompactor` | 区间树加速的水平压缩,O(n log n) | +| `withOverlap(compactor)` | 包装任意压缩器以允许元素重叠 | + +### PositionStrategy + +可插拔的定位策略接口。控制栅格元素在 DOM 中的定位方式。 + +```ts +interface PositionStrategy { + getStyle(top: number, left: number, width: number, height: number): Record + getRtlStyle(top: number, right: number, width: number, height: number): Record +} +``` + +内置策略: + +| 策略 | 说明 | +| --- | --- | +| `transformStrategy` | 使用 CSS `translate3d` 定位(默认) | +| `absoluteStrategy` | 使用 CSS `top`/`left` 定位 | +| `scaledStrategy(scale)` | 对位置和尺寸应用缩放因子 | + +### GridConfig + +```ts +interface GridConfig { + colNum?: number + rowHeight?: number + maxRows?: number + margin?: number[] + autoSize?: boolean +} +``` + +### DragConfig + +```ts +interface DragConfig { + isDraggable?: boolean + dragThreshold?: number + restoreOnDrag?: boolean +} +``` + +### ResizeConfig + +```ts +interface ResizeConfig { + isResizable?: boolean +} +``` + +### DropConfig + +```ts +interface DropConfig { + isDroppable?: boolean + dropItem?: { w: number; h: number } +} +``` + ## GridLayout ### layout @@ -71,7 +151,7 @@ type ResponsiveLayout = Record 如果 `responsive` 设置为 `true`,该配置将作为栅格中每个断点的初始布局。 -对象的键值是断点的名称,每个值则对应 `layout` 属性所定义的数组,例如:`{ lg: [layout items], md: [layout items] }`. +对象的键值是断点的名称,每个值则对应 `layout` 属性所定义的数组,例如:`{ lg: [layout items], md: [layout items] }`。 注意,在创建栅格布局后再设置该属性是无效的。 @@ -144,6 +224,8 @@ type ResponsiveLayout = Record ### vertical-compact +> ⚠️ **已废弃** — 请使用 [`compactor`](#compactor) 代替。传入 `verticalCompactor`(默认)或 `noCompactor` 来控制压缩行为。 + - 类型:`boolean` - 默认值:`true` @@ -161,10 +243,12 @@ type ResponsiveLayout = Record - 类型:`boolean` - 默认值:`false` -表示是否防止元素碰撞,值为 `ture` 时,元素只能拖放至空白处。 +表示是否防止元素碰撞,值为 `true` 时,元素只能拖放至空白处。 ### use-css-transforms +> ⚠️ **已废弃** — 请使用 [`positionStrategy`](#position-strategy) 代替。传入 `transformStrategy`(默认)或 `absoluteStrategy`。 + - 类型:`boolean` - 默认值:`true` @@ -206,11 +290,120 @@ type ResponsiveLayout = Record ### transform-scale +> ⚠️ **已废弃** — 请使用 [`positionStrategy`](#position-strategy) 配合 `scaledStrategy(scale)` 代替。 + - 类型:`number` - 默认值:`1` 为栅格元素的大小设置缩放比例,`1` 表示 100%。 +### compactor + +- 类型:`Compactor` +- 默认值:`verticalCompactor` + +设置布局的压缩算法。从 `grid-layout-plus` 导入内置压缩器: + +```ts +import { horizontalCompactor, noCompactor, verticalCompactor, withOverlap } from 'grid-layout-plus' +``` + +使用 `withOverlap(compactor)` 可以在应用压缩的同时允许元素重叠。 + +### position-strategy + +- 类型:`PositionStrategy` +- 默认值:`transformStrategy` + +设置栅格元素的定位策略。从 `grid-layout-plus` 导入内置策略: + +```ts +import { absoluteStrategy, scaledStrategy, transformStrategy } from 'grid-layout-plus' +``` + +当栅格在缩放容器中渲染时,使用 `scaledStrategy(scale)`。 + +### is-droppable + +- 类型:`boolean` +- 默认值:`false` + +启用从外部元素通过原生 HTML5 拖放到栅格中。需配合 [`drop-item`](#drop-item) 和 [拖放事件](./events#drop-drag-over) 使用。 + +### drop-item + +- 类型:`{ w: number, h: number }` +- 默认值:`{ w: 1, h: 1 }` + +设置从外部拖入栅格的元素的默认大小(栅格单位)。仅在 [`is-droppable`](#is-droppable) 为 `true` 时生效。 + +### drag-threshold + +- 类型:`number` +- 默认值:`0` + +设置指针在拖拽操作开始前必须移动的最小像素距离。用于防止意外拖拽。每个元素可以通过自身的 `drag-threshold` 属性覆盖此设置。 + +### grid-config + +- 类型:`GridConfig` +- 默认值:`undefined` + +栅格相关属性的分组配置对象。提供时,其值会覆盖对应的独立属性(`col-num`、`row-height`、`max-rows`、`margin`、`auto-size`)。 + +```ts +interface GridConfig { + colNum?: number + rowHeight?: number + maxRows?: number + margin?: number[] + autoSize?: boolean +} +``` + +### drag-config + +- 类型:`DragConfig` +- 默认值:`undefined` + +拖拽相关属性的分组配置对象。提供时,其值会覆盖对应的独立属性(`is-draggable`、`drag-threshold`、`restore-on-drag`)。 + +```ts +interface DragConfig { + isDraggable?: boolean + dragThreshold?: number + restoreOnDrag?: boolean +} +``` + +### resize-config + +- 类型:`ResizeConfig` +- 默认值:`undefined` + +缩放相关属性的分组配置对象。提供时,其值会覆盖对应的独立属性(`is-resizable`、`resize-handles`)。 + +```ts +interface ResizeConfig { + isResizable?: boolean + resizeHandles?: ResizeHandle[] +} +``` + +### drop-config + +- 类型:`DropConfig` +- 默认值:`undefined` + +拖放相关属性的分组配置对象。提供时,其值会覆盖对应的独立属性(`is-droppable`、`drop-item`)。 + +```ts +interface DropConfig { + isDroppable?: boolean + dropItem?: { w: number; h: number } +} +``` + ## GridItem ### i @@ -267,14 +460,14 @@ type ResponsiveLayout = Record - 类型:`number` - 默认值:`Infinity` -表示栅格元素的最大宽度。如果 `w` 大于 `min-w`,那 `w` 会被设置成 `min-w`。 +表示栅格元素的最大宽度。如果 `w` 大于 `max-w`,那 `w` 会被设置成 `max-w`。 ### max-h - 类型:`number` - 默认值:`Infinity` -表示栅格元素的最大高度。如果 `h` 大于 `min-h`,那 `h` 会被设置成 `min-h`。 +表示栅格元素的最大高度。如果 `h` 大于 `max-h`,那 `h` 会被设置成 `max-h`。 ### is-draggable @@ -352,4 +545,11 @@ type ResponsiveLayout = Record - 类型:`Record` - 默认值:`{}` -传递给 [interact.js 缩放配置](https://interactjs.io/docs/draggable/) 的对象。 +传递给 [interact.js 缩放配置](https://interactjs.io/docs/resizable/) 的对象。 + +### drag-threshold + +- 类型:`number` +- 默认值:`null` + +设置该元素的最小拖拽像素距离。如果为 `null`,则继承父容器 GridLayout 的 [`drag-threshold`](#drag-threshold) 属性。 diff --git a/docs/zh/guide/usage.md b/docs/zh/guide/usage.md index 97e117d..5d7ecd1 100644 --- a/docs/zh/guide/usage.md +++ b/docs/zh/guide/usage.md @@ -30,8 +30,6 @@ const layout = reactive([ :row-height="30" is-draggable is-resizable - vertical-compact - use-css-transforms > ``` + +## 压缩与定位 + +在 v2 中,`vertical-compact` 和 `use-css-transforms` 布尔属性已被可插拔的 `compactor` 和 `positionStrategy` 属性替代。默认行为保持不变——垂直压缩配合 CSS transforms——因此现有代码无需修改即可正常工作。 + +```vue + +``` + +详见 [属性](./properties#compactor) 了解所有可用的压缩器和定位策略。 diff --git a/package.json b/package.json index 2a08784..5821661 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "import": "./es/index.mjs", "require": "./lib/index.cjs" }, + "./core": { + "types": "./dist/core.d.ts", + "import": "./es/core.mjs", + "require": "./lib/core.js" + }, "./es": { "types": "./dist/index.d.ts", "import": "./es/index.mjs" diff --git a/src/components/grid-background.vue b/src/components/grid-background.vue new file mode 100644 index 0000000..77b0c7b --- /dev/null +++ b/src/components/grid-background.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/components/grid-item.vue b/src/components/grid-item.vue index 11fa88a..c35fbf0 100644 --- a/src/components/grid-item.vue +++ b/src/components/grid-item.vue @@ -17,10 +17,6 @@ import { isNull, nextTickOnce, throttle } from '@vexip-ui/utils' import { EMITTER_KEY, LAYOUT_KEY, - setTopLeft, - setTopRight, - setTransform, - setTransformRtl, useNameHelper, } from '../helpers/common' import { createCoreData, getControlPosition } from '../helpers/draggable' @@ -29,6 +25,7 @@ import { getDocumentDir } from '../helpers/dom' import interact from 'interactjs' +import type { PositionStrategy } from '../helpers/types' import type { GridItemProps } from './types' const props = withDefaults(defineProps(), { @@ -46,6 +43,7 @@ const props = withDefaults(defineProps(), { preserveAspectRatio: false, dragOption: () => ({}), resizeOption: () => ({}), + dragThreshold: undefined, }) const emit = defineEmits(['container-resized', 'resize', 'resized', 'move', 'moved']) @@ -70,8 +68,6 @@ const state = reactive({ draggable: undefined as boolean | undefined, resizable: undefined as boolean | undefined, bounded: undefined as boolean | undefined, - transformScale: 1, - useCssTransforms: true, useStyleCursor: true, isDragging: false, @@ -106,6 +102,10 @@ let innerY = props.y let innerW = props.w let innerH = props.h +// 拖拽阈值相关状态 +let dragStartPos: { x: number, y: number } | null = null +let dragThresholdExceeded = false + const wrapper = ref() const instance = reactive({ @@ -115,6 +115,23 @@ const instance = reactive({ calcXY, }) +/** 获取当前生效的拖拽阈值 */ +const effectiveDragThreshold = computed(() => { + return props.dragThreshold ?? layout.dragThreshold ?? 0 +}) + +/** 获取当前生效的定位策略 */ +const effectivePositionStrategy = computed(() => { + return layout.positionStrategy +}) + +/** 判断当前定位策略是否使用 CSS transforms */ +const useCssTransforms = computed(() => { + // 通过检查 getStyle 输出是否包含 transform 属性来判断 + const testStyle = effectivePositionStrategy.value.getStyle(0, 0, 100, 100) + return 'transform' in testStyle +}) + function updateWidthHandler(width: number) { updateWidth(width) } @@ -141,10 +158,6 @@ function setBoundedHandler(isBounded: boolean) { } } -function setTransformScaleHandler(transformScale: number) { - state.transformScale = transformScale -} - function setRowHeightHandler(rowHeight: number) { state.rowHeight = rowHeight } @@ -194,8 +207,6 @@ onMounted(() => { } else { state.bounded = props.isBounded } - state.transformScale = layout.transformScale - state.useCssTransforms = layout.useCssTransforms state.useStyleCursor = layout.useStyleCursor watchEffect(() => { @@ -211,7 +222,6 @@ onMounted(() => { emitter.on('setDraggable', setDraggableHandler) emitter.on('setResizable', setResizableHandler) emitter.on('setBounded', setBoundedHandler) - emitter.on('setTransformScale', setTransformScaleHandler) emitter.on('setRowHeight', setRowHeightHandler) emitter.on('setMaxRows', setMaxRowsHandler) emitter.on('directionchange', directionchangeHandler) @@ -224,7 +234,6 @@ onBeforeUnmount(() => { emitter.off('setDraggable', setDraggableHandler) emitter.off('setResizable', setResizableHandler) emitter.off('setBounded', setBoundedHandler) - emitter.off('setTransformScale', setTransformScaleHandler) emitter.off('setRowHeight', setRowHeightHandler) emitter.off('setMaxRows', setMaxRowsHandler) emitter.off('directionchange', directionchangeHandler) @@ -258,15 +267,11 @@ const className = computed(() => { [nh.bm('static')]: props.static, [nh.bm('resizing')]: state.isResizing, [nh.bm('dragging')]: state.isDragging, - [nh.bm('transform')]: state.useCssTransforms, + [nh.bm('transform')]: useCssTransforms.value, [nh.bm('rtl')]: renderRtl.value, [nh.bm('no-touch')]: isAndroid && draggableOrResizableAndNotStatic.value, } }) -const resizerClass = computed(() => { - // return renderRtl.value ? 'vue-resizable-handle vue-rtl-resizable-handle' : 'vue-resizable-handle' - return [nh.be('resizer'), renderRtl.value && nh.bem('resizer', 'rtl')].filter(Boolean) -}) watch( () => props.isDraggable, @@ -337,19 +342,32 @@ watch([() => layout.margin, () => layout.margin[0], () => layout.margin[1]], () }) function createStyle() { - if (props.x + props.w > state.cols) { - innerX = 0 - innerW = props.w > state.cols ? state.cols : props.w + let x: number, y: number, w: number, h: number + if (state.isResizing) { + x = innerX + y = innerY + w = innerW + h = innerH } else { - innerX = props.x - innerW = props.w + if (props.x + props.w > state.cols) { + x = 0 + w = props.w > state.cols ? state.cols : props.w + } else { + x = props.x + w = props.w + } + y = props.y + h = props.h + innerX = x + innerY = y + innerW = w + innerH = h } - const pos = calcPosition(innerX, innerY, innerW, innerH) + const pos = calcPosition(x, y, w, h) if (state.isDragging) { pos.top = state.dragging.top - // Add rtl support if (renderRtl.value) { pos.right = state.dragging.left } else { @@ -361,31 +379,18 @@ function createStyle() { pos.height = state.resizing.height } - let style - // CSS Transforms support (default) - if (state.useCssTransforms) { - // Add rtl support - if (renderRtl.value) { - style = setTransformRtl(pos.top, pos.right!, pos.width, pos.height) - } else { - style = setTransform(pos.top, pos.left!, pos.width, pos.height) - } + let style: Record + const strategy = effectivePositionStrategy.value + if (renderRtl.value) { + style = strategy.getRtlStyle(pos.top, pos.right!, pos.width, pos.height) } else { - // top,left (slow) - // Add rtl support - if (renderRtl.value) { - style = setTopRight(pos.top, pos.right!, pos.width, pos.height) - } else { - style = setTopLeft(pos.top, pos.left!, pos.width, pos.height) - } + style = strategy.getStyle(pos.top, pos.left!, pos.width, pos.height) } state.style = style } function emitContainerResized() { - // this.style has width and height with trailing 'px'. The - // resized event is without them const styleProps: Record = {} for (const prop of ['width', 'height']) { const val = state.style[prop] @@ -410,8 +415,7 @@ function handleResize(event: MouseEvent & { edges: any }) { } const position = getControlPosition(event) - // Get the current drag point from the event. This is used as the offset. - if (isNull(position)) return // not possible but satisfies flow + if (isNull(position)) return const { x, y } = position const newSize = { width: 0, height: 0 } @@ -429,23 +433,13 @@ function handleResize(event: MouseEvent & { edges: any }) { break } case 'resizemove': { - // A vertical resize ignores the horizontal delta - if (!event.edges.right && !event.edges.left) { - lastW = x - } - - // An horizontal resize ignores the vertical delta - if (!event.edges.top && !event.edges.bottom) { - lastH = y - } - const coreEvent = createCoreData(lastW, lastH, x, y) if (renderRtl.value) { - newSize.width = state.resizing.width - coreEvent.deltaX / state.transformScale + newSize.width = state.resizing.width - coreEvent.deltaX } else { - newSize.width = state.resizing.width + coreEvent.deltaX / state.transformScale + newSize.width = state.resizing.width + coreEvent.deltaX } - newSize.height = state.resizing.height + coreEvent.deltaY / state.transformScale + newSize.height = state.resizing.height + coreEvent.deltaY state.resizing = newSize break } @@ -453,7 +447,6 @@ function handleResize(event: MouseEvent & { edges: any }) { pos = calcPosition(innerX, innerY, innerW, innerH) newSize.width = pos.width newSize.height = pos.height - state.resizing = { width: -1, height: -1 } state.isResizing = false break @@ -485,6 +478,10 @@ function handleResize(event: MouseEvent & { edges: any }) { lastW = x lastH = y + if (state.isResizing) { + createStyle() + } + if (innerW !== pos.w || innerH !== pos.h) { emit('resize', props.i, pos.h, pos.w, newSize.height, newSize.width) } @@ -503,15 +500,12 @@ function handleDrag(event: MouseEvent) { } const position = getControlPosition(event) - - // Get the current drag point from the event. This is used as the offset. - if (isNull(position)) return // not possible but satisfies flow + if (isNull(position)) return const { x, y } = position const target = event.target as HTMLElement if (!target.offsetParent) return - // let shouldUpdate = false; const newPosition = { top: 0, left: 0 } switch (type) { case 'dragstart': { @@ -521,12 +515,12 @@ function handleDrag(event: MouseEvent) { const parentRect = target.offsetParent.getBoundingClientRect() const clientRect = target.getBoundingClientRect() - const cLeft = clientRect.left / state.transformScale - const pLeft = parentRect.left / state.transformScale - const cRight = clientRect.right / state.transformScale - const pRight = parentRect.right / state.transformScale - const cTop = clientRect.top / state.transformScale - const pTop = parentRect.top / state.transformScale + const cLeft = clientRect.left + const pLeft = parentRect.left + const cRight = clientRect.right + const pRight = parentRect.right + const cTop = clientRect.top + const pTop = parentRect.top if (renderRtl.value) { newPosition.left = (cRight - pRight) * -1 @@ -540,13 +534,12 @@ function handleDrag(event: MouseEvent) { } case 'dragmove': { const coreEvent = createCoreData(lastX, lastY, x, y) - // Add rtl support if (renderRtl.value) { - newPosition.left = state.dragging.left - coreEvent.deltaX / state.transformScale + newPosition.left = state.dragging.left - coreEvent.deltaX } else { - newPosition.left = state.dragging.left + coreEvent.deltaX / state.transformScale + newPosition.left = state.dragging.left + coreEvent.deltaX } - newPosition.top = state.dragging.top + coreEvent.deltaY / state.transformScale + newPosition.top = state.dragging.top + coreEvent.deltaY if (state.bounded) { const bottomBoundary = target.offsetParent.clientHeight - @@ -565,14 +558,13 @@ function handleDrag(event: MouseEvent) { const parentRect = target.offsetParent.getBoundingClientRect() const clientRect = target.getBoundingClientRect() - const cLeft = clientRect.left / state.transformScale - const pLeft = parentRect.left / state.transformScale - const cRight = clientRect.right / state.transformScale - const pRight = parentRect.right / state.transformScale - const cTop = clientRect.top / state.transformScale - const pTop = parentRect.top / state.transformScale + const cLeft = clientRect.left + const pLeft = parentRect.left + const cRight = clientRect.right + const pRight = parentRect.right + const cTop = clientRect.top + const pTop = parentRect.top - // Add rtl support if (renderRtl.value) { newPosition.left = (cRight - pRight) * -1 } else { @@ -585,7 +577,6 @@ function handleDrag(event: MouseEvent) { } } - // Get new XY let pos if (renderRtl.value) { pos = calcXY(newPosition.top, newPosition.left) @@ -607,8 +598,6 @@ function handleDrag(event: MouseEvent) { /** * Calculate the absolute left pixel position for a given grid column. - * Uses integer division to distribute rounding error evenly across columns, - * ensuring adjacent items share exact pixel boundaries with no gaps or overlaps. */ function calcGridColLeft(col: number) { const totalSpace = state.containerWidth - state.margin[0] * (state.cols + 1) @@ -617,8 +606,6 @@ function calcGridColLeft(col: number) { /** * Calculate the absolute top pixel position for a given grid row. - * Row height is fixed so no rounding distribution is needed, but we keep - * the same pattern for consistency. */ function calcGridRowTop(row: number) { return Math.round(state.rowHeight * row) + state.margin[1] * (row + 1) @@ -627,11 +614,9 @@ function calcGridRowTop(row: number) { function calcPosition(x: number, y: number, w: number, h: number) { const posLeft = calcGridColLeft(x) const posTop = calcGridRowTop(y) - // Width = distance between left edge of column (x+w) and left edge of column x, minus one margin const posWidth = w === Infinity ? w : calcGridColLeft(x + w) - posLeft - state.margin[0] const posHeight = h === Infinity ? h : calcGridRowTop(y + h) - posTop - state.margin[1] - // add rtl support let out if (renderRtl.value) { out = { @@ -652,24 +637,12 @@ function calcPosition(x: number, y: number, w: number, h: number) { return out } -/** - * Translate x and y coordinates from pixels to grid units. - * @param top Top position (relative to parent) in pixels. - * @param left Left position (relative to parent) in pixels. - * @return x and y in grid units. - */ -// TODO check if this function needs change in order to support rtl. function calcXY(top: number, left: number) { const totalSpace = state.containerWidth - state.margin[0] * (state.cols + 1) - // Reverse of calcGridColLeft: - // left = round(totalSpace * x / cols) + margin * (x + 1) - // left - margin = round(totalSpace * x / cols) + margin * x - // Approximate x then round: let x = Math.round((left - state.margin[0]) * state.cols / (totalSpace + state.margin[0] * state.cols)) let y = Math.round((top - state.margin[1]) / (state.rowHeight + state.margin[1])) - // Capping x = Math.max(Math.min(x, state.cols - innerW), 0) y = Math.max(Math.min(y, state.maxRows - innerH), 0) @@ -681,7 +654,6 @@ function calcColWidth() { } function calcGridItemWHPx(gridUnits: number, colOrRowSize: number, marginPx: number) { - // 0 * Infinity === NaN, which causes problems with resize constraints if (!Number.isFinite(gridUnits)) return gridUnits return Math.round(colOrRowSize * gridUnits + Math.max(0, gridUnits - 1) * marginPx) } @@ -690,19 +662,9 @@ function clamp(num: number, lowerBound: number, upperBound: number) { return Math.max(Math.min(num, upperBound), lowerBound) } -/** - * Given a height and width in pixel values, calculate grid units. - * @param height Height in pixels. - * @param width Width in pixels. - * @param autoSizeFlag function autoSize identifier. - * @return w, h as grid units. - */ function calcWH(height: number, width: number, autoSizeFlag = false) { const totalSpace = state.containerWidth - state.margin[0] * (state.cols + 1) - // Reverse of calcPosition width: - // width = calcGridColLeft(x + w) - calcGridColLeft(x) - margin - // Approximate w using average column width, then round let w = Math.round((width + state.margin[0]) * state.cols / (totalSpace + state.margin[0] * state.cols)) let h = 0 if (!autoSizeFlag) { @@ -711,7 +673,6 @@ function calcWH(height: number, width: number, autoSizeFlag = false) { h = Math.ceil((height + state.margin[1]) / (state.rowHeight + state.margin[1])) } - // Capping w = Math.max(Math.min(w, state.cols - innerX), 0) h = Math.max(Math.min(h, state.maxRows - innerY), 0) return { w, h } @@ -755,7 +716,36 @@ function tryMakeDraggable() { if (!dragEventSet) { dragEventSet = true interactObj.value.on('dragstart dragmove dragend', event => { - event.type === 'dragmove' ? throttleDrag(event) : handleDrag(event) + const threshold = effectiveDragThreshold.value + + if (event.type === 'dragstart') { + // 记录拖拽起始位置 + dragStartPos = { x: event.clientX, y: event.clientY } + dragThresholdExceeded = threshold <= 0 + if (dragThresholdExceeded) { + handleDrag(event) + } + } else if (event.type === 'dragmove') { + if (!dragThresholdExceeded && dragStartPos) { + const dx = event.clientX - dragStartPos.x + const dy = event.clientY - dragStartPos.y + const distance = Math.sqrt(dx * dx + dy * dy) + if (distance >= threshold) { + dragThresholdExceeded = true + // 触发 dragstart 以初始化拖拽状态 + handleDrag({ ...event, type: 'dragstart' }) + } + } + if (dragThresholdExceeded) { + throttleDrag(event) + } + } else if (event.type === 'dragend') { + if (dragThresholdExceeded) { + handleDrag(event) + } + dragStartPos = null + dragThresholdExceeded = false + } }) } } else { @@ -774,22 +764,23 @@ function tryMakeResizable() { const maximum = calcPosition(0, 0, props.maxW, props.maxH) const minimum = calcPosition(0, 0, props.minW, props.minH) + const resizerBase = `.${nh.be('resizer')}` const opts: Record = { edges: { - left: renderRtl.value ? `.${resizerClass.value[0]}` : false, - right: !renderRtl.value ? `.${resizerClass.value[0]}` : false, - bottom: `.${resizerClass.value[0]}`, top: false, + bottom: `${resizerBase}--se`, + left: false, + right: `${resizerBase}--se`, }, ignoreFrom: props.resizeIgnoreFrom, restrictSize: { min: { - height: minimum.height * state.transformScale, - width: minimum.width * state.transformScale, + height: minimum.height, + width: minimum.width, }, max: { - height: maximum.height * state.transformScale, - width: maximum.width * state.transformScale, + height: maximum.height, + width: maximum.width, }, }, ...props.resizeOption, @@ -815,6 +806,9 @@ function tryMakeResizable() { diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index f1f9e43..33bbbf2 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -1,5 +1,6 @@