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);