mirror of https://github.com/artf/grapesjs.git
Browse Source
getChildrenDim skips children whose DOM element does not match the itemSel (e.g. comment nodes rendered as nodeType=8). findPosition returns an index into the filtered dims array, which was used directly as the model-space insert index — off by however many children were skipped earlier in the list. Fix: stamp each Dimension with its unfiltered position (indexEl) in getChildrenDim, then translate in getDropPosition via `(dimension.indexEl ?? dimensionIndex)`. Also guards getDropPosition against the crash that occurred when all children were filtered (childrenDimensions.length === 0) but the parent still had model children. Fixes drops being off-by-N when MSO conditional comment siblings exist (e.g. content produced by grapesjs-mjml).pull/6818/head
3 changed files with 243 additions and 4 deletions
@ -0,0 +1,230 @@ |
|||
import { DropLocationDeterminer } from '../../../../src/utils/sorter/DropLocationDeterminer'; |
|||
import { SortableTreeNode } from '../../../../src/utils/sorter/SortableTreeNode'; |
|||
import Dimension from '../../../../src/utils/sorter/Dimension'; |
|||
import { DragDirection, DragSource } from '../../../../src/utils/sorter/types'; |
|||
import { setupTestEditor } from '../../../common'; |
|||
import Editor from '../../../../src/editor'; |
|||
|
|||
// Minimal concrete SortableTreeNode for test use only.
|
|||
// The treeClass constructor signature requires (model, dragSource?); we stash
|
|||
// the element and children on the instance after construction via factory helpers.
|
|||
class TestNode extends SortableTreeNode<any> { |
|||
public _element: HTMLElement | undefined; |
|||
public _children: TestNode[] = []; |
|||
public _parent: TestNode | null = null; |
|||
|
|||
constructor(model: any = {}, dragSource: DragSource<any> = {}) { |
|||
super(model, dragSource); |
|||
} |
|||
|
|||
static create(element?: HTMLElement, children: TestNode[] = []): TestNode { |
|||
const node = new TestNode(); |
|||
node._element = element; |
|||
node._children = children; |
|||
node._children.forEach((c) => (c._parent = node)); |
|||
return node; |
|||
} |
|||
|
|||
get element() { |
|||
return this._element; |
|||
} |
|||
get view() { |
|||
return undefined; |
|||
} |
|||
getChildren() { |
|||
return this._children.length ? this._children : null; |
|||
} |
|||
getParent() { |
|||
return this._parent; |
|||
} |
|||
addChildAt(node: TestNode, index: number) { |
|||
return node; |
|||
} |
|||
removeChildAt(_index: number) {} |
|||
indexOfChild(node: TestNode) { |
|||
return this._children.indexOf(node); |
|||
} |
|||
canMove() { |
|||
return true; |
|||
} |
|||
equals(other?: TestNode): other is TestNode { |
|||
return other === this; |
|||
} |
|||
} |
|||
|
|||
function makeDeterminer(em: any, itemSel = '.gjs-comp') { |
|||
const container = document.createElement('div'); |
|||
return new DropLocationDeterminer<any, TestNode>({ |
|||
em, |
|||
treeClass: TestNode, |
|||
containerContext: { |
|||
container, |
|||
itemSel, |
|||
document, |
|||
}, |
|||
positionOptions: { canvasRelative: false, relative: false, windowMargin: 0 }, |
|||
dragDirection: DragDirection.Vertical, |
|||
eventHandlers: {}, |
|||
}); |
|||
} |
|||
|
|||
function makeDim(top: number, height: number, indexEl?: number): Dimension { |
|||
const d = new Dimension({ top, left: 0, height, width: 200, offsets: {} as any, dir: true }); |
|||
d.indexEl = indexEl; |
|||
return d; |
|||
} |
|||
|
|||
describe('Sorter drop-index offset (comment-node skew)', () => { |
|||
let editor: Editor; |
|||
let fixtures: HTMLElement; |
|||
|
|||
beforeEach(() => { |
|||
({ editor, fixtures } = setupTestEditor({ withCanvas: true })); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
editor.destroy(); |
|||
}); |
|||
|
|||
describe('Dimension', () => { |
|||
test('clone preserves indexEl', () => { |
|||
const d = makeDim(0, 100, 3); |
|||
const cloned = d.clone(); |
|||
expect(cloned.indexEl).toBe(3); |
|||
}); |
|||
|
|||
test('clone without indexEl keeps it undefined', () => { |
|||
const d = makeDim(0, 100); |
|||
expect(d.clone().indexEl).toBeUndefined(); |
|||
}); |
|||
}); |
|||
|
|||
describe('getChildrenDim', () => { |
|||
test('stamps indexEl with unfiltered child position, skipping null-element children', () => { |
|||
const determiner = makeDeterminer(editor.getModel()); |
|||
const parentEl = document.createElement('div'); |
|||
|
|||
// Children: [no-el, div, no-el, div, div] — indices 0,1,2,3,4 in model
|
|||
const div1 = document.createElement('div'); |
|||
const div2 = document.createElement('div'); |
|||
const div3 = document.createElement('div'); |
|||
div1.className = 'gjs-comp'; |
|||
div2.className = 'gjs-comp'; |
|||
div3.className = 'gjs-comp'; |
|||
|
|||
const parent = TestNode.create(parentEl, [ |
|||
TestNode.create(undefined), |
|||
TestNode.create(div1), |
|||
TestNode.create(undefined), |
|||
TestNode.create(div2), |
|||
TestNode.create(div3), |
|||
]); |
|||
|
|||
jest |
|||
.spyOn(determiner as any, 'getDim') |
|||
.mockImplementation((el: unknown) => makeDim(el === div1 ? 0 : el === div2 ? 100 : 200, 100)); |
|||
jest.spyOn(determiner as any, 'getDirection').mockReturnValue(true); |
|||
|
|||
const dims: Dimension[] = (determiner as any).getChildrenDim(parent); |
|||
|
|||
expect(dims).toHaveLength(3); |
|||
expect(dims[0].indexEl).toBe(1); |
|||
expect(dims[1].indexEl).toBe(3); |
|||
expect(dims[2].indexEl).toBe(4); |
|||
}); |
|||
}); |
|||
|
|||
describe('getDropPosition', () => { |
|||
test('returns model-space index when earlier children are filtered out', () => { |
|||
// Scenario: parent [skip, div, skip, div, div]
|
|||
// Mouse after the last div → should land at model index 5 (after index 4)
|
|||
const determiner = makeDeterminer(editor.getModel()); |
|||
const parentEl = document.createElement('div'); |
|||
|
|||
const parent = TestNode.create(parentEl, [ |
|||
TestNode.create(undefined), |
|||
TestNode.create(document.createElement('div')), |
|||
TestNode.create(undefined), |
|||
TestNode.create(document.createElement('div')), |
|||
TestNode.create(document.createElement('div')), |
|||
]); |
|||
|
|||
const parentDim = makeDim(0, 300); |
|||
parent.nodeDimensions = parentDim; |
|||
// Pre-supply childrenDimensions with correct indexEl so getChildrenDim is bypassed
|
|||
parent.childrenDimensions = [makeDim(0, 100, 1), makeDim(100, 100, 3), makeDim(200, 100, 4)]; |
|||
|
|||
// y=280 is inside the last child (top=200, height=100) and past its center
|
|||
const result = (determiner as any).getDropPosition(parent, 100, 280); |
|||
|
|||
expect(result.placement).toBe('after'); |
|||
// Without fix: index would be 2+1=3; with fix: (4 ?? 2)+1=5
|
|||
expect(result.index).toBe(5); |
|||
}); |
|||
|
|||
test('returns correct index when drop is before a middle child', () => { |
|||
const determiner = makeDeterminer(editor.getModel()); |
|||
const parentEl = document.createElement('div'); |
|||
|
|||
const parent = TestNode.create(parentEl, [ |
|||
TestNode.create(undefined), |
|||
TestNode.create(document.createElement('div')), |
|||
TestNode.create(undefined), |
|||
TestNode.create(document.createElement('div')), |
|||
TestNode.create(document.createElement('div')), |
|||
]); |
|||
|
|||
parent.nodeDimensions = makeDim(0, 300); |
|||
// Pre-supply childrenDimensions: skip items 0 and 2
|
|||
parent.childrenDimensions = [makeDim(0, 100, 1), makeDim(100, 100, 3), makeDim(200, 100, 4)]; |
|||
|
|||
// y=40 is in first child (top=0, height=100) before its center (50)
|
|||
const result = (determiner as any).getDropPosition(parent, 100, 40); |
|||
|
|||
expect(result.placement).toBe('before'); |
|||
// dimensionIndex=0, indexEl=1, placement='before' → index = 1+0 = 1
|
|||
expect(result.index).toBe(1); |
|||
}); |
|||
|
|||
test('does not crash when all children lack measurable elements', () => { |
|||
// Second bug: parent with only un-measurable children → childrenDimensions=[]
|
|||
const determiner = makeDeterminer(editor.getModel()); |
|||
const parentEl = document.createElement('div'); |
|||
|
|||
const parent = TestNode.create(parentEl, [TestNode.create(undefined), TestNode.create(undefined)]); |
|||
|
|||
parent.nodeDimensions = makeDim(0, 200); |
|||
parent.childrenDimensions = []; // all filtered out
|
|||
|
|||
// Should not throw and should return index=0, placement='inside'
|
|||
let result: any; |
|||
expect(() => { |
|||
result = (determiner as any).getDropPosition(parent, 100, 100); |
|||
}).not.toThrow(); |
|||
|
|||
expect(result.index).toBe(0); |
|||
expect(result.placement).toBe('inside'); |
|||
}); |
|||
|
|||
test('clean children (no skipped nodes) are unaffected', () => { |
|||
// Regression: if no children are filtered, indexEl == dimensionIndex and result is identical
|
|||
const determiner = makeDeterminer(editor.getModel()); |
|||
const parentEl = document.createElement('div'); |
|||
|
|||
const parent = TestNode.create(parentEl, [ |
|||
TestNode.create(document.createElement('div')), |
|||
TestNode.create(document.createElement('div')), |
|||
TestNode.create(document.createElement('div')), |
|||
]); |
|||
|
|||
parent.nodeDimensions = makeDim(0, 300); |
|||
parent.childrenDimensions = [makeDim(0, 100, 0), makeDim(100, 100, 1), makeDim(200, 100, 2)]; |
|||
|
|||
// y=280 → after last child (index 2) → 2+1=3
|
|||
const result = (determiner as any).getDropPosition(parent, 100, 280); |
|||
expect(result.placement).toBe('after'); |
|||
expect(result.index).toBe(3); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue