diff --git a/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue b/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue index a9466b8cb7..5f7c9a8f0f 100644 --- a/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +++ b/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue @@ -171,6 +171,7 @@ export default { if (!this.hasLayoutMeta) { this.onCreateProvision(); } + this.updateWidgetStore(this.parentOptions?.widgetId, 'layout:switch', this.layoutMode); }, beforeUnmount() { apos.bus.$off('widget-breadcrumb-operation', this.executeWidgetOperation); diff --git a/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue b/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue index 4352975fe4..f940fd7707 100644 --- a/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +++ b/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue @@ -3,8 +3,10 @@ ref="root" class="apos-layout" > -
-
+ @@ -117,7 +121,12 @@ export default { data() { return { isResizing: false, - isMoving: false + isMoving: false, + // Live preview from manager: patches to apply to items for render-only + preview: { + patches: null, + key: null + } }; }, computed: { @@ -138,12 +147,16 @@ export default { return slots; }, renderItems() { + const base = this.gridState.current.items; + // Apply live preview patches if present (render-only) + if (Array.isArray(this.preview.patches) && this.preview.patches.length) { + return this.applyPreviewPatches(base, this.preview.patches); + } if (!this.syntheticItems.length) { - return this.gridState.current.items; + return base; } - // Merge and sort by order to ensure CSS order consistency const merged = [ - ...this.gridState.current.items, + ...base, ...this.syntheticItems ]; return merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); @@ -162,6 +175,61 @@ export default { 'is-moving': this.isMoving }; } + }, + methods: { + onPreviewMove({ patches, key }) { + if (!Array.isArray(patches) || !patches.length) { + this.onPreviewClear(); + return; + } + if (this.preview.key === key) { + return; + } + this.preview = { + patches, + key + }; + }, + onPreviewClear() { + if (this.preview.patches || this.preview.key) { + this.preview = { + patches: null, + key: null + }; + } + }, + applyPreviewPatches(items, patches) { + // Map items by _id for quick lookup + const map = new Map(items.map((it, idx) => [ it._id, { + it, + idx + } ])); + const out = items.map(it => ({ ...it })); + for (const p of patches) { + const ref = map.get(p._id); + if (!ref) { + continue; + } + const target = out[ref.idx]; + // Only apply layout-related fields present in the patch + if ('colstart' in p) { + target.colstart = p.colstart; + } + if ('rowstart' in p) { + target.rowstart = p.rowstart; + } + if ('colspan' in p) { + target.colspan = p.colspan; + } + if ('rowspan' in p) { + target.rowspan = p.rowspan; + } + if ('order' in p) { + target.order = p.order; + } + } + return out; + } } }; @@ -207,9 +275,19 @@ export default { /* stylelint-disable-next-line media-feature-name-allowed-list */ @media (prefers-reduced-motion: no-preference) { - .apos-layout__grid { - transition: all 300ms ease; + /* TransitionGroup move/enter/leave animations for grid items */ + .apos-grid-move { + transition: transform 200ms ease; } -} + .apos-grid-enter-active, + .apos-grid-leave-active { + transition: opacity 200ms ease; + } + + .apos-grid-enter-from, + .apos-grid-leave-to { + opacity: 0.01; + } +} diff --git a/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue b/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue index d19c6cc103..1c1a20a3d2 100644 --- a/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +++ b/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue @@ -28,6 +28,7 @@ }" >
{ if (type === 'resize') { this.sceneResizeIndex += 1; @@ -325,7 +333,9 @@ export default { // without the resize observer being properly cleaned up. this.manager.onSceneResizeDebounced(entries); }); - this.resizeObserver.observe(this.$parent.$refs.grid); + if (gridEl) { + this.resizeObserver.observe(gridEl); + } await this.$nextTick(); this.cloneCalculateIndex += 1; @@ -408,6 +418,33 @@ export default { this.ghostDataWrite.colstart = colstart ?? this.ghostDataWrite.colstart; this.ghostDataWrite.colspan = colspan ?? this.ghostDataWrite.colspan; this.ghostDataWrite.direction = direction ?? this.ghostDataWrite.direction; + + // Live preview of resize result when direction is known + if (this.ghostDataWrite.direction && this.ghostDataWrite.id) { + const newKey = `${this.ghostDataWrite.colstart}:${this.ghostDataWrite.colspan}:${this.ghostDataWrite.direction}`; + if (this.lastPreviewKey !== newKey) { + const patches = this.manager.performItemResize({ + data: this.ghostDataWrite, + state: this.gridState, + item: this.gridState.lookup.get(this.ghostDataWrite.id) + }); + if (Array.isArray(patches) && patches.length) { + this.$emit('preview-move', { // reuse same preview channel + patches, + key: newKey + }); + this.lastPreviewKey = newKey; + } else if (this.lastPreviewKey) { + // No effective change, clear existing preview + this.$emit('preview-clear'); + this.lastPreviewKey = null; + } + } + } else if (this.lastPreviewKey) { + // Lost direction (e.g., mouse returned to start), clear preview + this.$emit('preview-clear'); + this.lastPreviewKey = null; + } } }, onStartMove(item, event) { @@ -476,6 +513,36 @@ export default { if (colstart && rowstart) { this.ghostDataWrite.colstart = colstart; this.ghostDataWrite.rowstart = rowstart; + // Emit preview of would-be state when snap target changes + const newKey = `${colstart}:${rowstart}`; + if ( + typeof this.ghostData.snapLeft === 'number' && + typeof this.ghostData.snapTop === 'number' + ) { + if (this.lastPreviewKey !== newKey) { + const patches = this.manager.performItemMove({ + data: this.ghostDataWrite, + state: this.gridState, + item: this.gridState.lookup.get(this.ghostDataWrite.id), + precomp: this.movePrecomp + }); + if (Array.isArray(patches) && patches.length) { + this.$emit('preview-move', { + patches, + key: newKey + }); + this.lastPreviewKey = newKey; + } else if (this.lastPreviewKey) { + // No effective change, clear existing preview + this.$emit('preview-clear'); + this.lastPreviewKey = null; + } + } + } else if (this.lastPreviewKey) { + // Lost snapping, clear preview + this.$emit('preview-clear'); + this.lastPreviewKey = null; + } } }, onMouseMove(event) { @@ -510,6 +577,11 @@ export default { } }, resetGhostData() { + // Always clear any preview when ghost state resets + if (this.lastPreviewKey) { + this.$emit('preview-clear'); + this.lastPreviewKey = null; + } Object.keys(this.ghostData).forEach(key => { this.ghostData[key] = typeof this.ghostData[key] === 'boolean' ? false diff --git a/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-manager.js b/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-manager.js index 877671f3ff..f29c446c0f 100644 --- a/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-manager.js +++ b/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-manager.js @@ -405,9 +405,21 @@ export class GridManager { const maxStartX = Math.max(1, columns - colspan + 1); const maxStartY = Math.max(1, rows - rowspan + 1); - // Initial nearest indices from current pixel position - let c = Math.round(left / stepX) + 1; - let r = Math.round(top / stepY) + 1; + // Initial nearest indices with custom snap threshold. + const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + const tMoveOpt = ( + state?.options?.snapThresholdMove ?? state?.options?.snapThreshold ?? 0.6 + ); + const tMove = Number(tMoveOpt); + const tMoveClamped = clamp( + Number.isFinite(tMove) ? tMove : 0.6, + 0.05, + 0.95 + ); + const shiftX = (1 - tMoveClamped) * stepX; + const shiftY = (1 - tMoveClamped) * stepY; + let c = Math.floor((left + shiftX) / stepX) + 1; + let r = Math.floor((top + shiftY) / stepY) + 1; c = Math.max(1, Math.min(c, maxStartX)); r = Math.max(1, Math.min(r, maxStartY)); @@ -470,7 +482,20 @@ export class GridManager { const columnWidth = containerRect.width / state.columns; const direction = deltaX > 0 ? 'east' : 'west'; const directionCorrection = data.side === direction ? 1 : -1; - const deltaColspan = Math.round(Math.abs(deltaX) / columnWidth) * directionCorrection; + const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + const tResizeOpt = ( + state?.options?.snapThresholdResize ?? state?.options?.snapThreshold ?? 0.5 + ); + const tResize = Number(tResizeOpt); + const SNAP_THRESHOLD = clamp( + Number.isFinite(tResize) ? tResize : 0.5, + 0.05, + 0.95 + ); + const deltaSteps = Math.floor( + (Math.abs(deltaX) + (1 - SNAP_THRESHOLD) * columnWidth) / columnWidth + ); + const deltaColspan = deltaSteps * directionCorrection; const desired = Math.max( state.options.minSpan, Math.min(item.colspan + deltaColspan, state.columns) diff --git a/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs b/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs index b9afc57acc..f7416ffc1c 100644 --- a/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +++ b/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs @@ -81,7 +81,9 @@ export function itemsToState({ const resolvedOptions = { ...options, columns: meta.columns || options.columns, - gap: [ 'layout', 'focus' ].includes(layoutMode) ? gap || '2px' : options.gap + gap: [ 'layout', 'focus' ].includes(layoutMode) ? gap || '2px' : options.gap, + snapThresholdMove: 0.7, + snapThreshold: 0.5 }; const positionsIndex = createPositionIndex(current.items, current.rows);