From 9857e4d559a78d4886ea0d3c9ab92025636a2a43 Mon Sep 17 00:00:00 2001 From: qmhc Date: Wed, 6 May 2026 18:32:21 +0800 Subject: [PATCH 01/11] feat(core): add pluggable compactor, position strategy, composables and core subpackage export --- package.json | 5 + src/components/types.ts | 64 +++- src/composables/useContainerWidth.ts | 45 +++ src/composables/useGridLayout.ts | 92 ++++++ src/composables/useResponsiveLayout.ts | 101 +++++++ src/core.ts | 49 +++ src/core/compactors.ts | 400 +++++++++++++++++++++++++ src/core/position-strategies.ts | 61 ++++ src/core/utils.ts | 29 ++ src/helpers/responsive.ts | 1 - src/helpers/types.ts | 37 ++- src/index.ts | 11 + vite.config.ts | 2 +- 13 files changed, 885 insertions(+), 12 deletions(-) create mode 100644 src/composables/useContainerWidth.ts create mode 100644 src/composables/useGridLayout.ts create mode 100644 src/composables/useResponsiveLayout.ts create mode 100644 src/core.ts create mode 100644 src/core/compactors.ts create mode 100644 src/core/position-strategies.ts create mode 100644 src/core/utils.ts 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/types.ts b/src/components/types.ts index ee4ba27..d51bfa1 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -1,4 +1,35 @@ -import type { Breakpoints, Layout, ResponsiveLayout } from '../helpers/types' +import type { + Breakpoints, + Compactor, + Layout, + PositionStrategy, + ResizeHandle, + 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, + resizeHandles?: ResizeHandle[], +} + +export interface DropConfig { + isDroppable?: boolean, + dropItem?: { w: number, h: number }, +} export interface GridLayoutProps { autoSize?: boolean, @@ -10,17 +41,33 @@ 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, + /** 所有子项的默认缩放手柄方向 */ + resizeHandles?: ResizeHandle[], + /** 是否允许外部拖入 */ + isDroppable?: boolean, + /** 外部拖入元素的默认尺寸 */ + dropItem?: { w: number, h: number }, + /** 拖拽阈值(像素) */ + dragThreshold?: number, + + /** 分组配置对象 */ + gridConfig?: GridConfig, + dragConfig?: DragConfig, + resizeConfig?: ResizeConfig, + dropConfig?: DropConfig, } export interface GridItemProps { @@ -42,5 +89,10 @@ export interface GridItemProps { resizeIgnoreFrom?: string, preserveAspectRatio?: boolean, dragOption?: Record, - resizeOption?: Record + resizeOption?: Record, + + /** 缩放手柄方向(覆盖 GridLayout 的默认值) */ + resizeHandles?: ResizeHandle[], + /** 拖拽阈值(覆盖 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..6df6e1d --- /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 + : (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..d3a33e7 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -1,3 +1,27 @@ +export type ResizeHandle = 's' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne' + +/** 压缩器接口 */ +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 +38,8 @@ export interface LayoutItem extends LayoutItemRequired { moved?: boolean, static?: boolean, isDraggable?: boolean, - isResizable?: boolean + isResizable?: boolean, + resizeHandles?: ResizeHandle[], } export type Layout = Array @@ -35,11 +60,15 @@ export interface LayoutInstance { isDraggable: boolean, isResizable: boolean, isBounded: boolean, - transformScale: number, - useCssTransforms: boolean, useStyleCursor: boolean, maxRows: number, isMirrored: boolean, + compactor: Compactor, + positionStrategy: PositionStrategy, + resizeHandles: ResizeHandle[], + 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..61640bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,14 @@ export { default as GridItem } from './components/grid-item.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' 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: [ { From 6d852b6a2020170b75135c81117c6f0d728e19e1 Mon Sep 17 00:00:00 2001 From: qmhc Date: Wed, 6 May 2026 18:32:38 +0800 Subject: [PATCH 02/11] feat(components): add PositionStrategy, Compactor, ResizeHandles, DragThreshold and Droppable support --- src/components/grid-item.vue | 280 +++++++++++++++++++-------------- src/components/grid-layout.vue | 223 ++++++++++++++++++-------- src/style.scss | 139 ++++++++++++++-- 3 files changed, 453 insertions(+), 189 deletions(-) diff --git a/src/components/grid-item.vue b/src/components/grid-item.vue index 11fa88a..ff58641 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, ResizeHandle } from '../helpers/types' import type { GridItemProps } from './types' const props = withDefaults(defineProps(), { @@ -46,6 +43,8 @@ const props = withDefaults(defineProps(), { preserveAspectRatio: false, dragOption: () => ({}), resizeOption: () => ({}), + resizeHandles: undefined, + dragThreshold: undefined, }) const emit = defineEmits(['container-resized', 'resize', 'resized', 'move', 'moved']) @@ -70,8 +69,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 +103,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 +116,28 @@ const instance = reactive({ calcXY, }) +/** 获取当前生效的缩放手柄方向列表 */ +const effectiveResizeHandles = computed(() => { + return props.resizeHandles ?? layout.resizeHandles ?? ['se'] +}) + +/** 获取当前生效的拖拽阈值 */ +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 +164,6 @@ function setBoundedHandler(isBounded: boolean) { } } -function setTransformScaleHandler(transformScale: number) { - state.transformScale = transformScale -} - function setRowHeightHandler(rowHeight: number) { state.rowHeight = rowHeight } @@ -194,8 +213,6 @@ onMounted(() => { } else { state.bounded = props.isBounded } - state.transformScale = layout.transformScale - state.useCssTransforms = layout.useCssTransforms state.useStyleCursor = layout.useStyleCursor watchEffect(() => { @@ -211,7 +228,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 +240,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 +273,20 @@ 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) -}) + +/** 为每个缩放手柄方向生成 CSS 类名 */ +function getHandleClass(handle: ResizeHandle) { + return [ + nh.be('resizer'), + nh.bem('resizer', handle), + renderRtl.value && nh.bem('resizer', 'rtl'), + ].filter(Boolean) +} watch( () => props.isDraggable, @@ -349,7 +369,6 @@ function createStyle() { if (state.isDragging) { pos.top = state.dragging.top - // Add rtl support if (renderRtl.value) { pos.right = state.dragging.left } else { @@ -361,31 +380,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 +416,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 +434,33 @@ 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 - } + const coreEvent = createCoreData(lastW, lastH, x, y) - // An horizontal resize ignores the vertical delta - if (!event.edges.top && !event.edges.bottom) { - lastH = y + // 根据缩放方向处理尺寸变化 + if (event.edges.right) { + if (renderRtl.value) { + newSize.width = state.resizing.width - coreEvent.deltaX + } else { + newSize.width = state.resizing.width + coreEvent.deltaX + } + } else if (event.edges.left) { + if (renderRtl.value) { + newSize.width = state.resizing.width + coreEvent.deltaX + } else { + newSize.width = state.resizing.width - coreEvent.deltaX + } + } else { + newSize.width = state.resizing.width } - const coreEvent = createCoreData(lastW, lastH, x, y) - if (renderRtl.value) { - newSize.width = state.resizing.width - coreEvent.deltaX / state.transformScale + if (event.edges.bottom) { + newSize.height = state.resizing.height + coreEvent.deltaY + } else if (event.edges.top) { + newSize.height = state.resizing.height - coreEvent.deltaY } else { - newSize.width = state.resizing.width + coreEvent.deltaX / state.transformScale + newSize.height = state.resizing.height } - newSize.height = state.resizing.height + coreEvent.deltaY / state.transformScale + state.resizing = newSize break } @@ -485,13 +500,25 @@ function handleResize(event: MouseEvent & { edges: any }) { lastW = x lastH = y + // 处理 n/w/nw 等方向缩放时同时更新位置 + let newX = innerX + let newY = innerY + if (event.edges.left) { + // 从左侧缩放:x 位置需要调整 + newX = innerX + (innerW - pos.w) + } + if (event.edges.top) { + // 从顶部缩放:y 位置需要调整 + newY = innerY + (innerH - pos.h) + } + if (innerW !== pos.w || innerH !== pos.h) { emit('resize', props.i, pos.h, pos.w, newSize.height, newSize.width) } if (event.type === 'resizeend' && (previousW !== innerW || previousH !== innerH)) { emit('resized', props.i, pos.h, pos.w, newSize.height, newSize.width) } - emitter.emit('resizeEvent', event.type, props.i, innerX, innerY, pos.h, pos.w) + emitter.emit('resizeEvent', event.type, props.i, newX, newY, pos.h, pos.w) } function handleDrag(event: MouseEvent) { @@ -503,15 +530,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 +545,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 +564,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 +588,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 +607,6 @@ function handleDrag(event: MouseEvent) { } } - // Get new XY let pos if (renderRtl.value) { pos = calcXY(newPosition.top, newPosition.left) @@ -607,8 +628,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 +636,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 +644,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 +667,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 +684,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 +692,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 +703,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 +746,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 { @@ -765,6 +785,28 @@ function tryMakeDraggable() { const throttleResize = throttle(handleResize) +/** + * 根据缩放手柄方向计算 interactjs 的 edges 配置。 + */ +function getEdgesForHandles(handles: ResizeHandle[]) { + const hasHandle = (h: ResizeHandle) => handles.includes(h) + + const hasTop = hasHandle('n') || hasHandle('nw') || hasHandle('ne') + const hasBottom = hasHandle('s') || hasHandle('sw') || hasHandle('se') + const hasLeft = hasHandle('w') || hasHandle('nw') || hasHandle('sw') + const hasRight = hasHandle('e') || hasHandle('ne') || hasHandle('se') + + // 使用 CSS 选择器匹配对应方向的手柄元素 + const resizerBase = `.${nh.be('resizer')}` + + return { + top: hasTop ? resizerBase : false, + bottom: hasBottom ? resizerBase : false, + left: hasLeft ? resizerBase : false, + right: hasRight ? resizerBase : false, + } +} + function tryMakeResizable() { tryInteract() @@ -774,22 +816,20 @@ function tryMakeResizable() { const maximum = calcPosition(0, 0, props.maxW, props.maxH) const minimum = calcPosition(0, 0, props.minW, props.minH) + const handles = effectiveResizeHandles.value + const edges = getEdgesForHandles(handles) + const opts: Record = { - edges: { - left: renderRtl.value ? `.${resizerClass.value[0]}` : false, - right: !renderRtl.value ? `.${resizerClass.value[0]}` : false, - bottom: `.${resizerClass.value[0]}`, - top: false, - }, + edges, 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 +855,12 @@ function tryMakeResizable() { diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index f1f9e43..3650773 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -30,8 +30,10 @@ import { getBreakpointFromWidth, getColsFromBreakpoint, } from '../helpers/responsive' +import { verticalCompactor } from '../core/compactors' +import { transformStrategy } from '../core/position-strategies' -import type { Breakpoint, Layout, LayoutInstance } from '../helpers/types' +import type { Breakpoint, Compactor, Layout, LayoutInstance, PositionStrategy, ResizeHandle } from '../helpers/types' import type { GridLayoutProps } from './types' const props = withDefaults(defineProps(), { @@ -44,16 +46,19 @@ const props = withDefaults(defineProps(), { isResizable: true, isMirrored: false, isBounded: false, - useCssTransforms: true, - verticalCompact: true, restoreOnDrag: false, responsive: false, responsiveLayouts: () => ({}), - transformScale: 1, breakpoints: () => ({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }), cols: () => ({ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }), preventCollision: false, useStyleCursor: true, + compactor: () => verticalCompactor, + positionStrategy: () => transformStrategy, + resizeHandles: () => ['se'] as ResizeHandle[], + isDroppable: false, + dropItem: () => ({ w: 1, h: 1 }), + dragThreshold: 0, }) const emit = defineEmits([ @@ -63,8 +68,24 @@ const emit = defineEmits([ 'breakpoint-changed', 'update:layout', 'layout-ready', + 'drop-drag-over', + 'drop', + 'drop-drag-leave', ]) +/** + * Config 合并逻辑:扁平 props 优先于分组 config。 + * 对于每个配置项,如果扁平 prop 被显式传入(非 undefined),则使用扁平 prop 的值; + * 否则使用分组 config 中的值;最后回退到默认值。 + * + * 注意:由于 withDefaults 已经为扁平 props 设置了默认值, + * 我们通过检查 $attrs 和 props 来判断是否显式传入。 + * 实际上,withDefaults 使得扁平 props 始终有值, + * 所以分组 config 只在扁平 props 使用默认值时才可能覆盖。 + * 但根据需求 8.5,扁平 props 优先级更高,即使是默认值也优先。 + * 因此分组 config 仅作为替代写法,不会覆盖已有默认值的扁平 props。 + */ + const state = reactive({ width: -1, mergedStyle: {}, @@ -77,9 +98,11 @@ const state = reactive({ h: 0, i: '' as number | string, }, - layouts: {} as Record, // array to store all layouts from different breakpoints - lastBreakpoint: null as Breakpoint | null, // store last active breakpoint - originalLayout: null! as Layout, // store original Layout + layouts: {} as Record, + lastBreakpoint: null as Breakpoint | null, + originalLayout: null! as Layout, + // 外部拖入占位符状态 + dropPlaceholder: null as { x: number, y: number, w: number, h: number } | null, }) const itemInstances = new Map() @@ -108,7 +131,7 @@ onMounted(() => { nextTick(() => { initResponsiveFeatures() wrapper.value && observeResize(wrapper.value, debounce(onWindowResize, 16)) - compact(currentLayout.value, props.verticalCompact) + compactLayout() emit('layout-updated', currentLayout.value) updateHeight() onWindowResize() @@ -143,32 +166,37 @@ function dragEventHandler( dragEvent(eventType, i, x, y, h, w) } +/** + * 使用可插拔 compactor 执行布局压缩。 + * 替代原来直接调用 compact(layout, verticalCompact) 的方式。 + */ +function compactLayout(positionsBeforeDrag?: Record) { + if (positionsBeforeDrag) { + // restoreOnDrag 模式:使用旧的 compact 函数以支持 minPositions + compact(currentLayout.value, true, positionsBeforeDrag) + } else { + // 使用可插拔 compactor + const result = props.compactor.compact(currentLayout.value, props.colNum) + // 将结果同步回 currentLayout(保持引用稳定) + for (let i = 0; i < currentLayout.value.length; i++) { + const src = result.find(r => r.i === currentLayout.value[i].i) + if (src) { + currentLayout.value[i].x = src.x + currentLayout.value[i].y = src.y + currentLayout.value[i].w = src.w + currentLayout.value[i].h = src.h + currentLayout.value[i].moved = src.moved + } + } + } +} + watch( () => state.width, (newVal, oldVal) => { nextTick(() => { emitter.emit('updateWidth', newVal) if (oldVal === -1) { - /* - If oldVal === -1 is when the width has never been - set before. That only occurs when mounting is - finished, and onWindowResize has been called and - this.width has been changed the first time after it - got set to null in the constructor. It is now time - to issue layout-ready events as the GridItems have - their sizes configured properly. - - The reason for emitting the layout-ready events on - the next tick is to allow for the newly-emitted - updateWidth event (above) to have reached the - children GridItem-s and had their effect, so we're - sure that they have the final size before we emit - layout-ready (for this GridLayout) and - item-layout-ready (for the GridItem-s). - - This way any client event handlers can reliably - investigate stable sizes of GridItem-s. - */ nextTick(() => { emit('layout-ready', currentLayout.value) }) @@ -214,12 +242,6 @@ watch( emitter.emit('setBounded', value) }, ) -watch( - () => props.transformScale, - value => { - emitter.emit('setTransformScale', value) - }, -) watch( () => props.responsive, value => { @@ -236,6 +258,15 @@ watch( emitter.emit('setMaxRows', value) }, ) +watch( + () => props.compactor, + () => { + compactLayout() + emitter.emit('updateWidth', state.width) + updateHeight() + emit('layout-updated', currentLayout.value) + }, +) watch([() => props.margin, () => props.margin[1]], updateHeight) provide( @@ -281,7 +312,7 @@ function layoutUpdate() { initResponsiveFeatures() } - compact(currentLayout.value, props.verticalCompact) + compactLayout() emitter.emit('updateWidth', state.width) updateHeight() @@ -323,12 +354,13 @@ function dragEvent( ) { let l = getLayoutItem(currentLayout.value, id)! - // GetLayoutItem sometimes returns null object if (isNull(l)) { l = { h: 0, w: 0, x: 0, y: 0, i: '' } } - if (eventName === 'dragstart' && !props.verticalCompact) { + if (eventName === 'dragstart' && props.compactor.allowOverlap) { + // allowOverlap 模式下不需要记录位置 + } else if (eventName === 'dragstart') { positionsBeforeDrag = currentLayout.value.reduce( (result, { i, x, y }) => ({ ...result, @@ -356,20 +388,23 @@ function dragEvent( }) } - // Move the element to the dragged location. - currentLayout.value = moveElement(currentLayout.value, l, x, y, true, props.preventCollision) + if (props.compactor.allowOverlap) { + // allowOverlap 模式:直接更新位置,不做碰撞检测和推开 + l.x = x + l.y = y + l.moved = true + } else { + currentLayout.value = moveElement(currentLayout.value, l, x, y, true, props.preventCollision) + } - if (props.restoreOnDrag) { - // Do not compact items more than in layout before drag - // Set moved item as static to avoid to compact it + if (props.restoreOnDrag && !props.compactor.allowOverlap) { l.static = true - compact(currentLayout.value, props.verticalCompact, positionsBeforeDrag) + compactLayout(positionsBeforeDrag) l.static = false - } else { - compact(currentLayout.value, props.verticalCompact) + } else if (!props.compactor.allowOverlap) { + compactLayout() } - // needed because vue can't detect changes on array element properties emitter.emit('compact') updateHeight() if (eventName === 'dragend') { @@ -387,7 +422,6 @@ function resizeEvent( w: number, ) { let l = getLayoutItem(currentLayout.value, id)! - // GetLayoutItem sometimes return null object if (isNull(l)) { l = { h: 0, w: 0, x: 0, y: 0, i: '' } } @@ -399,9 +433,7 @@ function resizeEvent( ) hasCollisions = collisions.length > 0 - // If we're colliding, we need adjust the placeholder. if (hasCollisions) { - // adjust w && h to maximum allowed space let leastX = Infinity let leastY = Infinity collisions.forEach(layoutItem => { @@ -415,7 +447,6 @@ function resizeEvent( } if (!hasCollisions) { - // Set new width and height. l.w = w l.h = h } @@ -429,7 +460,6 @@ function resizeEvent( nextTick(() => { state.isDragging = true }) - // this.$broadcast("updateWidth", this.width); emitter.emit('updateWidth', state.width) } else if (eventName) { nextTick(() => { @@ -439,7 +469,7 @@ function resizeEvent( if (props.responsive) responsiveGridLayout() - compact(currentLayout.value, props.verticalCompact) + compactLayout() emitter.emit('compact') updateHeight() @@ -455,12 +485,10 @@ function responsiveGridLayout() { const newCols = getColsFromBreakpoint(newBreakpoint, props.cols) - // save actual layout in layouts if (!isNull(state.lastBreakpoint) && !state.layouts[state.lastBreakpoint]) { state.layouts[state.lastBreakpoint] = cloneLayout(currentLayout.value) } - // Find or generate a new layout. const layout = findOrGenerateResponsiveLayout( state.originalLayout, state.layouts, @@ -468,10 +496,9 @@ function responsiveGridLayout() { newBreakpoint, state.lastBreakpoint!, newCols, - props.verticalCompact, + true, ) - // Store the new layout. state.layouts[newBreakpoint] = layout if (state.lastBreakpoint !== newBreakpoint) { @@ -480,7 +507,6 @@ function responsiveGridLayout() { currentLayout.value = layout - // new prop sync emit('update:layout', layout) state.lastBreakpoint = newBreakpoint @@ -488,7 +514,6 @@ function responsiveGridLayout() { } function initResponsiveFeatures() { - // clear layouts state.layouts = Object.assign({} as Record, props.responsiveLayouts) } @@ -496,19 +521,82 @@ function findDifference(layout: Layout, originalLayout: Layout) { const originalIds = new Set(originalLayout.map(item => item.i)) const ids = new Set(layout.map(item => item.i)) - // Find values that are in result1 but not in result2 const uniqueResultOne = layout.filter(item => !originalIds.has(item.i)) - - // Find values that are in result2 but not in result1 const uniqueResultTwo = originalLayout.filter(item => !ids.has(item.i)) - // Combine the two arrays of unique entries# return uniqueResultOne.concat(uniqueResultTwo) } + +// --------------------------------------------------------------------------- +// 外部拖入功能(需求 6) +// --------------------------------------------------------------------------- + +function handleDragOver(event: DragEvent) { + if (!props.isDroppable) return + event.preventDefault() + + if (!wrapper.value) return + + const rect = wrapper.value.getBoundingClientRect() + const marginX = props.margin[0] || 0 + const marginY = props.margin[1] || 0 + const colWidth = (state.width - marginX * (props.colNum + 1)) / props.colNum + + // 计算网格坐标 + const relX = event.clientX - rect.left + const relY = event.clientY - rect.top + let gridX = Math.round((relX - marginX) / (colWidth + marginX)) + let gridY = Math.round((relY - marginY) / (props.rowHeight + marginY)) + + const dw = props.dropItem.w + const dh = props.dropItem.h + + // Clamp 到有效范围 + gridX = Math.max(0, Math.min(gridX, props.colNum - dw)) + gridY = Math.max(0, gridY) + if (props.maxRows !== Infinity) { + gridY = Math.min(gridY, props.maxRows - dh) + } + + state.dropPlaceholder = { x: gridX, y: gridY, w: dw, h: dh } + + emit('drop-drag-over', { x: gridX, y: gridY }, event) +} + +function handleDrop(event: DragEvent) { + if (!props.isDroppable) return + event.preventDefault() + + if (state.dropPlaceholder) { + const { x, y, w, h } = state.dropPlaceholder + emit('drop', { x, y, w, h }, event) + } + + state.dropPlaceholder = null +} + +function handleDragLeave(event: DragEvent) { + if (!props.isDroppable) return + + // 检查是否真的离开了容器(而不是进入子元素) + if (wrapper.value && event.relatedTarget instanceof Node && wrapper.value.contains(event.relatedTarget)) { + return + } + + state.dropPlaceholder = null + emit('drop-drag-leave', event) +} +``` + +**操作步骤**: + +1. **drag over**: + - 从外部拖拽元素进入 GridLayout 区域 + - [ ] 视觉上出现 placeholder(半透明灰色块),尺寸为 2x2(dropItem 配置) + - [ ] placeholder 跟随鼠标在网格间移动,实时吸附到最近网格单元 + - [ ] 控制台打印 `drop-drag-over` 事件,包含当前网格坐标 + +2. **drop**: + - 在目标位置松手 + - [ ] placeholder 消失 + - [ ] 新元素被添加到 layout 中,i 为自动生成 + - [ ] 控制台打印 `drop` 事件 + - [ ] 布局自动压缩(若 compactor 不是 noCompactor) + +3. **drag leave**: + - 拖拽到 GridLayout 外部后松手 + - [ ] placeholder 消失 + - [ ] layout 不发生变化 + - [ ] 控制台打印 `drop-drag-leave` 事件 + +4. **isDroppable = false(默认)**: + - [ ] 外部元素拖拽到 GridLayout 上时,浏览器显示"禁止"光标 + - [ ] 无 placeholder 出现 + +**回滚触发条件**: +- placeholder 尺寸不是 dropItem 配置的尺寸 +- placeholder 位置与鼠标实际位置偏差超过一个网格单元 +- drop 后新元素未正确插入 layout 或导致现有元素位置错乱 + +--- + +### Step 9 — GridBackground + +**代码范围**:`src/components/grid-background.vue` + +**浏览器验证**: + +#### 9.1 独立使用 +```vue + +``` + +- [ ] 页面上出现 SVG 网格,横向 6 列,每格之间有 10px 间隔 +- [ ] 网格线颜色为默认 `rgba(0,0,0,0.1)` +- [ ] `:rows="5"` 时 SVG 高度为 5 行 + +#### 9.2 作为 GridLayout 子组件 +```vue + + + + {{ item.i }} + + +``` + +- [ ] GridBackground 自动继承 GridLayout 的 colNum、rowHeight、margin、width +- [ ] 网格线与 GridItem 的边界精确对齐(误差 <1px) +- [ ] 窗口 resize 时网格线自动重新计算并对齐 + +#### 9.3 自定义样式 +```vue + +``` + +- [ ] 网格线变为红色 +- [ ] 线宽为 2px + +**回滚触发条件**: +- 网格线与 GridItem 边界不对齐(肉眼可见偏差) +- 窗口 resize 后网格不更新 +- SVG 尺寸为 0(未正确计算 width/height) + +--- + +### Step 10 — Config Grouping + +**代码范围**:`src/components/types.ts` + `src/components/grid-layout.vue` + +**浏览器验证**: + +```vue + + ... + +``` + +**验证要点**: +- [ ] 扁平 prop `:col-num="12"` 优先级高于 `gridConfig.colNum`,若同时设置则扁平 prop 生效 +- [ ] 未设置扁平 prop 时,`gridConfig` 中的值生效(如 `rowHeight: 40`) +- [ ] `dragConfig.isDraggable: false` 时所有元素不可拖拽 +- [ ] `resizeConfig.isResizable: false` 时所有元素不可 resize +- [ ] `dragConfig.dragThreshold: 10` 时拖拽阈值生效 + +**回滚触发条件**: +- 扁平 prop 未覆盖分组 config(优先级错误) +- 分组 config 完全未生效 + +--- + +## 四、验证工具链 + +### 必装浏览器插件 +- **Vue DevTools**:验证组件 props、provide/inject 值 +- **Page Ruler**:测量元素像素尺寸和位置,验证对齐精度 + +### 关键 DevTools 操作 +1. **验证 PositionStrategy**: + - Elements → 选中 GridItem → Computed → 查看 `transform` 或 `top/left` +2. **验证 ResizeHandles**: + - Elements → 选中 `.vgl-item__resizer--se` → 确认 CSS 类名和 cursor +3. **验证 DragThreshold**: + - Console → 手动执行 `document.addEventListener('dragstart', e => console.log(e))` 观察事件触发时机 +4. **验证 Droppable**: + - Network/Console → 观察 `drop-drag-over`/`drop`/`drop-drag-leave` 事件日志 + +--- + +## 五、Rollback 策略 + +每步验证不通过时的回退方式: + +| 步骤 | 回退命令 | 说明 | +|------|---------|------| +| Step 4 | `git checkout HEAD -- src/components/grid-layout.vue src/components/grid-item.vue` | 回退定位策略替换,保留 core | +| Step 5 | `git checkout HEAD -- src/components/grid-layout.vue` | 回退 compactor 替换 | +| Step 6 | `git checkout HEAD -- src/components/grid-item.vue src/style.scss` | 回退多手柄,保留其他 | +| Step 7 | `git checkout HEAD -- src/components/grid-item.vue` | 回退拖拽阈值 | +| Step 8 | `git checkout HEAD -- src/components/grid-layout.vue` | 回退拖放支持 | +| Step 9 | `git checkout HEAD -- src/components/grid-background.vue src/style.scss` | 回退背景组件 | +| Step 10 | `git checkout HEAD -- src/components/grid-layout.vue` | 回退 config 合并逻辑 | + +--- + +## 六、执行顺序建议 + +``` +Day 1: Step 0 → 1 → 2 → 3 (纯代码/类型,无浏览器) +Day 2: Step 4 → 5 (核心组件替换,浏览器验证最耗时) +Day 3: Step 6 → 7 → 8 → 9 → 10 (交互功能,逐个在浏览器验证) +Day 4: 全量回归 + 文档站验证 +``` + +每步执行后必须: +1. `pnpm test` 通过 +2. 浏览器验证清单全部打勾 +3. `git commit` 锁定 checkpoint diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index f8f35b4..b05ecd1 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -1,5 +1,6 @@ - - -``` - -**验证要点**: -- [ ] 点击按钮后 `currentLayout[0].x === 3` -- [ ] `layout.value[0].x` 仍为 0(不修改原始输入) -- [ ] 当前布局经 compactor 压缩后无碰撞 - ---- - -### Step 4 — PositionStrategy 替换(🔴 最高风险) - -**代码范围**:`src/components/grid-layout.vue` + `src/components/grid-item.vue` - -**为什么高风险**:这个改动直接决定每个 grid item 在 DOM 中的定位方式。如果策略切换逻辑有误,会导致元素位置错乱、尺寸异常。 - -**浏览器验证 — 必做**: - -在 `dev-server/` 中创建三个测试页面,分别使用三种策略: - -#### 4.1 transformStrategy(默认) -```vue - - - {{ item.i }} - - -``` - -**验证要点**: -- [ ] 打开 DevTools → Elements,选中任意 GridItem -- [ ] 确认 `style` 属性包含 `transform: translate3d(...)` -- [ ] 拖拽一个元素,确认松手后位置正确吸附到网格 -- [ ] 窗口缩小时,元素宽度按比例缩小,无溢出或断裂 - -#### 4.2 absoluteStrategy -```vue - - ... - - - -``` - -**验证要点**: -- [ ] DevTools 中确认 `style` 属性包含 `top: ...px; left: ...px`(无 transform) -- [ ] 拖拽、resize 行为与默认策略完全一致 -- [ ] 快速拖拽后无"元素漂移"或"位置残留" - -#### 4.3 scaledStrategy(2) -```vue - - ... - - - -``` - -**验证要点**: -- [ ] DevTools 中确认每个元素的 `width` / `height` 是实际网格计算值的 2 倍 -- [ ] 视觉上元素确实比默认策略大一圈 -- [ ] 拖拽时鼠标指针与元素边缘的相对位置保持一致 - -**回滚触发条件**: -- 任一策略下元素位置明显偏移(>2px) -- 拖拽后元素不吸附或吸附到错误位置 -- resize 时尺寸计算错误 - ---- - -### Step 5 — Compactor 替换 - -**代码范围**:`src/components/grid-layout.vue` 中的 `compactLayout()` - -**浏览器验证**: - -在 `dev-server/` 中创建四个测试布局: - -#### 5.1 verticalCompactor(默认,应等同旧行为) -- 放置两个元素:A 在 (0,0),B 在 (0,2) -- 删除 A -- **预期**:B 自动上浮到 y=0 - -#### 5.2 noCompactor -```vue - -``` - -- 放置两个元素:A 在 (0,0),B 在 (0,2) -- 删除 A -- **预期**:B 保持在 y=2,不上浮 - -#### 5.3 horizontalCompactor -```vue - -``` - -- 放置两个元素:A 在 (0,0),B 在 (2,0) -- 删除 A -- **预期**:B 向左移动到 x=0,y 保持不变 - -#### 5.4 withOverlap(noCompactor) -```vue - -``` - -- 拖拽 B 到 A 的位置 -- **预期**:B 可以与 A 重叠,松手后两者 occupy 同一网格单元 - -**回滚触发条件**: -- 默认 verticalCompactor 与旧行为不一致(元素不上浮或上浮过度) -- allowOverlap 模式下拖拽时 still 触发位置记录导致异常 - ---- - -### Step 6 — ResizeHandles 多方向缩放 - -**代码范围**:`src/components/grid-item.vue` + `src/style.scss` - -**浏览器验证**: - -#### 6.1 默认 se 手柄(旧行为) -- [ ] 每个 GridItem 右下角出现对角线 resizer -- [ ] 鼠标悬停时 cursor 变为 `se-resize` -- [ ] 拖拽 resize 时宽度和高度同步增加 - -#### 6.2 全方向手柄 -```vue - -``` - -**逐一手柄验证**: - -| 手柄 | 鼠标悬停 cursor | 拖拽操作 | 预期视觉反馈 | -|------|----------------|---------|-------------| -| `se` | `se-resize` | 向右下拖 | 宽度和高度同时增加,元素位置不变 | -| `sw` | `sw-resize` | 向左下拖 | 宽度增加、高度增加,元素**向左移动** | -| `ne` | `ne-resize` | 向右上拖 | 宽度增加、高度增加,元素**向上移动** | -| `nw` | `nw-resize` | 向左上拖 | 宽度增加、高度增加,元素**向左上移动** | -| `s` | `s-resize` | 向下拖 | 仅高度增加,宽度不变,位置不变 | -| `n` | `n-resize` | 向上拖 | 仅高度增加,宽度不变,元素**向上移动** | -| `e` | `e-resize` | 向右拖 | 仅宽度增加,高度不变,位置不变 | -| `w` | `w-resize` | 向左拖 | 仅宽度增加,高度不变,元素**向左移动** | - -#### 6.3 RTL 模式 -```vue - -``` - -- [ ] `se` 手柄的 cursor 变为 `sw-resize` -- [ ] `sw` 手柄的 cursor 变为 `se-resize` -- [ ] 向右拖拽 `e` 手柄时,元素实际向左扩展(RTL 逻辑) - -**回滚触发条件**: -- 任意手柄拖拽时元素位置反向移动(如 nw 手柄向右下拖反而使元素向左上跑) -- 手柄在 DOM 中未渲染或样式缺失(无对角线/无边框) -- resize 结束后元素尺寸未正确吸附到网格 - ---- - -### Step 7 — DragThreshold - -**代码范围**:`src/components/grid-item.vue` 中的拖拽阈值逻辑 - -**浏览器验证**: - -#### 7.1 默认 threshold = 0 -- [ ] 鼠标在元素上按下后立即移动 1px,元素即开始跟随拖拽 - -#### 7.2 threshold = 20 -```vue - -``` - -**精确操作**: -- [ ] 鼠标在元素上按下,缓慢移动 10px(<20px) -- [ ] **预期**:元素不移动,仍在原位,无 placeholder 出现 -- [ ] 继续移动,总位移超过 20px -- [ ] **预期**:元素突然"粘附"到鼠标位置,开始正常拖拽 -- [ ] 松手后元素正确吸附到目标网格位置 - -#### 7.3 Item 级覆盖 -```vue - -``` - -- [ ] 该 item 需要移动 50px 才触发拖拽,其他 item 仍按 layout 默认值 - -**回滚触发条件**: -- threshold > 0 时,鼠标微动( -
外部可拖元素
- - - {{ item.i }} - - - -``` - -**操作步骤**: - -1. **drag over**: - - 从外部拖拽元素进入 GridLayout 区域 - - [ ] 视觉上出现 placeholder(半透明灰色块),尺寸为 2x2(dropItem 配置) - - [ ] placeholder 跟随鼠标在网格间移动,实时吸附到最近网格单元 - - [ ] 控制台打印 `drop-drag-over` 事件,包含当前网格坐标 - -2. **drop**: - - 在目标位置松手 - - [ ] placeholder 消失 - - [ ] 新元素被添加到 layout 中,i 为自动生成 - - [ ] 控制台打印 `drop` 事件 - - [ ] 布局自动压缩(若 compactor 不是 noCompactor) - -3. **drag leave**: - - 拖拽到 GridLayout 外部后松手 - - [ ] placeholder 消失 - - [ ] layout 不发生变化 - - [ ] 控制台打印 `drop-drag-leave` 事件 - -4. **isDroppable = false(默认)**: - - [ ] 外部元素拖拽到 GridLayout 上时,浏览器显示"禁止"光标 - - [ ] 无 placeholder 出现 - -**回滚触发条件**: -- placeholder 尺寸不是 dropItem 配置的尺寸 -- placeholder 位置与鼠标实际位置偏差超过一个网格单元 -- drop 后新元素未正确插入 layout 或导致现有元素位置错乱 - ---- - -### Step 9 — GridBackground - -**代码范围**:`src/components/grid-background.vue` - -**浏览器验证**: - -#### 9.1 独立使用 -```vue - -``` - -- [ ] 页面上出现 SVG 网格,横向 6 列,每格之间有 10px 间隔 -- [ ] 网格线颜色为默认 `rgba(0,0,0,0.1)` -- [ ] `:rows="5"` 时 SVG 高度为 5 行 - -#### 9.2 作为 GridLayout 子组件 -```vue - - - - {{ item.i }} - - -``` - -- [ ] GridBackground 自动继承 GridLayout 的 colNum、rowHeight、margin、width -- [ ] 网格线与 GridItem 的边界精确对齐(误差 <1px) -- [ ] 窗口 resize 时网格线自动重新计算并对齐 - -#### 9.3 自定义样式 -```vue - -``` - -- [ ] 网格线变为红色 -- [ ] 线宽为 2px - -**回滚触发条件**: -- 网格线与 GridItem 边界不对齐(肉眼可见偏差) -- 窗口 resize 后网格不更新 -- SVG 尺寸为 0(未正确计算 width/height) - ---- - -### Step 10 — Config Grouping - -**代码范围**:`src/components/types.ts` + `src/components/grid-layout.vue` - -**浏览器验证**: - -```vue - - ... - -``` - -**验证要点**: -- [ ] 扁平 prop `:col-num="12"` 优先级高于 `gridConfig.colNum`,若同时设置则扁平 prop 生效 -- [ ] 未设置扁平 prop 时,`gridConfig` 中的值生效(如 `rowHeight: 40`) -- [ ] `dragConfig.isDraggable: false` 时所有元素不可拖拽 -- [ ] `resizeConfig.isResizable: false` 时所有元素不可 resize -- [ ] `dragConfig.dragThreshold: 10` 时拖拽阈值生效 - -**回滚触发条件**: -- 扁平 prop 未覆盖分组 config(优先级错误) -- 分组 config 完全未生效 - ---- - -## 四、验证工具链 - -### 必装浏览器插件 -- **Vue DevTools**:验证组件 props、provide/inject 值 -- **Page Ruler**:测量元素像素尺寸和位置,验证对齐精度 - -### 关键 DevTools 操作 -1. **验证 PositionStrategy**: - - Elements → 选中 GridItem → Computed → 查看 `transform` 或 `top/left` -2. **验证 ResizeHandles**: - - Elements → 选中 `.vgl-item__resizer--se` → 确认 CSS 类名和 cursor -3. **验证 DragThreshold**: - - Console → 手动执行 `document.addEventListener('dragstart', e => console.log(e))` 观察事件触发时机 -4. **验证 Droppable**: - - Network/Console → 观察 `drop-drag-over`/`drop`/`drop-drag-leave` 事件日志 - ---- - -## 五、Rollback 策略 - -每步验证不通过时的回退方式: - -| 步骤 | 回退命令 | 说明 | -|------|---------|------| -| Step 4 | `git checkout HEAD -- src/components/grid-layout.vue src/components/grid-item.vue` | 回退定位策略替换,保留 core | -| Step 5 | `git checkout HEAD -- src/components/grid-layout.vue` | 回退 compactor 替换 | -| Step 6 | `git checkout HEAD -- src/components/grid-item.vue src/style.scss` | 回退多手柄,保留其他 | -| Step 7 | `git checkout HEAD -- src/components/grid-item.vue` | 回退拖拽阈值 | -| Step 8 | `git checkout HEAD -- src/components/grid-layout.vue` | 回退拖放支持 | -| Step 9 | `git checkout HEAD -- src/components/grid-background.vue src/style.scss` | 回退背景组件 | -| Step 10 | `git checkout HEAD -- src/components/grid-layout.vue` | 回退 config 合并逻辑 | - ---- - -## 六、执行顺序建议 - -``` -Day 1: Step 0 → 1 → 2 → 3 (纯代码/类型,无浏览器) -Day 2: Step 4 → 5 (核心组件替换,浏览器验证最耗时) -Day 3: Step 6 → 7 → 8 → 9 → 10 (交互功能,逐个在浏览器验证) -Day 4: 全量回归 + 文档站验证 -``` - -每步执行后必须: -1. `pnpm test` 通过 -2. 浏览器验证清单全部打勾 -3. `git commit` 锁定 checkpoint diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0a645d1..8d0937b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -72,7 +72,6 @@ export default defineConfig({ { text: 'Horizontal Compaction', link: '/example/horizontal-compact' }, { text: 'No Compaction', link: '/example/no-compact' }, { text: 'Allow Overlap', link: '/example/allow-overlap' }, - { text: 'Multi-directional Resize Handles', link: '/example/multi-resize-handles' }, { text: 'Drag Threshold', link: '/example/drag-threshold' }, { text: 'Native Drag & Drop', link: '/example/native-drop' }, { text: 'Grid Background', link: '/example/grid-background' }, @@ -132,7 +131,6 @@ export default defineConfig({ { text: '水平压缩', link: '/zh/example/horizontal-compact' }, { text: '无压缩', link: '/zh/example/no-compact' }, { text: '允许重叠', link: '/zh/example/allow-overlap' }, - { text: '多方向缩放手柄', link: '/zh/example/multi-resize-handles' }, { text: '拖拽阈值', link: '/zh/example/drag-threshold' }, { text: '原生拖放', link: '/zh/example/native-drop' }, { text: '网格背景', link: '/zh/example/grid-background' }, diff --git a/docs/demos/config-grouping.vue b/docs/demos/config-grouping.vue index 33e5018..84d4188 100644 --- a/docs/demos/config-grouping.vue +++ b/docs/demos/config-grouping.vue @@ -16,7 +16,6 @@ const dragConfig = reactive({ const resizeConfig = reactive({ isResizable: true, - resizeHandles: ['se'], }) const dropConfig = reactive({ diff --git a/docs/demos/multi-resize-handles.vue b/docs/demos/multi-resize-handles.vue deleted file mode 100644 index 684e879..0000000 --- a/docs/demos/multi-resize-handles.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/docs/example/multi-resize-handles.md b/docs/example/multi-resize-handles.md deleted file mode 100644 index 034e908..0000000 --- a/docs/example/multi-resize-handles.md +++ /dev/null @@ -1,11 +0,0 @@ -# Multi-directional Resize Handles - -## Effect - - - - - -## Source - -<<< @/demos/multi-resize-handles.vue diff --git a/docs/guide/properties.md b/docs/guide/properties.md index dcd36d5..16266ef 100644 --- a/docs/guide/properties.md +++ b/docs/guide/properties.md @@ -53,12 +53,6 @@ type Breakpoints = Record type ResponsiveLayout = Record ``` -### ResizeHandle - -```ts -type ResizeHandle = 's' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne' -``` - ### Compactor The pluggable compaction algorithm interface. A compactor receives a layout and column count, and returns a new compacted layout. @@ -127,7 +121,6 @@ interface DragConfig { ```ts interface ResizeConfig { isResizable?: boolean - resizeHandles?: ResizeHandle[] } ``` @@ -330,15 +323,6 @@ import { absoluteStrategy, scaledStrategy, transformStrategy } from 'grid-layout Use `scaledStrategy(scale)` when the grid is rendered inside a scaled container. -### resize-handles - -- type: `ResizeHandle[]` -- default: `['se']` - -Defines which resize handles are shown on all grid items. Each item can override this via its own `resize-handles` prop. - -Possible values: `'s'`, `'w'`, `'e'`, `'n'`, `'sw'`, `'nw'`, `'se'`, `'ne'`. - ### is-droppable - type: `boolean` @@ -563,14 +547,7 @@ Passthrough object for the grid item [interact.js draggable configuration](https Passthrough object for the grid item [interact.js resizable configuration](https://interactjs.io/docs/resizable/). -### resize-handles - -- type: `ResizeHandle[]` -- default: `null` - -Sets which resize handles are shown on this item. If `null`, inherits from the parent GridLayout's [`resize-handles`](#resize-handles) prop. -Possible values: `'s'`, `'w'`, `'e'`, `'n'`, `'sw'`, `'nw'`, `'se'`, `'ne'`. ### drag-threshold diff --git a/docs/zh/example/multi-resize-handles.md b/docs/zh/example/multi-resize-handles.md deleted file mode 100644 index 10773f8..0000000 --- a/docs/zh/example/multi-resize-handles.md +++ /dev/null @@ -1,11 +0,0 @@ -# 多方向缩放手柄 - -## 效果 - - - - - -## 源码 - -<<< @/demos/multi-resize-handles.vue diff --git a/docs/zh/guide/properties.md b/docs/zh/guide/properties.md index d940924..d95e516 100644 --- a/docs/zh/guide/properties.md +++ b/docs/zh/guide/properties.md @@ -53,12 +53,6 @@ type Breakpoints = Record type ResponsiveLayout = Record ``` -### ResizeHandle - -```ts -type ResizeHandle = 's' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne' -``` - ### Compactor 可插拔的布局压缩算法接口。压缩器接收布局和列数,返回压缩后的新布局。 @@ -127,7 +121,6 @@ interface DragConfig { ```ts interface ResizeConfig { isResizable?: boolean - resizeHandles?: ResizeHandle[] } ``` @@ -330,15 +323,6 @@ import { absoluteStrategy, scaledStrategy, transformStrategy } from 'grid-layout 当栅格在缩放容器中渲染时,使用 `scaledStrategy(scale)`。 -### resize-handles - -- 类型:`ResizeHandle[]` -- 默认值:`['se']` - -定义所有栅格元素上显示的缩放手柄方向。每个元素可以通过自身的 `resize-handles` 属性覆盖此设置。 - -可选值:`'s'`、`'w'`、`'e'`、`'n'`、`'sw'`、`'nw'`、`'se'`、`'ne'`。 - ### is-droppable - 类型:`boolean` @@ -563,15 +547,6 @@ interface DropConfig { 传递给 [interact.js 缩放配置](https://interactjs.io/docs/resizable/) 的对象。 -### resize-handles - -- 类型:`ResizeHandle[]` -- 默认值:`null` - -设置该元素上显示的缩放手柄方向。如果为 `null`,则继承父容器 GridLayout 的 [`resize-handles`](#resize-handles) 属性。 - -可选值:`'s'`、`'w'`、`'e'`、`'n'`、`'sw'`、`'nw'`、`'se'`、`'ne'`。 - ### drag-threshold - 类型:`number` diff --git a/src/components/grid-item.vue b/src/components/grid-item.vue index 27760da..c35fbf0 100644 --- a/src/components/grid-item.vue +++ b/src/components/grid-item.vue @@ -25,7 +25,7 @@ import { getDocumentDir } from '../helpers/dom' import interact from 'interactjs' -import type { PositionStrategy, ResizeHandle } from '../helpers/types' +import type { PositionStrategy } from '../helpers/types' import type { GridItemProps } from './types' const props = withDefaults(defineProps(), { @@ -43,7 +43,6 @@ const props = withDefaults(defineProps(), { preserveAspectRatio: false, dragOption: () => ({}), resizeOption: () => ({}), - resizeHandles: undefined, dragThreshold: undefined, }) @@ -103,15 +102,6 @@ let innerY = props.y let innerW = props.w let innerH = props.h -// resize 过程中跟踪当前状态(用于 n/w/nw/ne 方向) -let resizeCurrX = innerX -let resizeCurrY = innerY -let resizeCurrW = innerW -let resizeCurrH = innerH - -// 通过 interactjs event.edges 记录 resize 方向 -let resizeDirection = '' - // 拖拽阈值相关状态 let dragStartPos: { x: number, y: number } | null = null let dragThresholdExceeded = false @@ -125,11 +115,6 @@ const instance = reactive({ calcXY, }) -/** 获取当前生效的缩放手柄方向列表 */ -const effectiveResizeHandles = computed(() => { - return props.resizeHandles ?? layout.resizeHandles ?? ['se'] -}) - /** 获取当前生效的拖拽阈值 */ const effectiveDragThreshold = computed(() => { return props.dragThreshold ?? layout.dragThreshold ?? 0 @@ -288,15 +273,6 @@ const className = computed(() => { } }) -/** 为每个缩放手柄方向生成 CSS 类名 */ -function getHandleClass(handle: ResizeHandle) { - return [ - nh.be('resizer'), - nh.bem('resizer', handle), - renderRtl.value && nh.bem('resizer', 'rtl'), - ].filter(Boolean) -} - watch( () => props.isDraggable, value => { @@ -368,10 +344,10 @@ watch([() => layout.margin, () => layout.margin[0], () => layout.margin[1]], () function createStyle() { let x: number, y: number, w: number, h: number if (state.isResizing) { - x = resizeCurrX - y = resizeCurrY - w = resizeCurrW - h = resizeCurrH + x = innerX + y = innerY + w = innerW + h = innerH } else { if (props.x + props.w > state.cols) { x = 0 @@ -449,17 +425,6 @@ function handleResize(event: MouseEvent & { edges: any }) { tryMakeResizable() previousW = innerW previousH = innerH - resizeCurrX = innerX - resizeCurrY = innerY - resizeCurrW = innerW - resizeCurrH = innerH - resizeDirection = '' - // 使用 interactjs 的 event.edges(edges 改回 CSS 选择器后已可正确识别) - const edges = event.edges || {} - if (edges.left) resizeDirection += 'L' - if (edges.right) resizeDirection += 'R' - if (edges.top) resizeDirection += 'T' - if (edges.bottom) resizeDirection += 'B' pos = calcPosition(innerX, innerY, innerW, innerH) newSize.width = pos.width newSize.height = pos.height @@ -469,34 +434,12 @@ function handleResize(event: MouseEvent & { edges: any }) { } case 'resizemove': { const coreEvent = createCoreData(lastW, lastH, x, y) - - // 使用 interactjs 的 event.edges 判断方向 - const edges = event.edges || {} - - if (edges.right) { - if (renderRtl.value) { - newSize.width = state.resizing.width - coreEvent.deltaX - } else { - newSize.width = state.resizing.width + coreEvent.deltaX - } - } else if (edges.left) { - if (renderRtl.value) { - newSize.width = state.resizing.width + coreEvent.deltaX - } else { - newSize.width = state.resizing.width - coreEvent.deltaX - } - } else { - newSize.width = state.resizing.width - } - - if (edges.bottom) { - newSize.height = state.resizing.height + coreEvent.deltaY - } else if (edges.top) { - newSize.height = state.resizing.height - coreEvent.deltaY + if (renderRtl.value) { + newSize.width = state.resizing.width - coreEvent.deltaX } else { - newSize.height = state.resizing.height + newSize.width = state.resizing.width + coreEvent.deltaX } - + newSize.height = state.resizing.height + coreEvent.deltaY state.resizing = newSize break } @@ -504,10 +447,8 @@ 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 - resizeDirection = '' break } } @@ -537,28 +478,6 @@ function handleResize(event: MouseEvent & { edges: any }) { lastW = x lastH = y - // TODO: placeholder 在 n/w/nw 方向 resize 时存在视觉抖动问题。 - // 根因是 interactjs 直接修改 DOM 位置与 Vue 响应式样式更新之间存在时间差。 - // 当从左侧/顶部 resize 时,interactjs 先修改 DOM 的 left/top,然后 createStyle() - // 在下一次 tick 才覆盖,导致 placeholder 和 item 之间出现短暂错位。 - // 处理 n/w/nw 等方向缩放时同时更新位置 - let newX = resizeCurrX - let newY = resizeCurrY - if (event.edges?.left) { - // 从左侧缩放:x 位置需要调整 - newX = resizeCurrX + (resizeCurrW - pos.w) - } - if (event.edges?.top) { - // 从顶部缩放:y 位置需要调整 - newY = resizeCurrY + (resizeCurrH - pos.h) - } - - // 同步 resize 跟踪状态,使连续 resize 计算正确 - resizeCurrX = newX - resizeCurrY = newY - resizeCurrW = pos.w - resizeCurrH = pos.h - if (state.isResizing) { createStyle() } @@ -569,7 +488,7 @@ function handleResize(event: MouseEvent & { edges: any }) { if (event.type === 'resizeend' && (previousW !== innerW || previousH !== innerH)) { emit('resized', props.i, pos.h, pos.w, newSize.height, newSize.width) } - emitter.emit('resizeEvent', event.type, props.i, newX, newY, pos.h, pos.w) + emitter.emit('resizeEvent', event.type, props.i, innerX, innerY, pos.h, pos.w) } function handleDrag(event: MouseEvent) { @@ -836,27 +755,6 @@ function tryMakeDraggable() { const throttleResize = throttle(handleResize) -/** - * 根据缩放手柄方向计算 interactjs 的 edges 配置。 - */ -function getEdgesForHandles(handles: ResizeHandle[]) { - const hasHandle = (h: ResizeHandle) => handles.includes(h) - - const hasTop = hasHandle('n') || hasHandle('nw') || hasHandle('ne') - const hasBottom = hasHandle('s') || hasHandle('sw') || hasHandle('se') - const hasLeft = hasHandle('w') || hasHandle('nw') || hasHandle('sw') - const hasRight = hasHandle('e') || hasHandle('ne') || hasHandle('se') - - const resizerBase = `.${nh.be('resizer')}` - - return { - top: hasTop ? `${resizerBase}--n, ${resizerBase}--nw, ${resizerBase}--ne` : false, - bottom: hasBottom ? `${resizerBase}--s, ${resizerBase}--sw, ${resizerBase}--se` : false, - left: hasLeft ? `${resizerBase}--w, ${resizerBase}--nw, ${resizerBase}--sw` : false, - right: hasRight ? `${resizerBase}--e, ${resizerBase}--ne, ${resizerBase}--se` : false, - } -} - function tryMakeResizable() { tryInteract() @@ -866,11 +764,14 @@ function tryMakeResizable() { const maximum = calcPosition(0, 0, props.maxW, props.maxH) const minimum = calcPosition(0, 0, props.minW, props.minH) - const handles = effectiveResizeHandles.value - const edges = getEdgesForHandles(handles) - + const resizerBase = `.${nh.be('resizer')}` const opts: Record = { - edges, + edges: { + top: false, + bottom: `${resizerBase}--se`, + left: false, + right: `${resizerBase}--se`, + }, ignoreFrom: props.resizeIgnoreFrom, restrictSize: { min: { @@ -905,12 +806,9 @@ function tryMakeResizable() { diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index b05ecd1..33bbbf2 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -34,7 +34,7 @@ import { import { verticalCompactor } from '../core/compactors' import { transformStrategy } from '../core/position-strategies' -import type { Breakpoint, Compactor, Layout, LayoutInstance, PositionStrategy, ResizeHandle } from '../helpers/types' +import type { Breakpoint, Compactor, Layout, LayoutInstance, PositionStrategy } from '../helpers/types' import type { GridLayoutProps } from './types' const props = withDefaults(defineProps(), { @@ -56,7 +56,6 @@ const props = withDefaults(defineProps(), { useStyleCursor: true, compactor: () => verticalCompactor, positionStrategy: () => transformStrategy, - resizeHandles: undefined, isDroppable: undefined, dropItem: undefined, dragThreshold: undefined, @@ -91,9 +90,6 @@ const effectiveIsDraggable = computed(() => props.isDraggable ?? props.dragConfi const effectiveDragThreshold = computed(() => props.dragThreshold ?? props.dragConfig?.dragThreshold ?? 0) const effectiveRestoreOnDrag = computed(() => props.restoreOnDrag ?? props.dragConfig?.restoreOnDrag ?? false) const effectiveIsResizable = computed(() => props.isResizable ?? props.resizeConfig?.isResizable ?? true) -const effectiveResizeHandles = computed(() => - (props.resizeHandles ?? props.resizeConfig?.resizeHandles ?? ['se']) as ResizeHandle[], -) const effectiveIsDroppable = computed(() => props.isDroppable ?? props.dropConfig?.isDroppable ?? false) const effectiveDropItem = computed(() => props.dropItem ?? props.dropConfig?.dropItem ?? { w: 1, h: 1 }) @@ -108,7 +104,6 @@ const effectiveConfig = computed(() => ({ isDroppable: effectiveIsDroppable.value, dropItem: effectiveDropItem.value, dragThreshold: effectiveDragThreshold.value, - resizeHandles: effectiveResizeHandles.value, restoreOnDrag: effectiveRestoreOnDrag.value, })) @@ -310,7 +305,6 @@ provide( isDroppable: effectiveIsDroppable, dropItem: effectiveDropItem, dragThreshold: effectiveDragThreshold, - resizeHandles: effectiveResizeHandles, restoreOnDrag: effectiveRestoreOnDrag, increaseItem, decreaseItem, diff --git a/src/components/types.ts b/src/components/types.ts index d51bfa1..8b1b0f6 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -3,7 +3,6 @@ import type { Compactor, Layout, PositionStrategy, - ResizeHandle, ResponsiveLayout, } from '../helpers/types' @@ -23,7 +22,6 @@ export interface DragConfig { export interface ResizeConfig { isResizable?: boolean, - resizeHandles?: ResizeHandle[], } export interface DropConfig { @@ -54,8 +52,6 @@ export interface GridLayoutProps { compactor?: Compactor, /** 可插拔定位策略(默认 transformStrategy) */ positionStrategy?: PositionStrategy, - /** 所有子项的默认缩放手柄方向 */ - resizeHandles?: ResizeHandle[], /** 是否允许外部拖入 */ isDroppable?: boolean, /** 外部拖入元素的默认尺寸 */ @@ -91,8 +87,6 @@ export interface GridItemProps { dragOption?: Record, resizeOption?: Record, - /** 缩放手柄方向(覆盖 GridLayout 的默认值) */ - resizeHandles?: ResizeHandle[], /** 拖拽阈值(覆盖 GridLayout 的默认值) */ dragThreshold?: number, } diff --git a/src/helpers/types.ts b/src/helpers/types.ts index d3a33e7..05242f1 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -1,5 +1,3 @@ -export type ResizeHandle = 's' | 'w' | 'e' | 'n' | 'sw' | 'nw' | 'se' | 'ne' - /** 压缩器接口 */ export interface Compactor { /** 对布局执行压缩,返回新布局(不修改输入) */ @@ -39,7 +37,6 @@ export interface LayoutItem extends LayoutItemRequired { static?: boolean, isDraggable?: boolean, isResizable?: boolean, - resizeHandles?: ResizeHandle[], } export type Layout = Array @@ -65,7 +62,6 @@ export interface LayoutInstance { isMirrored: boolean, compactor: Compactor, positionStrategy: PositionStrategy, - resizeHandles: ResizeHandle[], isDroppable: boolean, dropItem: { w: number, h: number }, dragThreshold: number, diff --git a/tests/config-merge.spec.tsx b/tests/config-merge.spec.tsx index 2ebbf60..1eaac7f 100644 --- a/tests/config-merge.spec.tsx +++ b/tests/config-merge.spec.tsx @@ -100,7 +100,6 @@ describe('Config 合并逻辑(需求 8.5, 8.6)', () => { expect(vm.effectiveConfig.isResizable).toBe(true) expect(vm.effectiveConfig.isDroppable).toBe(false) expect(vm.effectiveConfig.dragThreshold).toBe(0) - expect(vm.effectiveConfig.resizeHandles).toEqual(['se']) wrapper.unmount() }) diff --git a/tests/grid-item-handles.spec.tsx b/tests/grid-item-handles.spec.tsx index 0740435..1c45d0b 100644 --- a/tests/grid-item-handles.spec.tsx +++ b/tests/grid-item-handles.spec.tsx @@ -4,14 +4,13 @@ import { nextTick } from 'vue' import { GridLayout } from '../src' -import type { Layout, ResizeHandle } from '../src/helpers/types' +import type { Layout } from '../src/helpers/types' /** * 辅助函数:挂载 GridLayout 并等待初始化完成,返回 wrapper。 */ async function mountGrid(opts: { layout: Layout, - resizeHandles?: ResizeHandle[], isResizable?: boolean, }) { const wrapper = mount(GridLayout, { @@ -22,7 +21,6 @@ async function mountGrid(opts: { margin: [10, 10], isDraggable: false, isResizable: opts.isResizable ?? true, - resizeHandles: opts.resizeHandles, }, attachTo: document.body, }) @@ -37,16 +35,15 @@ async function mountGrid(opts: { return wrapper } -describe('GridItem 缩放手柄渲染(需求 5.2, 5.5)', () => { +describe('GridItem 缩放手柄渲染', () => { const baseLayout: Layout = [ { x: 0, y: 0, w: 2, h: 2, i: '0' }, ] - it('默认 resizeHandles=["se"] 只渲染一个手柄', async () => { + it('默认可缩放时只渲染一个 se 手柄', async () => { const wrapper = await mountGrid({ layout: baseLayout }) const items = wrapper.findAll('.vgl-item') - // 找到非 placeholder 的 item const gridItem = items.find(item => { const style = item.attributes('style') || '' return !style.includes('display: none') && !style.includes('display:none') @@ -61,52 +58,6 @@ describe('GridItem 缩放手柄渲染(需求 5.2, 5.5)', () => { wrapper.unmount() }) - it('resizeHandles=["se","nw"] 渲染两个手柄', async () => { - const wrapper = await mountGrid({ - layout: baseLayout, - resizeHandles: ['se', 'nw'], - }) - - 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(2) - - const classes = handles.map(h => h.classes()).flat() - expect(classes).toContain('vgl-item__resizer--se') - expect(classes).toContain('vgl-item__resizer--nw') - - wrapper.unmount() - }) - - it('resizeHandles 包含所有 8 个方向时渲染 8 个手柄', async () => { - const allHandles: ResizeHandle[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'] - const wrapper = await mountGrid({ - layout: baseLayout, - resizeHandles: allHandles, - }) - - 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(8) - - for (const dir of allHandles) { - const hasClass = handles.some(h => h.classes().includes(`vgl-item__resizer--${dir}`)) - expect(hasClass, `应包含方向 ${dir} 的手柄`).toBe(true) - } - - wrapper.unmount() - }) - it('isResizable=false 时不渲染任何手柄', async () => { const wrapper = await mountGrid({ layout: baseLayout, @@ -125,11 +76,8 @@ describe('GridItem 缩放手柄渲染(需求 5.2, 5.5)', () => { wrapper.unmount() }) - it('每个手柄都有基础 CSS 类名', async () => { - const wrapper = await mountGrid({ - layout: baseLayout, - resizeHandles: ['n', 'e'], - }) + it('se 手柄有基础 CSS 类名', async () => { + const wrapper = await mountGrid({ layout: baseLayout }) const items = wrapper.findAll('.vgl-item') const gridItem = items.find(item => { @@ -137,13 +85,10 @@ describe('GridItem 缩放手柄渲染(需求 5.2, 5.5)', () => { }) expect(gridItem).toBeDefined() - const handles = gridItem!.findAll('.vgl-item__resizer') - expect(handles.length).toBe(2) - - // 每个手柄都应有基础类名 - for (const handle of handles) { - expect(handle.classes()).toContain('vgl-item__resizer') - } + 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() })